AdocStudioMojo.java

package network.ike.docs.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 java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Generate Adoc Studio sidecar projects for assembly modules.
 *
 * <p>Extracts a bundled Swift script and runs it against the current
 * project directory. For each assembly module (directory with a
 * {@code pom.xml} and {@code src/docs/asciidoc/}), the script creates
 * an {@code .adocproject} file in the sidecar directory. Each project's
 * anchor folder contains a macOS NSURL bookmark pointing back into the
 * Maven source tree, so edits in Adoc Studio land on the canonical
 * sources.
 *
 * <p>The sidecar directory defaults to
 * {@code ~/Documents/ike-adoc-studio/} and stays outside the
 * Syncthing/git tree. Each machine generates its own bookmarks.
 *
 * <p>Prerequisite: run {@code mvn validate} first to unpack topic
 * dependencies into {@code target/generated-sources/asciidoc/} so
 * Adoc Studio can resolve includes.
 *
 * <p>Usage:
 * <pre>
 *   mvnw ike:adocstudio                          # default sidecar
 *   mvnw ike:adocstudio -Dadocstudio.outputDir=~/my-adoc-projects
 * </pre>
 *
 * <p>macOS only — the Swift runtime is required for NSURL bookmark
 * generation. On non-macOS platforms, the goal logs a warning and
 * exits cleanly.
 */
@Mojo(name = "adocstudio", projectRequired = false)
public class AdocStudioMojo 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; }

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

    private static final String SWIFT_RESOURCE =
            "/adocstudio/bootstrap-adocprojects.swift";

    /**
     * Root directory containing assembly modules. Defaults to the
     * current working directory (typically {@code ike-lab-documents/}).
     */
    @Parameter(property = "adocstudio.sourceDir",
               defaultValue = "${user.dir}")
    private String sourceDir;

    /**
     * Sidecar output directory for generated {@code .adocproject}
     * files. Each assembly gets a subdirectory here.
     */
    @Parameter(property = "adocstudio.outputDir",
               defaultValue = "${user.home}/Documents/ike-adoc-studio")
    private String outputDir;

    @Override
    public void execute() throws MojoException {
        getLog().info("");
        getLog().info("IKE Adoc Studio — Sidecar Generator");
        getLog().info("══════════════════════════════════════════════════════════════");

        // ── Platform guard ───────────────────────────────────
        if (!isMacOS()) {
            getLog().warn("  Adoc Studio sidecar generation requires macOS.");
            getLog().warn("  NSURL bookmarks cannot be created on this platform.");
            getLog().info("");
            return;
        }

        // ── Verify Swift is available ────────────────────────
        if (!isSwiftAvailable()) {
            throw new MojoException(
                    "Swift runtime not found. Install Xcode or "
                    + "Command Line Tools: xcode-select --install");
        }

        // ── Extract Swift script to temp file ────────────────
        Path scriptFile;
        try {
            scriptFile = extractScript();
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to extract Swift script from plugin JAR", e);
        }

        // ── Resolve paths ────────────────────────────────────
        Path source = Path.of(sourceDir).toAbsolutePath().normalize();
        Path output = Path.of(resolveHome(outputDir))
                          .toAbsolutePath().normalize();

        getLog().info("  Source: " + source);
        getLog().info("  Output: " + output);
        getLog().info("");

        if (!Files.isDirectory(source)) {
            throw new MojoException(
                    "Source directory does not exist: " + source);
        }

        // ── Execute Swift script ─────────────────────────────
        try {
            int exitCode = runSwift(scriptFile, source, output);
            if (exitCode != 0) {
                throw new MojoException(
                        "Swift script exited with code " + exitCode);
            }
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute Swift script", e);
        } finally {
            // Clean up temp file
            try {
                Files.deleteIfExists(scriptFile);
            } catch (IOException ignored) {
                // Best-effort cleanup
            }
        }

        getLog().info("");
        getLog().info("  Open any project in Adoc Studio from:");
        getLog().info("    " + output);
        getLog().info("");
        getLog().info("  Tip: run 'mvn validate' first to unpack topic");
        getLog().info("  dependencies so includes resolve in the preview.");
        getLog().info("");
    }

    // ── Helpers ──────────────────────────────────────────────

    private Path extractScript() throws IOException, MojoException {
        try (InputStream is = getClass().getResourceAsStream(SWIFT_RESOURCE)) {
            if (is == null) {
                throw new MojoException(
                        "Swift script not found on classpath: "
                        + SWIFT_RESOURCE);
            }
            Path tempScript = Files.createTempFile(
                    "ike-adocstudio-", ".swift");
            Files.write(tempScript, is.readAllBytes());
            return tempScript;
        }
    }

    private int runSwift(Path scriptFile, Path source, Path output)
            throws IOException, InterruptedException {

        ProcessBuilder pb = new ProcessBuilder(
                "swift", scriptFile.toString(),
                source.toString(),
                output.toString());
        pb.redirectErrorStream(false);

        Process proc = pb.start();

        // Stream stdout to Maven log
        try (BufferedReader stdout = new BufferedReader(
                new InputStreamReader(proc.getInputStream(),
                        StandardCharsets.UTF_8))) {
            stdout.lines().forEach(line -> getLog().info(line));
        }

        // Stream stderr to Maven log as warnings
        try (BufferedReader stderr = new BufferedReader(
                new InputStreamReader(proc.getErrorStream(),
                        StandardCharsets.UTF_8))) {
            stderr.lines().forEach(line -> getLog().warn(line));
        }

        return proc.waitFor();
    }

    private boolean isMacOS() {
        String os = System.getProperty("os.name", "").toLowerCase();
        return os.contains("mac") || os.contains("darwin");
    }

    private boolean isSwiftAvailable() {
        try {
            Process proc = new ProcessBuilder("swift", "--version")
                    .redirectErrorStream(true)
                    .start();
            proc.getInputStream().readAllBytes();
            return proc.waitFor() == 0;
        } catch (IOException | InterruptedException e) {
            return false;
        }
    }

    /**
     * Expand leading {@code ~} to user home directory.
     */
    private String resolveHome(String path) {
        if (path.startsWith("~/") || path.equals("~")) {
            return System.getProperty("user.home")
                    + path.substring(1);
        }
        return path;
    }
}