DeltaEmitter.java

package org.egothor.methodatlas.emit;

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

import org.egothor.methodatlas.DeltaEntry;
import org.egothor.methodatlas.DeltaReport;
import org.egothor.methodatlas.api.ScanRecord;

/**
 * Formats and writes a MethodAtlas delta report to a {@link PrintWriter}.
 *
 * <h2>Output format</h2>
 *
 * <p>
 * The emitted text is designed to be both human-readable in a terminal and
 * parseable by simple shell scripts (e.g. {@code grep "^+"} to list added
 * methods). The format is:
 * </p>
 *
 * <pre>
 * MethodAtlas delta report
 *   before: before.csv  (scanned: 2026-04-10T09:00:00Z · 45 methods · 5 security-relevant)
 *   after:  after.csv   (scanned: 2026-04-24T14:30:00Z · 47 methods · 7 security-relevant)
 *
 * + com.acme.auth.Oauth2FlowTest  test_authCode
 * + com.acme.auth.Oauth2FlowTest  test_tokenRefresh
 * - com.acme.auth.LegacyAuthTest  test_basicAuth
 * ~ com.acme.crypto.AesGcmTest    roundTrip_encryptDecrypt  [source; security: false → true]
 *
 * 2 added  ·  1 removed  ·  1 modified  ·  42 unchanged
 * security-relevant: 5 → 7  (+2)
 * </pre>
 *
 * @see DeltaReport
 * @see DeltaEntry
 */
public final class DeltaEmitter {

    private DeltaEmitter() {
    }

    /**
     * Emits a full delta report to {@code out}.
     *
     * @param result the delta result to format
     * @param out    writer that receives all output; flushed but not closed
     */
    public static void emit(DeltaReport.DeltaResult result, PrintWriter out) {
        emitHeader(result, out);
        out.println();
        emitEntries(result, out);
        out.println();
        emitSummary(result, out);
        out.flush();
    }

    private static void emitHeader(DeltaReport.DeltaResult result, PrintWriter out) {
        out.println("MethodAtlas delta report");
        out.println("  before: " + result.beforePath().getFileName()
                + fileSummary(result.beforeTimestamp(), result.totalBefore(),
                        result.securityRelevantBefore()));
        out.println("  after:  " + result.afterPath().getFileName()
                + fileSummary(result.afterTimestamp(), result.totalAfter(),
                        result.securityRelevantAfter()));
    }

    private static String fileSummary(String timestamp, int total, int security) {
        StringBuilder sb = new StringBuilder(64);
        sb.append("  (");
        if (timestamp != null) {
            sb.append("scanned: ").append(timestamp).append(" · ");
        }
        sb.append(total).append(" method").append(total == 1 ? "" : "s")
          .append(" · ").append(security).append(" security-relevant)");
        return sb.toString();
    }

    private static void emitEntries(DeltaReport.DeltaResult result, PrintWriter out) {
        if (result.entries().isEmpty()) {
            out.println("No changes detected.");
            return;
        }
        for (DeltaEntry entry : result.entries()) {
            emitEntry(entry, out);
        }
    }

    private static void emitEntry(DeltaEntry entry, PrintWriter out) {
        String symbol = switch (entry.changeType()) {
            case ADDED -> "+";
            case REMOVED -> "-";
            case MODIFIED -> "~";
        };

        ScanRecord rec = entry.record();
        out.print(symbol + " " + rec.fqcn() + "  " + rec.method());

        if (entry.changeType() == DeltaEntry.ChangeType.MODIFIED
                && !entry.changedFields().isEmpty()) {
            out.print("  [" + formatChangedFields(entry) + "]");
        }

        out.println();
    }

    private static String formatChangedFields(DeltaEntry entry) {
        List<String> parts = new ArrayList<>();
        for (String field : entry.changedFields()) {
            switch (field) {
                case "source" -> parts.add("source");
                case "loc" -> parts.add("loc: " + entry.before().loc() + " → " + entry.after().loc());
                case "tags" -> parts.add("tags");
                case "display_name" -> parts.add("display_name");
                case "ai_security_relevant" ->
                    parts.add("security: " + entry.before().aiSecurityRelevant()
                            + " → " + entry.after().aiSecurityRelevant());
                case "ai_tags" -> parts.add("ai_tags");
                default -> parts.add(field);
            }
        }
        return String.join("; ", parts);
    }

    private static void emitSummary(DeltaReport.DeltaResult result, PrintWriter out) {
        out.println(result.addedCount() + " added  ·  "
                + result.removedCount() + " removed  ·  "
                + result.modifiedCount() + " modified  ·  "
                + result.unchangedCount() + " unchanged");

        int secBefore = result.securityRelevantBefore();
        int secAfter = result.securityRelevantAfter();
        int delta = secAfter - secBefore;
        String deltaStr = delta > 0 ? "(+" + delta + ")"
                : delta < 0 ? "(" + delta + ")"
                : "(no change)";
        out.println("security-relevant: " + secBefore + " → " + secAfter + "  " + deltaStr);
    }
}