OutputEmitter.java
package org.egothor.methodatlas;
import java.io.PrintWriter;
import java.util.List;
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
* @see MethodAtlasApp
*/
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;
/**
* 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
*/
/* default */ OutputEmitter(PrintWriter out, boolean aiEnabled, boolean confidenceEnabled,
boolean contentHashEnabled) {
this.out = out;
this.aiEnabled = aiEnabled;
this.confidenceEnabled = confidenceEnabled;
this.contentHashEnabled = contentHashEnabled;
}
/**
* Emits {@code # key: value} metadata comment lines before the CSV header.
*
* <p>
* The lines are prefixed with {@code #} so standard CSV parsers treat them as
* comments and skip them. The metadata describes the conditions under which the
* scan was performed so that historical output files remain interpretable.
* </p>
*
* <p>
* Three lines are emitted: {@code tool_version}, {@code scan_timestamp}, and
* {@code taxonomy}.
* </p>
*
* @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
*/
/* default */ 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
*/
/* default */ void emitCsvHeader(OutputMode mode) {
if (mode != OutputMode.CSV) {
return;
}
StringBuilder header = new StringBuilder(128).append("fqcn,method,loc,tags");
if (contentHashEnabled) {
header.append(",content_hash");
}
if (aiEnabled) {
header.append(",ai_security_relevant,ai_display_name,ai_tags,ai_reason");
if (confidenceEnabled) {
header.append(",ai_confidence");
}
}
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 JUnit tags extracted from the method
* @param suggestion AI suggestion for the method, or {@code null} if none
* is available
*/
/* default */ void emit(OutputMode mode, String fqcn, String method, int loc, String contentHash,
List<String> tags, AiMethodSuggestion suggestion) {
if (mode == OutputMode.PLAIN) {
emitPlain(fqcn, method, loc, contentHash, tags, suggestion);
} else {
emitCsv(fqcn, method, loc, contentHash, tags, suggestion);
}
}
/**
* Emits a record in plain text format.
*
* @param fqcn fully qualified class name
* @param method test method name
* @param loc inclusive line count
* @param contentHash SHA-256 fingerprint, or {@code null}
* @param tags source-level JUnit tags
* @param suggestion AI suggestion, or {@code null}
*/
private void emitPlain(String fqcn, String method, int loc, String contentHash,
List<String> tags, AiMethodSuggestion suggestion) {
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);
if (contentHashEnabled) {
line.append(", HASH=").append(contentHash != null ? contentHash : PLAIN_ABSENT);
}
if (aiEnabled) {
appendAiPlainFields(line, suggestion);
}
out.println(line.toString());
}
/**
* Appends AI-related fields to a plain-text line builder.
*
* @param line string builder receiving the AI field tokens
* @param suggestion AI suggestion, or {@code null}
*/
@SuppressWarnings("PMD.NPathComplexity")
private void appendAiPlainFields(StringBuilder line, AiMethodSuggestion suggestion) {
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();
line.append(", AI_SECURITY=").append(aiSecurity)
.append(", AI_DISPLAY=").append(aiDisplayName)
.append(", AI_TAGS=").append(aiTags)
.append(", AI_REASON=").append(aiReason);
if (confidenceEnabled) {
String aiConfidence = suggestion == null ? PLAIN_ABSENT
: String.format("%.1f", suggestion.confidence());
line.append(", AI_CONFIDENCE=").append(aiConfidence);
}
}
/**
* Emits a record in CSV format.
*
* @param fqcn fully qualified class name
* @param method test method name
* @param loc inclusive line count
* @param contentHash SHA-256 fingerprint, or {@code null}
* @param tags source-level JUnit tags
* @param suggestion AI suggestion, or {@code null}
*/
private void emitCsv(String fqcn, String method, int loc, String contentHash,
List<String> tags, AiMethodSuggestion suggestion) {
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));
if (contentHashEnabled) {
line.append(',').append(contentHash != null ? contentHash : CSV_ABSENT);
}
if (aiEnabled) {
appendAiCsvFields(line, suggestion);
}
out.println(line.toString());
}
/**
* Appends AI-related CSV fields to a line builder.
*
* @param line string builder receiving the AI columns
* @param suggestion AI suggestion, or {@code null}
*/
private void appendAiCsvFields(StringBuilder line, AiMethodSuggestion suggestion) {
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();
line.append(',').append(csvEscape(aiSecurity))
.append(',').append(csvEscape(aiDisplayName))
.append(',').append(csvEscape(aiTags))
.append(',').append(csvEscape(aiReason));
if (confidenceEnabled) {
String aiConfidence = suggestion == null ? CSV_ABSENT
: String.format("%.1f", suggestion.confidence());
line.append(',').append(csvEscape(aiConfidence));
}
}
/**
* 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. 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}
*/
/* default */ static String csvEscape(String value) {
if (value == null) {
return CSV_ABSENT;
}
boolean mustQuote = value.indexOf(',') >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!mustQuote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
}