DatabaseAuditReport.java

package io.github.databaseaudits.spring.boot.report;

import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import io.github.databaseaudits.audit.finding.Finding;
import io.github.databaseaudits.spring.boot.assertion.AuditFamily;
import io.github.databaseaudits.spring.boot.assertion.DatabaseAuditFailure;

/**
 * Assembles the consolidated audit report from the {@link DatabaseAuditFailure}s
 * a run collected. Findings are grouped by {@link AuditFamily} then audit, each
 * audit showing its curated header and the verbatim finding descriptions; when a
 * {@link FixFormat} other than {@link FixFormat#NONE} is selected, each finding's
 * remediation — raw SQL, or a Liquibase change set — is placed inline under its
 * audit, in one consolidated section, or both, per the {@link FixPlacement}. The
 * output uses {@code \n} line breaks so a report is byte-for-byte deterministic
 * across platforms.
 */
final class DatabaseAuditReport {
    private static final String NL = "\n";

    private static final String SECTION_BREAK = NL + NL;

    private static final String LIQUIBASE_HEADER = """
            <?xml version="1.0" encoding="UTF-8"?>
            <databaseChangeLog
                xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">""";

    private DatabaseAuditReport() {
    }

    /**
     * Renders the report for the collected failures.
     *
     * @param failures
     *                      the audit failures collected over the run; must be
     *                      non-empty.
     * @param format
     *                      the report output format.
     * @param fixFormat
     *                      the remediation format, or {@link FixFormat#NONE} to
     *                      omit fixes.
     * @param placement
     *                      where fixes are placed — inline under each audit, in a
     *                      consolidated section, or both.
     * @param fixRenderer
     *                      the renderer that turns findings into fixes.
     * @return the rendered report.
     */
    static String render(final List<DatabaseAuditFailure> failures,
            final ReportFormat format, final FixFormat fixFormat,
            final FixPlacement placement, final FixRenderer fixRenderer) {
        final List<DatabaseAuditFailure> ordered = failures.stream()
                .sorted(Comparator
                        .comparingInt(
                                (final DatabaseAuditFailure f) -> f.family()
                                        .ordinal())
                        .thenComparing(DatabaseAuditFailure::auditName))
                .toList();
        final boolean withFixes = fixFormat != FixFormat.NONE;

        final StringBuilder report = new StringBuilder();
        report.append(format.heading(1, "Database Audit Report"))
                .append(SECTION_BREAK).append(summary(ordered));

        AuditFamily currentFamily = null;
        for (final DatabaseAuditFailure failure : ordered) {
            if (failure.family() != currentFamily) {
                currentFamily = failure.family();
                report.append(SECTION_BREAK)
                        .append(format.heading(2, currentFamily.name()));
            }
            report.append(SECTION_BREAK)
                    .append(format.heading(3, failure.auditName()))
                    .append(SECTION_BREAK).append(failure.header())
                    .append(SECTION_BREAK)
                    .append(format.codeBlock("", descriptions(failure)));
            if (withFixes && placement.inline()) {
                report.append(SECTION_BREAK).append("Suggested fixes (")
                        .append(fixFormat.id()).append("):")
                        .append(SECTION_BREAK).append(fixBlock(List.of(failure),
                                format, fixFormat, fixRenderer, false));
            }
        }

        if (withFixes && placement.section()) {
            report.append(SECTION_BREAK)
                    .append(format.heading(2, "Suggested fixes ("
                            + fixFormat.id() + ")"))
                    .append(SECTION_BREAK).append(fixBlock(ordered, format,
                            fixFormat, fixRenderer, true));
        }

        return report.append(NL).toString();
    }

    private static String summary(final List<DatabaseAuditFailure> failures) {
        final int findingCount =
                failures.stream().mapToInt(f -> f.findings().size()).sum();
        final Set<String> audits = failures.stream()
                .map(f -> f.family() + "/" + f.auditName())
                .collect(Collectors.toCollection(LinkedHashSet::new));
        return plural(findingCount, "finding") + " across "
                + plural(audits.size(), "audit") + ".";
    }

    private static String descriptions(final DatabaseAuditFailure failure) {
        return failure.findings().stream().map(Finding::description)
                .collect(Collectors.joining(NL));
    }

    private static String fixBlock(final List<DatabaseAuditFailure> failures,
            final ReportFormat format, final FixFormat fixFormat,
            final FixRenderer fixRenderer, final boolean wrapChangelog) {
        if (fixFormat == FixFormat.LIQUIBASE_XML) {
            final List<String> changeSets = failures.stream()
                    .flatMap(failure -> failure.findings().stream()
                            .map(finding -> fixRenderer.liquibaseChangeSet(
                                    finding, failure.platform())))
                    .toList();
            // A standalone changelog must not repeat a change-set id; identical
            // findings render byte-identical change sets, so collapse them.
            final String joined = (wrapChangelog ? changeSets.stream().distinct()
                    : changeSets.stream()).collect(Collectors.joining(NL));
            final String xml = wrapChangelog
                    ? LIQUIBASE_HEADER + NL + joined + NL + "</databaseChangeLog>"
                    : joined;
            return format.codeBlock("xml", xml);
        }
        final String sql = failures.stream()
                .flatMap(failure -> failure.findings().stream().map(finding -> {
                    final Fix fix =
                            fixRenderer.fixFor(finding, failure.platform());
                    return "-- " + failure.auditName() + " ["
                            + fix.fidelity().label() + "]" + NL + fix.ddl();
                })).collect(Collectors.joining(SECTION_BREAK));
        return format.codeBlock("sql", sql);
    }

    private static String plural(final int count, final String noun) {
        return count + " " + noun + (count == 1 ? "" : "s");
    }
}