AiCacheStore.java
package org.egothor.methodatlas;
import java.io.BufferedWriter;
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.Collection;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
/**
* Reads and writes the unified AI result cache as a JSON Lines file: one
* {@link AiCacheEntry} per line.
*
* <p>
* JSON Lines is chosen over a single JSON array so the file streams, appends
* cleanly, and a single corrupt line degrades to one lost cache entry rather than
* an unreadable file. The format is independent of the stable per-method scan CSV
* (which cannot carry credential verdicts or a prompt signature) and of the
* separate credential CSV; it is the sole carrier of cache state.
* </p>
*
* <p>
* This class is a stateless, thread-safe utility holder.
* </p>
*
* @since 4.1.0
*/
public final class AiCacheStore {
private static final Logger LOG = Logger.getLogger(AiCacheStore.class.getName());
/** Shared, thread-safe JSON mapper (immutable after build). */
private static final JsonMapper MAPPER = JsonMapper.builder().build();
private AiCacheStore() {
// utility class
}
/**
* Returns {@code true} if {@code file} appears to be a unified JSON-Lines cache
* rather than a legacy scan CSV, by inspecting the first non-whitespace byte.
*
* @param file path to inspect; never {@code null}
* @return {@code true} when the first non-blank character is an opening brace
* @throws IOException if the file cannot be read
*/
public static boolean looksLikeJsonLines(Path file) throws IOException {
for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String trimmed = line.strip();
if (!trimmed.isEmpty()) {
return trimmed.charAt(0) == '{';
}
}
return false;
}
/**
* Reads all cache entries from a JSON-Lines file. Blank lines are ignored and a
* single unparseable line is skipped (logged at {@code FINE}) so a partially
* corrupt cache never aborts a scan.
*
* @param file path to the cache file; never {@code null}
* @return the parsed entries in file order; never {@code null}; may be empty
* @throws IOException if the file cannot be read
*/
public static List<AiCacheEntry> read(Path file) throws IOException {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
List<AiCacheEntry> entries = new ArrayList<>(lines.size());
for (String line : lines) {
String trimmed = line.strip();
if (trimmed.isEmpty()) {
continue;
}
try {
entries.add(MAPPER.readValue(trimmed, AiCacheEntry.class));
} catch (JacksonException e) {
LOG.log(Level.FINE, e, () -> "Skipping unparseable AI cache line");
}
}
return entries;
}
/**
* Writes cache entries to a JSON-Lines file, one entry per line, overwriting any
* existing file. Parent directories are created if absent.
*
* @param file destination path; never {@code null}
* @param entries entries to write; never {@code null}
* @throws IOException if the file cannot be written
*/
public static void write(Path file, Collection<AiCacheEntry> entries) throws IOException {
Path parent = file.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
for (AiCacheEntry entry : entries) {
writer.write(MAPPER.writeValueAsString(entry));
writer.write('\n');
}
}
}
}