CommentRedaction.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.command;

import java.nio.file.Path;
import java.util.List;

import org.egothor.methodatlas.api.CommentRedactionException;
import org.egothor.methodatlas.api.CommentRedactor;

/**
 * Applies the loaded {@link CommentRedactor}s to source text on its way to an
 * AI provider, failing closed.
 *
 * <p>
 * The first redactor whose {@link CommentRedactor#supports(Path)} accepts the
 * file performs the redaction. If none does, or the redactor reports it cannot
 * guarantee redaction, {@link RedactionAbortedException} is thrown so that no
 * un-redacted source can leave the process.
 * </p>
 *
 * <p>
 * Instances are immutable; thread safety is that of the wrapped redactors.
 * </p>
 *
 * @see CommentRedactor
 * @see PluginLoader#loadRedactors
 * @since 4.1.0
 */
public final class CommentRedaction {

    private static final char LF = '\n';
    private static final char CR = '\r';
    private static final char BLANK = ' ';

    private final List<CommentRedactor> redactors;

    /**
     * Creates a service over the given redactors.
     *
     * @param redactors redactors in priority order; must not be {@code null}
     */
    public CommentRedaction(List<CommentRedactor> redactors) {
        this.redactors = List.copyOf(redactors);
    }

    /**
     * Returns a service with no redactors, which rejects every file.
     *
     * @return a new empty instance; never {@code null}
     */
    public static CommentRedaction none() {
        return new CommentRedaction(List.of());
    }

    /**
     * Returns {@code source} with comments blanked by the redactor responsible
     * for {@code file}.
     *
     * @param file   the file the text came from; {@code null} is treated as an
     *               unknown language and rejected
     * @param source full source text; must not be {@code null}
     * @return redacted text, same length and line structure as {@code source}
     * @throws RedactionAbortedException if no redactor supports the file or the
     *                                   redactor fails
     */
    public String redactOrThrow(Path file, String source) {
        if (file == null) {
            throw new RedactionAbortedException("-redact-comments cannot determine the language of a source "
                    + "without a file path; refusing to send it to the AI provider.");
        }
        for (CommentRedactor redactor : redactors) {
            if (redactor.supports(file)) {
                return redactWith(redactor, file, source);
            }
        }
        throw new RedactionAbortedException("-redact-comments is set but no comment redactor supports "
                + file + " (language: " + languageOf(file) + "). -redact-comments requires a redactor "
                + "for every scanned language; the run is aborted so no un-redacted source reaches the "
                + "AI provider.");
    }

    /**
     * Runs one redactor defensively and verifies its output, so a faulty current or
     * future redactor can never let un-redacted text through.
     *
     * @throws RedactionAbortedException if the redactor throws anything other than an
     *         unrecoverable VM error, or returns text violating the redaction contract
     */
    @SuppressWarnings("PMD.AvoidCatchingGenericException")
    private static String redactWith(CommentRedactor redactor, Path file, String source) {
        String redacted;
        try {
            redacted = redactor.redact(file, source);
        } catch (CommentRedactionException e) {
            throw new RedactionAbortedException("-redact-comments could not redact " + file
                    + " (" + languageOf(file) + "): " + e.getMessage(), e);
        } catch (RuntimeException | StackOverflowError e) {
            throw new RedactionAbortedException("-redact-comments redactor '" + redactor.pluginId()
                    + "' failed on " + file + " (" + languageOf(file) + "): " + e, e);
        }
        String violation = contractViolation(source, redacted);
        if (violation != null) {
            throw new RedactionAbortedException("-redact-comments redactor '" + redactor.pluginId()
                    + "' violated the redaction contract on " + file + " (" + languageOf(file) + "): "
                    + violation + "; refusing to send the text to the AI provider.");
        }
        return redacted;
    }

    /**
     * Checks the post-conditions of the redaction contract.
     *
     * @return a description of the first violation, or {@code null} when the output is valid:
     *         non-null, same length, line terminators untouched, and differing from the input
     *         only where a non-terminator character became a space
     */
    private static String contractViolation(String source, String redacted) {
        if (redacted == null) {
            return "returned null";
        }
        if (redacted.length() != source.length()) {
            return "changed the length from " + source.length() + " to " + redacted.length();
        }
        for (int i = 0; i < source.length(); i++) {
            char in = source.charAt(i);
            char out = redacted.charAt(i);
            if (in == out) {
                continue;
            }
            if (in == LF || in == CR) {
                return "altered a line terminator at offset " + i;
            }
            if (out != BLANK) {
                return "replaced a character with something other than a space at offset " + i;
            }
        }
        return null;
    }

    private static String languageOf(Path file) {
        Path name = file.getFileName();
        String text = name == null ? file.toString() : name.toString();
        int dot = text.lastIndexOf('.');
        return dot >= 0 && dot < text.length() - 1 ? "*" + text.substring(dot) : text;
    }
}