MavenWrapper.java

package network.ike.plugin.ws;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Shared generator for the three Maven Wrapper files that every workspace
 * needs at the root:
 *
 * <ul>
 *   <li>{@code .mvn/wrapper/maven-wrapper.properties}</li>
 *   <li>{@code mvnw} (POSIX launcher — must be LF; executable bit set)</li>
 *   <li>{@code mvnw.cmd} (Windows launcher — must be CRLF on Windows; see
 *       {@code .gitattributes} {@code *.cmd text eol=crlf} rule)</li>
 * </ul>
 *
 * <p>The {@code mvnw} / {@code mvnw.cmd} scripts are the <b>standard
 * Apache Maven Wrapper</b> launchers, vendored verbatim as plugin
 * resources under {@code wrapper/}. The {@code only-script} distribution
 * type is used — the scripts download Maven themselves, so no
 * {@code maven-wrapper.jar} binary is committed to a workspace repo.
 * The properties file carries the standard {@code wrapperVersion},
 * {@code distributionType}, and {@code distributionUrl} keys, which is
 * what IDEs (IntelliJ) key on to auto-adopt the wrapper.
 *
 * <p>Used by {@code ws:scaffold-init} when scaffolding a new workspace,
 * and by {@code ScaffoldConventionReconciler}'s {@code mvnw} step (run
 * as part of {@code ws:scaffold-publish}) — both to fill in missing
 * files and to replace the legacy custom "minimal bootstrap" wrapper
 * that earlier plugin versions generated (IKE-Network/ike-issues#405).
 */
public final class MavenWrapper {

    /**
     * Apache Maven Wrapper version of the vendored {@code mvnw} /
     * {@code mvnw.cmd} scripts. Written into {@code wrapperVersion} of
     * the generated {@code maven-wrapper.properties}.
     */
    public static final String WRAPPER_VERSION = "3.3.2";

    /**
     * Wrapper distribution type. {@code only-script} keeps the wrapper
     * jar-free — the launcher scripts download Maven directly.
     */
    public static final String DISTRIBUTION_TYPE = "only-script";

    /** Extracts the Maven version from a wrapper {@code distributionUrl}. */
    private static final Pattern DISTRIBUTION_VERSION =
            Pattern.compile("apache-maven-(.+?)-bin\\.(?:zip|tar\\.gz)");

    private MavenWrapper() {}

    /**
     * Install any of the three wrapper files that are missing from the
     * given workspace directory. Never overwrites an existing file — the
     * user may have pinned a specific version in
     * {@code maven-wrapper.properties} or customized the launcher scripts.
     * To replace a legacy wrapper in full, use {@link #writeAll}.
     *
     * @param wsDir        workspace root
     * @param mavenVersion Maven version to write into
     *                     {@code maven-wrapper.properties} when creating it
     * @return the number of files created (0 when all three already exist)
     * @throws IOException if writing any of the files fails
     */
    public static int writeMissingFiles(Path wsDir, String mavenVersion) throws IOException {
        int written = 0;

        Path propsFile = wsDir.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.properties");
        if (!Files.exists(propsFile)) {
            writePropertiesFile(propsFile, mavenVersion);
            written++;
        }

        Path mvnw = wsDir.resolve("mvnw");
        if (!Files.exists(mvnw)) {
            writeMvnwScript(mvnw);
            written++;
        }

        Path mvnwCmd = wsDir.resolve("mvnw.cmd");
        if (!Files.exists(mvnwCmd)) {
            writeMvnwCmdScript(mvnwCmd);
            written++;
        }

        return written;
    }

    /**
     * Unconditionally (re)write all three wrapper files, overwriting any
     * that already exist. Used to replace the legacy custom wrapper with
     * the standard one — see {@link #isLegacyWrapper}.
     *
     * @param wsDir        workspace root
     * @param mavenVersion Maven version to write into
     *                     {@code maven-wrapper.properties}
     * @throws IOException if writing any of the files fails
     */
    public static void writeAll(Path wsDir, String mavenVersion) throws IOException {
        writePropertiesFile(
                wsDir.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.properties"),
                mavenVersion);
        writeMvnwScript(wsDir.resolve("mvnw"));
        writeMvnwCmdScript(wsDir.resolve("mvnw.cmd"));
    }

    /**
     * Report whether the workspace carries the legacy custom "minimal
     * bootstrap" {@code mvnw} that pre-standardization plugin versions
     * generated. Detected by the {@code minimal bootstrap} marker in the
     * script header — the standard Apache launcher does not carry it,
     * and neither would a user's own hand-written {@code mvnw}.
     *
     * @param wsDir workspace root
     * @return true when {@code mvnw} exists and is the legacy custom
     *         launcher; false when absent, standard, or user-authored
     * @throws IOException if {@code mvnw} exists but cannot be read
     */
    public static boolean isLegacyWrapper(Path wsDir) throws IOException {
        Path mvnw = wsDir.resolve("mvnw");
        if (!Files.exists(mvnw)) {
            return false;
        }
        return Files.readString(mvnw, StandardCharsets.UTF_8).contains("minimal bootstrap");
    }

    /**
     * Read the pinned Maven version from an existing
     * {@code .mvn/wrapper/maven-wrapper.properties}, or return null if the
     * file does not exist or no version can be determined.
     *
     * <p>The version is parsed from the standard {@code distributionUrl}
     * key. A legacy wrapper's explicit {@code maven.version} key is used
     * as a fallback so that migrating an old workspace preserves its
     * pinned version.
     *
     * @param wsDir workspace root
     * @return the pinned Maven version, or null when not determinable
     * @throws IOException if the properties file exists but cannot be read
     */
    public static String readPinnedVersion(Path wsDir) throws IOException {
        Path propsFile = wsDir.resolve(".mvn").resolve("wrapper").resolve("maven-wrapper.properties");
        if (!Files.exists(propsFile)) {
            return null;
        }
        Properties props = new Properties();
        try (var reader = Files.newBufferedReader(propsFile, StandardCharsets.UTF_8)) {
            props.load(reader);
        }
        String url = props.getProperty("distributionUrl");
        if (url != null) {
            Matcher m = DISTRIBUTION_VERSION.matcher(url);
            if (m.find()) {
                return m.group(1);
            }
        }
        return props.getProperty("maven.version");
    }

    /**
     * Write {@code maven-wrapper.properties} in the standard
     * {@code only-script} form for the given Maven version. Creates the
     * parent directory as needed. Overwrites any existing file.
     *
     * @param propsFile    target path (typically
     *                     {@code .mvn/wrapper/maven-wrapper.properties})
     * @param mavenVersion Maven version (e.g. {@code 4.0.0-rc-5})
     * @throws IOException if writing fails
     */
    public static void writePropertiesFile(Path propsFile, String mavenVersion) throws IOException {
        Files.createDirectories(propsFile.getParent());
        String props = "# Maven Wrapper properties — managed by ws:scaffold-init from workspace.yaml\n"
                + "wrapperVersion=" + WRAPPER_VERSION + "\n"
                + "distributionType=" + DISTRIBUTION_TYPE + "\n"
                + "distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/"
                + "apache-maven/" + mavenVersion + "/apache-maven-" + mavenVersion
                + "-bin.zip\n";
        Files.writeString(propsFile, props, StandardCharsets.UTF_8);
    }

    /**
     * Write the standard Apache POSIX {@code mvnw} launcher script and
     * mark it executable. The script reads {@code distributionUrl} from
     * {@code .mvn/wrapper/maven-wrapper.properties} at runtime and
     * downloads Maven on first use. Overwrites any existing file.
     *
     * @param mvnw target path (typically {@code mvnw} at workspace root)
     * @throws IOException if writing fails
     */
    public static void writeMvnwScript(Path mvnw) throws IOException {
        copyResource("wrapper/mvnw", mvnw);
        mvnw.toFile().setExecutable(true);
    }

    /**
     * Write the standard Apache Windows {@code mvnw.cmd} launcher script.
     * The workspace {@code .gitattributes} {@code *.cmd text eol=crlf}
     * rule is what keeps this file usable on Windows after checkout —
     * without it, cmd.exe chokes on LF line endings
     * (IKE-Network/ike-issues#189). Overwrites any existing file.
     *
     * @param mvnwCmd target path (typically {@code mvnw.cmd} at workspace root)
     * @throws IOException if writing fails
     */
    public static void writeMvnwCmdScript(Path mvnwCmd) throws IOException {
        copyResource("wrapper/mvnw.cmd", mvnwCmd);
    }

    /**
     * Copy a vendored wrapper resource verbatim to the target path,
     * preserving its byte content (and thus line endings).
     *
     * @param resource resource name relative to this class's package
     * @param target   destination path
     * @throws IOException if the resource is missing or the copy fails
     */
    private static void copyResource(String resource, Path target) throws IOException {
        if (target.getParent() != null) {
            Files.createDirectories(target.getParent());
        }
        try (InputStream in = MavenWrapper.class.getResourceAsStream(resource)) {
            if (in == null) {
                throw new IOException("Bundled Maven wrapper resource not found: " + resource);
            }
            Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }
}