JsonEmitter.java

package org.egothor.methodatlas.emit;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;

import org.egothor.methodatlas.ai.AiMethodSuggestion;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;

/**
 * Buffers test method records and serializes them as a flat JSON array when
 * {@link #flush(PrintWriter)} is called.
 *
 * <p>
 * Each element of the array is a JSON object whose fields mirror the CSV
 * columns produced by {@link OutputEmitter}, with the following differences:
 * </p>
 * <ul>
 * <li>{@code tags} and {@code ai_tags} are JSON arrays rather than
 *     semicolon-separated strings</li>
 * <li>Numeric fields ({@code loc}, {@code ai_interaction_score},
 *     {@code ai_confidence}) are JSON numbers</li>
 * <li>{@code ai_security_relevant} is a JSON boolean</li>
 * <li>Optional columns ({@code source_root}, {@code content_hash},
 *     {@code ai_*}, {@code tag_ai_drift}) are omitted from each object
 *     when the corresponding flag is not enabled</li>
 * </ul>
 *
 * <p>
 * All records are accumulated in memory via {@link #record} and the complete
 * JSON array is serialized to the writer in a single {@link #flush} call.
 * </p>
 *
 * <p>
 * This class implements {@link TestMethodSink} so it can be passed directly to
 * the orchestration layer. The {@link TestMethodSink#record} implementation
 * calls {@link #record(String, String, int, int, String, List, String, AiMethodSuggestion, String)}
 * with a {@code null} source root; callers that know the scan root (such as
 * {@code JsonCommand} in the application layer) should call the extended
 * method directly.
 * </p>
 *
 * @see OutputEmitter
 * @see TestMethodSink
 */
public final class JsonEmitter implements TestMethodSink, RecordEmitter {

    private final boolean aiEnabled;
    private final boolean confidenceEnabled;
    private final boolean contentHashEnabled;
    private final boolean driftDetect;
    private final boolean emitSourceRoot;
    private final List<MethodRecord> records = new ArrayList<>();

    /**
     * Creates a new JSON emitter.
     *
     * @param aiEnabled          whether AI enrichment columns should be included
     * @param confidenceEnabled  whether the {@code ai_confidence} field should be
     *                           included; only meaningful when {@code aiEnabled} is
     *                           {@code true}
     * @param contentHashEnabled whether the {@code content_hash} field should be
     *                           included
     * @param driftDetect        whether the {@code tag_ai_drift},
     *                           {@code tags_added}, and {@code tags_removed}
     *                           fields should be included; only meaningful when
     *                           {@code aiEnabled} is {@code true}
     * @param emitSourceRoot     whether the {@code source_root} field should be
     *                           included
     */
    public JsonEmitter(boolean aiEnabled, boolean confidenceEnabled,
            boolean contentHashEnabled, boolean driftDetect, boolean emitSourceRoot) {
        this.aiEnabled = aiEnabled;
        this.confidenceEnabled = confidenceEnabled;
        this.contentHashEnabled = contentHashEnabled;
        this.driftDetect = driftDetect;
        this.emitSourceRoot = emitSourceRoot;
    }

    /**
     * Buffers a single test method record (without a source root).
     *
     * <p>
     * Delegates to
     * {@link #record(String, String, int, int, String, List, String, AiMethodSuggestion, String)}
     * with {@code sourceRoot=null}. Callers that know the scan root should use
     * the extended form directly.
     * </p>
     */
    @Override
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
    public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
            List<String> tags, String displayName, AiMethodSuggestion suggestion) {
        record(fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion, null);
    }

    /**
     * Buffers a single test method record including the scan root.
     *
     * @param fqcn        fully qualified class name
     * @param method      test method name
     * @param beginLine   first line of the method declaration (1-based)
     * @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, or an
     *                    empty string/null if absent
     * @param suggestion  AI suggestion for the method, or {@code null} if none
     * @param sourceRoot  CWD-relative path of the scan root that produced this
     *                    record, or {@code null} when {@code -emit-source-root} is
     *                    not enabled
     */
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
    public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
            List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourceRoot) {

        String resolvedSourceRoot = emitSourceRoot ? sourceRoot : null;
        String resolvedContentHash = contentHashEnabled ? contentHash : null;

        Boolean aiSecurityRelevant = null;
        String aiDisplayName = null;
        List<String> aiTags = null;
        String aiReason = null;
        Double aiInteractionScore = null;
        Double aiConfidence = null;
        String tagAiDrift = null;
        String tagsAdded = null;
        String tagsRemoved = null;

        if (aiEnabled) {
            if (suggestion != null) {
                aiSecurityRelevant = suggestion.securityRelevant();
                aiDisplayName = suggestion.displayName();
                aiTags = suggestion.tags() != null ? List.copyOf(suggestion.tags()) : List.of();
                aiReason = suggestion.reason();
                aiInteractionScore = suggestion.interactionScore();
                aiConfidence = confidenceEnabled ? suggestion.confidence() : null;
                DriftFields drift = driftFields(tags, suggestion);
                tagAiDrift = drift.drift();
                tagsAdded = drift.added();
                tagsRemoved = drift.removed();
            } else {
                aiTags = List.of();
            }
        }

        records.add(new MethodRecord(
                fqcn,
                method,
                loc,
                tags == null ? List.of() : List.copyOf(tags),
                displayName != null ? displayName : "",
                resolvedSourceRoot,
                resolvedContentHash,
                aiSecurityRelevant,
                aiDisplayName,
                aiTags,
                aiReason,
                aiInteractionScore,
                aiConfidence,
                tagAiDrift,
                tagsAdded,
                tagsRemoved));
    }

    /**
     * Computes the drift-detection fields ({@code tag_ai_drift}, {@code tags_added},
     * {@code tags_removed}) for a method with an AI suggestion.
     *
     * <p>
     * Extracted from {@link #record} to keep that method's branching complexity
     * within the project's PMD limits. Returns {@link DriftFields#EMPTY} (all
     * {@code null}, so the fields are omitted from the JSON) when drift detection
     * is disabled.
     * </p>
     *
     * @param tags       source-level tags of the method, may be {@code null}
     * @param suggestion non-null AI suggestion for the method
     * @return the three drift fields, or {@link DriftFields#EMPTY} when disabled
     */
    private DriftFields driftFields(List<String> tags, AiMethodSuggestion suggestion) {
        if (!driftDetect) {
            return DriftFields.EMPTY;
        }
        String drift = TagAiDrift.compute(tags != null ? tags : List.of(), suggestion).toValue();
        return new DriftFields(drift,
                TagAiDrift.tagDifference(tags, suggestion.tags()),
                TagAiDrift.tagDifference(suggestion.tags(), tags));
    }

    /** Triple of the drift-detection fields, or all {@code null} when disabled. */
    private record DriftFields(String drift, String added, String removed) {
        private static final DriftFields EMPTY = new DriftFields(null, null, null);
    }

    /**
     * Serializes all buffered records as a JSON array and writes the result to
     * {@code out}.
     *
     * @param out writer that receives the JSON output
     * @throws IllegalStateException if JSON serialization fails or the
     *                               underlying stream reports a write error
     */
    public void flush(PrintWriter out) {
        JsonMapper mapper = JsonMapper.builder()
                .configure(SerializationFeature.INDENT_OUTPUT, true)
                .build();
        try {
            out.println(mapper.writeValueAsString(records));
        } catch (JacksonException e) {
            throw new IllegalStateException("Failed to serialize JSON output", e);
        }
        // PrintWriter swallows write errors; surface them so a truncated JSON
        // file (e.g. on a full disk) is never reported as a successful run.
        if (out.checkError()) {
            throw new IllegalStateException(
                    "Failed to write JSON output: the underlying stream reported an error");
        }
    }

    // -------------------------------------------------------------------------
    // Internal record POJO
    // -------------------------------------------------------------------------

    /**
     * Immutable value object representing one serialized test method.
     * Fields annotated with {@link JsonInclude}{@code (NON_NULL)} are omitted
     * from the JSON output when their value is {@code null}.
     */
    @JsonInclude(Include.NON_NULL)
    private record MethodRecord(
            @JsonProperty("fqcn") String fqcn,
            @JsonProperty("method") String method,
            @JsonProperty("loc") int loc,
            @JsonProperty("tags") List<String> tags,
            @JsonProperty("display_name") String displayName,
            @JsonProperty("source_root") String sourceRoot,
            @JsonProperty("content_hash") String contentHash,
            @JsonProperty("ai_security_relevant") Boolean aiSecurityRelevant,
            @JsonProperty("ai_display_name") String aiDisplayName,
            @JsonProperty("ai_tags") List<String> aiTags,
            @JsonProperty("ai_reason") String aiReason,
            @JsonProperty("ai_interaction_score") Double aiInteractionScore,
            @JsonProperty("ai_confidence") Double aiConfidence,
            @JsonProperty("tag_ai_drift") String tagAiDrift,
            @JsonProperty("tags_added") String tagsAdded,
            @JsonProperty("tags_removed") String tagsRemoved) {
    }
}