DiffCommand.java

package org.egothor.methodatlas.command;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.util.List;

import org.egothor.methodatlas.emit.ClassificationOverride;
import org.egothor.methodatlas.emit.DeltaEmitter;
import org.egothor.methodatlas.emit.DeltaEntry;
import org.egothor.methodatlas.emit.DeltaReport;

/**
 * CLI command handler for the {@code -diff} mode.
 *
 * <p>
 * Compares two MethodAtlas scan CSV outputs and emits a delta report showing
 * added, removed, and modified test methods.
 * </p>
 *
 * <p>
 * When {@code requireJustification} is {@code true}, every security-relevant
 * method that was removed must have a matching entry in the {@code overrideFile}.
 * The command exits with code {@code 1} when any unjustified removal is found.
 * </p>
 *
 * @see org.egothor.methodatlas.emit.DeltaReport
 * @see org.egothor.methodatlas.emit.DeltaEmitter
 */
public final class DiffCommand implements Command {

    private static final int EXIT_GATE_FAILED = 1;

    private final Path before;
    private final Path after;
    private final boolean requireJustification;
    private final Path overrideFile;

    /**
     * Creates a new diff command without the removal gate.
     *
     * @param before path to the <em>before</em> scan CSV
     * @param after  path to the <em>after</em> scan CSV
     */
    public DiffCommand(Path before, Path after) {
        this(before, after, false, null);
    }

    /**
     * Creates a new diff command with optional removal-gate enforcement.
     *
     * @param before               path to the <em>before</em> scan CSV
     * @param after                path to the <em>after</em> scan CSV
     * @param requireJustification when {@code true}, exits non-zero if any
     *                             security-relevant test was removed without a
     *                             matching entry in {@code overrideFile}
     * @param overrideFile         path to a MethodAtlas override YAML supplying
     *                             removal justifications; ignored when
     *                             {@code requireJustification} is {@code false};
     *                             {@code null} means no justifications are on file
     * @since 3.0.0
     */
    public DiffCommand(Path before, Path after, boolean requireJustification, Path overrideFile) {
        this.before = before;
        this.after = after;
        this.requireJustification = requireJustification;
        this.overrideFile = overrideFile;
    }

    /**
     * Computes and emits the delta between the two scan CSV outputs, and
     * optionally enforces the removal-justification gate.
     *
     * @param out writer that receives the delta report
     * @return {@code 0} on success; {@code 1} when the removal gate fires
     * @throws IOException if either CSV file cannot be read
     */
    @Override
    public int execute(PrintWriter out) throws IOException {
        DeltaReport.DeltaResult result = DeltaReport.compute(before, after);
        DeltaEmitter.emit(result, out);
        if (requireJustification) {
            return checkRemovals(result, out);
        }
        return 0;
    }

    /**
     * Checks that every security-relevant removal has a justification in the
     * override file.
     *
     * @param result delta result from the comparison
     * @param out    writer used to print gate-failure details
     * @return {@code 0} when all removals are justified; {@code 1} otherwise
     */
    private int checkRemovals(DeltaReport.DeltaResult result, PrintWriter out) {
        List<DeltaEntry> removals = result.entries().stream()
                .filter(e -> e.changeType() == DeltaEntry.ChangeType.REMOVED)
                .filter(e -> Boolean.TRUE.equals(e.record().aiSecurityRelevant()))
                .toList();

        if (removals.isEmpty()) {
            return 0;
        }

        ClassificationOverride override = new OverrideLoader().load(overrideFile);
        List<DeltaEntry> unjustified = removals.stream()
                .filter(e -> !override.hasOverrideFor(e.record().fqcn(), e.record().method()))
                .toList();

        if (unjustified.isEmpty()) {
            return 0;
        }

        out.println();
        out.println("GATE FAILED: " + unjustified.size()
                + " security-relevant test(s) removed without justification.");
        out.println("Add an entry for each method in an override file and pass it via -override-file:");
        for (DeltaEntry e : unjustified) {
            out.println("  - " + e.record().fqcn() + " : " + e.record().method());
        }
        return EXIT_GATE_FAILED;
    }
}