FacepalmRunner.java

/*
 * SPDX-License-Identifier: Apache-2.0
 * Copyright 2026 Nikolas Charalambidis
 */

package dev.nichar.facepalm.engine;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.nichar.facepalm.FacepalmConfig;
import dev.nichar.facepalm.config.ScoringConfig;
import dev.nichar.facepalm.report.FindingReport;
import dev.nichar.facepalm.report.Reporter;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugin.logging.Log;
import org.apache.maven.plugin.logging.SystemStreamLog;

/**
 * Orchestrates the scanning lifecycle.
 * Manages gitignore discovery, extraction, scoring, and report serialization.
 */
@Named
@Singleton
public class FacepalmRunner {

    @Inject
    private @Nullable Log log;

    @Inject
    private FacepalmConfig context;

    @Inject
    private ScannerEngine engine;

    @Inject
    private GitIgnoreService gitIgnoreService;

    @Inject
    private Reporter reporter;

    private Log getLog() {
        return log != null ? log : new SystemStreamLog();
    }

    /**
     * Executes the end-to-end scanning workflow.
     * Evaluates build failure conditions based on discovery results.
     *
     * @param root Base directory to scan.
     * @param outputDir Target directory for findings and reports.
     * @param version Scanner version for metadata.
     */
    public void run(@Nonnull final Path root,
                    @Nonnull final Path outputDir,
                    @Nonnull final String version) throws Exception {

        getLog().info("Scanning " + root);

        gitIgnoreService.loadAllGitIgnores(root);
        final var findings = engine.scan(root);
        final var stats = engine.getStats();

        reporter.printLogs(stats, findings);

        // Persist findings for downstream report generation.
        final var resultsFile = new File(outputDir.toFile(), "facepalm-findings.json");
        saveFindingsToJson(findings, resultsFile);

        // Enforce build failure policies based on finding severity.
        final var scoring = context.getScoring();
        final long errors = findings.stream().filter(f -> f.getSeverity(scoring) == Severity.ERROR).count();
        final long warnings = findings.stream().filter(f -> f.getSeverity(scoring) == Severity.WARNING).count();
        final var failure = failureMessage(scoring, errors, warnings);
        if (failure != null) {
            throw new MojoFailureException(failure);
        }
    }

    /**
     * Serializes findings to a machine-readable JSON format for the reporting phase.
     */
    private void saveFindingsToJson(@Nonnull final List<Finding> findings, @Nonnull final File outputFile)
            throws Exception {
        if (!outputFile.getParentFile().exists()) {
            outputFile.getParentFile().mkdirs();
        }

        final var findingsDto = findings.stream()
            .map(finding -> mapToDto(finding, context.getScoring()))
            .toList();

        final var mapper = new ObjectMapper();
        mapper.writerWithDefaultPrettyPrinter().writeValue(outputFile, findingsDto);
    }

    /**
     * Maps a raw scan finding to a serializable DTO.
     */
    @Nonnull
    public FindingReport mapToDto(@Nonnull final Finding finding, @Nonnull final ScoringConfig config) {
        return FindingReport.builder()
            .patternName(finding.getPatternName())
            .fileAbsolutePath(finding.getContext().getPath().toAbsolutePath().toString())
            .lineNumber(finding.getLineNumber())
            .maskedSecret(finding.getMaskedSecret())
            .contextSnippet(finding.getMaskedSnippet())
            .finalScore(finding.getNumericScore(config))
            .finalSeverity(finding.getSeverity(config).name())
            .riskScore(finding.getRiskScore())
            .confidenceScore(finding.getConfidenceScore())
            .build();
    }

    /**
     * Decides whether the build fails for the given finding counts.
     * {@code failOnError} fails on findings at the error threshold; {@code failOnWarnings} fails on any finding at the
     * warning threshold or above, so critical findings count too, independent of {@code failOnError}.
     *
     * @param scoring Failure flags.
     * @param errors Number of findings with severity {@link Severity#ERROR}.
     * @param warnings Number of findings with severity {@link Severity#WARNING}.
     * @return The failure message, or {@code null} if the build passes.
     */
    @Nullable
    static String failureMessage(@Nonnull final ScoringConfig scoring, final long errors, final long warnings) {
        if (scoring.isFailOnError() && errors > 0) {
            return "Facepalm scan failed: " + errors + " critical findings detected.";
        }
        final long atOrAboveWarning = errors + warnings;
        if (scoring.isFailOnWarnings() && atOrAboveWarning > 0) {
            return "Facepalm scan failed: " + atOrAboveWarning + " findings at or above the warning threshold ("
                + errors + " critical, " + warnings + " warnings) and failOnWarnings is true.";
        }
        return null;
    }
}