75: Implement the Nenjim Journal model, text parser, manager and filesystem service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user