ManualPrepareEngine.java
package org.egothor.methodatlas.ai;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Handles the prepare phase of the manual AI workflow.
*
* <p>
* This engine supports operators who cannot use an automated AI API but can
* interact with an AI through a standard chat window. For each test class it
* writes a <em>work file</em> to the configured work directory. Each work file
* contains:
* </p>
*
* <ul>
* <li>human-readable operator instructions</li>
* <li>the complete AI prompt (taxonomy, method list, class source) that the
* operator should paste into their AI chat window</li>
* </ul>
*
* <p>
* After the AI responds the operator pastes the response text into the
* pre-created {@code <fqcn>.response.txt} stub and then runs the consume phase
* (via {@link ManualConsumeEngine}) to produce the final enriched CSV.
* </p>
*
* <h2>File naming</h2>
*
* <p>
* Work files are named {@code <fqcn>.txt} and written to the work directory.
* Empty response stubs are named {@code <fqcn>.response.txt} and written to
* the response directory. Both directories are flat (no sub-directory
* structure). The two directories may be the same path.
* </p>
*
* @see ManualConsumeEngine
* @see PromptBuilder
*/
public final class ManualPrepareEngine {
private static final String SEPARATOR = "=".repeat(80);
private final Path workDir;
private final Path responseDir;
private final String taxonomyText;
private final boolean confidence;
/**
* Creates a new prepare engine that writes work files and response stubs to
* separate directories.
*
* <p>
* Both directories are created if they do not already exist. The two paths may
* point to the same directory.
* </p>
*
* @param workDir path to the directory where work files ({@code <fqcn>.txt})
* will be written
* @param responseDir path to the directory where empty response stubs
* ({@code <fqcn>.response.txt}) will be pre-created
* @param options AI options used to load the taxonomy text; only taxonomy
* settings are relevant here — provider settings are ignored
* @throws AiSuggestionException if either directory cannot be created or the
* configured taxonomy file cannot be read
*/
public ManualPrepareEngine(Path workDir, Path responseDir, AiOptions options) throws AiSuggestionException {
this.workDir = workDir;
this.responseDir = responseDir;
this.taxonomyText = loadTaxonomy(options);
this.confidence = options.confidence();
try {
Files.createDirectories(workDir);
} catch (IOException e) {
throw new AiSuggestionException("Cannot create work directory: " + workDir, e);
}
if (!responseDir.equals(workDir)) {
try {
Files.createDirectories(responseDir);
} catch (IOException e) {
throw new AiSuggestionException("Cannot create response directory: " + responseDir, e);
}
}
}
/**
* Builds and writes the work file for the specified test class, and
* pre-creates an empty response file alongside it.
*
* <p>
* The work file contains operator instructions at the top followed by the full
* AI prompt. The prompt is built using {@link PromptBuilder#build} and embeds
* the complete class source so the operator can paste the entire block into
* their AI chat window without attaching any files separately.
* </p>
*
* <p>
* File names are derived from {@code fileStem} (a dot-separated path identifier
* based on the source file's location relative to the scan root) rather than the
* FQCN. This ensures uniqueness in multi-module projects where the same FQCN
* may appear in multiple modules. For a standard Maven source root
* (e.g. {@code src/test/java}), the stem is identical to the FQCN.
* </p>
*
* <p>
* An empty {@code <fileStem>.response.txt} file is also written to the response
* directory so the operator only needs to paste the AI response into the
* pre-existing file rather than creating it manually. If the response file
* already contains content (e.g. from a previous run) it is left untouched.
* </p>
*
* @param fileStem dot-separated path stem used as the base name for the
* work file and response stub; derived from the source
* file path relative to the scan root
* @param fqcn fully qualified class name of the test class; used in
* the work file content and AI prompt
* @param classSource complete source code of the test class
* @param targetMethods deterministically discovered JUnit test methods to
* classify
* @return path of the written work file
* @throws AiSuggestionException if the work file or the empty response file
* cannot be written
*/
public Path prepare(String fileStem, String fqcn, String classSource,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, confidence);
Path outputFile = workDir.resolve(fileStem + ".txt");
String content = buildFileContent(fileStem, fqcn, prompt);
try {
Files.writeString(outputFile, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new AiSuggestionException("Failed to write work file: " + outputFile, e);
}
Path responseFile = responseDir.resolve(fileStem + ".response.txt");
if (!Files.exists(responseFile)) {
try {
Files.writeString(responseFile, "", StandardCharsets.UTF_8);
} catch (IOException e) {
throw new AiSuggestionException("Failed to create response file: " + responseFile, e);
}
}
return outputFile;
}
private static String buildFileContent(String fileStem, String fqcn, String prompt) {
String responseFileName = fileStem + ".response.txt";
return SEPARATOR + "\n"
+ "OPERATOR INSTRUCTIONS\n"
+ SEPARATOR + "\n"
+ "Class : " + fqcn + "\n"
+ "Work file : " + fileStem + ".txt\n"
+ "Response : " + responseFileName + "\n"
+ "\n"
+ "Steps:\n"
+ " 1. Copy the AI PROMPT block below (between the BEGIN/END markers)\n"
+ " into your AI chat window.\n"
+ " 2. Wait for the AI to respond.\n"
+ " 3. Paste the complete AI response into the pre-created stub file:\n"
+ " " + responseFileName + "\n"
+ " (created empty in the response directory — do not rename it).\n"
+ " 4. Repeat for all other work files.\n"
+ " 5. After all responses are saved, run the consume phase:\n"
+ " java -jar methodatlas.jar -manual-consume <workdir> <responsedir> <source-roots...>\n"
+ SEPARATOR + "\n"
+ "\n"
+ "--- BEGIN AI PROMPT ---\n"
+ prompt
+ "--- END AI PROMPT ---\n";
}
private static String loadTaxonomy(AiOptions options) throws AiSuggestionException {
if (options.taxonomyFile() != null) {
try {
return Files.readString(options.taxonomyFile());
} catch (IOException e) {
throw new AiSuggestionException("Failed to read taxonomy file: " + options.taxonomyFile(), e);
}
}
return switch (options.taxonomyMode()) {
case DEFAULT -> DefaultSecurityTaxonomy.text();
case OPTIMIZED -> OptimizedSecurityTaxonomy.text();
};
}
}