Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ public async Task<string> PromptForStringAsync(string promptText, Func<string, V
{
if (binding != null)
{
if (binding.DefaultValue != null)
if (binding.NonInteractiveDefaultValue != null)
{
ValidateResolvedStringValue(binding.DefaultValue, required, validator, binding.SymbolDisplayName);
return binding.DefaultValue;
ValidateResolvedStringValue(binding.NonInteractiveDefaultValue, required, validator, binding.SymbolDisplayName);
return binding.NonInteractiveDefaultValue;
}

ThrowNonInteractiveError(binding.SymbolDisplayName);
Expand Down Expand Up @@ -229,9 +229,9 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T
{
if (binding != null)
{
if (defaultValue != null)
if (binding.NonInteractiveDefaultValue != null)
{
return MatchChoiceOrThrow(defaultValue, binding, choicesList, choiceFormatter);
return MatchChoiceOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter);
}

ThrowNonInteractiveError(binding.SymbolDisplayName);
Expand Down Expand Up @@ -281,9 +281,9 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex
{
if (binding != null)
{
if (defaultValue != null)
if (binding.NonInteractiveDefaultValue != null)
{
return MatchChoicesOrThrow(defaultValue, binding, choicesList, choiceFormatter);
return MatchChoicesOrThrow(binding.NonInteractiveDefaultValue, binding, choicesList, choiceFormatter);
}

ThrowNonInteractiveError(binding.SymbolDisplayName);
Expand Down Expand Up @@ -499,7 +499,7 @@ public async Task<bool> PromptConfirmAsync(string promptText, PromptBinding<bool
{
if (binding.HasExplicitDefault)
{
return binding.DefaultValue;
return binding.NonInteractiveDefaultValue;
}

ThrowNonInteractiveError(binding.SymbolDisplayName);
Expand Down
63 changes: 51 additions & 12 deletions src/Aspire.Cli/Interaction/PromptBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Aspire.Cli.Resources;

namespace Aspire.Cli.Interaction;

Expand All @@ -23,12 +22,24 @@ internal PromptBinding(
Func<ParseResult, (bool WasProvided, T? Value)> resolver,
T? defaultValue,
bool hasExplicitDefault)
: this(parseResult, symbolDisplayName, resolver, defaultValue, hasExplicitDefault, hasExplicitDefault ? defaultValue : default)
{
}

internal PromptBinding(
ParseResult? parseResult,
string symbolDisplayName,
Func<ParseResult, (bool WasProvided, T? Value)> resolver,
T? defaultValue,
bool hasExplicitDefault,
T? nonInteractiveDefaultValue)
{
_parseResult = parseResult;
SymbolDisplayName = symbolDisplayName;
_resolver = resolver;
DefaultValue = defaultValue;
HasExplicitDefault = hasExplicitDefault;
NonInteractiveDefaultValue = nonInteractiveDefaultValue;
}

/// <summary>
Expand All @@ -38,10 +49,15 @@ internal PromptBinding(
public string SymbolDisplayName { get; }

/// <summary>
/// Gets the default value to use when non-interactive and the symbol was not provided.
/// Gets the default value to use for interactive prompts when the symbol was not provided.
/// </summary>
public T? DefaultValue { get; }

/// <summary>
/// Gets the default value to use when non-interactive and the symbol was not provided.
/// </summary>
public T? NonInteractiveDefaultValue { get; }

/// <summary>
/// Gets whether a default value was explicitly specified when this binding was created.
/// When <c>false</c>, prompt methods should throw in non-interactive mode instead of
Expand All @@ -67,7 +83,7 @@ internal PromptBinding(
/// Creates a new <see cref="PromptBinding{T}"/> with the same resolver but a different default value.
/// </summary>
public PromptBinding<T> WithDefault(T? newDefault) =>
new(_parseResult, SymbolDisplayName, _resolver, newDefault, hasExplicitDefault: true);
new(_parseResult, SymbolDisplayName, _resolver, newDefault, hasExplicitDefault: true, nonInteractiveDefaultValue: newDefault);
}

/// <summary>
Expand Down Expand Up @@ -115,16 +131,39 @@ public static PromptBinding<bool> CreateInvertedBoolConfirm(ParseResult parseRes
new(parseResult, FormatOptionName(option), BuildResolver<bool?, bool>(option, value => value != true), defaultValue, hasExplicitDefault: true);

/// <summary>
/// Creates a <see cref="PromptBinding{T}"/> for a <c>bool?</c> option that maps to Yes/No selection choices.
/// When the option is explicitly provided, resolves to <paramref name="trueValue"/> or <paramref name="falseValue"/>.
/// When not provided in non-interactive mode, defaults to <paramref name="falseValue"/>.
/// Creates a <see cref="PromptBinding{T}"/> for a <c>bool?</c> option that maps to a confirmation prompt.
/// When the option is explicitly provided, the binding resolves to <c>true</c> only when the option value is <c>true</c>.
/// When the option is not explicitly provided, <paramref name="defaultValue"/> is used as the confirmation default,
/// including as the interactive prompt default when the user accepts the prompt by pressing Enter.
/// </summary>
public static PromptBinding<string?> CreateBoolAsSelection(ParseResult parseResult, Option<bool?> option, string? trueValue = null, string? falseValue = null)
{
trueValue ??= TemplatingStrings.Yes;
falseValue ??= TemplatingStrings.No;
return new(parseResult, FormatOptionName(option), BuildResolver<bool?, string?>(option, value => value == true ? trueValue : falseValue), falseValue, hasExplicitDefault: true);
}
/// <param name="parseResult">The parse result used to determine whether <paramref name="option"/> was explicitly provided.</param>
/// <param name="option">The nullable Boolean option to bind to the confirmation prompt.</param>
/// <param name="defaultValue">The default confirmation value to use when <paramref name="option"/> was not explicitly provided.</param>
/// <returns>
/// A <see cref="PromptBinding{T}"/> that resolves the explicitly provided <c>bool?</c> option to a <see cref="bool"/>,
/// where <c>true</c> maps to <c>true</c> and any other value maps to <c>false</c>, and otherwise exposes
/// <paramref name="defaultValue"/> as the prompt default.
/// </returns>
public static PromptBinding<bool> CreateBoolConfirm(ParseResult parseResult, Option<bool?> option, bool defaultValue) =>
CreateBoolConfirm(parseResult, option, interactiveDefault: defaultValue, nonInteractiveDefault: defaultValue);

/// <summary>
/// Creates a <see cref="PromptBinding{T}"/> for a <c>bool?</c> option that maps to a confirmation prompt.
/// When the option is explicitly provided, the binding resolves to <c>true</c> only when the option value is <c>true</c>.
/// When the option is not explicitly provided, <paramref name="interactiveDefault"/> is used as the confirmation prompt default,
/// and <paramref name="nonInteractiveDefault"/> is used when interactive input is not available.
/// </summary>
/// <param name="parseResult">The parse result used to determine whether <paramref name="option"/> was explicitly provided.</param>
/// <param name="option">The nullable Boolean option to bind to the confirmation prompt.</param>
/// <param name="interactiveDefault">The default confirmation value to use for the interactive prompt.</param>
/// <param name="nonInteractiveDefault">The default confirmation value to use when interactive input is not available.</param>
/// <returns>
/// A <see cref="PromptBinding{T}"/> that resolves the explicitly provided <c>bool?</c> option to a <see cref="bool"/>,
/// where <c>true</c> maps to <c>true</c> and any other value maps to <c>false</c>, and otherwise exposes
/// <paramref name="interactiveDefault"/> as the prompt default.
/// </returns>
public static PromptBinding<bool> CreateBoolConfirm(ParseResult parseResult, Option<bool?> option, bool interactiveDefault, bool nonInteractiveDefault) =>
new(parseResult, FormatOptionName(option), BuildResolver<bool?, bool>(option, value => value == true), interactiveDefault, hasExplicitDefault: true, nonInteractiveDefaultValue: nonInteractiveDefault);

private static string FormatOptionName<T>(Option<T> option) => $"'{option.Name}'";

Expand Down
14 changes: 4 additions & 10 deletions src/Aspire.Cli/Packaging/NuGetConfigPrompter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,10 @@ public async Task PromptToCreateOrUpdateAsync(DirectoryInfo targetDirectory, Pac

if (!hasConfigInTargetDir)
{
var choice = await _interactionService.PromptForSelectionAsync(
var shouldCreate = await _interactionService.PromptConfirmAsync(
TemplatingStrings.CreateNugetConfigConfirmation,
[TemplatingStrings.Yes, TemplatingStrings.No],
c => c,
binding: PromptBinding.CreateDefault<string?>(TemplatingStrings.Yes),
binding: PromptBinding.CreateDefault(true),
cancellationToken: cancellationToken);
var shouldCreate = string.Equals(choice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput);

if (shouldCreate)
{
Expand All @@ -63,13 +60,10 @@ public async Task PromptToCreateOrUpdateAsync(DirectoryInfo targetDirectory, Pac
}
else if (hasMissingSources)
{
var updateChoice = await _interactionService.PromptForSelectionAsync(
var shouldUpdate = await _interactionService.PromptConfirmAsync(
TemplatingStrings.UpdateNuGetConfigConfirmation,
[TemplatingStrings.Yes, TemplatingStrings.No],
c => c,
binding: PromptBinding.CreateDefault<string?>(TemplatingStrings.Yes),
binding: PromptBinding.CreateDefault(true),
cancellationToken: cancellationToken);
var shouldUpdate = string.Equals(updateChoice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput);

if (shouldUpdate)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,13 @@ private async Task<TemplateResult> ApplyEmptyAppHostTemplateAsync(CallbackTempla

private async Task<bool> ResolveUseLocalhostTldAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
{
var binding = PromptBinding.CreateBoolAsSelection(parseResult, _localhostTldOption);
var binding = PromptBinding.CreateBoolConfirm(parseResult, _localhostTldOption, defaultValue: false);

var selected = await _interactionService.PromptForSelectionAsync(
var useLocalhostTld = await _interactionService.PromptConfirmAsync(
TemplatingStrings.UseLocalhostTld_Prompt,
[TemplatingStrings.No, TemplatingStrings.Yes],
choice => choice,
binding: binding,
cancellationToken: cancellationToken);

var useLocalhostTld = string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput);

if (useLocalhostTld)
{
_interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseLocalhostTld_UsingLocalhostTld);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,13 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process(

private async Task<bool> ResolveUseRedisCacheAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
{
var binding = PromptBinding.CreateBoolAsSelection(parseResult, _useRedisCacheOption);
var binding = PromptBinding.CreateBoolConfirm(parseResult, _useRedisCacheOption, interactiveDefault: true, nonInteractiveDefault: false);

var selected = await _interactionService.PromptForSelectionAsync(
var useRedisCache = await _interactionService.PromptConfirmAsync(
TemplatingStrings.UseRedisCache_Prompt,
[TemplatingStrings.Yes, TemplatingStrings.No],
choice => choice,
binding: binding,
cancellationToken: cancellationToken);

var useRedisCache = string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput);

if (useRedisCache)
{
_interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseRedisCache_UsingRedisCache);
Expand Down
23 changes: 9 additions & 14 deletions src/Aspire.Cli/Templating/DotNetTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,14 @@ private async Task<string[]> PromptForExtraAspireXUnitOptionsAsync(ParseResult r

private async Task PromptForDevLocalhostTldOptionAsync(ParseResult result, List<string> extraArgs, CancellationToken cancellationToken)
{
var binding = PromptBinding.CreateBoolAsSelection(result, _localhostTldOption);
var binding = PromptBinding.CreateBoolConfirm(result, _localhostTldOption, defaultValue: false);

var selected = await interactionService.PromptForSelectionAsync(
var useLocalhostTld = await interactionService.PromptConfirmAsync(
TemplatingStrings.UseLocalhostTld_Prompt,
[TemplatingStrings.No, TemplatingStrings.Yes],
choice => choice,
binding: binding,
cancellationToken: cancellationToken);

if (string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput))
if (useLocalhostTld)
{
interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseLocalhostTld_UsingLocalhostTld);
extraArgs.Add("--localhost-tld");
Expand All @@ -308,16 +306,14 @@ private async Task PromptForDevLocalhostTldOptionAsync(ParseResult result, List<

private async Task PromptForRedisCacheOptionAsync(ParseResult result, List<string> extraArgs, CancellationToken cancellationToken)
{
var binding = PromptBinding.CreateBoolAsSelection(result, _useRedisCacheOption);
var binding = PromptBinding.CreateBoolConfirm(result, _useRedisCacheOption, interactiveDefault: true, nonInteractiveDefault: false);

var selected = await interactionService.PromptForSelectionAsync(
var useRedisCache = await interactionService.PromptConfirmAsync(
TemplatingStrings.UseRedisCache_Prompt,
[TemplatingStrings.Yes, TemplatingStrings.No],
choice => choice,
binding: binding,
cancellationToken: cancellationToken);

if (string.Equals(selected, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput))
if (useRedisCache)
{
interactionService.DisplayMessage(KnownEmojis.CheckMarkButton, TemplatingStrings.UseRedisCache_UsingRedisCache);
extraArgs.Add("--use-redis-cache");
Expand All @@ -336,13 +332,12 @@ private async Task PromptForTestFrameworkOptionsAsync(ParseResult result, List<s
return;
}

var createTestProject = await interactionService.PromptForSelectionAsync(
var createTestProject = await interactionService.PromptConfirmAsync(
TemplatingStrings.PromptForTFMOptions_Prompt,
[TemplatingStrings.No, TemplatingStrings.Yes],
choice => choice,
binding: PromptBinding.CreateDefault(false),
cancellationToken: cancellationToken);

if (string.Equals(createTestProject, TemplatingStrings.No, StringComparisons.CliInputOrOutput))
if (!createTestProject)
{
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Use Redis Cache").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(10),
description: "Redis cache prompt");
await auto.DownAsync(); // Navigate to "No"
await auto.EnterAsync();
await auto.TypeAsync("n");

await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("Do you want to create a test project?").Search(s).Count > 0,
Expand Down
22 changes: 10 additions & 12 deletions tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ public async Task NewCommandWithExplicitCSharpEmptyTemplateCreatesCSharpAppHost(
}

[Fact]
public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesSelection()
public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndUsesConfirmation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var localhostPrompted = false;
Expand All @@ -924,17 +924,16 @@ public async Task NewCommandWithEmptyTemplateAndCSharpPromptsForLocalhostTldAndU

options.InteractionServiceFactory = _ => new TestInteractionService
{
ConfirmCallback = (_, _) => false,
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
ConfirmCallback = (promptText, defaultValue) =>
{
if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
{
localhostPrompted = true;
return choices.Cast<object>().Single(choice =>
string.Equals(choiceFormatter(choice), TemplatingStrings.Yes, StringComparisons.CliInputOrOutput));
Assert.False(defaultValue);
return true;
}

return choices.Cast<object>().First();
return false;
}
};
options.NewCommandPrompterFactory = (sp) =>
Expand Down Expand Up @@ -1037,7 +1036,7 @@ public async Task NewCommandWithEmptyTemplateNormalizesDefaultOutputPath()
}

[Fact]
public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesSelection()
public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTldAndUsesConfirmation()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var scaffoldingInvoked = false;
Expand All @@ -1049,17 +1048,16 @@ public async Task NewCommandWithEmptyTemplateAndTypeScriptPromptsForLocalhostTld

options.InteractionServiceFactory = _ => new TestInteractionService
{
ConfirmCallback = (_, _) => false,
PromptForSelectionCallback = (promptText, choices, choiceFormatter, cancellationToken) =>
ConfirmCallback = (promptText, defaultValue) =>
{
if (string.Equals(promptText, TemplatingStrings.UseLocalhostTld_Prompt, StringComparison.Ordinal))
{
localhostPrompted = true;
return choices.Cast<object>().Single(choice =>
string.Equals(choiceFormatter(choice), TemplatingStrings.Yes, StringComparisons.CliInputOrOutput));
Assert.False(defaultValue);
return true;
}

return choices.Cast<object>().First();
return false;
}
};
options.NewCommandPrompterFactory = (sp) =>
Expand Down
Loading
Loading