75: Implement the Nenjim Journal model, text parser, manager and filesystem service

This commit is contained in:
2026-08-20 12:28:16 +02:00
parent f29013e9c2
commit b230e15ece
43 changed files with 3303 additions and 276 deletions
@@ -1,6 +1,5 @@
package com.r35157.nenjim.hubd;
import com.r35157.nenjim.hubd.journal.Journal;
import crypto.r35157.nenjim.NenjimProcess;
import org.jetbrains.annotations.NotNull;
@@ -1,11 +0,0 @@
package com.r35157.nenjim.hubd.impl.ref;
import com.r35157.nenjim.hubd.journal.Journal;
import com.r35157.nenjim.hubd.journal.JournalManager;
public class JournalManagerImpl implements JournalManager {
@Override
public Journal getJournal(String s) {
return new Journal(null, null, null);
}
}
@@ -2,26 +2,14 @@ package com.r35157.nenjim.hubd.impl.ref;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.hubd.ctx.Context;
import com.r35157.nenjim.hubd.journal.Journal;
import com.r35157.nenjim.hubd.journal.JournalManager;
import com.r35157.nenjim.hubd.module.Release;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class NenjimClassLoader extends ClassLoader {
// TODO: Check protection - it is only main that can create a 'half' initialized classloader (and set the rest with setters afterwards)
NenjimClassLoader(JournalManager journalManager, Context context) {
this.journalManager = journalManager;
NenjimClassLoader(Context context) {
this.context = context;
//moduleVersion = new SemanticVersion(0, 1, 0);
}
private String getModuleName(String className) {
return className.substring(0, className.lastIndexOf("."));
}
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
System.out.println("NenjimClassLoader asked to load '" + className + "'...");
@@ -31,12 +19,9 @@ public final class NenjimClassLoader extends ClassLoader {
if(version == null) {
// No local configurations for a specific version has been configured for this package
// in this Context. Use the version the developer do recommend in the Journal.
String moduleName = getModuleName(className);
System.out.println(" Context (" + context.getName() + ") does NOT have any special version requirements for the module '" + moduleName + "' - use vendor recommendation from journal...");
Journal journal = journalManager.getJournal(moduleName);
Release release = journal.getRelease(version);
System.out.println(" Journal did recommend version '" + version + "' for the module '" + moduleName + "'");
// in this Context. Resolution and Journal integration are not implemented yet.
System.out.println(" Context (" + context.getName()
+ ") does NOT have a resolved version for '" + className + "'.");
}
System.out.println("Searching for class '" + className + "' in local Nenjim class cache...");
String relativePath = className.replace('.', '/') + ".class";
@@ -58,6 +43,5 @@ public final class NenjimClassLoader extends ClassLoader {
return null;
}
private JournalManager journalManager;
private Context context;
}
@@ -1,26 +0,0 @@
package com.r35157.nenjim.hubd.journal;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.hubd.module.Dependency;
import com.r35157.nenjim.hubd.module.Release;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
public record Journal(
@NotNull ΩJournalIdΩ id,
@NotNull String name,
@NotNull Set<Release> releases
) {
public Release getRelease(SemanticVersion version) {
Set<Dependency> dependencies = new HashSet<>();
return new Release(
new SemanticVersion(0, 1, 0),
new Date(),
null,
dependencies);
}
}
@@ -1,5 +0,0 @@
package com.r35157.nenjim.hubd.journal;
public interface JournalManager {
Journal getJournal(String moduleName);
}
@@ -1,8 +0,0 @@
package com.r35157.nenjim.hubd.module;
import org.jetbrains.annotations.NotNull;
public record Dependency(
@NotNull ΩJournalIdΩ dependencyId
) {
}
@@ -1,4 +0,0 @@
package com.r35157.nenjim.hubd.module;
public record Module() {
}
@@ -1,14 +0,0 @@
package com.r35157.nenjim.hubd.module;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.Set;
public record Release(
@NotNull SemanticVersion version,
@NotNull Date releaseDate,
@NotNull ΩChecksumΩ checksum,
@NotNull Set<Dependency> dependencies
) {}
@@ -0,0 +1,35 @@
package com.r35157.nenjim.service.journal;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
import com.r35157.nenjim.service.journal.model.Journal;
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/** Filesystem-independent, read-only access to current and historical Journal knowledge. */
public interface JournalService {
/** Lists known artifact coordinates in stable lexical order. */
Set<ArtifactCoordinate> artifactCoordinates();
/** Lists complete revisions in ascending Journal-version order. */
List<Journal> revisions(ArtifactCoordinate coordinate);
/** Returns the current complete worldview for an artifact. */
Optional<Journal> latest(ArtifactCoordinate coordinate);
/** Returns one exact historical complete worldview. */
Optional<Journal> revision(ArtifactCoordinate coordinate, JournalVersion journalVersion);
/** Returns the newest complete worldview published no later than the requested Journal version. */
Optional<Journal> revisionAtOrBefore(ArtifactCoordinate coordinate, JournalVersion journalVersion);
/** Returns an exact release from one selected Journal worldview. */
Optional<ArtifactRelease> release(
ArtifactCoordinate coordinate,
JournalVersion journalVersion,
SemanticVersion releaseVersion);
}
@@ -0,0 +1,22 @@
package com.r35157.nenjim.service.journal;
import java.io.IOException;
import java.nio.file.Path;
/** Lifecycle and administrative access for one filesystem-backed Journal service. */
public interface JournalServiceManager {
/** Starts the service and completes its initial Journal loading before returning. */
void start() throws IOException;
/** Stops service-owned maintenance without changing Journal files. */
void stop();
/** Returns the normalized configured Journal data root. */
Path journalRoot();
/** Returns the read-only service after successful initialization. */
JournalService journalService();
/** Explicitly reloads the configured runtime Journal files. */
void refresh() throws IOException;
}
@@ -0,0 +1,50 @@
package com.r35157.nenjim.service.journal.exception;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
/** Reports a structural, semantic, or chain error in a Journal update. */
public final class InvalidJournalException extends IllegalArgumentException {
private final String reason;
private final Optional<String> sourceName;
private final OptionalInt lineNumber;
public InvalidJournalException(String reason) {
this(null, 0, reason, null);
}
public InvalidJournalException(String sourceName, int lineNumber, String reason) {
this(sourceName, lineNumber, reason, null);
}
public InvalidJournalException(String sourceName, int lineNumber, String reason, Throwable cause) {
super(formatMessage(sourceName, lineNumber, reason), cause);
this.reason = Objects.requireNonNull(reason, "reason");
this.sourceName = sourceName == null || sourceName.isBlank()
? Optional.empty() : Optional.of(sourceName);
if (lineNumber < 0) {
throw new IllegalArgumentException("lineNumber cannot be negative");
}
this.lineNumber = lineNumber == 0 ? OptionalInt.empty() : OptionalInt.of(lineNumber);
}
public String reason() {
return reason;
}
public Optional<String> sourceName() {
return sourceName;
}
public OptionalInt lineNumber() {
return lineNumber;
}
private static String formatMessage(String sourceName, int lineNumber, String reason) {
Objects.requireNonNull(reason, "reason");
String location = sourceName == null || sourceName.isBlank() ? "Journal" : sourceName;
if (lineNumber > 0) location += ":" + lineNumber;
return location + ": " + reason;
}
}
@@ -0,0 +1,244 @@
package com.r35157.nenjim.service.journal.impl.ref;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.service.journal.JournalService;
import com.r35157.nenjim.service.journal.exception.InvalidJournalException;
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
import com.r35157.nenjim.service.journal.model.Journal;
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* Filesystem-independent, thread-safe reference service for immutable per-artifact Journal chains.
*/
public final class JournalServiceImpl implements JournalService {
private static final String DEFAULT_SOURCE = "<journal>";
private final Object stateLock = new Object();
private final JournalTextParser parser;
private final Map<ArtifactCoordinate, NavigableMap<JournalVersion, StoredRevision>> chains = new HashMap<>();
private final Map<String, ArtifactCoordinate> digestOwners = new HashMap<>();
JournalServiceImpl() {
this(new JournalTextParser());
}
JournalServiceImpl(JournalTextParser parser) {
this.parser = Objects.requireNonNull(parser, "parser");
}
/** Parses and validates a complete snapshot without changing service state. */
Journal parse(String completeJournalText) {
return parse(completeJournalText, DEFAULT_SOURCE);
}
/** Parses and validates a complete snapshot without changing service state. */
Journal parse(String completeJournalText, String logicalSourceName) {
return parser.parse(completeJournalText, sourceName(logicalSourceName));
}
/** Atomically accepts one complete textual Journal snapshot. */
Journal ingest(String completeJournalText) {
return ingest(completeJournalText, DEFAULT_SOURCE);
}
/** Atomically accepts one complete textual Journal snapshot with a diagnostic source name. */
Journal ingest(String completeJournalText, String logicalSourceName) {
String source = sourceName(logicalSourceName);
Journal candidate = parser.parse(completeJournalText, source);
ContentDigest digest = exactTextDigest(completeJournalText);
ArtifactCoordinate coordinate = candidate.metadata().coordinate();
JournalVersion version = candidate.journalVersion();
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> existingChain = chains.get(coordinate);
StoredRevision existingRevision = existingChain == null ? null : existingChain.get(version);
if (existingRevision != null) {
if (existingRevision.completeText.equals(completeJournalText)) {
return existingRevision.journal;
}
throw chainError(source,
"Conflicting text already exists for " + coordinate + " at Journal version " + version);
}
if (existingChain == null) {
if (candidate.previousJournalDigest().isPresent()) {
throw invalidPredecessor(source, coordinate, candidate.previousJournalDigest().orElseThrow());
}
} else {
if (candidate.previousJournalDigest().isEmpty()) {
throw chainError(source,
"Artifact " + coordinate + " already has a genesis revision; a successor must name its predecessor");
}
StoredRevision head = existingChain.lastEntry().getValue();
if (version.compareTo(head.journal.journalVersion()) <= 0) {
throw chainError(source,
"Journal version " + version + " must be strictly greater than current head "
+ head.journal.journalVersion() + " for " + coordinate);
}
ContentDigest predecessor = candidate.previousJournalDigest().orElseThrow();
if (!predecessor.equals(head.digest)) {
throw invalidPredecessor(source, coordinate, predecessor);
}
}
TreeMap<JournalVersion, StoredRevision> updated = existingChain == null
? new TreeMap<>() : new TreeMap<>(existingChain);
updated.put(version, new StoredRevision(candidate, completeJournalText, digest));
chains.put(coordinate, Collections.unmodifiableNavigableMap(updated));
digestOwners.put(digest.value(), coordinate);
return candidate;
}
}
/** Lists known artifact coordinates in stable lexical order. */
@Override
public Set<ArtifactCoordinate> artifactCoordinates() {
synchronized (stateLock) {
TreeSet<ArtifactCoordinate> coordinates = new TreeSet<>(Comparator.comparing(ArtifactCoordinate::toString));
coordinates.addAll(chains.keySet());
return Collections.unmodifiableSet(coordinates);
}
}
/** Lists complete revisions in ascending Journal-version order. */
@Override
public List<Journal> revisions(ArtifactCoordinate coordinate) {
Objects.requireNonNull(coordinate, "coordinate");
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
if (chain == null) return List.of();
ArrayList<Journal> result = new ArrayList<>(chain.size());
chain.values().forEach(stored -> result.add(stored.journal));
return List.copyOf(result);
}
}
/** Returns the current complete worldview for an artifact. */
@Override
public Optional<Journal> latest(ArtifactCoordinate coordinate) {
Objects.requireNonNull(coordinate, "coordinate");
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
return chain == null ? Optional.empty() : Optional.of(chain.lastEntry().getValue().journal);
}
}
/** Returns one exact historical complete worldview. */
@Override
public Optional<Journal> revision(ArtifactCoordinate coordinate, JournalVersion journalVersion) {
Objects.requireNonNull(coordinate, "coordinate");
Objects.requireNonNull(journalVersion, "journalVersion");
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
if (chain == null) return Optional.empty();
StoredRevision stored = chain.get(journalVersion);
return stored == null ? Optional.empty() : Optional.of(stored.journal);
}
}
/** Returns the newest complete worldview published no later than the requested Journal version. */
@Override
public Optional<Journal> revisionAtOrBefore(
ArtifactCoordinate coordinate,
JournalVersion journalVersion) {
Objects.requireNonNull(coordinate, "coordinate");
Objects.requireNonNull(journalVersion, "journalVersion");
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
if (chain == null) return Optional.empty();
Map.Entry<JournalVersion, StoredRevision> entry = chain.floorEntry(journalVersion);
return entry == null ? Optional.empty() : Optional.of(entry.getValue().journal);
}
}
/** Returns an exact release from one selected Journal worldview. */
@Override
public Optional<ArtifactRelease> release(
ArtifactCoordinate coordinate,
JournalVersion journalVersion,
SemanticVersion releaseVersion) {
Objects.requireNonNull(releaseVersion, "releaseVersion");
return revision(coordinate, journalVersion).flatMap(journal -> journal.release(releaseVersion));
}
/** Returns the exact UTF-8 text digest stored for a selected revision. */
Optional<ContentDigest> revisionDigest(
ArtifactCoordinate coordinate,
JournalVersion journalVersion) {
Objects.requireNonNull(coordinate, "coordinate");
Objects.requireNonNull(journalVersion, "journalVersion");
synchronized (stateLock) {
NavigableMap<JournalVersion, StoredRevision> chain = chains.get(coordinate);
if (chain == null) return Optional.empty();
StoredRevision stored = chain.get(journalVersion);
return stored == null ? Optional.empty() : Optional.of(stored.digest);
}
}
private InvalidJournalException invalidPredecessor(
String source,
ArtifactCoordinate candidateCoordinate,
ContentDigest predecessor) {
ArtifactCoordinate owner = digestOwners.get(predecessor.value());
if (owner == null) {
return chainError(source,
"PREVIOUS_JOURNAL_DIGEST names a missing predecessor: " + predecessor);
}
if (!owner.equals(candidateCoordinate)) {
return chainError(source,
"PREVIOUS_JOURNAL_DIGEST belongs to artifact " + owner
+ ", not " + candidateCoordinate);
}
return chainError(source,
"PREVIOUS_JOURNAL_DIGEST names a non-head predecessor and would fork the chain for "
+ candidateCoordinate);
}
private static ContentDigest exactTextDigest(String completeJournalText) {
Objects.requireNonNull(completeJournalText, "completeJournalText");
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] digest = sha256.digest(completeJournalText.getBytes(StandardCharsets.UTF_8));
return ContentDigest.sha256(HexFormat.of().formatHex(digest));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("Java runtime does not provide SHA-256", impossible);
}
}
private static String sourceName(String source) {
return source == null || source.isBlank() ? DEFAULT_SOURCE : source;
}
private static InvalidJournalException chainError(String source, String reason) {
return new InvalidJournalException(source, 0, reason);
}
private record StoredRevision(Journal journal, String completeText, ContentDigest digest) {
private StoredRevision {
Objects.requireNonNull(journal, "journal");
Objects.requireNonNull(completeText, "completeText");
Objects.requireNonNull(digest, "digest");
}
}
}
@@ -0,0 +1,164 @@
package com.r35157.nenjim.service.journal.impl.ref;
import com.r35157.nenjim.service.journal.JournalService;
import com.r35157.nenjim.service.journal.JournalServiceManager;
import com.r35157.nenjim.service.journal.model.Journal;
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
/** Filesystem-backed reference lifecycle manager for a read-only Journal service. */
public final class JournalServiceManagerImpl implements JournalServiceManager {
private static final String JOURNAL_SUFFIX = ".journal";
private static final String EXAMPLE_SUFFIX = ".journal.example";
private final Path journalRoot;
private JournalServiceImpl activeService;
public JournalServiceManagerImpl(Path journalRoot) {
this.journalRoot = Objects.requireNonNull(journalRoot, "journalRoot").toAbsolutePath().normalize();
}
@Override
public synchronized void start() throws IOException {
if (activeService != null) {
return;
}
JournalServiceImpl candidate = new JournalServiceImpl();
loadAll(candidate);
activeService = candidate;
}
@Override
public synchronized void stop() {
activeService = null;
}
@Override
public Path journalRoot() {
return journalRoot;
}
@Override
public synchronized JournalService journalService() {
return requireActiveService();
}
@Override
public synchronized void refresh() throws IOException {
loadAll(requireActiveService());
}
private JournalServiceImpl requireActiveService() {
if (activeService == null) {
throw new IllegalStateException("Journal service manager has not been started");
}
return activeService;
}
private void loadAll(JournalServiceImpl service) throws IOException {
validateRoot();
for (Path artifactDirectory : listEntries(journalRoot)) {
if (!Files.isDirectory(artifactDirectory)) {
throw new IOException("Unexpected entry in Journal root; expected an artifact directory: "
+ artifactDirectory);
}
loadArtifactDirectory(service, artifactDirectory);
}
}
private void validateRoot() throws IOException {
if (!Files.exists(journalRoot)) {
throw new IOException("Configured Journal root does not exist: " + journalRoot);
}
if (!Files.isDirectory(journalRoot)) {
throw new IOException("Configured Journal root is not a directory: " + journalRoot);
}
if (!Files.isReadable(journalRoot)) {
throw new IOException("Configured Journal root is not readable: " + journalRoot);
}
}
private static void loadArtifactDirectory(
JournalServiceImpl service,
Path directory) throws IOException {
String directoryName = directory.getFileName().toString();
ArtifactCoordinate expectedCoordinate;
try {
expectedCoordinate = ArtifactCoordinate.parse(directoryName);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid artifact-coordinate directory name: " + directory, exception);
}
if (!Files.isReadable(directory)) {
throw new IOException("Artifact Journal directory is not readable: " + directory);
}
ArrayList<JournalFile> runtimeFiles = new ArrayList<>();
for (Path entry : listEntries(directory)) {
if (!Files.isRegularFile(entry)) {
throw new IOException("Unexpected entry in artifact Journal directory: " + entry);
}
String filename = entry.getFileName().toString();
if (filename.endsWith(EXAMPLE_SUFFIX)) {
continue;
}
if (!filename.endsWith(JOURNAL_SUFFIX)) {
throw new IOException("Unexpected file in artifact Journal directory: " + entry);
}
if (!Files.isReadable(entry)) {
throw new IOException("Journal file is not readable: " + entry);
}
String versionText = filename.substring(0, filename.length() - JOURNAL_SUFFIX.length());
JournalVersion expectedVersion;
try {
expectedVersion = JournalVersion.parse(versionText);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid Journal filename; expected <journal-version>.journal: " + entry,
exception);
}
runtimeFiles.add(new JournalFile(entry, expectedVersion));
}
runtimeFiles.sort(Comparator.comparing(JournalFile::journalVersion));
for (JournalFile runtimeFile : runtimeFiles) {
String text = Files.readString(runtimeFile.path, StandardCharsets.UTF_8);
String source = runtimeFile.path.toString();
Journal parsed = service.parse(text, source);
if (!parsed.metadata().coordinate().equals(expectedCoordinate)) {
throw new IOException("Journal metadata coordinate " + parsed.metadata().coordinate()
+ " does not match directory " + expectedCoordinate + ": " + runtimeFile.path);
}
if (!parsed.journalVersion().equals(runtimeFile.journalVersion)) {
throw new IOException("JOURNAL_VERSION " + parsed.journalVersion()
+ " does not match filename version " + runtimeFile.journalVersion + ": " + runtimeFile.path);
}
service.ingest(text, source);
}
}
private static List<Path> listEntries(Path directory) throws IOException {
try (Stream<Path> entries = Files.list(directory)) {
return entries.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
} catch (SecurityException exception) {
throw new IOException("Cannot read Journal directory: " + directory, exception);
}
}
private record JournalFile(Path path, JournalVersion journalVersion) {
private JournalFile {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(journalVersion, "journalVersion");
}
}
}
@@ -0,0 +1,683 @@
package com.r35157.nenjim.service.journal.impl.ref;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.service.journal.exception.InvalidJournalException;
import com.r35157.nenjim.service.journal.model.ArtifactDependency;
import com.r35157.nenjim.service.journal.model.ArtifactRelease;
import com.r35157.nenjim.service.journal.model.Journal;
import com.r35157.nenjim.service.journal.model.JournalMetadata;
import com.r35157.nenjim.service.journal.model.JournalPolicy;
import com.r35157.nenjim.service.journal.model.PolicyRule;
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
import com.r35157.nenjim.service.journal.valuetypes.VersionExpression;
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Strict line-oriented parser for Journal format version 1. */
final class JournalTextParser {
private static final Pattern FIELD_NAME = Pattern.compile("[A-Z][A-Z0-9_]*");
private static final Pattern RELEASE_SECTION = Pattern.compile("\\[RELEASE ([^]]+)]");
private static final String VERSION_NUMBER = "(?:0|[1-9]\\d*)";
private static final Pattern VERSION_ENDPOINT = Pattern.compile(
"(" + VERSION_NUMBER + ")\\.(" + VERSION_NUMBER + ")(?:\\.(" + VERSION_NUMBER + "))?");
private static final Pattern EXACT_VERSION = Pattern.compile(
VERSION_NUMBER + "\\." + VERSION_NUMBER + "\\." + VERSION_NUMBER);
Journal parse(String completeJournalText) {
return parse(completeJournalText, "<journal>");
}
Journal parse(String completeJournalText, String logicalSourceName) {
if (completeJournalText == null) {
throw new InvalidJournalException(logicalSourceName, 0, "Journal text cannot be null");
}
if (completeJournalText.isBlank()) {
throw new InvalidJournalException(logicalSourceName, 0, "Journal text cannot be empty or blank");
}
String source = logicalSourceName == null || logicalSourceName.isBlank()
? "<journal>" : logicalSourceName;
Cursor cursor = new Cursor(tokenize(completeJournalText), source);
if (!cursor.hasNext()) {
throw new InvalidJournalException(source, 0, "Journal contains no configuration entries");
}
Line formatLine = cursor.next();
Field format = parseField(formatLine, source);
if (!"FORMAT_VERSION".equals(format.name)) {
throw invalid(source, formatLine,
"FORMAT_VERSION=1 must be the first actual configuration entry");
}
if (!"1".equals(format.value)) {
throw invalid(source, formatLine,
"Unsupported FORMAT_VERSION '" + format.value + "'; only version 1 is supported");
}
Line versionLine = cursor.requireNext("Missing required JOURNAL_VERSION");
Field versionField = parseField(versionLine, source);
if (!"JOURNAL_VERSION".equals(versionField.name)) {
throw invalid(source, versionLine, "Expected JOURNAL_VERSION after FORMAT_VERSION");
}
JournalVersion journalVersion = parseJournalVersion(versionField.value, versionLine, source);
Optional<ContentDigest> previousDigest = Optional.empty();
if (cursor.hasNext() && cursor.peek().content.startsWith("PREVIOUS_JOURNAL_DIGEST")) {
Line previousLine = cursor.next();
Field previousField = parseField(previousLine, source);
if (!"PREVIOUS_JOURNAL_DIGEST".equals(previousField.name)) {
throw invalid(source, previousLine, "Malformed PREVIOUS_JOURNAL_DIGEST entry");
}
ContentDigest digest = parseContentDigest(previousField.value, previousLine, source);
if (!ContentDigest.SHA256.equals(digest.scheme())) {
throw invalid(source, previousLine, "PREVIOUS_JOURNAL_DIGEST must use sha256");
}
previousDigest = Optional.of(digest);
}
Line metadataHeader = cursor.requireNext("Missing required [METADATA] section");
if (!"[METADATA]".equals(metadataHeader.content)) {
throw invalid(source, metadataHeader, "Expected [METADATA] section");
}
JournalMetadata metadata = parseMetadata(cursor, metadataHeader, source);
ParsedPolicy parsedPolicy = new ParsedPolicy(JournalPolicy.empty(), List.of());
if (cursor.hasNext() && "[POLICY]".equals(cursor.peek().content)) {
Line policyHeader = cursor.next();
parsedPolicy = parsePolicy(cursor, policyHeader, source);
}
if (!cursor.hasNext()) {
throw invalid(source, metadataHeader, "A Journal requires at least one [RELEASE <version>] section");
}
LinkedHashMap<SemanticVersion, ArtifactRelease> releases = new LinkedHashMap<>();
while (cursor.hasNext()) {
Line releaseHeader = cursor.next();
Matcher sectionMatcher = RELEASE_SECTION.matcher(releaseHeader.content);
if (!sectionMatcher.matches()) {
throw invalid(source, releaseHeader,
"Expected [RELEASE <major.minor.patch>] section, found '" + releaseHeader.content + "'");
}
SemanticVersion releaseVersion = parseExactVersion(sectionMatcher.group(1), releaseHeader, source);
if (releases.containsKey(releaseVersion)) {
throw invalid(source, releaseHeader, "Duplicate release section for " + releaseVersion);
}
ArtifactRelease release = parseRelease(cursor, releaseHeader, releaseVersion, source);
releases.put(releaseVersion, release);
}
validateRecommendations(parsedPolicy.recommendedRules, releases, source);
try {
return new Journal(1, journalVersion, previousDigest, metadata, parsedPolicy.policy, releases);
} catch (IllegalArgumentException exception) {
throw invalid(source, metadataHeader, exception.getMessage(), exception);
}
}
private static JournalMetadata parseMetadata(Cursor cursor, Line header, String source) {
Map<String, FieldAtLine> values = new LinkedHashMap<>();
Set<String> allowed = Set.of("GROUP", "MODULE", "ARTIFACT", "DESCRIPTION", "INFORMATION_URI");
while (cursor.hasNext() && !isSection(cursor.peek())) {
Line line = cursor.next();
Field field = parseField(line, source);
if (!allowed.contains(field.name)) {
throw invalid(source, line, "Unknown or misplaced [METADATA] field '" + field.name + "'");
}
if (values.putIfAbsent(field.name, new FieldAtLine(field.value, line)) != null) {
throw invalid(source, line, "Duplicate [METADATA] field '" + field.name + "'");
}
}
FieldAtLine group = requireField(values, "GROUP", header, source);
FieldAtLine module = requireField(values, "MODULE", header, source);
FieldAtLine artifact = requireField(values, "ARTIFACT", header, source);
FieldAtLine description = requireField(values, "DESCRIPTION", header, source);
requirePlainValue(group, "GROUP", source);
requirePlainValue(module, "MODULE", source);
requirePlainValue(artifact, "ARTIFACT", source);
String decodedDescription = parseQuoted(description.value, "DESCRIPTION", description.line, source);
ArtifactCoordinate coordinate;
try {
coordinate = new ArtifactCoordinate(group.value, module.value, artifact.value);
} catch (IllegalArgumentException exception) {
throw invalid(source, group.line, exception.getMessage(), exception);
}
Optional<URI> informationUri = Optional.empty();
FieldAtLine information = values.get("INFORMATION_URI");
if (information != null) {
String decoded = parseQuoted(information.value, "INFORMATION_URI", information.line, source);
try {
URI uri = new URI(decoded);
if (!uri.isAbsolute()) {
throw invalid(source, information.line,
"INFORMATION_URI must be an absolute URI: '" + decoded + "'");
}
informationUri = Optional.of(uri);
} catch (URISyntaxException exception) {
throw invalid(source, information.line,
"INFORMATION_URI is not a syntactically valid URI: '" + decoded + "'", exception);
}
}
return new JournalMetadata(coordinate, decodedDescription, informationUri);
}
private static ParsedPolicy parsePolicy(Cursor cursor, Line header, String source) {
List<PolicyRule> recommended = new ArrayList<>();
List<RuleAtLine> recommendedAtLines = new ArrayList<>();
List<PolicyRule> discouraged = new ArrayList<>();
List<PolicyRule> blacklist = new ArrayList<>();
while (cursor.hasNext() && !isSection(cursor.peek())) {
Line line = cursor.next();
Field field = parseField(line, source);
switch (field.name) {
case "RECOMMENDED" -> {
PolicyRule rule = parseRule(field.value, line, source, true);
recommended.add(rule);
recommendedAtLines.add(new RuleAtLine(rule, line));
}
case "DISCOURAGED" -> discouraged.add(parseRule(field.value, line, source, false));
case "BLACKLIST" -> blacklist.add(parseRule(field.value, line, source, false));
default -> throw invalid(source, line,
"Unknown or misplaced [POLICY] field '" + field.name + "'");
}
}
try {
return new ParsedPolicy(
new JournalPolicy(recommended, discouraged, blacklist),
List.copyOf(recommendedAtLines));
} catch (IllegalArgumentException exception) {
throw invalid(source, header, exception.getMessage(), exception);
}
}
private static ArtifactRelease parseRelease(
Cursor cursor,
Line header,
SemanticVersion version,
String source) {
FieldAtLine license = null;
FieldAtLine publishedAt = null;
FieldAtLine contentSize = null;
LinkedHashMap<String, ContentDigest> digests = new LinkedHashMap<>();
LinkedHashMap<DependencyTarget, DependencyBuilder> dependencyBuilders = new LinkedHashMap<>();
while (cursor.hasNext() && !isSection(cursor.peek())) {
Line line = cursor.next();
Field field = parseField(line, source);
switch (field.name) {
case "LICENSE" -> {
if (license != null) throw invalid(source, line, "Duplicate LICENSE field");
license = new FieldAtLine(field.value, line);
}
case "PUBLISHED_AT" -> {
if (publishedAt != null) throw invalid(source, line, "Duplicate PUBLISHED_AT field");
publishedAt = new FieldAtLine(field.value, line);
}
case "CONTENT_DIGEST" -> {
ContentDigest digest = parseContentDigest(field.value, line, source);
if (digests.putIfAbsent(digest.scheme(), digest) != null) {
throw invalid(source, line,
"Duplicate CONTENT_DIGEST scheme '" + digest.scheme() + "'");
}
}
case "CONTENT_SIZE" -> {
if (contentSize != null) throw invalid(source, line, "Duplicate CONTENT_SIZE field");
contentSize = new FieldAtLine(field.value, line);
}
case "DEPENDS_ON", "EXCLUDES", "PREFERRED" -> parseDependencyRule(
field, line, source, dependencyBuilders);
default -> throw invalid(source, line,
"Unknown or misplaced release field '" + field.name + "'");
}
}
if (license == null) throw invalid(source, header, "Release " + version + " is missing LICENSE");
if (publishedAt == null) throw invalid(source, header, "Release " + version + " is missing PUBLISHED_AT");
if (digests.isEmpty()) throw invalid(source, header, "Release " + version + " requires CONTENT_DIGEST");
if (contentSize == null) throw invalid(source, header, "Release " + version + " is missing CONTENT_SIZE");
String decodedLicense = parseQuoted(license.value, "LICENSE", license.line, source);
Instant publicationTime = parseJournalVersion(publishedAt.value, publishedAt.line, source).instant();
long size;
try {
size = Long.parseLong(contentSize.value);
if (size < 0) throw new NumberFormatException("negative");
} catch (NumberFormatException exception) {
throw invalid(source, contentSize.line,
"CONTENT_SIZE must be a non-negative long: '" + contentSize.value + "'", exception);
}
List<ArtifactDependency> dependencies = new ArrayList<>();
for (DependencyBuilder builder : dependencyBuilders.values()) {
if (builder.dependsOn == null) {
throw invalid(source, builder.firstLine,
builder.target + " has EXCLUDES or PREFERRED without DEPENDS_ON");
}
try {
dependencies.add(new ArtifactDependency(
builder.target, builder.dependsOn, builder.exclusions, builder.preferences));
} catch (IllegalArgumentException exception) {
throw invalid(source, builder.firstLine, exception.getMessage(), exception);
}
}
try {
return new ArtifactRelease(
version,
decodedLicense,
publicationTime,
List.copyOf(digests.values()),
size,
dependencies);
} catch (IllegalArgumentException exception) {
throw invalid(source, header, exception.getMessage(), exception);
}
}
private static void parseDependencyRule(
Field field,
Line line,
String source,
Map<DependencyTarget, DependencyBuilder> builders) {
int assignment = field.value.indexOf('=');
if (assignment <= 0 || assignment == field.value.length() - 1) {
throw invalid(source, line,
field.name + " must have the form <scheme>:<coordinate>=<version-expression-list>");
}
String targetText = field.value.substring(0, assignment).trim();
String ruleText = field.value.substring(assignment + 1).trim();
DependencyTarget target = parseDependencyTarget(targetText, line, source);
DependencyBuilder builder = builders.computeIfAbsent(
target, ignored -> new DependencyBuilder(target, line));
if ("DEPENDS_ON".equals(field.name)) {
if (builder.dependsOn != null) {
throw invalid(source, line, "Duplicate DEPENDS_ON for " + target);
}
builder.dependsOn = parseVersionExpressionSet(ruleText, line, source);
return;
}
PolicyRule rule = parseRule(ruleText, line, source, false);
if ("EXCLUDES".equals(field.name)) builder.exclusions.add(rule);
else builder.preferences.add(rule);
}
private static DependencyTarget parseDependencyTarget(String text, Line line, String source) {
int separator = text.indexOf(':');
if (separator <= 0 || separator == text.length() - 1) {
throw invalid(source, line,
"Dependency target must have the form <scheme>:<scheme-specific-coordinate>");
}
String scheme = text.substring(0, separator);
if (!DependencyTarget.NENJIM_SCHEME.equals(scheme)) {
throw invalid(source, line,
"Unsupported dependency scheme '" + scheme + "'; format version 1 supports only nenjim");
}
try {
return DependencyTarget.nenjim(ArtifactCoordinate.parse(text.substring(separator + 1)));
} catch (IllegalArgumentException exception) {
throw invalid(source, line, exception.getMessage(), exception);
}
}
private static PolicyRule parseRule(String text, Line line, String source, boolean exactOnly) {
DescribedValue described = splitDescription(text, line, source);
List<String> expressionTexts = splitExpressionList(described.value, line, source);
if (exactOnly && expressionTexts.size() != 1) {
throw invalid(source, line, "RECOMMENDED must identify exactly one exact release per line");
}
if (exactOnly && expressionTexts.getFirst().contains("-->")) {
throw invalid(source, line, "RECOMMENDED does not accept range syntax");
}
List<VersionExpression> expressions = new ArrayList<>();
for (String expressionText : expressionTexts) {
expressions.add(parseVersionExpression(expressionText, line, source));
}
VersionExpressionSet set = new VersionExpressionSet(expressions);
if (exactOnly && (set.expressions().size() != 1 || !set.expressions().getFirst().isExact())) {
throw invalid(source, line, "RECOMMENDED must identify one exact major.minor.patch release");
}
return new PolicyRule(set, described.description);
}
private static VersionExpressionSet parseVersionExpressionSet(String text, Line line, String source) {
List<VersionExpression> expressions = new ArrayList<>();
for (String expressionText : splitExpressionList(text, line, source)) {
expressions.add(parseVersionExpression(expressionText, line, source));
}
return new VersionExpressionSet(expressions);
}
private static List<String> splitExpressionList(String text, Line line, String source) {
if (text.isBlank()) throw invalid(source, line, "Version expression list cannot be empty");
String[] elements = text.split(",", -1);
List<String> result = new ArrayList<>(elements.length);
for (String element : elements) {
String trimmed = element.trim();
if (trimmed.isEmpty()) throw invalid(source, line, "Version expression list contains an empty element");
result.add(trimmed);
}
return result;
}
private static VersionExpression parseVersionExpression(String text, Line line, String source) {
if (EXACT_VERSION.matcher(text).matches()) {
return VersionExpression.exact(parseExactVersion(text, line, source));
}
if (text.startsWith("[") && text.endsWith("]") && !text.contains("-->")) {
Endpoint endpoint = parseEndpoint(text.substring(1, text.length() - 1), line, source);
if (endpoint.hasPatch()) {
return VersionExpression.exact(endpoint.toSemanticVersion());
}
return VersionExpression.minorSeries(endpoint.major, endpoint.minor);
}
if ((text.startsWith("[") || text.startsWith("(")) && text.contains("-->")) {
char lowerDelimiter = text.charAt(0);
int arrow = text.indexOf("-->");
if (text.indexOf("-->", arrow + 3) >= 0) {
throw invalid(source, line, "Version range contains more than one '-->' separator");
}
Endpoint lowerEndpoint = parseEndpoint(text.substring(1, arrow).trim(), line, source);
VersionExpression.Boundary lower = lowerEndpoint.boundary();
if (lowerDelimiter == '(') {
lower = lowerEndpoint.hasPatch() ? lower.nextPatch() : lower.nextMinor();
}
String upperText = text.substring(arrow + 3).trim();
VersionExpression.Boundary upper;
if (upperText.isEmpty()) {
upper = lowerEndpoint.boundary().nextMajor();
} else {
char upperDelimiter = upperText.charAt(upperText.length() - 1);
if (upperDelimiter != ']' && upperDelimiter != ')') {
throw invalid(source, line, "A bounded version range must end with ']' or ')'");
}
Endpoint upperEndpoint = parseEndpoint(
upperText.substring(0, upperText.length() - 1).trim(), line, source);
upper = upperEndpoint.boundary();
if (upperDelimiter == ']') {
upper = upperEndpoint.hasPatch() ? upper.nextPatch() : upper.nextMinor();
}
}
try {
return new VersionExpression(lower, upper);
} catch (IllegalArgumentException exception) {
throw invalid(source, line, exception.getMessage(), exception);
}
}
throw invalid(source, line,
"Invalid version expression '" + text
+ "'; use an exact major.minor.patch value or a Nenjim bracket range");
}
private static Endpoint parseEndpoint(String text, Line line, String source) {
Matcher matcher = VERSION_ENDPOINT.matcher(text);
if (!matcher.matches()) {
throw invalid(source, line,
"Version-expression endpoints require major.minor with no internal whitespace: '" + text + "'");
}
try {
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
Integer patch = matcher.group(3) == null ? null : Integer.parseInt(matcher.group(3));
return new Endpoint(major, minor, patch);
} catch (NumberFormatException exception) {
throw invalid(source, line, "Version element is outside the supported integer range: '" + text + "'", exception);
}
}
private static SemanticVersion parseExactVersion(String text, Line line, String source) {
if (!EXACT_VERSION.matcher(text).matches()) {
throw invalid(source, line,
"Artifact release version must contain exact major.minor.patch without prerelease or build metadata: '"
+ text + "'");
}
Endpoint endpoint = parseEndpoint(text, line, source);
return endpoint.toSemanticVersion();
}
private static DescribedValue splitDescription(String text, Line line, String source) {
int separator = text.indexOf(':');
if (separator < 0) return new DescribedValue(text.trim(), Optional.empty());
String value = text.substring(0, separator).trim();
if (value.isEmpty()) throw invalid(source, line, "Rule value cannot be empty");
String descriptionText = text.substring(separator + 1).trim();
String description = parseQuoted(descriptionText, "rule description", line, source);
return new DescribedValue(value, Optional.of(description));
}
private static String parseQuoted(String text, String fieldName, Line line, String source) {
String value = text.trim();
if (value.length() < 2 || value.charAt(0) != '\'' || value.charAt(value.length() - 1) != '\'') {
throw invalid(source, line, fieldName + " must be enclosed in single quotes");
}
StringBuilder decoded = new StringBuilder();
for (int index = 1; index < value.length() - 1; index++) {
char current = value.charAt(index);
if (current == '\\') {
if (index + 1 >= value.length() - 1) {
throw invalid(source, line, fieldName + " ends with an incomplete escape");
}
char escaped = value.charAt(++index);
if (escaped != '\'' && escaped != '\\') {
throw invalid(source, line,
fieldName + " supports only \\' and \\\\ escapes");
}
decoded.append(escaped);
} else if (current == '\'') {
throw invalid(source, line, fieldName + " contains an unescaped single quote");
} else {
decoded.append(current);
}
}
if (decoded.toString().isBlank()) {
throw invalid(source, line, fieldName + " cannot be empty or blank");
}
return decoded.toString();
}
private static ContentDigest parseContentDigest(String text, Line line, String source) {
try {
return ContentDigest.parse(text);
} catch (IllegalArgumentException exception) {
throw invalid(source, line, exception.getMessage(), exception);
}
}
private static JournalVersion parseJournalVersion(String text, Line line, String source) {
try {
return JournalVersion.parse(text);
} catch (IllegalArgumentException exception) {
throw invalid(source, line, exception.getMessage(), exception);
}
}
private static void validateRecommendations(
List<RuleAtLine> recommendedRules,
Map<SemanticVersion, ArtifactRelease> releases,
String source) {
for (RuleAtLine ruleAtLine : recommendedRules) {
SemanticVersion version = ruleAtLine.rule.versions()
.expressions().getFirst().exactVersion().orElseThrow();
if (!releases.containsKey(version)) {
throw invalid(source, ruleAtLine.line,
"RECOMMENDED release " + version + " does not exist in the same Journal snapshot");
}
}
}
private static Field parseField(Line line, String source) {
int separator = line.content.indexOf('=');
if (separator <= 0) {
throw invalid(source, line, "Expected KEY=value configuration entry");
}
String name = line.content.substring(0, separator).trim();
String value = line.content.substring(separator + 1).trim();
if (!FIELD_NAME.matcher(name).matches()) {
throw invalid(source, line, "Malformed field name '" + name + "'");
}
if (value.isEmpty()) {
throw invalid(source, line, "Field '" + name + "' cannot have an empty value");
}
return new Field(name, value);
}
private static void requirePlainValue(FieldAtLine field, String name, String source) {
if (field.value.isBlank()) throw invalid(source, field.line, name + " cannot be blank");
if (field.value.startsWith("'") || field.value.endsWith("'")) {
throw invalid(source, field.line, name + " must be an unquoted coordinate component");
}
}
private static FieldAtLine requireField(
Map<String, FieldAtLine> values,
String name,
Line header,
String source) {
FieldAtLine value = values.get(name);
if (value == null) throw invalid(source, header, "[METADATA] is missing required field '" + name + "'");
return value;
}
private static boolean isSection(Line line) {
return line.content.startsWith("[");
}
private static List<Line> tokenize(String text) {
String[] physicalLines = text.split("\\R", -1);
ArrayList<Line> lines = new ArrayList<>();
for (int index = 0; index < physicalLines.length; index++) {
String content = stripComment(physicalLines[index]).trim();
if (!content.isEmpty()) lines.add(new Line(index + 1, content));
}
return List.copyOf(lines);
}
private static String stripComment(String line) {
StringBuilder content = new StringBuilder();
boolean inQuote = false;
boolean escaped = false;
for (int index = 0; index < line.length(); index++) {
char current = line.charAt(index);
if (!inQuote && current == '#') break;
content.append(current);
if (inQuote && escaped) {
escaped = false;
} else if (inQuote && current == '\\') {
escaped = true;
} else if (current == '\'') {
inQuote = !inQuote;
}
}
return content.toString();
}
private static InvalidJournalException invalid(String source, Line line, String reason) {
return new InvalidJournalException(source, line.number, reason);
}
private static InvalidJournalException invalid(
String source,
Line line,
String reason,
Throwable cause) {
return new InvalidJournalException(source, line.number, reason, cause);
}
private record Line(int number, String content) {
}
private record Field(String name, String value) {
}
private record FieldAtLine(String value, Line line) {
}
private record DescribedValue(String value, Optional<String> description) {
}
private record RuleAtLine(PolicyRule rule, Line line) {
}
private record ParsedPolicy(JournalPolicy policy, List<RuleAtLine> recommendedRules) {
}
private record Endpoint(int major, int minor, Integer patch) {
boolean hasPatch() {
return patch != null;
}
VersionExpression.Boundary boundary() {
return new VersionExpression.Boundary(major, minor, patch == null ? 0 : patch);
}
SemanticVersion toSemanticVersion() {
if (patch == null) throw new IllegalStateException("Endpoint is not exact");
return new SemanticVersion(major, minor, patch);
}
}
private static final class DependencyBuilder {
private final DependencyTarget target;
private final Line firstLine;
private VersionExpressionSet dependsOn;
private final List<PolicyRule> exclusions = new ArrayList<>();
private final List<PolicyRule> preferences = new ArrayList<>();
private DependencyBuilder(DependencyTarget target, Line firstLine) {
this.target = Objects.requireNonNull(target, "target");
this.firstLine = Objects.requireNonNull(firstLine, "firstLine");
}
}
private static final class Cursor {
private final List<Line> lines;
private final String source;
private int index;
private Cursor(List<Line> lines, String source) {
this.lines = lines;
this.source = source;
}
boolean hasNext() {
return index < lines.size();
}
Line peek() {
return lines.get(index);
}
Line next() {
return lines.get(index++);
}
Line requireNext(String reason) {
if (!hasNext()) throw new InvalidJournalException(source, 0, reason);
return next();
}
}
}
@@ -0,0 +1,56 @@
package com.r35157.nenjim.service.journal.model;
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
import java.util.List;
import java.util.Objects;
/** The complete version contract for one release-to-artifact relationship. */
public record ArtifactDependency(
DependencyTarget target,
VersionExpressionSet dependsOn,
List<PolicyRule> exclusions,
List<PolicyRule> preferences) {
public ArtifactDependency {
Objects.requireNonNull(target, "target");
Objects.requireNonNull(dependsOn, "dependsOn");
if (dependsOn.isEmpty()) {
throw new IllegalArgumentException("DEPENDS_ON cannot be empty");
}
exclusions = copyRules(exclusions, "exclusions");
preferences = copyRules(preferences, "preferences");
VersionExpressionSet excluded = union(exclusions);
VersionExpressionSet preferred = union(preferences);
VersionExpressionSet permitted = dependsOn.subtract(excluded);
if (!permitted.containsAll(preferred)) {
throw new IllegalArgumentException(
"PREFERRED versions for " + target + " must be a subset of DEPENDS_ON minus EXCLUDES");
}
}
public VersionExpressionSet excludedVersions() {
return union(exclusions);
}
public VersionExpressionSet preferredVersions() {
return union(preferences);
}
public VersionExpressionSet permittedVersions() {
return dependsOn.subtract(excludedVersions());
}
private static List<PolicyRule> copyRules(List<PolicyRule> rules, String name) {
Objects.requireNonNull(rules, name);
rules.forEach(rule -> Objects.requireNonNull(rule, name + " rule"));
return List.copyOf(rules);
}
private static VersionExpressionSet union(List<PolicyRule> rules) {
VersionExpressionSet result = VersionExpressionSet.empty();
for (PolicyRule rule : rules) result = result.union(rule.versions());
return result;
}
}
@@ -0,0 +1,76 @@
package com.r35157.nenjim.service.journal.model;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
import com.r35157.nenjim.service.journal.valuetypes.DependencyTarget;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** All knowledge about one exact artifact release in one Journal snapshot. */
public record ArtifactRelease(
SemanticVersion version,
String license,
Instant publishedAt,
List<ContentDigest> contentDigests,
long contentSize,
List<ArtifactDependency> dependencies) {
public ArtifactRelease {
Objects.requireNonNull(version, "version");
Objects.requireNonNull(license, "license");
if (license.isBlank()) {
throw new IllegalArgumentException("Release license cannot be blank");
}
Objects.requireNonNull(publishedAt, "publishedAt");
if (publishedAt.getNano() % 1_000_000 != 0) {
throw new IllegalArgumentException("Release publication time must have millisecond precision");
}
if (contentSize < 0) {
throw new IllegalArgumentException("Release content size cannot be negative");
}
contentDigests = copyDigests(contentDigests);
dependencies = copyDependencies(dependencies);
}
public Optional<ContentDigest> contentDigest(String scheme) {
Objects.requireNonNull(scheme, "scheme");
return contentDigests.stream().filter(digest -> digest.scheme().equals(scheme)).findFirst();
}
public Optional<ArtifactDependency> dependency(DependencyTarget target) {
Objects.requireNonNull(target, "target");
return dependencies.stream().filter(dependency -> dependency.target().equals(target)).findFirst();
}
private static List<ContentDigest> copyDigests(List<ContentDigest> digests) {
Objects.requireNonNull(digests, "contentDigests");
if (digests.isEmpty()) {
throw new IllegalArgumentException("A release requires at least one content digest");
}
Set<String> schemes = new HashSet<>();
for (ContentDigest digest : digests) {
Objects.requireNonNull(digest, "content digest");
if (!schemes.add(digest.scheme())) {
throw new IllegalArgumentException("Duplicate content digest scheme: '" + digest.scheme() + "'");
}
}
return List.copyOf(digests);
}
private static List<ArtifactDependency> copyDependencies(List<ArtifactDependency> dependencies) {
Objects.requireNonNull(dependencies, "dependencies");
Set<DependencyTarget> targets = new HashSet<>();
for (ArtifactDependency dependency : dependencies) {
Objects.requireNonNull(dependency, "dependency");
if (!targets.add(dependency.target())) {
throw new IllegalArgumentException("Duplicate dependency target: '" + dependency.target() + "'");
}
}
return List.copyOf(dependencies);
}
}
@@ -0,0 +1,73 @@
package com.r35157.nenjim.service.journal.model;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import com.r35157.nenjim.service.journal.valuetypes.ContentDigest;
import com.r35157.nenjim.service.journal.valuetypes.JournalVersion;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** One immutable, complete Journal revision for exactly one artifact. */
public record Journal(
int formatVersion,
JournalVersion journalVersion,
Optional<ContentDigest> previousJournalDigest,
JournalMetadata metadata,
JournalPolicy policy,
Map<SemanticVersion, ArtifactRelease> releases) {
public Journal {
if (formatVersion != 1) {
throw new IllegalArgumentException("Unsupported Journal format version: " + formatVersion);
}
Objects.requireNonNull(journalVersion, "journalVersion");
previousJournalDigest = Objects.requireNonNull(previousJournalDigest, "previousJournalDigest");
previousJournalDigest.ifPresent(digest -> {
if (!ContentDigest.SHA256.equals(digest.scheme())) {
throw new IllegalArgumentException("Previous Journal digest must use SHA-256");
}
});
Objects.requireNonNull(metadata, "metadata");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(releases, "releases");
if (releases.isEmpty()) {
throw new IllegalArgumentException("A Journal requires at least one artifact release");
}
LinkedHashMap<SemanticVersion, ArtifactRelease> copy = new LinkedHashMap<>();
releases.forEach((version, release) -> {
Objects.requireNonNull(version, "release version");
Objects.requireNonNull(release, "release");
if (!version.equals(release.version())) {
throw new IllegalArgumentException("Release map key does not match release version: " + version);
}
if (copy.put(version, release) != null) {
throw new IllegalArgumentException("Duplicate release version: " + version);
}
});
validateRecommendations(policy, copy);
releases = Collections.unmodifiableMap(copy);
}
public Optional<ArtifactRelease> release(SemanticVersion version) {
return Optional.ofNullable(releases.get(Objects.requireNonNull(version, "version")));
}
private static void validateRecommendations(
JournalPolicy policy,
Map<SemanticVersion, ArtifactRelease> releases) {
for (PolicyRule recommendation : policy.recommended()) {
SemanticVersion version = recommendation.versions().expressions().getFirst()
.exactVersion()
.orElseThrow(() -> new IllegalArgumentException(
"RECOMMENDED must identify exactly one exact major.minor.patch release"));
if (!releases.containsKey(version)) {
throw new IllegalArgumentException(
"RECOMMENDED release " + version
+ " does not exist in the same Journal snapshot");
}
}
}
}
@@ -0,0 +1,27 @@
package com.r35157.nenjim.service.journal.model;
import com.r35157.nenjim.service.journal.valuetypes.ArtifactCoordinate;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
/** Artifact identity and human-facing metadata in one Journal snapshot. */
public record JournalMetadata(
ArtifactCoordinate coordinate,
String description,
Optional<URI> informationUri) {
public JournalMetadata {
Objects.requireNonNull(coordinate, "coordinate");
Objects.requireNonNull(description, "description");
if (description.isBlank()) {
throw new IllegalArgumentException("Journal description cannot be blank");
}
informationUri = Objects.requireNonNull(informationUri, "informationUri");
informationUri.ifPresent(uri -> {
if (!uri.isAbsolute()) {
throw new IllegalArgumentException("Information URI must be absolute: '" + uri + "'");
}
});
}
}
@@ -0,0 +1,37 @@
package com.r35157.nenjim.service.journal.model;
import java.util.List;
import java.util.Objects;
/** Artifact-wide hard rules and selection hints in one Journal snapshot. */
public record JournalPolicy(
List<PolicyRule> recommended,
List<PolicyRule> discouraged,
List<PolicyRule> blacklist) {
public JournalPolicy {
recommended = copyRules(recommended, "recommended");
discouraged = copyRules(discouraged, "discouraged");
blacklist = copyRules(blacklist, "blacklist");
validateRecommendations(recommended);
}
public static JournalPolicy empty() {
return new JournalPolicy(List.of(), List.of(), List.of());
}
private static List<PolicyRule> copyRules(List<PolicyRule> rules, String name) {
Objects.requireNonNull(rules, name);
rules.forEach(rule -> Objects.requireNonNull(rule, name + " rule"));
return List.copyOf(rules);
}
private static void validateRecommendations(List<PolicyRule> recommendations) {
for (PolicyRule recommendation : recommendations) {
if (recommendation.versions().expressions().size() != 1
|| !recommendation.versions().expressions().getFirst().isExact()) {
throw new IllegalArgumentException(
"RECOMMENDED must identify exactly one exact major.minor.patch release");
}
}
}
}
@@ -0,0 +1,26 @@
package com.r35157.nenjim.service.journal.model;
import com.r35157.nenjim.service.journal.valuetypes.VersionExpressionSet;
import java.util.Objects;
import java.util.Optional;
/** One inspectable policy, exclusion, or preference line and its description. */
public record PolicyRule(VersionExpressionSet versions, Optional<String> description) {
public PolicyRule {
Objects.requireNonNull(versions, "versions");
if (versions.isEmpty()) {
throw new IllegalArgumentException("A policy rule must identify at least one version");
}
description = Objects.requireNonNull(description, "description");
description.ifPresent(value -> {
if (value.isBlank()) {
throw new IllegalArgumentException("An explicitly supplied rule description cannot be blank");
}
});
}
public PolicyRule(VersionExpressionSet versions) {
this(versions, Optional.empty());
}
}
@@ -0,0 +1,39 @@
package com.r35157.nenjim.service.journal.valuetypes;
import java.util.Objects;
import java.util.regex.Pattern;
/** Identifies exactly one Nenjim artifact and its Journal chain. */
public record ArtifactCoordinate(String group, String module, String artifact) {
private static final Pattern COMPONENT = Pattern.compile("[A-Za-z0-9_.]+");
public ArtifactCoordinate {
group = validate("group", group);
module = validate("module", module);
artifact = validate("artifact", artifact);
}
public static ArtifactCoordinate parse(String value) {
Objects.requireNonNull(value, "value");
String[] components = value.split("-", -1);
if (components.length != 3) {
throw new IllegalArgumentException(
"Artifact coordinate must have the form <GROUP>-<MODULE>-<ARTIFACT>: '" + value + "'");
}
return new ArtifactCoordinate(components[0], components[1], components[2]);
}
@Override
public String toString() {
return group + "-" + module + "-" + artifact;
}
private static String validate(String name, String value) {
Objects.requireNonNull(value, name);
if (!COMPONENT.matcher(value).matches()) {
throw new IllegalArgumentException(
"Artifact " + name + " must contain only letters, digits, '_' and '.': '" + value + "'");
}
return value;
}
}
@@ -0,0 +1,140 @@
package com.r35157.nenjim.service.journal.valuetypes;
import org.apache.commons.codec.binary.Base32;
import java.util.Objects;
import java.util.regex.Pattern;
/** A validated content identity supported by Journal format version 1. */
public record ContentDigest(String scheme, String value) {
public static final String SHA256 = "sha256";
public static final String CID1 = "cid1";
private static final Pattern SHA256_VALUE = Pattern.compile("[0-9a-f]{64}");
private static final Pattern CID1_VALUE = Pattern.compile("b[a-z2-7]+");
private static final String BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
public ContentDigest {
Objects.requireNonNull(scheme, "scheme");
Objects.requireNonNull(value, "value");
switch (scheme) {
case SHA256 -> {
if (!SHA256_VALUE.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid SHA-256 digest: '" + value + "'");
}
}
case CID1 -> validateCidV1(value);
default -> throw new IllegalArgumentException("Unsupported content digest scheme: '" + scheme + "'");
}
}
public static ContentDigest parse(String text) {
Objects.requireNonNull(text, "text");
int separator = text.indexOf(':');
if (separator <= 0 || separator == text.length() - 1 || text.indexOf(':', separator + 1) >= 0) {
throw new IllegalArgumentException("Content digest must have the form <scheme>:<value>: '" + text + "'");
}
return new ContentDigest(text.substring(0, separator), text.substring(separator + 1));
}
public static ContentDigest sha256(String hexadecimalDigest) {
return new ContentDigest(SHA256, hexadecimalDigest);
}
@Override
public String toString() {
return scheme + ":" + value;
}
private static void validateCidV1(String value) {
if (!CID1_VALUE.matcher(value).matches()) {
throw new IllegalArgumentException("Invalid CIDv1 base32 value: '" + value + "'");
}
byte[] decoded;
try {
decoded = new Base32().decode(value.substring(1));
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Invalid CIDv1 base32 value: '" + value + "'", exception);
}
if (decoded.length == 0) {
throw new IllegalArgumentException("Invalid empty CIDv1 value");
}
String encoded = encodeCanonicalBase32(decoded);
if (!value.substring(1).equals(encoded)) {
throw new IllegalArgumentException("CIDv1 must use canonical unpadded lowercase Base32: '" + value + "'");
}
VarInt version = readVarInt(decoded, 0, "CID version");
if (version.value != 1) {
throw new IllegalArgumentException("CID must encode version 1: '" + value + "'");
}
VarInt codec = readVarInt(decoded, version.nextOffset, "CID multicodec");
if (codec.value <= 0) {
throw new IllegalArgumentException("CIDv1 multicodec must be positive: '" + value + "'");
}
VarInt hashCode = readVarInt(decoded, codec.nextOffset, "CID multihash code");
if (hashCode.value <= 0) {
throw new IllegalArgumentException("CIDv1 multihash code must be positive: '" + value + "'");
}
VarInt hashLength = readVarInt(decoded, hashCode.nextOffset, "CID multihash length");
long remaining = decoded.length - hashLength.nextOffset;
if (hashLength.value <= 0 || hashLength.value != remaining) {
throw new IllegalArgumentException("CIDv1 multihash length does not match its digest: '" + value + "'");
}
}
private static String encodeCanonicalBase32(byte[] bytes) {
StringBuilder encoded = new StringBuilder((bytes.length * Byte.SIZE + 4) / 5);
int buffer = 0;
int bufferedBits = 0;
for (byte current : bytes) {
buffer = (buffer << Byte.SIZE) | Byte.toUnsignedInt(current);
bufferedBits += Byte.SIZE;
while (bufferedBits >= 5) {
bufferedBits -= 5;
encoded.append(BASE32_ALPHABET.charAt((buffer >>> bufferedBits) & 0x1f));
}
buffer = bufferedBits == 0 ? 0 : buffer & ((1 << bufferedBits) - 1);
}
if (bufferedBits > 0) {
encoded.append(BASE32_ALPHABET.charAt((buffer << (5 - bufferedBits)) & 0x1f));
}
return encoded.toString();
}
private static VarInt readVarInt(byte[] bytes, int offset, String label) {
long value = 0;
for (int index = offset; index < bytes.length; index++) {
int encodedBytes = index - offset;
if (encodedBytes >= 9) {
throw new IllegalArgumentException(label + " varint overflows the supported long range");
}
int current = Byte.toUnsignedInt(bytes[index]);
int shift = encodedBytes * 7;
int payload = current & 0x7f;
if (payload > (Long.MAX_VALUE >>> shift)) {
throw new IllegalArgumentException(label + " varint overflows the supported long range");
}
value |= (long) payload << shift;
if ((current & 0x80) == 0) {
if (encodedBytes + 1 != minimalVarIntLength(value)) {
throw new IllegalArgumentException(label + " uses a non-minimal varint encoding");
}
return new VarInt(value, index + 1);
}
}
throw new IllegalArgumentException(label + " contains an unterminated varint");
}
private static int minimalVarIntLength(long value) {
if (value == 0) {
return 1;
}
int significantBits = Long.SIZE - Long.numberOfLeadingZeros(value);
return (significantBits + 6) / 7;
}
private record VarInt(long value, int nextOffset) {
}
}
@@ -0,0 +1,25 @@
package com.r35157.nenjim.service.journal.valuetypes;
import java.util.Objects;
/** A scheme-qualified dependency target supported by Journal format version 1. */
public record DependencyTarget(String scheme, ArtifactCoordinate coordinate) {
public static final String NENJIM_SCHEME = "nenjim";
public DependencyTarget {
Objects.requireNonNull(scheme, "scheme");
Objects.requireNonNull(coordinate, "coordinate");
if (!NENJIM_SCHEME.equals(scheme)) {
throw new IllegalArgumentException("Unsupported dependency scheme: '" + scheme + "'");
}
}
public static DependencyTarget nenjim(ArtifactCoordinate coordinate) {
return new DependencyTarget(NENJIM_SCHEME, coordinate);
}
@Override
public String toString() {
return scheme + ":" + coordinate;
}
}
@@ -0,0 +1,48 @@
package com.r35157.nenjim.service.journal.valuetypes;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Locale;
import java.util.Objects;
/** UTC publication time of a complete Journal worldview. */
public record JournalVersion(Instant instant) implements Comparable<JournalVersion> {
private static final DateTimeFormatter FORMATTER = new DateTimeFormatterBuilder()
.appendPattern("uuuuMMddHHmmssSSS")
.appendLiteral('Z')
.toFormatter(Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
public JournalVersion {
Objects.requireNonNull(instant, "instant");
if (instant.getNano() % 1_000_000 != 0) {
throw new IllegalArgumentException("Journal version must have millisecond precision");
}
}
public static JournalVersion parse(String value) {
Objects.requireNonNull(value, "value");
try {
LocalDateTime timestamp = LocalDateTime.parse(value, FORMATTER);
return new JournalVersion(timestamp.toInstant(ZoneOffset.UTC));
} catch (DateTimeParseException exception) {
throw new IllegalArgumentException(
"Expected UTC timestamp in uuuuMMddHHmmssSSS'Z' format: '" + value + "'", exception);
}
}
@Override
public int compareTo(JournalVersion other) {
return instant.compareTo(Objects.requireNonNull(other, "other").instant);
}
@Override
public String toString() {
return FORMATTER.format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC));
}
}
@@ -0,0 +1,131 @@
package com.r35157.nenjim.service.journal.valuetypes;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import java.util.Objects;
import java.util.Optional;
/** A normalized, lower-inclusive and upper-exclusive set of stable releases. */
public final class VersionExpression {
private final Boundary lowerInclusive;
private final Boundary upperExclusive;
public VersionExpression(Boundary lowerInclusive, Boundary upperExclusive) {
this.lowerInclusive = Objects.requireNonNull(lowerInclusive, "lowerInclusive");
this.upperExclusive = Objects.requireNonNull(upperExclusive, "upperExclusive");
if (lowerInclusive.compareTo(upperExclusive) >= 0) {
throw new IllegalArgumentException("Version expression must not be empty or reversed");
}
}
public static VersionExpression exact(SemanticVersion version) {
Boundary lower = Boundary.from(Objects.requireNonNull(version, "version"));
return new VersionExpression(lower, lower.nextPatch());
}
public static VersionExpression minorSeries(int major, int minor) {
if (major < 0 || minor < 0) {
throw new IllegalArgumentException("Version elements cannot be negative");
}
Boundary lower = new Boundary(major, minor, 0);
return new VersionExpression(lower, lower.nextMinor());
}
public Boundary lowerInclusive() {
return lowerInclusive;
}
public Boundary upperExclusive() {
return upperExclusive;
}
public boolean contains(SemanticVersion version) {
Boundary candidate = Boundary.from(Objects.requireNonNull(version, "version"));
return lowerInclusive.compareTo(candidate) <= 0 && candidate.compareTo(upperExclusive) < 0;
}
public boolean isExact() {
return upperExclusive.equals(lowerInclusive.nextPatch())
&& lowerInclusive.major <= Integer.MAX_VALUE
&& lowerInclusive.minor <= Integer.MAX_VALUE
&& lowerInclusive.patch <= Integer.MAX_VALUE;
}
public Optional<SemanticVersion> exactVersion() {
if (!isExact()) {
return Optional.empty();
}
return Optional.of(new SemanticVersion(
(int) lowerInclusive.major,
(int) lowerInclusive.minor,
(int) lowerInclusive.patch));
}
VersionExpression merge(VersionExpression other) {
if (upperExclusive.compareTo(other.lowerInclusive) < 0
|| other.upperExclusive.compareTo(lowerInclusive) < 0) {
throw new IllegalArgumentException("Cannot merge disjoint version expressions");
}
Boundary lower = lowerInclusive.compareTo(other.lowerInclusive) <= 0
? lowerInclusive : other.lowerInclusive;
Boundary upper = upperExclusive.compareTo(other.upperExclusive) >= 0
? upperExclusive : other.upperExclusive;
return new VersionExpression(lower, upper);
}
@Override
public boolean equals(Object other) {
return this == other || other instanceof VersionExpression expression
&& lowerInclusive.equals(expression.lowerInclusive)
&& upperExclusive.equals(expression.upperExclusive);
}
@Override
public int hashCode() {
return Objects.hash(lowerInclusive, upperExclusive);
}
@Override
public String toString() {
return "[" + lowerInclusive + "-->" + upperExclusive + ")";
}
/** A fully expanded stable-version boundary used by normalized expressions. */
public record Boundary(long major, long minor, long patch) implements Comparable<Boundary> {
public Boundary {
if (major < 0 || minor < 0 || patch < 0) {
throw new IllegalArgumentException("Version boundary elements cannot be negative");
}
}
public static Boundary from(SemanticVersion version) {
Objects.requireNonNull(version, "version");
return new Boundary(version.major(), version.minor(), version.patch());
}
public Boundary nextPatch() {
return new Boundary(major, minor, Math.addExact(patch, 1));
}
public Boundary nextMinor() {
return new Boundary(major, Math.addExact(minor, 1), 0);
}
public Boundary nextMajor() {
return new Boundary(Math.addExact(major, 1), 0, 0);
}
@Override
public int compareTo(Boundary other) {
int majorComparison = Long.compare(major, other.major);
if (majorComparison != 0) return majorComparison;
int minorComparison = Long.compare(minor, other.minor);
return minorComparison != 0 ? minorComparison : Long.compare(patch, other.patch);
}
@Override
public String toString() {
return major + "." + minor + "." + patch;
}
}
}
@@ -0,0 +1,127 @@
package com.r35157.nenjim.service.journal.valuetypes;
import com.r35157.libs.valuetypes.basic.SemanticVersion;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
/** An immutable normalized union of version expressions. */
public final class VersionExpressionSet {
private final List<VersionExpression> expressions;
public VersionExpressionSet(Collection<VersionExpression> expressions) {
Objects.requireNonNull(expressions, "expressions");
ArrayList<VersionExpression> sorted = new ArrayList<>(expressions.size());
for (VersionExpression expression : expressions) {
sorted.add(Objects.requireNonNull(expression, "expression"));
}
sorted.sort(Comparator.comparing(VersionExpression::lowerInclusive));
ArrayList<VersionExpression> normalized = new ArrayList<>();
for (VersionExpression expression : sorted) {
if (normalized.isEmpty()) {
normalized.add(expression);
continue;
}
VersionExpression current = normalized.getLast();
if (current.upperExclusive().compareTo(expression.lowerInclusive()) >= 0) {
normalized.set(normalized.size() - 1, current.merge(expression));
} else {
normalized.add(expression);
}
}
this.expressions = List.copyOf(normalized);
}
public static VersionExpressionSet empty() {
return new VersionExpressionSet(List.of());
}
public static VersionExpressionSet of(VersionExpression... expressions) {
return new VersionExpressionSet(List.of(expressions));
}
public List<VersionExpression> expressions() {
return expressions;
}
public boolean isEmpty() {
return expressions.isEmpty();
}
public boolean contains(SemanticVersion version) {
return expressions.stream().anyMatch(expression -> expression.contains(version));
}
public VersionExpressionSet union(VersionExpressionSet other) {
Objects.requireNonNull(other, "other");
ArrayList<VersionExpression> combined = new ArrayList<>(expressions);
combined.addAll(other.expressions);
return new VersionExpressionSet(combined);
}
public VersionExpressionSet subtract(VersionExpressionSet exclusions) {
Objects.requireNonNull(exclusions, "exclusions");
ArrayList<VersionExpression> remaining = new ArrayList<>();
for (VersionExpression included : expressions) {
VersionExpression.Boundary cursor = included.lowerInclusive();
for (VersionExpression excluded : exclusions.expressions) {
if (excluded.upperExclusive().compareTo(cursor) <= 0) continue;
if (excluded.lowerInclusive().compareTo(included.upperExclusive()) >= 0) break;
if (cursor.compareTo(excluded.lowerInclusive()) < 0) {
VersionExpression.Boundary fragmentUpper = minimum(
excluded.lowerInclusive(), included.upperExclusive());
if (cursor.compareTo(fragmentUpper) < 0) {
remaining.add(new VersionExpression(cursor, fragmentUpper));
}
}
if (excluded.upperExclusive().compareTo(cursor) > 0) {
cursor = maximum(cursor, excluded.upperExclusive());
}
if (cursor.compareTo(included.upperExclusive()) >= 0) break;
}
if (cursor.compareTo(included.upperExclusive()) < 0) {
remaining.add(new VersionExpression(cursor, included.upperExclusive()));
}
}
return new VersionExpressionSet(remaining);
}
public boolean containsAll(VersionExpressionSet candidate) {
Objects.requireNonNull(candidate, "candidate");
return candidate.subtract(this).isEmpty();
}
@Override
public boolean equals(Object other) {
return this == other || other instanceof VersionExpressionSet set
&& expressions.equals(set.expressions);
}
@Override
public int hashCode() {
return expressions.hashCode();
}
@Override
public String toString() {
return expressions.toString();
}
private static VersionExpression.Boundary minimum(
VersionExpression.Boundary left,
VersionExpression.Boundary right) {
return left.compareTo(right) <= 0 ? left : right;
}
private static VersionExpression.Boundary maximum(
VersionExpression.Boundary left,
VersionExpression.Boundary right) {
return left.compareTo(right) >= 0 ? left : right;
}
}
@@ -1,12 +0,0 @@
package com.r35157.nenjim.valuetypes.journal;
import org.jetbrains.annotations.NotNull;
//import com.r35157.libs.valuetypes.basic.Id;
public record JournalId(
@NotNull String value
) { //implements Id {
public static JournalId of(String journalId) {
return new JournalId(journalId);
}
}