RenderSpdxLicensesMojo.java

package network.ike.plugin;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.yaml.snakeyaml.Yaml;

import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

/**
 * Render an SPDX-grouped {@code licenses.adoc} from the CycloneDX
 * SBOM (ike-issues#335).
 *
 * <p>Reads {@code target/bom.json} (produced by
 * {@code cyclonedx-maven-plugin} at {@code package} phase per
 * ike-issues#333), groups every component by its SPDX license
 * identifier or expression, and writes
 * {@code target/generated-site/asciidoc/licenses.adoc}. The Maven
 * Site Plugin then renders that AsciiDoc to
 * {@code target/site/licenses.html} with the same Forest-theme
 * chrome as every other site page — no manual HTML / skin-chrome
 * duplication.
 *
 * <p>Components with multiple {@code licenses} array entries (the
 * JRuby case: GPL-2.0 + LGPL-2.1 + EPL-2.0) are collapsed into a
 * single SPDX OR-expression with deterministic alphabetical
 * ordering, so the same combination always groups under the same
 * heading. Components carrying a CycloneDX
 * {@code license.expression} field use that verbatim.
 *
 * <p>The auto-generated {@code licenses.html} from
 * {@code maven-project-info-reports-plugin} should be disabled in
 * the reactor's {@code <reportSets>} configuration so the only
 * {@code licenses.html} produced is the SPDX-grouped one. Without
 * that disable step both reports race for the same target file
 * and the winner depends on plugin ordering.
 *
 * <p>Skip with {@code -Dike.skip.spdx-licenses=true} to fall back
 * to whatever the auto-generated {@code licenses} report would
 * produce (assuming it's still enabled in {@code <reportSets>}).
 *
 * <pre>{@code
 * mvn package                              # produces bom.json
 * mvn ike:render-spdx-licenses             # produces licenses.adoc
 * mvn site                                 # renders licenses.html
 * }</pre>
 *
 * @see GenerateBomMojo
 */
@Mojo(name = "render-spdx-licenses", defaultPhase = "pre-site")
public class RenderSpdxLicensesMojo implements org.apache.maven.api.plugin.Mojo {

    @org.apache.maven.api.di.Inject
    private org.apache.maven.api.plugin.Log log;

    /**
     * Access the Maven logger.
     *
     * @return the logger
     */
    protected org.apache.maven.api.plugin.Log getLog() { return log; }

    /**
     * Path to the CycloneDX SBOM produced by
     * {@code cyclonedx-maven-plugin} at {@code package} phase
     * (ike-issues#333). If the file is missing, this goal logs a
     * warning and exits cleanly — typically because the caller
     * skipped {@code package} or pre-condition phases haven't run
     * yet.
     */
    @Parameter(property = "ike.bom.path",
            defaultValue = "${project.build.directory}/bom.json")
    File bomPath;

    /**
     * Output AsciiDoc file. Default is the standard generated-site
     * location which Maven Site picks up at site:site time and
     * renders to {@code target/site/licenses.html} with the
     * project's skin chrome.
     */
    @Parameter(property = "ike.spdx-licenses.output",
            defaultValue = "${project.build.directory}/generated-site/asciidoc/licenses.adoc")
    File outputPath;

    /**
     * Skip generation. The auto-generated
     * {@code maven-project-info-reports-plugin} licenses report
     * remains as the licenses page if its reportSet still includes
     * {@code licenses}.
     */
    @Parameter(property = "ike.skip.spdx-licenses", defaultValue = "false")
    boolean skip;

    /** Project artifact ID, used in the rendered page title. */
    @Parameter(defaultValue = "${project.artifactId}", readonly = true)
    String projectArtifactId;

    /** Project version, used in the rendered page title. */
    @Parameter(defaultValue = "${project.version}", readonly = true)
    String projectVersion;

    /** Creates this goal instance. */
    public RenderSpdxLicensesMojo() {}

    @Override
    public void execute() throws MojoException {
        if (skip) {
            getLog().info("ike:render-spdx-licenses skipped "
                    + "(-Dike.skip.spdx-licenses=true)");
            return;
        }

        if (!bomPath.exists()) {
            getLog().warn("Skipping ike:render-spdx-licenses: SBOM not "
                    + "found at " + bomPath + ". Run 'mvn package' first "
                    + "to produce the SBOM (ike-issues#333). Falling "
                    + "back to the auto-generated licenses.html if "
                    + "maven-project-info-reports-plugin's licenses "
                    + "report is still enabled.");
            return;
        }

        Map<String, List<Component>> grouped;
        try {
            grouped = parseAndGroup(bomPath);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not parse SBOM at " + bomPath + ": "
                            + e.getMessage(), e);
        }

        String adoc = renderAdoc(grouped);

        File parent = outputPath.getParentFile();
        if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
            throw new MojoException(
                    "Could not create output directory: " + parent);
        }

        try {
            Files.writeString(outputPath.toPath(), adoc,
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not write " + outputPath + ": " + e.getMessage(),
                    e);
        }

        int totalComponents = grouped.values().stream()
                .mapToInt(List::size).sum();
        getLog().info("Wrote SPDX licenses page: " + outputPath);
        getLog().info("  Groups: " + grouped.size()
                + " distinct license expressions");
        getLog().info("  Components: " + totalComponents);
    }

    /**
     * Parse the SBOM JSON and group components by SPDX expression.
     *
     * <p>JSON is parsed via snakeyaml (JSON is a valid YAML 1.1
     * subset) so we don't need to add Jackson as a plugin
     * dependency — snakeyaml is already on the workspace-model
     * classpath.
     *
     * @param bom path to {@code bom.json}
     * @return map from SPDX expression to component list, ordered
     *         alphabetically by expression
     * @throws IOException if the file cannot be read or parsed
     */
    @SuppressWarnings("unchecked")
    private Map<String, List<Component>> parseAndGroup(File bom)
            throws IOException {
        Yaml yaml = new Yaml();
        Map<String, Object> root;
        try (Reader reader = Files.newBufferedReader(bom.toPath(),
                StandardCharsets.UTF_8)) {
            root = (Map<String, Object>) yaml.load(reader);
        }

        if (root == null) {
            return new TreeMap<>();
        }

        List<Map<String, Object>> components =
                (List<Map<String, Object>>) root.get("components");
        if (components == null) {
            return new TreeMap<>();
        }

        Map<String, List<Component>> grouped = new TreeMap<>();
        for (Map<String, Object> c : components) {
            String name    = stringOrEmpty(c.get("name"));
            String group   = stringOrEmpty(c.get("group"));
            String version = stringOrEmpty(c.get("version"));
            String purl    = stringOrEmpty(c.get("purl"));
            String spdx    = extractSpdxExpression(c);
            grouped.computeIfAbsent(spdx, k -> new ArrayList<>())
                    .add(new Component(group, name, version, purl));
        }

        // Sort components within each group by group:name:version.
        Comparator<Component> byCoord = Comparator
                .comparing((Component cm) -> cm.group)
                .thenComparing(cm -> cm.name)
                .thenComparing(cm -> cm.version);
        for (List<Component> list : grouped.values()) {
            list.sort(byCoord);
        }
        return grouped;
    }

    /**
     * Extract the SPDX license expression for a component.
     *
     * <p>Three CycloneDX shapes are handled, in priority order:
     * <ol>
     *   <li>A single license entry with {@code expression} set —
     *       used verbatim. This is how CycloneDX represents
     *       SPDX expressions like {@code Apache-2.0 OR MIT}.</li>
     *   <li>Multiple license entries with {@code id} fields —
     *       collapsed into a single OR-expression with
     *       alphabetical ordering for determinism. This is the
     *       JRuby case where the upstream POM declared three
     *       independent {@code <license>} blocks.</li>
     *   <li>A single license entry with {@code id} or {@code name}
     *       — used verbatim. Falls back to {@code name} for
     *       components whose POM declares a non-SPDX string.</li>
     * </ol>
     *
     * <p>Components with no license metadata are grouped under the
     * pseudo-identifier {@code (no license declared)} so they
     * surface visibly rather than silently disappearing.
     *
     * @param c the component map from the SBOM
     * @return the SPDX expression for grouping
     */
    @SuppressWarnings("unchecked")
    private String extractSpdxExpression(Map<String, Object> c) {
        Object licensesObj = c.get("licenses");
        if (!(licensesObj instanceof List<?> raw) || raw.isEmpty()) {
            return "(no license declared)";
        }
        List<Map<String, Object>> entries = (List<Map<String, Object>>) raw;

        // Shape (1): single entry with a top-level expression.
        if (entries.size() == 1) {
            Object exp = entries.get(0).get("expression");
            if (exp != null) {
                return exp.toString();
            }
        }

        // Collect SPDX IDs (or names) from each entry.
        List<String> ids = new ArrayList<>();
        for (Map<String, Object> entry : entries) {
            Map<String, Object> license =
                    (Map<String, Object>) entry.get("license");
            if (license == null) continue;
            Object id   = license.get("id");
            Object name = license.get("name");
            if (id != null) {
                ids.add(id.toString());
            } else if (name != null) {
                ids.add(name.toString());
            }
        }

        if (ids.isEmpty()) {
            return "(no license declared)";
        }
        if (ids.size() == 1) {
            return ids.get(0);
        }

        // Shape (2): synthesize OR-expression. Alphabetical
        // ordering keeps the same component-license combination
        // grouped under the same heading on every build.
        ids.sort(Comparator.naturalOrder());
        return String.join(" OR ", ids);
    }

    /**
     * Render the grouped components to AsciiDoc.
     *
     * @param grouped components keyed by SPDX expression
     * @return AsciiDoc page source
     */
    private String renderAdoc(Map<String, List<Component>> grouped) {
        StringBuilder sb = new StringBuilder();
        sb.append("= Licenses (SPDX)\n");
        sb.append(":icons: font\n");
        sb.append(":source-highlighter: coderay\n");
        sb.append(":toc: preamble\n");
        sb.append(":toclevels: 2\n\n");
        sb.append("Licenses for declared dependencies of `")
                .append(projectArtifactId)
                .append("` ")
                .append(projectVersion)
                .append(", grouped by SPDX expression. ");
        sb.append("Rendered from `link:bom.json[bom.json]` ")
                .append("(CycloneDX) at `pre-site` phase by ")
                .append("`ike:render-spdx-licenses` ")
                .append("(ike-issues#335).\n\n");

        if (grouped.isEmpty()) {
            sb.append("No declared dependencies in this module's "
                    + "SBOM — nothing to list.\n");
            return sb.toString();
        }

        // Summary table: one row per SPDX expression with the
        // component count.
        sb.append("== Summary\n\n");
        sb.append("|===\n");
        sb.append("| SPDX Expression | Components\n\n");
        int totalComponents = 0;
        for (Map.Entry<String, List<Component>> g : grouped.entrySet()) {
            sb.append("| ");
            sb.append(formatLicenseHeader(g.getKey()));
            sb.append("\n| ").append(g.getValue().size()).append("\n\n");
            totalComponents += g.getValue().size();
        }
        sb.append("| *Total* | *").append(totalComponents).append("*\n");
        sb.append("|===\n\n");

        // Per-license sections.
        for (Map.Entry<String, List<Component>> g : grouped.entrySet()) {
            String spdx = g.getKey();
            sb.append("== ").append(spdx).append("\n\n");

            String spdxUrl = spdxUrl(spdx);
            if (spdxUrl != null) {
                sb.append("Reference: ").append(spdxUrl).append("[")
                        .append(spdx).append(" on spdx.org]\n\n");
            }

            sb.append("|===\n");
            sb.append("| Group | Artifact | Version\n\n");
            for (Component c : g.getValue()) {
                sb.append("| `").append(escapeAdoc(c.group)).append("`\n");
                sb.append("| `").append(escapeAdoc(c.name)).append("`\n");
                sb.append("| `").append(escapeAdoc(c.version)).append("`\n\n");
            }
            sb.append("|===\n\n");
        }

        sb.append("== See also\n\n");
        sb.append("* link:bom.json[Software Bill of Materials (CycloneDX)] — "
                + "the canonical machine-readable inventory this page "
                + "is derived from.\n");
        sb.append("* link:THIRD_PARTY_NOTICES.html[Third-Party Notices] — "
                + "curated companion that covers components mechanical "
                + "reports can't see (Maven Site skin, external "
                + "services, fonts inside artifacts).\n");
        sb.append("* link:dependency-info.html[Dependency Info] — "
                + "consumption snippet for this module.\n");
        return sb.toString();
    }

    /**
     * Format an SPDX expression for display in a table cell.
     *
     * <p>Wraps in backticks so AsciiDoc renders it as inline code
     * (visually distinct from prose). The pseudo-id
     * {@code (no license declared)} is rendered as italics
     * instead, since it isn't really a code identifier.
     *
     * @param spdx the SPDX expression
     * @return rendered cell content
     */
    private String formatLicenseHeader(String spdx) {
        if (spdx.startsWith("(") && spdx.endsWith(")")) {
            return "_" + spdx + "_";
        }
        return "`" + spdx + "`";
    }

    /**
     * Build an spdx.org URL for a single SPDX identifier.
     *
     * <p>Returns {@code null} for compound expressions
     * ({@code OR} / {@code AND} / {@code WITH}) and the
     * {@code (no license declared)} pseudo-id, since those don't
     * resolve to a single SPDX entry.
     *
     * @param spdx the SPDX expression
     * @return the URL, or {@code null} if not a single identifier
     */
    private String spdxUrl(String spdx) {
        if (spdx == null
                || spdx.isBlank()
                || spdx.startsWith("(")
                || spdx.contains(" OR ")
                || spdx.contains(" AND ")
                || spdx.contains(" WITH ")) {
            return null;
        }
        return "https://spdx.org/licenses/" + spdx + ".html";
    }

    /**
     * Escape a string for safe AsciiDoc inline-code use.
     *
     * <p>Backticks are the only character that can break out of
     * inline code spans. Everything else (including HTML special
     * characters) is fine inside {@code `…`}.
     *
     * @param s the input string
     * @return the escaped string
     */
    private String escapeAdoc(String s) {
        if (s == null) return "";
        return s.replace("`", "\\`");
    }

    /**
     * Coerce a possibly-null map value to a non-null string.
     *
     * @param o the value
     * @return the string value, or empty if null
     */
    private static String stringOrEmpty(Object o) {
        return o == null ? "" : o.toString();
    }

    /** A single component projected from the SBOM. */
    private static final class Component {
        final String group;
        final String name;
        final String version;
        final String purl;

        Component(String group, String name, String version, String purl) {
            this.group = group;
            this.name = name;
            this.version = version;
            this.purl = purl;
        }
    }
}