CandidateStore.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.mining.core.candidate;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* Persistent candidate store. Candidate JSON is the authoritative record for an
* unreviewed proposal; reports and workflow issues are derived views.
*/
public class CandidateStore {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path storeDir;
public CandidateStore(Path storeDir) {
this.storeDir = storeDir;
}
/**
* Saves a candidate atomically. Corrected semantic content for the same stable
* candidate ID starts a new revision at {@link CandidateStatus#DISCOVERED},
* clears stale verification/rejection state, and therefore requires all gates
* and human review to run again. Status-only updates do not increment the
* revision. Promoted provenance is immutable. Legacy filenames for the same
* proposal are removed after a successful write.
*/
public void save(MiningCandidate candidate) throws IOException {
if (candidate == null) {
throw new IllegalArgumentException("candidate must not be null"); //$NON-NLS-1$
}
Files.createDirectories(storeDir);
if (candidate.getSchemaVersion() < 2) {
candidate.setSchemaVersion(2);
}
candidate.setRevision(candidate.getRevision());
Path target = storeDir.resolve(candidate.toFileName());
Optional<MiningCandidate> existing = load(target);
if (existing.isPresent()) {
MiningCandidate previous = existing.get();
boolean contentChanged = semanticContentChanged(previous, candidate);
if (contentChanged) {
startNewRevision(previous, candidate);
} else if (candidate.getRevision() < previous.getRevision()) {
candidate.setRevision(previous.getRevision());
}
}
Path tmpFile = storeDir.resolve(candidate.toFileName() + ".tmp"); //$NON-NLS-1$
Files.writeString(tmpFile, GSON.toJson(candidate) + System.lineSeparator(),
StandardCharsets.UTF_8);
try {
Files.move(tmpFile, target, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmpFile, target, StandardCopyOption.REPLACE_EXISTING);
}
deleteLegacyAliases(candidate, target);
}
/** Loads all candidate files in deterministic filename order. */
public List<MiningCandidate> loadAll() throws IOException {
if (!Files.exists(storeDir)) {
return List.of();
}
List<MiningCandidate> candidates = new ArrayList<>();
try (Stream<Path> paths = Files.list(storeDir)) {
paths.filter(CandidateStore::isCandidateFile)
.sorted()
.forEach(path -> load(path).ifPresent(candidates::add));
}
return candidates;
}
/** Loads candidates filtered by lifecycle status. */
public List<MiningCandidate> loadByStatus(CandidateStatus status) throws IOException {
if (status == null) {
return List.of();
}
return loadAll().stream().filter(candidate -> status == candidate.getStatus()).toList();
}
/** Loads a candidate by stable candidate ID. */
public Optional<MiningCandidate> findById(String candidateId) {
if (candidateId == null || candidateId.isBlank()) {
return Optional.empty();
}
return load(storeDir.resolve(candidateId + "-candidate.json")); //$NON-NLS-1$
}
/** Returns whether the same candidate revision content is already stored. */
public boolean containsCandidate(MiningCandidate candidate) {
if (candidate == null) {
return false;
}
Optional<MiningCandidate> stored = findById(candidate.getCandidateId());
return stored.filter(existing -> !semanticContentChanged(existing, candidate)).isPresent();
}
public Path getStoreDir() {
return storeDir;
}
private static boolean semanticContentChanged(MiningCandidate previous,
MiningCandidate candidate) {
return !previous.getRuleFingerprint().equals(candidate.getRuleFingerprint())
|| !previous.getBehaviorFingerprint().equals(candidate.getBehaviorFingerprint())
|| !Objects.equals(previous.getSourceVersion(), candidate.getSourceVersion());
}
private static void startNewRevision(MiningCandidate previous,
MiningCandidate candidate) {
if (previous.getStatus() == CandidateStatus.PROMOTED) {
throw new IllegalStateException(
"Promoted candidate content is immutable; create a new candidate origin"); //$NON-NLS-1$
}
int newRevision = Math.max(previous.getRevision() + 1, candidate.getRevision());
List<CandidateTransition> history = new ArrayList<>(previous.getTransitions());
history.add(new CandidateTransition(
previous.getStatus(), CandidateStatus.DISCOVERED,
"CandidateStore", //$NON-NLS-1$
"Semantic content changed; revision " + newRevision //$NON-NLS-1$
+ " requires deterministic verification and review", //$NON-NLS-1$
Instant.now().toString()));
candidate.setCandidateId(previous.getCandidateId());
candidate.setRevision(newRevision);
candidate.setStatus(CandidateStatus.DISCOVERED);
candidate.setVerification(null);
candidate.setRejectionReason(null);
candidate.setTransitions(history);
}
private void deleteLegacyAliases(MiningCandidate candidate, Path canonicalPath)
throws IOException {
try (Stream<Path> paths = Files.list(storeDir)) {
List<Path> aliases = paths.filter(CandidateStore::isCandidateFile)
.filter(path -> !path.equals(canonicalPath))
.filter(path -> load(path)
.filter(existing -> existing.getSchemaVersion() < 2)
.filter(existing -> sameProposal(existing, candidate))
.isPresent())
.toList();
for (Path alias : aliases) {
Files.deleteIfExists(alias);
}
}
}
private static boolean sameProposal(MiningCandidate first, MiningCandidate second) {
return Objects.equals(first.getSourceRepo(), second.getSourceRepo())
&& Objects.equals(first.getSourceCommit(), second.getSourceCommit())
&& Objects.equals(first.getCategory(), second.getCategory())
&& Objects.equals(first.getTargetHintFile(), second.getTargetHintFile())
&& first.getRuleFingerprint().equals(second.getRuleFingerprint())
&& first.getBehaviorFingerprint().equals(second.getBehaviorFingerprint());
}
private Optional<MiningCandidate> load(Path path) {
if (!Files.isRegularFile(path)) {
return Optional.empty();
}
try {
String json = Files.readString(path, StandardCharsets.UTF_8);
JsonObject object = JsonParser.parseString(json).getAsJsonObject();
MiningCandidate candidate = GSON.fromJson(object, MiningCandidate.class);
if (candidate == null) {
return Optional.empty();
}
if (!object.has("schemaVersion")) { //$NON-NLS-1$
candidate.setSchemaVersion(1);
}
if (!object.has("revision")) { //$NON-NLS-1$
candidate.setRevision(1);
}
if (!object.has("candidateId")) { //$NON-NLS-1$
candidate.setCandidateId(null);
}
if (!object.has("sourceVersion")) { //$NON-NLS-1$
candidate.setSourceVersion(null);
}
return Optional.of(candidate);
} catch (Exception e) {
System.err.println("Warning: could not load candidate " + path + ": " + e.getMessage()); //$NON-NLS-1$ //$NON-NLS-2$
return Optional.empty();
}
}
private static boolean isCandidateFile(Path path) {
Path fileName = path.getFileName();
return Files.isRegularFile(path)
&& fileName != null
&& fileName.toString().endsWith("-candidate.json"); //$NON-NLS-1$
}
}