OutputEmitter.java
package org.egothor.methodatlas.emit;
import java.io.PrintWriter;
import java.util.List;
import org.egothor.methodatlas.OutputMode;
import org.egothor.methodatlas.TagAiDrift;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
/**
* Formats and emits test method records to a configured output writer.
*
* <p>
* This class centralizes all output rendering logic for the MethodAtlas
* application. It supports both CSV and plain-text output modes and handles
* optional AI enrichment columns.
* </p>
*
* <p>
* The {@link PrintWriter} supplied at construction time is the sole output
* destination, which allows the caller to redirect output for testing or
* piping without manipulating {@code System.out}.
* </p>
*
* <p>
* Instances of this class are immutable after construction.
* </p>
*
* @see OutputMode
*/
public final class OutputEmitter {
private static final String PLAIN_ABSENT = "-";
private static final String CSV_ABSENT = "";
private final PrintWriter out;
private final boolean aiEnabled;
private final boolean confidenceEnabled;
private final boolean contentHashEnabled;
private final boolean driftDetect;
private final boolean emitSourceRoot;
/**
* Creates a new output emitter bound to the supplied writer.
*
* @param out writer to which all records are emitted
* @param aiEnabled whether AI enrichment columns should be included
* @param confidenceEnabled whether the {@code ai_confidence} column should be
* included; only meaningful when {@code aiEnabled} is
* {@code true}
* @param contentHashEnabled whether the {@code content_hash} column should be
* included
* @param driftDetect whether the {@code tag_ai_drift} column should be
* included; only meaningful when {@code aiEnabled} is
* {@code true}
* @param emitSourceRoot whether a {@code source_root} column (CSV) or
* {@code SRCROOT=} token (plain) should be included;
* enable with {@code -emit-source-root} when scanning
* a multi-root project where the same FQCN can appear
* under different source trees
*/
public OutputEmitter(PrintWriter out, boolean aiEnabled, boolean confidenceEnabled,
boolean contentHashEnabled, boolean driftDetect, boolean emitSourceRoot) {
this.out = out;
this.aiEnabled = aiEnabled;
this.confidenceEnabled = confidenceEnabled;
this.contentHashEnabled = contentHashEnabled;
this.driftDetect = driftDetect;
this.emitSourceRoot = emitSourceRoot;
}
/**
* Emits {@code # key: value} metadata comment lines before the CSV header.
*
* @param version tool version string, e.g. {@code 1.2.3} or {@code dev}
* @param scanTimestamp ISO-8601 timestamp of the scan start
* @param taxonomyInfo human-readable taxonomy descriptor
*/
public void emitMetadata(String version, String scanTimestamp, String taxonomyInfo) {
out.println("# tool_version: " + version);
out.println("# scan_timestamp: " + scanTimestamp);
out.println("# taxonomy: " + taxonomyInfo);
}
/**
* Emits the CSV header line when {@link OutputMode#CSV} is selected.
*
* <p>
* Does nothing when plain-text mode is active.
* </p>
*
* @param mode selected output mode
*/
public void emitCsvHeader(OutputMode mode) {
if (mode != OutputMode.CSV) {
return;
}
StringBuilder header = new StringBuilder(256).append("fqcn,method,loc,tags,display_name");
if (emitSourceRoot) {
header.append(",source_root");
}
if (contentHashEnabled) {
header.append(",content_hash");
}
if (aiEnabled) {
header.append(",ai_security_relevant,ai_display_name,ai_tags,ai_reason,ai_interaction_score");
if (confidenceEnabled) {
header.append(",ai_confidence");
}
if (driftDetect) {
header.append(",tag_ai_drift");
}
}
out.println(header.toString());
}
/**
* Emits a single test method record in the configured output mode.
*
* @param mode selected output mode
* @param fqcn fully qualified class name containing the method
* @param method test method name
* @param loc inclusive line count of the method declaration
* @param contentHash SHA-256 fingerprint of the enclosing class source, or
* {@code null} when {@code -content-hash} is not enabled
* @param tags source-level tags extracted from the method
* @param displayName text from an existing display-name annotation on the
* method, or an empty string if absent; {@code null}
* is treated as absent
* @param suggestion AI suggestion for the method, or {@code null} if none
* @param sourceRoot CWD-relative path of the scan root, or {@code null}
* when {@code -emit-source-root} is not enabled
*/
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void emit(OutputMode mode, String fqcn, String method, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourceRoot) {
if (mode == OutputMode.PLAIN) {
emitPlain(fqcn, method, loc, contentHash, tags, displayName, suggestion, sourceRoot);
} else {
emitCsv(fqcn, method, loc, contentHash, tags, displayName, suggestion, sourceRoot);
}
}
private void emitPlain(String fqcn, String method, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourceRoot) {
String existingTags = tags.isEmpty() ? PLAIN_ABSENT : String.join(";", tags);
StringBuilder line = new StringBuilder(fqcn)
.append(", ").append(method)
.append(", LOC=").append(loc)
.append(", TAGS=").append(existingTags)
.append(", DISPLAY=").append(displayName == null || displayName.isEmpty() ? PLAIN_ABSENT : displayName);
if (emitSourceRoot) {
line.append(", SRCROOT=").append(sourceRoot != null && !sourceRoot.isEmpty() ? sourceRoot : PLAIN_ABSENT);
}
if (contentHashEnabled) {
line.append(", HASH=").append(contentHash != null ? contentHash : PLAIN_ABSENT);
}
if (aiEnabled) {
TagAiDrift drift = driftDetect ? TagAiDrift.compute(tags, suggestion) : null;
appendAiPlainFields(line, suggestion, drift);
}
out.println(line.toString());
}
@SuppressWarnings("PMD.NPathComplexity")
private void appendAiPlainFields(StringBuilder line, AiMethodSuggestion suggestion, TagAiDrift drift) {
String aiSecurity = suggestion == null ? PLAIN_ABSENT : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null
? PLAIN_ABSENT : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null || suggestion.tags().isEmpty()
? PLAIN_ABSENT : String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null || suggestion.reason().isBlank()
? PLAIN_ABSENT : suggestion.reason();
String aiInteractionScore = suggestion == null ? PLAIN_ABSENT
: String.format("%.1f", suggestion.interactionScore());
line.append(", AI_SECURITY=").append(aiSecurity)
.append(", AI_DISPLAY=").append(aiDisplayName)
.append(", AI_TAGS=").append(aiTags)
.append(", AI_REASON=").append(aiReason)
.append(", AI_INTERACTION_SCORE=").append(aiInteractionScore);
if (confidenceEnabled) {
String aiConfidence = suggestion == null ? PLAIN_ABSENT
: String.format("%.1f", suggestion.confidence());
line.append(", AI_CONFIDENCE=").append(aiConfidence);
}
if (driftDetect) {
line.append(", TAG_AI_DRIFT=").append(drift != null ? drift.toValue() : PLAIN_ABSENT);
}
}
private void emitCsv(String fqcn, String method, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourceRoot) {
String existingTags = tags.isEmpty() ? CSV_ABSENT : String.join(";", tags);
StringBuilder line = new StringBuilder(csvEscape(fqcn))
.append(',').append(csvEscape(method))
.append(',').append(loc)
.append(',').append(csvEscape(existingTags))
.append(',').append(csvEscape(displayName != null ? displayName : CSV_ABSENT));
if (emitSourceRoot) {
line.append(',').append(csvEscape(sourceRoot != null ? sourceRoot : CSV_ABSENT));
}
if (contentHashEnabled) {
line.append(',').append(contentHash != null ? contentHash : CSV_ABSENT);
}
if (aiEnabled) {
TagAiDrift drift = driftDetect ? TagAiDrift.compute(tags, suggestion) : null;
appendAiCsvFields(line, suggestion, drift);
}
out.println(line.toString());
}
@SuppressWarnings("PMD.NPathComplexity")
private void appendAiCsvFields(StringBuilder line, AiMethodSuggestion suggestion, TagAiDrift drift) {
String aiSecurity = suggestion == null ? CSV_ABSENT : Boolean.toString(suggestion.securityRelevant());
String aiDisplayName = suggestion == null || suggestion.displayName() == null
? CSV_ABSENT : suggestion.displayName();
String aiTags = suggestion == null || suggestion.tags() == null
? CSV_ABSENT : String.join(";", suggestion.tags());
String aiReason = suggestion == null || suggestion.reason() == null
? CSV_ABSENT : suggestion.reason();
String aiInteractionScore = suggestion == null ? CSV_ABSENT
: String.format("%.1f", suggestion.interactionScore());
line.append(',').append(csvEscape(aiSecurity))
.append(',').append(csvEscape(aiDisplayName))
.append(',').append(csvEscape(aiTags))
.append(',').append(csvEscape(aiReason))
.append(',').append(csvEscape(aiInteractionScore));
if (confidenceEnabled) {
String aiConfidence = suggestion == null ? CSV_ABSENT
: String.format("%.1f", suggestion.confidence());
line.append(',').append(csvEscape(aiConfidence));
}
if (driftDetect) {
line.append(',').append(drift != null ? drift.toValue() : CSV_ABSENT);
}
}
/**
* Escapes a value for inclusion in a CSV field.
*
* <p>
* If the value contains a comma, double quote, carriage return, or line
* feed, it is wrapped in double quotes and embedded quotes are doubled. Values
* that start with {@code =}, {@code +}, {@code -}, or {@code @} are also
* quoted to prevent spreadsheet formula injection. A {@code null} input is
* converted to an empty field.
* </p>
*
* @param value value to escape; may be {@code null}
* @return CSV-safe representation of {@code value}
*/
public static String csvEscape(String value) {
if (value == null) {
return CSV_ABSENT;
}
boolean formulaPrefix = !value.isEmpty()
&& "=+-@".indexOf(value.charAt(0)) >= 0;
boolean mustQuote = formulaPrefix || value.indexOf(',') >= 0 || value.indexOf('"') >= 0
|| value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0;
if (!mustQuote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
}