File: Projects\JavaAppHostToolchainResolver.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.TypeSystem;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Projects;
 
/// <summary>
/// The build tool that owns a Java AppHost project.
/// </summary>
internal enum JavaAppHostToolchain
{
    /// <summary>
    /// No build tool. The AppHost is a single file compiled directly with <c>javac</c>.
    /// </summary>
    Javac,
 
    /// <summary>
    /// A Maven project, identified by <c>pom.xml</c>.
    /// </summary>
    Maven,
 
    /// <summary>
    /// A Gradle project, identified by <c>build.gradle</c> or <c>build.gradle.kts</c>.
    /// </summary>
    Gradle
}
 
/// <summary>
/// Selects the build tool for a Java AppHost from the files on disk and rewrites the runtime spec to
/// match, mirroring what <see cref="TypeScriptAppHostToolchainResolver"/> does for package managers.
/// </summary>
/// <remarks>
/// <para>
/// Java AppHosts started life as a single <c>AppHost.java</c> compiled with <c>javac</c>, which needs
/// nothing but a JDK. That remains the default and the fallback: adopting a build tool is opt-in, and
/// is done by adding a <c>pom.xml</c> or <c>build.gradle</c>. A project model is what unlocks
/// third-party dependencies and full IDE support, because the Java language server derives its
/// classpath from build files.
/// </para>
/// <para>
/// Under a build tool the AppHost is still launched with a plain <c>java</c> command rather than
/// through <c>mvn exec:java</c> or <c>gradle run</c>. Those run the application inside, or as a child
/// of, the build tool's own JVM, so console signals reach the build tool instead of the AppHost and an
/// orderly shutdown never happens. Launching directly also means the arguments the CLI appends arrive
/// as real argv entries instead of being spliced into a shell string.
/// </para>
/// </remarks>
internal static class JavaAppHostToolchainResolver
{
    private const string MavenPomFileName = "pom.xml";
    private const string GradleBuildFileName = "build.gradle";
    private const string GradleKotlinBuildFileName = "build.gradle.kts";
    private const string GradleSettingsFileName = "settings.gradle";
    private const string GradleKotlinSettingsFileName = "settings.gradle.kts";
 
    /// <summary>
    /// Directory the resolved runtime dependencies are copied into, relative to the build output.
    /// A directory of JARs is used rather than a classpath string because the JVM expands a
    /// <c>dir/*</c> classpath entry itself, so no shell and no generated argument file is needed.
    /// </summary>
    private const string DependencyDirectoryName = "aspire-deps";
 
    /// <summary>
    /// Gradle init script that adds a dependency-copying task to whatever project is being built.
    /// </summary>
    /// <remarks>
    /// An init script is used so a Gradle AppHost needs no cooperation from the user's build file.
    /// Maven ships <c>dependency:copy-dependencies</c> out of the box, but Gradle has no built-in
    /// equivalent, and requiring an edit to <c>build.gradle</c> would make adopting Gradle a worse
    /// experience than Maven for no reason.
    /// See https://docs.gradle.org/current/userguide/init_scripts.html.
    /// </remarks>
    private const string GradleInitScript = $$"""
        // Generated by Aspire. Adds the task that stages the AppHost's runtime classpath so the
        // AppHost can be launched with a plain `java` command instead of through Gradle.
        //
        // Sync rather than Copy: the classpath is the whole directory, so a JAR left behind by a
        // dependency that was upgraded or removed would still be loaded. Sync deletes whatever is
        // in the destination but not in the configuration, and stays incremental while doing it.
        // https://docs.gradle.org/current/dsl/org.gradle.api.tasks.Sync.html
        allprojects {
            tasks.register("aspireCopyDependencies", Sync) {
                from configurations.named("runtimeClasspath")
                into layout.buildDirectory.dir("{{DependencyDirectoryName}}")
            }
        }
        """;
 
    internal const string GradleInitScriptRelativePath = ".aspire/aspire-gradle-init.gradle";
 
    /// <summary>
    /// <see cref="GradleInitScriptRelativePath"/> with the separators this platform uses.
    /// </summary>
    private static string GradleInitScriptPath => GradleInitScriptRelativePath.Replace('/', Path.DirectorySeparatorChar);
 
    public static bool IsJavaLanguage(LanguageInfo? language)
    {
        return language is not null &&
            language.LanguageId.Value.Equals(KnownLanguageId.Java, StringComparison.OrdinalIgnoreCase);
    }
 
    /// <summary>
    /// Conventional source root a build tool compiles from. When the AppHost lives here the build
    /// file is at the project root, three directories up.
    /// https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html
    /// </summary>
    private static readonly string[] s_conventionalSourceRootSegments = ["src", "main", "java"];
 
    public static JavaAppHostToolchainResolution Resolve(DirectoryInfo appHostDirectory, ILogger? logger = null)
    {
        var resolution = ResolveWithReason(appHostDirectory);
        logger?.LogDebug(
            "Selected Java AppHost build tool '{BuildTool}' rooted at '{ProjectDirectory}' because {Reason}.",
            resolution.Toolchain,
            resolution.ProjectDirectory.FullName,
            resolution.Reason);
 
        return resolution;
    }
 
    internal static JavaAppHostToolchainResolution ResolveWithReason(DirectoryInfo appHostDirectory)
    {
        // The AppHost directory is checked first, then the project root implied by the conventional
        // source layout. An unbounded walk up the tree is deliberately avoided: a pom.xml in some
        // arbitrary ancestor usually belongs to an unrelated project that happens to contain the
        // AppHost folder, whereas src/main/java is only ever a build tool's source root.
        foreach (var candidate in GetCandidateProjectDirectories(appHostDirectory))
        {
            if (File.Exists(Path.Combine(candidate.FullName, MavenPomFileName)))
            {
                return new(JavaAppHostToolchain.Maven, candidate, $"{MavenPomFileName} found in {candidate.FullName}");
            }
 
            foreach (var gradleBuildFileName in new[] { GradleBuildFileName, GradleKotlinBuildFileName })
            {
                if (File.Exists(Path.Combine(candidate.FullName, gradleBuildFileName)))
                {
                    return new(JavaAppHostToolchain.Gradle, candidate, $"{gradleBuildFileName} found in {candidate.FullName}");
                }
            }
        }
 
        return new(JavaAppHostToolchain.Javac, appHostDirectory, $"no build file found in {appHostDirectory.FullName}");
    }
 
    private static IEnumerable<DirectoryInfo> GetCandidateProjectDirectories(DirectoryInfo appHostDirectory)
    {
        yield return appHostDirectory;
 
        // Walk back out of src/main/java only when the directory names actually match, so an AppHost
        // that merely sits three levels deep is not mistaken for a conventional project layout.
        var candidate = appHostDirectory;
        foreach (var segment in s_conventionalSourceRootSegments.Reverse())
        {
            if (!string.Equals(candidate.Name, segment, PathComparison) || candidate.Parent is null)
            {
                yield break;
            }
 
            candidate = candidate.Parent;
        }
 
        yield return candidate;
    }
 
    /// <summary>
    /// Locates the wrapper for <paramref name="toolchain"/>, preferring the project's own directory and
    /// otherwise walking up to the build root.
    /// </summary>
    /// <remarks>
    /// A Gradle multi-project build has exactly one <c>gradlew</c>, beside the <c>settings.gradle</c>
    /// that declares the subprojects, and a Maven multi-module repository keeps <c>mvnw</c> beside the
    /// aggregator POM. An AppHost that is one of those modules carries only its own build file, so
    /// requiring a wrapper next to it would reject the standard layout outright.
    /// <para>
    /// An ancestor only qualifies when it also holds that tool's build-root marker and is not
    /// world-writable, and the walk stops at the directory holding <c>.git</c> so a submodule or nested
    /// clone uses its own wrapper rather than the outer repository's. These are the same rules
    /// <c>Aspire.Hosting.Java</c>'s JavaBuildToolResolver applies to hosted resources; the logic is
    /// duplicated rather than shared because the CLI does not reference the hosting package.
    /// </para>
    /// </remarks>
    private static string? FindWrapper(DirectoryInfo projectDirectory, string wrapperName, JavaAppHostToolchain toolchain)
    {
        for (var directory = projectDirectory; directory is not null; directory = directory.Parent)
        {
            var candidate = Path.Combine(directory.FullName, wrapperName);
            var isProjectDirectory = directory.FullName == projectDirectory.FullName;
 
            // The project directory is named by the AppHost, so a wrapper beside it is the developer's
            // own instruction and needs no further qualification. Ancestors are inferred instead.
            if (File.Exists(candidate)
                && (isProjectDirectory
                    || (IsBuildRoot(directory.FullName, toolchain)
                        && !IsWorldWritable(directory.FullName)
                        && !IsWorldWritable(candidate))))
            {
                return candidate;
            }
 
            // A worktree or submodule records .git as a file rather than a directory, so both count.
            var gitPath = Path.Combine(directory.FullName, ".git");
            if (Directory.Exists(gitPath) || File.Exists(gitPath))
            {
                return null;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Returns whether a directory is the root of a build for <paramref name="toolchain"/>.
    /// </summary>
    private static bool IsBuildRoot(string directory, JavaAppHostToolchain toolchain) => toolchain switch
    {
        // Gradle requires a settings file at the root of a multi-project build; that is the directory
        // the wrapper is generated into. https://docs.gradle.org/current/userguide/multi_project_builds.html
        JavaAppHostToolchain.Gradle => File.Exists(Path.Combine(directory, GradleSettingsFileName))
                                       || File.Exists(Path.Combine(directory, GradleKotlinSettingsFileName)),
        // A Maven aggregator is itself a project, so its POM is the marker.
        // https://maven.apache.org/guides/introduction/introduction-to-the-pom.html
        JavaAppHostToolchain.Maven => File.Exists(Path.Combine(directory, MavenPomFileName)),
        _ => false
    };
 
    /// <summary>
    /// Returns whether any user on the machine can write to <paramref name="path"/>.
    /// </summary>
    /// <remarks>
    /// Only inferred ancestors are checked. On a shared machine an AppHost under a world-writable
    /// directory such as <c>/tmp</c> could otherwise pick up a wrapper another user planted beside a
    /// <c>pom.xml</c>, and the CLI would execute it with the developer's privileges before anything is
    /// built. Applied to the wrapper file as well as its directory, because rewriting a file in place
    /// needs write permission on the file rather than on the directory holding it.
    /// <para>
    /// Group-writable is deliberately not rejected: distributions that enable user private groups pair
    /// a umask of 002 with a group per user, so an ordinary checkout is mode 775 and rejecting it
    /// would break wrapper resolution for a large share of Linux users.
    /// </para>
    /// <para>
    /// Windows uses ACLs that <see cref="UnixFileMode"/> does not describe, and .NET reports
    /// <see cref="UnixFileMode.None"/> there, so the check is skipped.
    /// </para>
    /// </remarks>
    private static bool IsWorldWritable(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            return false;
        }
 
        try
        {
            return (File.GetUnixFileMode(path) & UnixFileMode.OtherWrite) != 0;
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // A path whose mode cannot be read cannot be shown safe, so treat it as unusable.
            return true;
        }
    }
 
    private static StringComparison PathComparison =>
        OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
 
    public static string GetDisplayName(JavaAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            JavaAppHostToolchain.Javac => "Java",
            JavaAppHostToolchain.Maven => "Java (Maven)",
            JavaAppHostToolchain.Gradle => "Java (Gradle)",
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    /// <summary>
    /// Returns how the build tool should be invoked. The wrapper checked into the project is required,
    /// so the AppHost always builds with the tool version the project pins.
    /// </summary>
    /// <param name="projectDirectory">Directory holding the build file and the wrapper.</param>
    /// <param name="appHostDirectory">Directory the command runs in, which the wrapper path is relative to.</param>
    /// <param name="toolchain">The resolved build tool.</param>
    internal static JavaToolInvocation GetToolInvocation(
        DirectoryInfo projectDirectory,
        DirectoryInfo appHostDirectory,
        JavaAppHostToolchain toolchain)
    {
        var (wrapperName, generateCommand) = toolchain switch
        {
            // -N keeps the goal from recursing into the modules of a multi-module build.
            JavaAppHostToolchain.Maven => (OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw", "mvn -N wrapper:wrapper"),
            JavaAppHostToolchain.Gradle => (OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew", "gradle wrapper"),
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
 
        var wrapperPath = FindWrapper(projectDirectory, wrapperName, toolchain);
 
        // A globally installed Maven or Gradle is deliberately not used as a fallback: the wrapper pins the
        // tool version in the repository, so every machine builds the AppHost with the same one. Falling
        // back silently would make the AppHost build depend on whatever the developer happens to have.
        if (wrapperPath is null)
        {
            throw new InvalidOperationException(
                $"The Java AppHost project in '{projectDirectory.FullName}' declares a {GetDisplayName(toolchain)} " +
                $"build but ships no {wrapperName}, and none was found at an enclosing build root. Generate one " +
                $"with '{generateCommand}', or remove the build file to build the AppHost with javac instead.");
        }
 
        if (!OperatingSystem.IsWindows())
        {
            // Invoked through "sh" rather than executed directly because a wrapper checked out on
            // Windows, or committed without its mode bit, arrives without the executable bit and
            // exec fails with "Permission denied". The wrappers are POSIX shell scripts and are
            // documented to be run that way, so "sh <path>" is always valid. This matches how the
            // hosted Java resources invoke wrappers (JavaHostingExtensions.WrapperInvocationFor).
            //
            // The absolute path is kept because the process is started without a shell, so a bare
            // "mvnw" would be looked up on PATH and never found in the project directory.
            return new JavaToolInvocation("sh", [wrapperPath]);
        }
 
        // On Windows the wrappers are batch files. Launching one directly with redirected stdout can
        // silently produce no output (see NpmRunner, which hits the same problem with npm.cmd), so the
        // command interpreter runs it instead.
        //
        // The path is made relative to the working directory to keep it short, and "call" runs it. That
        // matters because cmd.exe strips quotes in a way that does not match how ProcessStartInfo
        // escapes arguments: when the *first* token on the line is quoted, cmd removes that quote and
        // the last one on the line, mangling everything in between. A wrapper reached through a
        // directory whose name contains a space is quoted, so with the wrapper first the line would be
        // mangled; with "call" first the first character is never a quote and the rule cannot apply.
        // "call" is also how one batch file invokes another: it returns control and propagates the
        // exit code. See the quote-processing rules printed by `cmd /?`.
        var relativeWrapperPath = Path.GetRelativePath(appHostDirectory.FullName, wrapperPath);
 
        return new JavaToolInvocation(
            Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe",
            ["/c", "call", relativeWrapperPath]);
    }
 
    /// <summary>
    /// Rewrites the runtime spec for the resolved build tool. The <see cref="JavaAppHostToolchain.Javac"/>
    /// spec is returned unchanged so an AppHost without build files behaves exactly as before.
    /// </summary>
    public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, JavaAppHostToolchainResolution resolution, DirectoryInfo appHostDirectory)
    {
        var toolchain = resolution.Toolchain;
        if (toolchain == JavaAppHostToolchain.Javac)
        {
            return baseRuntimeSpec;
        }
 
        var invocation = GetToolInvocation(resolution.ProjectDirectory, appHostDirectory, toolchain);
 
        // Commands run with the AppHost directory as their working directory, which is not the project
        // directory in the src/main/java layout, so every path below is rewritten relative to it.
        var projectPath = GetRelativeProjectPath(resolution.ProjectDirectory, appHostDirectory);
        var classesDirectory = CombineProjectPath(projectPath, GetClassesDirectory(toolchain));
        var dependencyDirectory = CombineProjectPath(projectPath, GetDependencyDirectory(toolchain));
 
        return new RuntimeSpec
        {
            Language = baseRuntimeSpec.Language,
            DisplayName = GetDisplayName(toolchain),
            CodeGenLanguage = baseRuntimeSpec.CodeGenLanguage,
            DetectionPatterns = baseRuntimeSpec.DetectionPatterns,
            Initialize = baseRuntimeSpec.Initialize,
            InstallDependencies = CreateInstallCommand(toolchain, invocation, projectPath),
            PreExecute = [CreateCompileCommand(toolchain, baseRuntimeSpec, projectPath, classesDirectory, dependencyDirectory)],
            Execute = CreateExecuteCommand(classesDirectory, dependencyDirectory),
            WatchExecute = baseRuntimeSpec.WatchExecute,
            PublishExecute = baseRuntimeSpec.PublishExecute,
            ExtensionLaunchCapability = baseRuntimeSpec.ExtensionLaunchCapability,
            CertificateBundleEnvironmentVariable = baseRuntimeSpec.CertificateBundleEnvironmentVariable,
            MigrationFiles = baseRuntimeSpec.MigrationFiles
        };
    }
 
    /// <summary>
    /// Writes the Gradle init script when the resolved toolchain needs it.
    /// </summary>
    /// <remarks>
    /// This is not done through <see cref="RuntimeSpec.MigrationFiles"/> because those are written
    /// with a plain file write that fails when the parent directory does not exist yet, and the
    /// script lives under <c>.aspire</c>.
    /// </remarks>
    public static async Task EnsureToolchainFilesExistAsync(
        JavaAppHostToolchainResolution resolution,
        CancellationToken cancellationToken)
    {
        if (resolution.Toolchain != JavaAppHostToolchain.Gradle)
        {
            return;
        }
 
        // Written into the project directory rather than the AppHost directory so it sits alongside
        // the build file it augments, which is where the --init-script argument points.
        var scriptPath = Path.Combine(resolution.ProjectDirectory.FullName, GradleInitScriptPath);
 
        Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
 
        // Rewritten every run rather than only when missing, so a script left behind by an older
        // Aspire version cannot silently keep staging dependencies the wrong way.
        //
        // Written through a temporary sibling and moved into place because writing directly truncates
        // first: a second `aspire run` starting while this one is mid-write leaves Gradle reading a
        // half-written script, which fails with a syntax error that points at the generated file rather
        // than at the race. File.Move within a directory is atomic on every platform we support.
        var temporaryPath = $"{scriptPath}.{Environment.ProcessId}.tmp";
 
        try
        {
            await File.WriteAllTextAsync(temporaryPath, GradleInitScript, cancellationToken).ConfigureAwait(false);
            File.Move(temporaryPath, scriptPath, overwrite: true);
        }
        catch
        {
            try
            {
                File.Delete(temporaryPath);
            }
            catch
            {
                // Losing a temp file matters far less than the original failure, which is rethrown.
            }
 
            throw;
        }
    }
 
    /// <summary>
    /// Removes staged dependencies that the build tool will not remove itself, immediately before it
    /// restages them.
    /// </summary>
    /// <remarks>
    /// The AppHost is launched with the whole staging directory on its classpath as <c>dir/*</c>, and
    /// <c>dependency:copy-dependencies</c> only ever adds to that directory. Upgrading a dependency
    /// changes the file name, so the old JAR stays behind and both versions end up on the classpath,
    /// where which one wins is left to directory order.
    /// <para>
    /// Gradle needs nothing here because its staging task is a <c>Sync</c>, which prunes the destination
    /// itself and stays incremental while doing it. Maven has no equivalent.
    /// </para>
    /// <para>
    /// This is called from the install path rather than when the toolchain is resolved so that clearing
    /// and restaging cannot be separated. Clearing without restaging would leave the AppHost with an
    /// empty classpath.
    /// </para>
    /// </remarks>
    public static void ClearStagedDependencies(JavaAppHostToolchainResolution resolution)
    {
        if (resolution.Toolchain != JavaAppHostToolchain.Maven)
        {
            return;
        }
 
        var dependencyDirectory = Path.Combine(
            resolution.ProjectDirectory.FullName,
            GetDependencyDirectory(resolution.Toolchain));
 
        try
        {
            if (Directory.Exists(dependencyDirectory))
            {
                Directory.Delete(dependencyDirectory, recursive: true);
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Continuing here would defeat the only reason this method exists. Maven adds the new
            // versioned JAR beside the stale one, both end up on the "dir/*" classpath, and which one
            // the JVM loads is left to directory order — a failure that surfaces later as an unrelated
            // NoSuchMethodError. Better to stop now with a message that names the directory.
            throw new InvalidOperationException(
                $"The staged dependency directory '{dependencyDirectory}' could not be cleared, so the " +
                "AppHost would run with both the old and new versions of any upgraded dependency on its " +
                "classpath. Close anything holding files in that directory, or delete it manually, then " +
                "run the command again.",
                ex);
        }
    }
 
    /// <summary>
    /// Path from the AppHost directory to the project directory, or <see langword="null"/> when they are
    /// the same. Null rather than "." so the common flat layout keeps clean, unprefixed relative paths.
    /// </summary>
    private static string? GetRelativeProjectPath(DirectoryInfo projectDirectory, DirectoryInfo appHostDirectory)
    {
        var relativePath = Path.GetRelativePath(appHostDirectory.FullName, projectDirectory.FullName);
 
        return relativePath == "." ? null : relativePath;
    }
 
    private static string CombineProjectPath(string? projectPath, string path)
    {
        return projectPath is null ? path : Path.Combine(projectPath, path);
    }
 
    /// <summary>
    /// Arguments that point the build tool at a project other than its working directory. Both tools
    /// need this because the AppHost is launched from its own directory, not the project root.
    /// </summary>
    private static string[] GetProjectSelectionArgs(JavaAppHostToolchain toolchain, string? projectPath)
    {
        if (projectPath is null)
        {
            return [];
        }
 
        return toolchain switch
        {
            // https://maven.apache.org/ref/current/maven-embedder/cli.html
            JavaAppHostToolchain.Maven => ["-f", Path.Combine(projectPath, MavenPomFileName)],
            // https://docs.gradle.org/current/userguide/command_line_interface.html
            JavaAppHostToolchain.Gradle => ["-p", projectPath],
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    private static string GetClassesDirectory(JavaAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            // Both are the build tool's conventional output for main classes.
            // https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html
            // https://docs.gradle.org/current/userguide/java_plugin.html#sec:java_project_layout
            JavaAppHostToolchain.Maven => Path.Combine("target", "classes"),
            JavaAppHostToolchain.Gradle => Path.Combine("build", "classes", "java", "main"),
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    private static string GetDependencyDirectory(JavaAppHostToolchain toolchain)
    {
        return toolchain switch
        {
            JavaAppHostToolchain.Maven => Path.Combine("target", DependencyDirectoryName),
            JavaAppHostToolchain.Gradle => Path.Combine("build", DependencyDirectoryName),
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
    }
 
    private static CommandSpec CreateInstallCommand(
        JavaAppHostToolchain toolchain,
        JavaToolInvocation invocation,
        string? projectPath)
    {
        var toolArgs = toolchain switch
        {
            // Batch mode keeps the transfer progress spinner out of the CLI's captured output.
            // Only runtime-scoped dependencies are staged: test and provided dependencies are not
            // on the application's runtime classpath and staging them can shadow real versions.
            JavaAppHostToolchain.Maven => (string[])
            [
                "-B", "-q",
                .. GetProjectSelectionArgs(toolchain, projectPath),
                "dependency:copy-dependencies",
                // Maven resolves a relative outputDirectory against the project's base directory, so
                // the path is expressed relative to the project rather than the working directory.
                $"-DoutputDirectory={GetDependencyDirectory(toolchain)}",
                "-DincludeScope=runtime"
            ],
            JavaAppHostToolchain.Gradle =>
            [
                "-q",
                .. GetProjectSelectionArgs(toolchain, projectPath),
                "--init-script", CombineProjectPath(projectPath, GradleInitScriptPath),
                "aspireCopyDependencies"
            ],
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
 
        return invocation.CreateCommand(toolArgs);
    }
 
    /// <summary>
    /// Compiles the AppHost with javac, against the dependency classpath the build tool staged.
    /// </summary>
    /// <remarks>
    /// The build tool resolves dependencies but does not compile, because neither Maven nor Gradle can
    /// be told from the command line about the generated SDK under <c>.aspire/modules</c>. Maven has no
    /// user property for an extra source root or compiler argument — <c>compilerArgs</c>,
    /// <c>compilerArgument</c>, and the source roots are all pom-only — so <c>mvnw compile</c> fails with
    /// "package aspire does not exist" no matter what is passed after the goal. Editing the user's
    /// <c>pom.xml</c> to add a source root is not an option either: the build file belongs to them.
    /// <para>
    /// Compiling with javac instead keeps the AppHost building the same way under all three toolchains,
    /// and the build tool keeps the two jobs it is actually needed for: resolving third-party
    /// dependencies, and giving the IDE a real project model.
    /// </para>
    /// <para>
    /// The consequence, which is worth knowing: compiler configuration in <c>pom.xml</c> or
    /// <c>build.gradle</c> — annotation processors such as Lombok, custom lint settings, an alternative
    /// compiler — does not apply to <c>AppHost.java</c>.
    /// </para>
    /// </remarks>
    /// <summary>
    /// Inputs that decide what the AppHost is compiled <em>against</em>, as opposed to what it is
    /// compiled <em>from</em>.
    /// </summary>
    /// <remarks>
    /// A dependency bump changes no Java source at all: the build descriptor changes, and the build
    /// tool then stages a differently-named JAR. Without these the cached bytecode is reused and the
    /// AppHost runs against an API that is no longer on its classpath, usually surfacing as a
    /// NoSuchMethodError at the point of use rather than anything that names the real cause.
    /// <para>
    /// The staged dependency directory is listed non-recursively and its JARs are outside the check's
    /// source extensions on purpose. Staging runs on every launch and can rewrite the JARs in place,
    /// so only the directory's own timestamp is meaningful — and that moves exactly when the resolved
    /// set changes, which is the question being asked.
    /// </para>
    /// </remarks>
    private static string[] GetDependencyInputs(JavaAppHostToolchain toolchain, string? projectPath, string dependencyDirectory)
    {
        string[] buildDescriptors = toolchain switch
        {
            JavaAppHostToolchain.Maven => [MavenPomFileName],
            // The version catalog is Gradle's other place for dependency coordinates, and it is
            // conventionally a sibling of the settings file rather than of the build file.
            // https://docs.gradle.org/current/userguide/version_catalogs.html
            JavaAppHostToolchain.Gradle =>
            [
                GradleBuildFileName,
                GradleKotlinBuildFileName,
                GradleSettingsFileName,
                GradleKotlinSettingsFileName,
                Path.Combine("gradle", "libs.versions.toml")
            ],
            _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null)
        };
 
        return
        [
            .. buildDescriptors.Select(descriptor => CombineProjectPath(projectPath, descriptor)),
            dependencyDirectory
        ];
    }
 
    private static CommandSpec CreateCompileCommand(JavaAppHostToolchain toolchain, RuntimeSpec baseRuntimeSpec, string? projectPath, string classesDirectory, string dependencyDirectory)
    {
        var baseCompile = baseRuntimeSpec.PreExecute?.FirstOrDefault()
            ?? throw new InvalidOperationException("The Java runtime spec has no compile step to adapt for a build tool.");
 
        var baseArgs = baseCompile.Args ?? [];
 
        // "dir/*" is expanded by the JVM's own launcher rather than the shell, so it needs no shell and
        // stays correct when the directory is empty.
        // https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#standard-options
        string[] addedArgs =
        [
            "-classpath", Path.Combine(dependencyDirectory, "*"),
            // The AppHost directory is the working directory and is also a source root under both
            // supported layouts, so this is what lets the AppHost reference the project's own classes.
            // It has to be set explicitly because javac otherwise defaults the source path to the class
            // path, which -classpath has just replaced.
            //
            // src/main/java is included as well for the flat layout, where AppHost.java sits beside
            // pom.xml at the project root and the project's own sources are one source root down. The
            // build tool only stages dependencies here - it never compiles - so without this entry a
            // reference to the project's own class fails with "cannot find symbol". Under the
            // conventional layout the working directory already is src/main/java, which makes the extra
            // entry a path that does not exist; javac ignores those, so it costs nothing.
            "-sourcepath", string.Join(Path.PathSeparator, ".", Path.Combine("src", "main", "java")),
            // Output goes to the build tool's own directory rather than the javac toolchain's
            // .java-build, so that `mvn clean` / `gradle clean` removes it and it is already ignored by
            // any Maven or Gradle .gitignore.
            "-d", classesDirectory
        ];
 
        // The javac options and the source arguments are reused from the base spec rather than repeated
        // here, so the two toolchains cannot drift apart. The output directory is the one thing replaced.
        var outputIndex = Array.IndexOf(baseArgs, "-d");
 
        var args = outputIndex >= 0 && outputIndex + 1 < baseArgs.Length
            ? (string[])[.. baseArgs[..outputIndex], .. addedArgs, .. baseArgs[(outputIndex + 2)..]]
            : [.. addedArgs, .. baseArgs];
 
        return new CommandSpec
        {
            Command = baseCompile.Command,
            Args = args,
            // The stamp has to move with the output directory. Left in .java-build it would survive a
            // `mvn clean`, and the next launch would skip a compile whose classes had just been deleted.
            UpToDateCheck = baseCompile.UpToDateCheck is null
                ? null
                : new CommandUpToDateCheck
                {
                    // The base spec guesses at "src/main/java" relative to the AppHost directory, which
                    // is only right for the flat layout. Here the project's real source root is known,
                    // so it replaces the guess: under the conventional layout that root *is* the AppHost
                    // directory, and it has to be scanned recursively for the packages beneath it.
                    Inputs =
                    [
                        .. baseCompile.UpToDateCheck.Inputs.Where(static input => !input.StartsWith("src/main/java", StringComparison.Ordinal)),
                        $"{CombineProjectPath(projectPath, Path.Combine("src", "main", "java"))}/**",
                        .. GetDependencyInputs(toolchain, projectPath, dependencyDirectory)
                    ],
                    FileExtensions = baseCompile.UpToDateCheck.FileExtensions,
                    StampFile = Path.Combine(classesDirectory, Path.GetFileName(baseCompile.UpToDateCheck.StampFile))
                }
        };
    }
 
    private static CommandSpec CreateExecuteCommand(string classesDirectory, string dependencyDirectory)
    {
        // "dir/*" is expanded by the JVM into every JAR in that directory. It is not a shell glob, so
        // it works with no shell involved and stays correct when the directory is empty.
        // https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html#standard-options-for-java
        var classPath = string.Join(Path.PathSeparator, [classesDirectory, Path.Combine(dependencyDirectory, "*")]);
 
        return new CommandSpec
        {
            Command = "java",
            // {args} is deliberately absent. When no argument contains that placeholder the CLI
            // appends its arguments as separate argv entries, whereas substituting the placeholder
            // joins them into one space-separated string that the AppHost then has to re-split.
            Args = ["-cp", classPath, "AppHost"]
        };
    }
}
 
/// <summary>
/// How a build tool wrapper is launched: the executable, plus any arguments that must precede the
/// tool's own. Windows needs a command interpreter in front of the batch wrapper, other platforms
/// invoke it directly.
/// </summary>
internal readonly record struct JavaToolInvocation(string Command, string[] PrefixArgs)
{
    public CommandSpec CreateCommand(string[] args)
    {
        return new CommandSpec
        {
            Command = Command,
            Args = [.. PrefixArgs, .. args]
        };
    }
}
 
/// <summary>
/// The build tool selected for a Java AppHost, the directory its build file lives in, and why it was chosen.
/// </summary>
/// <remarks>
/// <see cref="ProjectDirectory"/> is not always the AppHost's own directory: with the conventional
/// <c>src/main/java/AppHost.java</c> layout the build file is at the project root. Commands are still
/// launched from the AppHost directory, so paths are made relative to it rather than to the project.
/// </remarks>
internal readonly record struct JavaAppHostToolchainResolution(
    JavaAppHostToolchain Toolchain,
    DirectoryInfo ProjectDirectory,
    string Reason);