File: RustPublicApiTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Rust.Tests\Aspire.Hosting.Rust.Tests.csproj (Aspire.Hosting.Rust.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREEXTENSION001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.TestUtilities;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Rust.Tests;
 
public class RustPublicApiTests
{
    [Fact]
    public void CargoArgsCallbackAnnotationIsAnImplementationDetail()
    {
        var annotationType = typeof(RustAppResource).Assembly.GetType(
            "Aspire.Hosting.Rust.RustCargoArgsCallbackAnnotation",
            throwOnError: true)!;
 
        Assert.False(annotationType.IsPublic);
    }
 
    [Fact]
    public void AddRustAppShouldThrowWhenBuilderIsNull()
    {
        IDistributedApplicationBuilder builder = null!;
 
        var action = () => builder.AddRustApp("api", "/src/rust-app");
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(builder), exception.ParamName);
    }
 
    [Fact]
    public async Task AddRustAppDefaultArgsAreRunWithSeparator()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--"], args);
    }
 
    [Fact]
    public async Task AddRustAppWithCargoAndAppArgsPreservesSeparatorOrdering()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoArgs("--release")
            .WithArgs("--port", "8080");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--release", "--", "--port", "8080"], args);
    }
 
    [Fact]
    public async Task WithCargoFeaturesAndTargetSelectionMapToCargoArgs()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoFeatures("tokio", "serde")
            .WithCargoArgs("--bin", "worker");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--features", "tokio,serde", "--bin", "worker", "--"], args);
    }
 
    [Fact]
    public async Task WithCargoFeaturesAccumulatesAcrossCalls()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoFeatures("tokio")
            .WithCargoFeatures("serde", "tracing");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--features", "tokio,serde,tracing", "--"], args);
    }
 
    [Fact]
    public async Task WithCargoTargetSelectionMethodsMapToCargoArgs()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoFeatures("tokio")
            .WithCargoBinTarget("worker")
            .WithCargoPackage("services")
            .WithCargoManifestPath("crates/worker/Cargo.toml")
            .WithCargoTarget("aarch64-unknown-linux-musl")
            .WithCargoProfile("dist");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(
            ["run", "--features", "tokio", "--bin", "worker", "--package", "services", "--manifest-path", "crates/worker/Cargo.toml", "--target", "aarch64-unknown-linux-musl", "--profile", "dist", "--"],
            args);
    }
 
    [Fact]
    public async Task WithCargoExampleMapsToCargoArgs()
    {
        // --bin and --example are mutually exclusive in cargo, so the example gets its own test rather
        // than being folded into the target selection case above.
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoExample("demo");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--example", "demo", "--"], args);
    }
 
    [Fact]
    public async Task WithCargoProfileWinsOverWithCargoReleaseBuild()
    {
        // Cargo rejects --profile and --release together.
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoReleaseBuild()
            .WithCargoProfile("dist");
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--profile", "dist", "--"], args);
    }
 
    [Fact]
    public async Task WithCargoLockedMapsToCargoArgs()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoLocked();
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--locked", "--"], args);
    }
 
    [Fact]
    public async Task RunModeDoesNotOptIntoLockedOrRelease()
    {
        // Both default to cargo's own behaviour locally; only publishing turns them on by default, so a
        // `cargo run` that works from the terminal keeps working through the app host.
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.Equal(["run", "--"], args);
    }
 
    [Fact]
    public async Task WithCargoArgsCallbackReceivesTheRustResourceInRunMode()
    {
        var builder = DistributedApplication.CreateBuilder();
        var observed = new List<RustAppResource>();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoArgs(context => observed.Add(context.Resource));
 
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.NotEmpty(observed);
        Assert.All(observed, resource => Assert.Same(app.Resource, resource));
    }
 
    [Fact]
    public async Task WithCargoArgsAsyncCallbackReceivesTheRustResourceInRunMode()
    {
        var builder = DistributedApplication.CreateBuilder();
        var observed = new List<RustAppResource>();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoArgs(context =>
            {
                observed.Add(context.Resource);
                return Task.CompletedTask;
            });
 
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        Assert.NotEmpty(observed);
        Assert.All(observed, resource => Assert.Same(app.Resource, resource));
    }
 
    [Fact]
    public void RustCargoArgsCallbackContextShouldThrowWhenResourceIsNull()
    {
        RustAppResource resource = null!;
 
        var action = () => new RustCargoArgsCallbackContext(resource, []);
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(resource), exception.ParamName);
    }
 
    [Fact]
    public void RustCargoArgsCallbackContextShouldThrowWhenArgsIsNull()
    {
        IList<string> args = null!;
 
        var action = () => new RustCargoArgsCallbackContext(new RustAppResource("api", "/src/rust-app"), args);
 
        var exception = Assert.Throws<ArgumentNullException>(action);
        Assert.Equal(nameof(args), exception.ParamName);
    }
 
    [Fact]
    public void RustCargoArgsCallbackContextCarriesTheSuppliedCancellationToken()
    {
        using var cts = new CancellationTokenSource();
 
        var context = new RustCargoArgsCallbackContext(new RustAppResource("api", "/src/rust-app"), [], cts.Token);
 
        Assert.Equal(cts.Token, context.CancellationToken);
    }
 
    [Fact]
    public void AddRustAppEnablesDebuggingSupport()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        Assert.True(app.Resource.TryGetLastAnnotation<SupportsDebuggingAnnotation>(out _));
    }
 
    [Fact]
    public async Task LaunchConfigurationCarriesCargoBuildArguments()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoFeatures("tls-ring")
            .WithCargoReleaseBuild();
 
        var launchConfig = await InvokeLaunchConfigurationProducerAsync(builder, app.Resource);
 
        Assert.Equal("rust", launchConfig.Type);
        Assert.Equal(Path.GetFullPath(builder.AppHostDirectory), launchConfig.WorkingDirectory);
 
        var cargo = Assert.IsType<RustCargoLaunchTarget>(launchConfig.Cargo);
        Assert.Equal(["build", "--features", "tls-ring", "--release"], cargo.Args);
    }
 
    [Fact]
    public async Task LaunchConfigurationCarriesCargoTargetSelectionArguments()
    {
        // `cargo build` builds every binary target unless the arguments narrow it, so the target
        // selection that makes `cargo run` unambiguous has to reach the debug build too.
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoArgs("--bin", "worker");
 
        var launchConfig = await InvokeLaunchConfigurationProducerAsync(builder, app.Resource);
 
        var cargo = Assert.IsType<RustCargoLaunchTarget>(launchConfig.Cargo);
        Assert.Equal(["build", "--bin", "worker"], cargo.Args);
    }
 
    [Fact]
    public async Task LaunchConfigurationReusesResolvedCargoArgumentsInsteadOfRunningCallbacksAgain()
    {
        // A cargo argument callback may be one-shot or nondeterministic, so building the launch
        // configuration must reuse what argument evaluation already produced rather than re-running it.
        var invocations = 0;
 
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithCargoArgs(context =>
            {
                invocations++;
                context.Args.Add($"--config=build.jobs={invocations}");
            });
 
        var launchConfig = await InvokeLaunchConfigurationProducerAsync(builder, app.Resource);
 
        Assert.Equal(1, invocations);
        var cargo = Assert.IsType<RustCargoLaunchTarget>(launchConfig.Cargo);
        Assert.Equal(["build", "--config=build.jobs=1"], cargo.Args);
    }
 
    [Fact]
    public async Task LaunchConfigurationCarriesTheExecutableTheBuildWillProduce()
    {
        // The extension runs a plain `cargo build` and debugs this path, rather than parsing cargo's JSON
        // artifact stream, so the debugged process is the same binary publishing containerizes.
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var launchConfig = await InvokeLaunchConfigurationProducerAsync(builder, app.Resource);
 
        var cargo = Assert.IsType<RustCargoLaunchTarget>(launchConfig.Cargo);
        var expected = Path.Combine("/app/target", "debug", OperatingSystem.IsWindows() ? "my-service.exe" : "my-service");
 
        Assert.Equal(expected, cargo.ExecutablePath);
    }
 
    [Fact]
    public async Task LaunchConfigurationUsesResolvedCargoEnvironmentWithoutReevaluatingCallbacks()
    {
        // cargo metadata is queried with the resource's resolved environment, so CARGO_TARGET_DIR moves the
        // reported target directory, and CARGO_BUILD_TARGET adds the triple directory that cargo does not
        // report at all. Without both, the debugger would be pointed at a file the build never wrote.
        var environmentCallbackInvocations = 0;
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory)
            .WithEnvironment(context =>
            {
                environmentCallbackInvocations++;
                context.EnvironmentVariables["CARGO_TARGET_DIR"] = "/wrong";
                context.EnvironmentVariables["CARGO_BUILD_TARGET"] = "wrong-target";
            });
 
        var launchConfig = await InvokeLaunchConfigurationProducerAsync(
            builder,
            app.Resource,
            new Dictionary<string, string>
            {
                ["CARGO_TARGET_DIR"] = "/elsewhere",
                ["CARGO_BUILD_TARGET"] = "aarch64-unknown-linux-musl"
            });
 
        var cargo = Assert.IsType<RustCargoLaunchTarget>(launchConfig.Cargo);
        var expected = Path.Combine(
            Path.GetFullPath("/elsewhere", builder.AppHostDirectory),
            "aarch64-unknown-linux-musl",
            "debug",
            OperatingSystem.IsWindows() ? "my-service.exe" : "my-service");
 
        Assert.Equal(expected, cargo.ExecutablePath);
        Assert.Equal(0, environmentCallbackInvocations);
    }
 
    [Fact]
    public async Task LaunchConfigurationThrowsWhenCargoArgumentsHaveNotBeenResolved()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(app.Resource);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(app.Resource, callbackContext));
 
        Assert.Contains("have not been resolved", exception.Message);
    }
 
    [Fact]
    [RequiresTools(["cargo"])]
    public async Task LaunchConfigurationReadsCurrentRealCargoMetadataForEachRequest()
    {
        CargoTestHelpers.SkipIfUnavailable();
 
        using var crate = new TempCrateDirectory();
        crate.Write("Cargo.toml", CreateManifest("first"));
        Directory.CreateDirectory(Path.Combine(crate.Path, "src"));
        crate.Write(Path.Combine("src", "main.rs"), "fn main() {}");
 
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", crate.Path);
 
        await using var built = builder.Build();
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var first = Assert.IsType<RustLaunchConfiguration>(
            await app.Resource.CreateLaunchConfigurationAsync(
                ExecutableLaunchMode.Debug,
                TestContext.Current.CancellationToken));
 
        crate.Write("Cargo.toml", CreateManifest("second"));
 
        var second = Assert.IsType<RustLaunchConfiguration>(
            await app.Resource.CreateLaunchConfigurationAsync(
                ExecutableLaunchMode.Debug,
                TestContext.Current.CancellationToken));
 
        Assert.EndsWith(
            Path.Combine("debug", OperatingSystem.IsWindows() ? "first.exe" : "first"),
            Assert.IsType<RustCargoLaunchTarget>(first.Cargo).ExecutablePath);
        Assert.EndsWith(
            Path.Combine("debug", OperatingSystem.IsWindows() ? "second.exe" : "second"),
            Assert.IsType<RustCargoLaunchTarget>(second.Cargo).ExecutablePath);
 
        static string CreateManifest(string packageName) => $"""
            [package]
            name = "{packageName}"
            version = "0.1.0"
            edition = "2021"
            """;
    }
 
    [Fact]
    public async Task LaunchConfigurationUsesTheResolvedEnvironmentForEachExecutableCreation()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var reader = new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service"));
        builder.Services.AddSingleton<ICargoMetadataReader>(reader);
 
        await using var built = builder.Build();
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var firstContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            app.Resource,
            environmentVariables: new Dictionary<string, string> { ["CARGO_TARGET_DIR"] = "/first-target" });
        var secondContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            app.Resource,
            environmentVariables: new Dictionary<string, string> { ["CARGO_TARGET_DIR"] = "/second-target" });
 
        var first = Assert.IsType<RustLaunchConfiguration>(
            await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(app.Resource, firstContext));
        var second = Assert.IsType<RustLaunchConfiguration>(
            await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(app.Resource, secondContext));
 
        Assert.StartsWith(Path.GetFullPath("/first-target", builder.AppHostDirectory), Assert.IsType<RustCargoLaunchTarget>(first.Cargo).ExecutablePath);
        Assert.StartsWith(Path.GetFullPath("/second-target", builder.AppHostDirectory), Assert.IsType<RustCargoLaunchTarget>(second.Cargo).ExecutablePath);
        Assert.Equal(2, reader.ReadCount);
    }
 
    [Fact]
    public async Task LaunchConfigurationQueriesCurrentCargoMetadataOncePerRequest()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var reader = new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("first"))
        {
            MetadataJsonFactory = readCount => CargoMetadataFactory.SinglePackage(readCount == 1 ? "first" : "second")
        };
        builder.Services.AddSingleton<ICargoMetadataReader>(reader);
 
        await using var built = builder.Build();
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        var first = Assert.IsType<RustLaunchConfiguration>(
            await app.Resource.CreateLaunchConfigurationAsync(
                ExecutableLaunchMode.Debug,
                TestContext.Current.CancellationToken));
        var second = Assert.IsType<RustLaunchConfiguration>(
            await app.Resource.CreateLaunchConfigurationAsync(
                ExecutableLaunchMode.Debug,
                TestContext.Current.CancellationToken));
 
        Assert.EndsWith(Path.Combine("debug", OperatingSystem.IsWindows() ? "first.exe" : "first"), Assert.IsType<RustCargoLaunchTarget>(first.Cargo).ExecutablePath);
        Assert.EndsWith(Path.Combine("debug", OperatingSystem.IsWindows() ? "second.exe" : "second"), Assert.IsType<RustCargoLaunchTarget>(second.Cargo).ExecutablePath);
        Assert.Equal(2, reader.ReadCount);
    }
 
    [Fact]
    public async Task LaunchConfigurationRetriesCargoMetadataOnTheNextRequest()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        var attempts = 0;
        var reader = new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service"))
        {
            OnRead = _ =>
            {
                if (Interlocked.Increment(ref attempts) == 1)
                {
                    throw new InvalidOperationException("Transient cargo metadata failure.");
                }
 
                return Task.CompletedTask;
            }
        };
 
        builder.Services.AddSingleton<ICargoMetadataReader>(reader);
 
        await using var built = builder.Build();
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => app.Resource.CreateLaunchConfigurationAsync(
                ExecutableLaunchMode.Debug,
                TestContext.Current.CancellationToken));
 
        await app.Resource.CreateLaunchConfigurationAsync(
            ExecutableLaunchMode.Debug,
            TestContext.Current.CancellationToken);
 
        Assert.Equal(2, reader.ReadCount);
    }
 
    [Fact]
    public async Task LaunchConfigurationCancelsCargoMetadataWhenCancellationIsRequested()
    {
        var builder = DistributedApplication.CreateBuilder();
        var app = builder.AddRustApp("api", builder.AppHostDirectory);
 
        using var cargoStarted = new SemaphoreSlim(0, 1);
        var reader = new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service"))
        {
            // Stands in for a cold `cargo metadata` that outlives the app host: it only completes when the
            // caller's token is cancelled.
            OnRead = async cancellationToken =>
            {
                cargoStarted.Release();
                await Task.Delay(Timeout.Infinite, cancellationToken);
            }
        };
 
        builder.Services.AddSingleton<ICargoMetadataReader>(reader);
 
        await using var built = builder.Build();
        await ArgumentEvaluator.GetArgumentListAsync(app.Resource);
 
        using var cts = new CancellationTokenSource();
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(app.Resource, cancellationToken: cts.Token);
        var launchTask = LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(app.Resource, callbackContext);
 
        await cargoStarted.WaitAsync(TimeSpan.FromSeconds(30));
        await cts.CancelAsync();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => launchTask);
    }
 
    private static async Task<RustLaunchConfiguration> InvokeLaunchConfigurationProducerAsync(
        IDistributedApplicationBuilder builder,
        RustAppResource resource,
        IReadOnlyDictionary<string, string>? environmentVariables = null)
    {
        // The debug launch configuration resolves the executable cargo will produce from the crate's
        // metadata, so answer that from a canned document rather than requiring a Rust toolchain. The app
        // has to be built for the resolved reader to be reachable through the execution context.
        builder.Services.AddSingleton<ICargoMetadataReader>(
            new FakeCargoMetadataReader(CargoMetadataFactory.SinglePackage("my-service")));
 
        await using var app = builder.Build();
 
        // DCP resolves the resource's arguments before it asks for the launch configuration, and the launch
        // configuration reuses them, so evaluate them first.
        await ArgumentEvaluator.GetArgumentListAsync(resource);
 
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            resource,
            environmentVariables: environmentVariables);
        var launchConfiguration = await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(
            resource,
            callbackContext);
 
        return Assert.IsType<RustLaunchConfiguration>(launchConfiguration);
    }
}