File: Commands\ParseResultHelperTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Cli.Commands;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
using ParseResult = System.CommandLine.ParseResult;
 
namespace Aspire.Cli.Tests.Commands;
 
internal static class ParseResultHelperTestData
{
    public static TheoryData<string[], bool, string[], int> ProjectionCases => new()
    {
        {
            ["run", "--apphost=selector", "--isolated=false", "--debug", "--", "--apphost", "app-value"],
            false,
            ["--isolated", "false", "--debug", "--", "--apphost", "app-value"],
            3
        },
        {
            ["start", "--debug", "--detach", "--unknown", "value"],
            false,
            ["--debug", "--", "--detach", "--unknown", "value"],
            1
        },
        {
            ["run", "--debug", "--secret", "value"],
            false,
            ["--debug", "--", "--secret", "value"],
            1
        },
        {
            ["start", "--log-level", "Debug", "--unknown", "value"],
            false,
            ["--log-level", "Debug", "--", "--unknown", "value"],
            2
        },
        {
            ["run", "--project=selector", "--debug"],
            false,
            ["--debug"],
            1
        },
        {
            // Repeating a single-value option produces a parse error, but the parse tree
            // still owns both value tokens and the projector must exclude both occurrences.
            ["run", "--apphost=first", "--apphost", "second", "--debug"],
            true,
            ["--debug"],
            1
        },
        {
            ["run", "--apphost", "same-value", "--", "same-value"],
            false,
            ["--", "same-value"],
            0
        },
        {
            ["start", "--debug", "--before", "before-value", "--", "after-value", "--after"],
            false,
            ["--debug", "--", "--before", "before-value", "after-value", "--after"],
            1
        },
        {
            ["start", "--capture-profile-output=--", "--", "--custom-arg"],
            false,
            [$"--capture-profile-output={new FileInfo("--").FullName}", "--", "--custom-arg"],
            1
        },
        {
            ["start", "--debug", "--"],
            false,
            ["--debug"],
            1
        }
    };
}
 
public sealed class ParseResultHelperTests : IDisposable
{
    private readonly TemporaryWorkspace _workspace;
    private readonly ServiceProvider _provider;
    private readonly RootCommand _command;
 
    public ParseResultHelperTests(ITestOutputHelper outputHelper)
    {
        _workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        _provider = CliTestHelper.CreateServiceCollection(_workspace, outputHelper).BuildServiceProvider();
        _command = _provider.GetRequiredService<RootCommand>();
    }
 
    [Theory]
    [MemberData(nameof(ParseResultHelperTestData.ProjectionCases), MemberType = typeof(ParseResultHelperTestData))]
    public void GetForwardedArguments_ProjectsTokens(
        string[] commandLine,
        bool expectParseErrors,
        string[] expectedTokens,
        int expectedOptionCount)
    {
        var parseResult = _command.Parse(commandLine);
 
        Assert.Equal(expectParseErrors, parseResult.Errors.Count > 0);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        Assert.Equal(expectedTokens, forwardedArguments.Tokens);
        Assert.Equal(expectedOptionCount, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void GetForwardedArguments_NormalizesSingleFileSystemInfoValue()
    {
        var relativePath = Path.Combine("Profile Output", "profile.zip");
        var parseResult = _command.Parse(["run", "--capture-profile-output", relativePath]);
 
        Assert.Empty(parseResult.Errors);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        Assert.Equal(["--capture-profile-output", new FileInfo(relativePath).FullName], forwardedArguments.Tokens);
        Assert.Equal(2, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void GetForwardedArguments_PreservesInvalidFileSystemInfoValue()
    {
        var parseResult = _command.Parse(["run", "--capture-profile-output", ""]);
 
        Assert.NotEmpty(parseResult.Errors);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        Assert.Equal(["--capture-profile-output", ""], forwardedArguments.Tokens);
        Assert.Equal(2, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void GetForwardedArguments_NormalizesLiteralDoubleDashFileSystemInfoValue()
    {
        var parseResult = _command.Parse(["run", "--capture-profile-output=--"]);
 
        Assert.Empty(parseResult.Errors);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        Assert.Equal([$"--capture-profile-output={new FileInfo("--").FullName}"], forwardedArguments.Tokens);
        Assert.Equal(1, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void GetForwardedArguments_PreservesLiteralDoubleDashStringValue()
    {
        var valueOption = new System.CommandLine.Option<string>("--value");
        var command = new System.CommandLine.Command("test");
        command.Options.Add(valueOption);
        var parseResult = command.Parse(["test", "--value=--"]);
 
        Assert.Empty(parseResult.Errors);
        var forwardedArguments = ParseResultHelper.GetForwardedArguments(parseResult);
 
        Assert.Equal(["--value=--"], forwardedArguments.Tokens);
        Assert.Equal(1, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void ForwardedArguments_RoundTripsOptionShapedMultiValue()
    {
        var valuesOption = new System.CommandLine.Option<string[]>("--values")
        {
            Arity = System.CommandLine.ArgumentArity.ZeroOrMore,
            AllowMultipleArgumentsPerToken = true,
        };
        var flagOption = new System.CommandLine.Option<bool>("--flag");
        var command = new System.CommandLine.Command("test");
        command.Options.Add(valuesOption);
        command.Options.Add(flagOption);
 
        var parentParseResult = command.Parse(["test", "--values=--flag"]);
 
        Assert.Empty(parentParseResult.Errors);
        var parentValues = Assert.IsType<string[]>(parentParseResult.GetValue(valuesOption));
        Assert.Equal(["--flag"], parentValues);
        Assert.False(parentParseResult.GetValue(flagOption));
 
        var forwardedArguments = ParseResultHelper.GetForwardedArguments(parentParseResult);
        var childParseResult = command.Parse(["test", .. forwardedArguments.Tokens]);
 
        Assert.Empty(childParseResult.Errors);
        Assert.Equal(parentValues, Assert.IsType<string[]>(childParseResult.GetValue(valuesOption)));
        Assert.Equal(parentParseResult.GetValue(flagOption), childParseResult.GetValue(flagOption));
        Assert.Equal(["--values=--flag"], forwardedArguments.Tokens);
    }
 
    [Fact]
    public void GetForwardedArguments_ExcludesOptionAliases()
    {
        var parseResult = _command.Parse(["run", "-d", "--isolated"]);
 
        Assert.Empty(parseResult.Errors);
        var forwardedArguments = ParseResultHelper.GetForwardedArguments(
            parseResult,
            RootCommand.DebugOption);
 
        Assert.Equal(["--isolated"], forwardedArguments.Tokens);
        Assert.Equal(1, forwardedArguments.OptionCount);
    }
 
    [Fact]
    public void ForwardedArguments_RoundTripsChildRunSemantics()
    {
        var parseResult = _command.Parse(
            ["run", "--apphost=selector", "--isolated=false", "--debug", "--", "--apphost", "app-value"]);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        forwardedArguments.InsertCliOption("--non-interactive", "--capture-profile");
 
        Assert.Equal(5, forwardedArguments.OptionCount);
        Assert.Equal(
            ["--isolated", "false", "--debug", "--non-interactive", "--capture-profile", "--", "--apphost", "app-value"],
            forwardedArguments.Tokens);
 
        var childParseResult = _command.Parse(["run", .. forwardedArguments.Tokens]);
 
        Assert.Empty(childParseResult.Errors);
        Assert.False(childParseResult.GetValue(AppHostLauncher.s_isolatedOption));
        Assert.True(childParseResult.GetValue(RootCommand.DebugOption));
        Assert.True(childParseResult.GetValue(RootCommand.NonInteractiveOption));
        Assert.True(childParseResult.GetValue(RootCommand.CaptureProfileOption));
        Assert.Null(childParseResult.GetValue(AppHostLauncher.s_appHostOption));
        Assert.Equal(["--apphost", "app-value"], childParseResult.UnmatchedTokens);
    }
 
    [Fact]
    public void ForwardedArguments_RoundTripsDelimiterFreeAppHostArgumentsAfterSeparator()
    {
        var parseResult = _command.Parse(["run", "--debug", "--secret", "value"]);
        var forwardedArguments = GetForwardedArguments(parseResult);
 
        forwardedArguments.InsertCliOption("--non-interactive", "--capture-profile");
 
        Assert.Equal(3, forwardedArguments.OptionCount);
        Assert.Equal(
            ["--debug", "--non-interactive", "--capture-profile", "--", "--secret", "value"],
            forwardedArguments.Tokens);
 
        var childParseResult = _command.Parse(["run", .. forwardedArguments.Tokens]);
 
        Assert.Empty(childParseResult.Errors);
        Assert.True(childParseResult.GetValue(RootCommand.DebugOption));
        Assert.True(childParseResult.GetValue(RootCommand.NonInteractiveOption));
        Assert.True(childParseResult.GetValue(RootCommand.CaptureProfileOption));
        Assert.Equal(["--secret", "value"], childParseResult.UnmatchedTokens);
    }
 
    [Fact]
    public void GetLoggableArguments_RedactsForwardedTokensWithoutSeparator()
    {
        var parseResult = _command.Parse(["run", "--ApiKey", "sk-live-secret"]);
        var loggable = ParseResultHelper.GetLoggableArguments(parseResult);
 
        Assert.Equal("run <redacted> <redacted>", loggable);
        Assert.DoesNotContain("sk-live-secret", loggable, StringComparison.Ordinal);
        Assert.Equal("aspire run <redacted> <redacted>", $"aspire {loggable}");
    }
 
    [Fact]
    public void GetLoggableArguments_PreservesRecognizedOptionsAndRedactsTheRest()
    {
        var parseResult = _command.Parse(["run", "--debug", "--secret", "value"]);
        var loggable = ParseResultHelper.GetLoggableArguments(parseResult);
 
        Assert.Equal("run --debug <redacted> <redacted>", loggable);
        Assert.DoesNotContain("--secret", loggable, StringComparison.Ordinal);
    }
 
    [Fact]
    public void GetLoggableArguments_PreservesSeparatorAndRedactsTrailingTokens()
    {
        var parseResult = _command.Parse(["run", "--", "--ApiKey", "secret"]);
        var loggable = ParseResultHelper.GetLoggableArguments(parseResult);
 
        Assert.Equal("run -- <redacted> <redacted>", loggable);
        Assert.DoesNotContain("secret", loggable, StringComparison.Ordinal);
    }
 
    [Fact]
    public void GetLoggableArguments_PreservesCommandArguments()
    {
        var parseResult = _command.Parse(["add", "Aspire.Hosting.Redis"]);
 
        Assert.Equal("add Aspire.Hosting.Redis", ParseResultHelper.GetLoggableArguments(parseResult));
    }
 
    [Fact]
    public void GetLoggableArguments_PreservesRecognizedOptionValues()
    {
        var parseResult = _command.Parse(["run", "--apphost", "path/to/App.csproj"]);
 
        Assert.Equal("run --apphost path/to/App.csproj", ParseResultHelper.GetLoggableArguments(parseResult));
    }
 
    [Fact]
    public void GetLoggableArguments_ReturnsEmptyStringForBareInvocation()
    {
        Assert.Equal(string.Empty, ParseResultHelper.GetLoggableArguments(_command.Parse([])));
 
        // System.CommandLine keeps the first argument when it already names the root command, so
        // the synthesized and the user-supplied forms must both render as an empty argument list.
        Assert.Equal(string.Empty, ParseResultHelper.GetLoggableArguments(_command.Parse([_command.Name])));
    }
 
    [Fact]
    public void GetLoggableArguments_RedactsRepeatedValueOnlyInForwardedPosition()
    {
        var parseResult = _command.Parse(["run", "--apphost", "same-value", "--", "same-value"]);
        var loggable = ParseResultHelper.GetLoggableArguments(parseResult);
 
        Assert.Equal("run --apphost same-value -- <redacted>", loggable);
    }
 
    public void Dispose()
    {
        _provider.Dispose();
        _workspace.Dispose();
    }
 
    private static ForwardedArguments GetForwardedArguments(ParseResult parseResult)
        => ParseResultHelper.GetForwardedArguments(
            parseResult,
            AppHostLauncher.s_appHostOption.InnerOption,
            AppHostLauncher.s_appHostOption.LegacyOption);
}