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.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Persistent store for {@link MiningCandidate} objects in the
* {@code mining-candidates/} directory.
*
* <p>Each candidate is stored as an individual JSON file named after a stable
* candidate ID (SHA-256 derived from repo + commit + category + hint target + DSL).
* This allows
* individual candidates to be reviewed, promoted, or rejected without
* touching productive bundled hint files.</p>
*
* <p>Usage example:</p>
* <pre>
* CandidateStore store = new CandidateStore(Path.of("mining-candidates"));
* store.save(candidate);
* List<MiningCandidate> all = store.loadAll();
* </pre>
*/
public class CandidateStore {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final Path storeDir;
/**
* Creates a new candidate store for the given directory.
* The directory will be created if it does not exist.
*
* @param storeDir the directory to store candidate JSON files in
*/
public CandidateStore(Path storeDir) {
this.storeDir = storeDir;
}
/**
* Saves a candidate to its JSON file. If a file for the same commit
* hash already exists, it is overwritten atomically.
*
* @param candidate the candidate to save
* @throws IOException if the file cannot be written
*/
public void save(MiningCandidate candidate) throws IOException {
Files.createDirectories(storeDir);
String fileName = candidate.toFileName();
Path target = storeDir.resolve(fileName);
Path tmpFile = storeDir.resolve(fileName + ".tmp"); //$NON-NLS-1$
String json = GSON.toJson(candidate);
Files.writeString(tmpFile, json, StandardCharsets.UTF_8);
Files.move(tmpFile, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
/**
* Loads all candidate JSON files from the store directory.
* Files that cannot be parsed are silently skipped with a warning.
*
* @return list of all loaded candidates
* @throws IOException if the directory cannot be listed
*/
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(p -> p.getFileName().toString().endsWith("-candidate.json")) //$NON-NLS-1$
.sorted()
.forEach(p -> {
try {
String json = Files.readString(p, StandardCharsets.UTF_8);
MiningCandidate candidate = GSON.fromJson(json, MiningCandidate.class);
if (candidate != null) {
candidates.add(candidate);
}
} catch (Exception e) {
System.err.println("Warning: could not load candidate " + p + ": " + e.getMessage()); //$NON-NLS-1$
}
});
}
return candidates;
}
/**
* Loads candidates filtered by status.
*
* @param status the status to filter by
* @return list of candidates with the given status
* @throws IOException if the directory cannot be listed
*/
public List<MiningCandidate> loadByStatus(CandidateStatus status) throws IOException {
List<MiningCandidate> all = loadAll();
List<MiningCandidate> filtered = new ArrayList<>();
for (MiningCandidate c : all) {
if (status.equals(c.getStatus())) {
filtered.add(c);
}
}
return filtered;
}
/**
* Returns whether this exact candidate already exists in the store.
*
* @param candidate the candidate to check
* @return {@code true} if a candidate file exists for this candidate ID
*/
public boolean containsCandidate(MiningCandidate candidate) {
if (candidate == null) {
return false;
}
return Files.exists(storeDir.resolve(candidate.toFileName()));
}
/**
* Returns the store directory path.
*
* @return the store directory
*/
public Path getStoreDir() {
return storeDir;
}
}