WorkspaceReport.java

package network.ike.plugin.ws;

import org.apache.maven.api.plugin.Log;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

/**
 * Per-goal report writer for {@code ws:*} goals.
 *
 * <p>Each goal writes its own file directly at the workspace root
 * (alongside {@code workspace.yaml} and the aggregator {@code pom.xml}).
 * Files are <b>overwritten</b> on each run (not appended), so the
 * content always reflects the latest execution.
 *
 * <p>Filenames use {@code ꞉} (U+A789 MODIFIER LETTER COLON) to cluster
 * visually as {@code ws꞉goal-name.md} in IDE file browsers. For
 * draft/publish goals, the filename includes the variant:
 * {@code ws꞉feature-start-draft.md}, {@code ws꞉feature-start-publish.md}.
 *
 * <p><strong>Self-healing gitignore:</strong> before writing, this class
 * ensures {@code ws꞉*.md} is listed in the {@code .gitignore} of the
 * nearest {@code .git} ancestor. If the pattern is missing, it is
 * appended. This keeps reports out of git without any manual setup —
 * a fresh clone of a workspace becomes report-ready the first time a
 * {@code ws:*} goal runs.
 *
 * <p>Parallels {@code network.ike.plugin.IkeReport} in the ike plugin;
 * both writers now target their respective project roots.
 */
public final class WorkspaceReport {

    /** U+A789 MODIFIER LETTER COLON — filesystem-safe visual colon. */
    private static final char COLON = '\uA789';

    /**
     * Glob appended to {@code .gitignore} when missing. Matches every
     * {@code ws꞉*.md} report at the workspace root.
     */
    static final String GITIGNORE_PATTERN = "ws\uA789*.md";

    private static final DateTimeFormatter TIMESTAMP_FMT =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    private WorkspaceReport() {}

    /**
     * Write a goal's report to its per-goal file at the workspace root,
     * overwriting any previous content. Self-heals the nearest
     * {@code .gitignore} so the report does not land in git.
     *
     * @param workspaceRoot the workspace root directory
     * @param goalName      the goal name including variant (e.g., "ws:feature-start-draft")
     * @param content       the markdown content to write
     * @param log           Maven logger (warnings only; null-safe)
     */
    public static void write(Path workspaceRoot, String goalName,
                              String content, Log log) {
        String filename = "ws" + COLON + stripPrefix(goalName) + ".md";
        Path reportFile = workspaceRoot.resolve(filename);

        try {
            ensureGitignored(workspaceRoot, log);

            String timestamp = LocalDateTime.now().format(TIMESTAMP_FMT);
            String fullContent = "# " + goalName + "\n"
                    + "_" + timestamp + " · ike-workspace-maven-plugin "
                    + pluginVersion() + "_\n\n"
                    + content.stripTrailing() + "\n";

            Files.writeString(reportFile, fullContent, StandardCharsets.UTF_8);
        } catch (IOException e) {
            if (log != null) {
                log.debug("Could not write report " + filename + ": "
                        + e.getMessage());
            }
        }
    }

    /**
     * Resolve the report file path for a specific goal.
     *
     * @param workspaceRoot the workspace root directory
     * @param goalName      the goal name (e.g., "ws:overview")
     * @return path to the report file at the workspace root (may not exist yet)
     */
    public static Path reportPath(Path workspaceRoot, String goalName) {
        String filename = "ws" + COLON + stripPrefix(goalName) + ".md";
        return workspaceRoot.resolve(filename);
    }

    /**
     * Open the workspace root in the default file manager or IDE so
     * the user can browse the {@code ws꞉*.md} reports alongside
     * {@code workspace.yaml} and the aggregator {@code pom.xml}.
     *
     * @param workspaceRoot the workspace root directory
     * @param log           Maven logger
     * @return {@code true} if opened successfully
     */
    public static boolean openInBrowser(Path workspaceRoot, Log log) {
        if (!Files.isDirectory(workspaceRoot)) {
            if (log != null) {
                log.warn("Workspace root not found: " + workspaceRoot);
            }
            return false;
        }

        try {
            if (System.getProperty("os.name", "").toLowerCase().contains("mac")) {
                new ProcessBuilder("open", workspaceRoot.toString()).start();
                return true;
            }
            if (java.awt.Desktop.isDesktopSupported()) {
                java.awt.Desktop.getDesktop().open(workspaceRoot.toFile());
                return true;
            }
        } catch (IOException e) {
            if (log != null) {
                log.warn("Could not open workspace root: " + e.getMessage());
            }
        }
        return false;
    }

    /**
     * Walk up from {@code workspaceRoot} looking for a {@code .git}
     * directory; ensure its sibling {@code .gitignore} lists
     * {@link #GITIGNORE_PATTERN}. If the file is missing, create it.
     * If the pattern is missing, append it. No-op when no {@code .git}
     * ancestor is found (e.g. the workspace is not yet in a git repo —
     * pipeline-ws itself is a syncthing folder rather than a git repo).
     *
     * @param workspaceRoot the workspace root to search from
     * @param log           Maven logger (null-safe)
     * @throws IOException if the gitignore file cannot be read or written
     */
    static void ensureGitignored(Path workspaceRoot, Log log)
            throws IOException {
        Path gitRoot = findGitRoot(workspaceRoot);
        if (gitRoot == null) return;

        Path gitignore = gitRoot.resolve(".gitignore");
        if (Files.exists(gitignore)) {
            List<String> lines = Files.readAllLines(gitignore,
                    StandardCharsets.UTF_8);
            for (String line : lines) {
                if (matchesPattern(line.trim(), GITIGNORE_PATTERN)) {
                    return;
                }
            }
            String existing = Files.readString(gitignore,
                    StandardCharsets.UTF_8);
            String appended = existing.endsWith("\n") ? existing
                    : existing + "\n";
            Files.writeString(gitignore,
                    appended + "\n# ws:* goal reports\n"
                            + GITIGNORE_PATTERN + "\n",
                    StandardCharsets.UTF_8);
        } else {
            Files.writeString(gitignore,
                    "# ws:* goal reports\n"
                            + GITIGNORE_PATTERN + "\n",
                    StandardCharsets.UTF_8);
        }
        if (log != null) {
            log.info("Added " + GITIGNORE_PATTERN
                    + " to " + gitRoot.relativize(gitignore));
        }
    }

    /**
     * Walk up from {@code start} looking for a directory that contains
     * a {@code .git} entry (directory or file — the latter for
     * worktrees and submodules).
     *
     * @param start the starting directory for the search
     * @return the git root directory, or {@code null} if none is found
     */
    private static Path findGitRoot(Path start) {
        Path current = start.toAbsolutePath();
        while (current != null) {
            if (Files.exists(current.resolve(".git"))) return current;
            current = current.getParent();
        }
        return null;
    }

    /**
     * Test whether a {@code .gitignore} line matches the given pattern
     * after normalizing leading slashes and skipping comments or blanks.
     *
     * @param line    the {@code .gitignore} line, already trimmed
     * @param pattern the normalized pattern to match against
     * @return {@code true} if the line covers the pattern
     */
    private static boolean matchesPattern(String line, String pattern) {
        if (line.isEmpty() || line.startsWith("#")) return false;
        String normalized = line.startsWith("/") ? line.substring(1) : line;
        return normalized.equals(pattern);
    }

    /**
     * Read the plugin version from the JAR manifest's
     * {@code Implementation-Version} attribute (set by
     * {@code maven-jar-plugin}'s {@code addDefaultImplementationEntries}).
     * Reports persist across plugin upgrades, so embedding the version
     * in the header lets a reader tell which release generated an old
     * file when the goal's output format or semantics has shifted.
     *
     * @return the plugin version, or {@code "(unknown)"} when the
     *         manifest attribute is absent (e.g. during in-IDE test runs)
     */
    static String pluginVersion() {
        String v = WorkspaceReport.class.getPackage()
                .getImplementationVersion();
        return v != null ? v : "(unknown)";
    }

    /**
     * Strip the "ws:" prefix from a goal name for use in filenames.
     * "ws:overview" → "overview", "ws:feature-start-draft" → "feature-start-draft"
     */
    private static String stripPrefix(String goalName) {
        if (goalName.startsWith("ws:")) {
            return goalName.substring(3);
        }
        return goalName;
    }
}