IkeCascadeExportMojo.java

package network.ike.plugin;

import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
import network.ike.workspace.cascade.CascadeAssembler;
import network.ike.workspace.cascade.CascadeEdge;
import network.ike.workspace.cascade.ProjectCascade;
import network.ike.workspace.cascade.ProjectCascadeIo;
import network.ike.workspace.cascade.ReleaseCascade;
import network.ike.workspace.cascade.UrlCascadeResolver;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Exports the foundation release cascade topology in a machine-readable
 * format (IKE-Network/ike-issues#403, #420).
 *
 * <p>Read-only. The cascade is decentralized — each foundation repo
 * version-controls its own {@code src/main/cascade/release-cascade.yaml}
 * declaring only its own edges (#420). This goal reads the local
 * repo's manifest, walks the edges into its sibling checkouts to
 * assemble the full ordered graph, and writes it as JSON or
 * {@code .properties} so a CI meta-runner can derive the build-chain
 * edges from the cascade instead of hand-wiring them.
 *
 * <p>The traversal resolves each member sibling-first: a member
 * checked out as a directory alongside this repo is read from disk;
 * one that is not is shallow-cloned from its edge's {@code url}
 * (IKE-Network/ike-issues#429). The goal therefore works both on a
 * developer workstation with sibling checkouts and on a CI agent
 * that has only this repo checked out.
 *
 * <p>Usage:
 * <pre>
 *   mvn ike:cascade-export                          # JSON to stdout
 *   mvn ike:cascade-export -Dformat=properties
 *   mvn ike:cascade-export -DoutputFile=target/cascade.json
 *   mvn ike:cascade-export -Dike.release.cascade.clone-dir=/path
 * </pre>
 */
@Mojo(name = "cascade-export", projectRequired = false, aggregator = true)
public class IkeCascadeExportMojo extends AbstractGoalMojo {

    /** Output format: {@code json} (default) or {@code properties}. */
    @Parameter(property = "format", defaultValue = "json")
    String format;

    /**
     * File to write the export to. When unset, the export is logged
     * to stdout.
     */
    @Parameter(property = "outputFile")
    String outputFile;

    /**
     * Directory for the shallow clones made when a cascade member is
     * not checked out as a local sibling (IKE-Network/ike-issues#429).
     * Defaults to a fresh temporary directory.
     */
    @Parameter(property = "ike.release.cascade.clone-dir")
    String cascadeCloneDir;

    /** Override working directory for tests. If null, uses current directory. */
    File baseDir;

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

    @Override
    protected GoalReportSpec runGoal() throws MojoException {
        File startDir = baseDir != null ? baseDir : new File(".");
        File gitRoot = ReleaseSupport.gitRoot(startDir);

        CascadeExportFormat exportFormat;
        try {
            exportFormat = CascadeExportFormat.fromString(format);
        } catch (IllegalArgumentException e) {
            throw new MojoException(e.getMessage());
        }

        ReleaseCascade cascade = assembleCascade(gitRoot);
        String rendered = exportFormat.render(cascade);
        String formatLabel = exportFormat.name().toLowerCase();

        String location;
        if (outputFile != null && !outputFile.isBlank()) {
            Path out = Path.of(outputFile);
            try {
                if (out.getParent() != null) {
                    Files.createDirectories(out.getParent());
                }
                Files.writeString(out, rendered, StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new MojoException(
                        "Could not write " + out + ": " + e.getMessage(), e);
            }
            getLog().info("Cascade exported (" + formatLabel + ") to " + out);
            location = "written to `" + out + "`";
        } else {
            getLog().info("Cascade export (" + formatLabel + "):");
            rendered.lines().forEach(getLog()::info);
            location = "printed to the build log";
        }

        String report = new GoalReportBuilder()
                .section("Cascade export")
                .paragraph("Assembled the release cascade from the"
                        + " per-project `release-cascade.yaml` manifests"
                        + " and exported it as **" + formatLabel + "**, "
                        + location + ".")
                .codeBlock(formatLabel, rendered)
                .build();
        return new GoalReportSpec(IkeGoal.CASCADE_EXPORT,
                startDir.toPath(), report);
    }

    /**
     * Reads the local repo's {@code release-cascade.yaml} and assembles
     * the full cascade graph. Each edge is resolved sibling-first: a
     * member checked out alongside this repo is read from disk; one
     * that is not is shallow-cloned from its {@code url}.
     */
    private ReleaseCascade assembleCascade(File gitRoot) {
        Path localManifest = gitRoot.toPath().resolve(
                ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
        ProjectCascade local = ProjectCascadeIo.load(localManifest)
                .orElseThrow(() -> new MojoException(
                        "No " + ProjectCascadeIo.MANIFEST_RELATIVE_PATH
                        + " in " + gitRoot + " — run ike:cascade-export"
                        + " from a foundation cascade repo."));

        File rootPom = new File(gitRoot, "pom.xml");
        CascadeEdge start = new CascadeEdge(
                ReleaseSupport.readPomGroupId(rootPom),
                ReleaseSupport.readPomArtifactId(rootPom),
                gitRoot.getName(), null, null);

        File siblings = gitRoot.getParentFile();
        UrlCascadeResolver urlResolver = new UrlCascadeResolver(
                resolveCloneDir(), getLog()::info);
        try {
            return CascadeAssembler.assemble(start, local, edge -> {
                Path sibling = siblings.toPath().resolve(edge.repo())
                        .resolve(ProjectCascadeIo.MANIFEST_RELATIVE_PATH);
                if (Files.isRegularFile(sibling)) {
                    return ProjectCascadeIo.read(sibling);
                }
                return urlResolver.resolve(edge);
            });
        } catch (RuntimeException e) {
            throw new MojoException(
                    "Cannot assemble the release cascade: "
                    + e.getMessage() + " — a member not checked out as a"
                    + " local sibling is cloned from its url, so every"
                    + " edge must declare one.", e);
        }
    }

    /**
     * The directory shallow clones land in — the
     * {@code ike.release.cascade.clone-dir} property, or a fresh
     * temporary directory when it is unset.
     */
    private Path resolveCloneDir() {
        if (cascadeCloneDir != null && !cascadeCloneDir.isBlank()) {
            return Path.of(cascadeCloneDir);
        }
        try {
            return Files.createTempDirectory("ike-cascade-");
        } catch (IOException e) {
            throw new MojoException(
                    "cannot create a temporary clone directory: "
                    + e.getMessage(), e);
        }
    }
}