Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Backchannel;

Expand Down Expand Up @@ -125,7 +126,7 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(
var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c,
c => c.EscapeMarkup(),
cancellationToken);

selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
Expand All @@ -148,7 +149,7 @@ public async Task<AppHostConnectionResult> ResolveConnectionAsync(
var selectedDisplay = await interactionService.PromptForSelectionAsync(
selectPrompt,
choices.Select(c => c.Display).ToArray(),
c => c,
c => c.EscapeMarkup(),
cancellationToken);

selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
Expand Down
9 changes: 5 additions & 4 deletions src/Aspire.Cli/Commands/AddCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Semver;
using Spectre.Console;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;

namespace Aspire.Cli.Commands;
Expand Down Expand Up @@ -325,7 +326,7 @@ internal class AddCommandPrompter(IInteractionService interactionService) : IAdd
// Helper to keep labels consistently formatted: "Version (source)"
static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, PackageChannel Channel) item)
{
return $"{item.Package.Version} ({item.Channel.SourceDetails})";
return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})";
}

async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync(
Expand Down Expand Up @@ -395,7 +396,7 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac
var item = channelGroup.HighestVersion;

rootChoices.Add((
Label: channel.Name,
Label: channel.Name.EscapeMarkup(),
// For explicit channels, we still show submenu but with only the highest version
Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct)
));
Expand Down Expand Up @@ -442,11 +443,11 @@ private static string PackageNameWithFriendlyNameIfAvailable((string FriendlyNam
{
if (packageWithFriendlyName.FriendlyName is { } friendlyName)
{
return $"[bold]{friendlyName}[/] ({packageWithFriendlyName.Package.Id})";
return $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageWithFriendlyName.Package.Id.EscapeMarkup()})";
}
else
{
return packageWithFriendlyName.Package.Id;
return packageWithFriendlyName.Package.Id.EscapeMarkup();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ later as needed.
var selectedProjects = await InteractionService.PromptForSelectionsAsync(
"Select projects to add to the AppHost:",
initContext.ExecutableProjects,
project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name),
project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(),
cancellationToken);

initContext.ExecutableProjectsToAddToAppHost = selectedProjects;
Expand Down Expand Up @@ -298,7 +298,7 @@ ServiceDefaults project contains helper code to make it easier
initContext.ProjectsToAddServiceDefaultsTo = await InteractionService.PromptForSelectionsAsync(
"Select projects to add ServiceDefaults reference to:",
initContext.ExecutableProjectsToAddToAppHost,
project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name),
project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(),
cancellationToken);
break;
case "none":
Expand Down
6 changes: 3 additions & 3 deletions src/Aspire.Cli/Commands/NewCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ internal class NewCommandPrompter(IInteractionService interactionService) : INew
static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) item)
{
// Keep it concise: "Version (source)"
return $"{item.Package.Version} ({item.Channel.SourceDetails})";
return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})";
}

async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync(
Expand Down Expand Up @@ -330,7 +330,7 @@ static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel)
var items = channelGroup.ToArray();

rootChoices.Add((
Label: channel.Name,
Label: channel.Name.EscapeMarkup(),
Action: ct => PromptForChannelPackagesAsync(channel, items, ct)
));
}
Expand Down Expand Up @@ -380,7 +380,7 @@ public virtual async Task<ITemplate> PromptForTemplateAsync(ITemplate[] validTem
return await interactionService.PromptForSelectionAsync(
NewCommandStrings.SelectAProjectTemplate,
validTemplates,
t => t.Description,
t => t.Description.EscapeMarkup(),
cancellationToken
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/PipelineCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo
var (value, displayText) = await InteractionService.PromptForSelectionAsync(
promptText,
options,
choice => choice.Value,
choice => choice.Value.EscapeMarkup(),
cancellationToken);

if (value == CustomChoiceValue)
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public virtual async Task<string> PromptForPublisherAsync(IEnumerable<string> pu
return await interactionService.PromptForSelectionAsync(
PublishCommandStrings.SelectAPublisher,
publishers,
p => p,
p => p.EscapeMarkup(),
cancellationToken
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
channel = await InteractionService.PromptForSelectionAsync(
UpdateCommandStrings.SelectChannelPrompt,
allChannels,
(c) => $"{c.Name} ({c.SourceDetails})",
(c) => $"{c.Name.EscapeMarkup()} ({c.SourceDetails.EscapeMarkup()})",
cancellationToken);
}
else
Expand Down
4 changes: 2 additions & 2 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T

var prompt = new SelectionPrompt<T>()
.Title(promptText)
.UseConverter(item => choiceFormatter(item).EscapeMarkup())
.UseConverter(choiceFormatter)
.AddChoices(choices)
.PageSize(10)
.EnableSearch();
Expand Down Expand Up @@ -174,7 +174,7 @@ public async Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptTex

var prompt = new MultiSelectionPrompt<T>()
.Title(promptText)
.UseConverter(item => choiceFormatter(item).EscapeMarkup())
.UseConverter(choiceFormatter)
.AddChoices(choices)
.PageSize(10);

Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Projects/ProjectLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Aspire.Cli.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Projects;

Expand Down Expand Up @@ -199,7 +200,7 @@ public async Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(F
projectFile = await interactionService.PromptForSelectionAsync(
InteractionServiceStrings.SelectAppHostToUse,
appHostProjects,
file => $"{file.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName)})",
file => $"{file.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName).EscapeMarkup()})",
cancellationToken
);
}
Expand Down Expand Up @@ -277,7 +278,7 @@ public async Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(F
selectedAppHost = multipleAppHostProjectsFoundBehavior switch
{
MultipleAppHostProjectsFoundBehavior.Throw => throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound),
MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName)})", cancellationToken),
MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken),
MultipleAppHostProjectsFoundBehavior.None => null,
_ => selectedAppHost
};
Expand Down
3 changes: 2 additions & 1 deletion src/Aspire.Cli/Projects/SolutionLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Projects;

Expand Down Expand Up @@ -39,7 +40,7 @@ internal sealed class SolutionLocator(ILogger<SolutionLocator> logger, IInteract
var selectedSolution = await interactionService.PromptForSelectionAsync(
InitCommandStrings.MultipleSolutionsFound,
solutionFiles,
solutionFile => $"{solutionFile.Name} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName)})",
solutionFile => $"{solutionFile.Name.EscapeMarkup()} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName).EscapeMarkup()})",
cancellationToken);

return selectedSolution;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,4 +547,70 @@ public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault()
var outputString = output.ToString();
Assert.Contains("[Module]", outputString);
}

[Fact]
public void SelectionPrompt_ConverterPreservesIntentionalMarkup()
{
// Arrange - verifies that PromptForSelectionAsync does NOT escape the formatter output,
// allowing callers to include intentional Spectre markup (e.g., [bold]...[/]).
// This is a regression test for https://github.com/dotnet/aspire/issues/XXXXX where
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
// blanket EscapeMarkup() in the converter broke [bold] rendering in 'aspire add'.
var output = new StringBuilder();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.Standard,
Out = new AnsiConsoleOutput(new StringWriter(output))
});

// Build a SelectionPrompt the same way ConsoleInteractionService does,
// using a formatter that returns intentional markup (like AddCommand does).
Func<string, string> choiceFormatter = item => $"[bold]{item}[/] (Aspire.Hosting.{item})";

var prompt = new SelectionPrompt<string>()
.Title("Select an integration:")
.UseConverter(choiceFormatter)
.AddChoices(["PostgreSQL", "Redis"]);

// Act - verify the converter output preserves the [bold] markup
// by checking that the converter is the formatter itself (not wrapped with EscapeMarkup)
var converterOutput = choiceFormatter("PostgreSQL");

// Assert - the formatter should produce raw markup, not escaped markup
Assert.Equal("[bold]PostgreSQL[/] (Aspire.Hosting.PostgreSQL)", converterOutput);
Assert.DoesNotContain("[[bold]]", converterOutput); // Must NOT be escaped
}

[Fact]
public void SelectionPrompt_ConverterWithBracketsInData_MustBeEscapedByCaller()
{
// Arrange - verifies that callers are responsible for escaping dynamic data
// that may contain bracket characters, while preserving intentional markup.
// This tests the pattern used by AddCommand.PackageNameWithFriendlyNameIfAvailable.
var output = new StringBuilder();
var console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(new StringWriter(output))
});

// Simulate a package name that contains brackets (e.g., from an external source)
var friendlyName = "Azure Storage [Preview]";
var packageId = "Aspire.Hosting.Azure.Storage";

// The formatter should escape dynamic values but preserve intentional markup
var formattedOutput = $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageId.EscapeMarkup()})";

// Assert - intentional markup preserved, dynamic brackets escaped
Assert.Equal("[bold]Azure Storage [[Preview]][/] (Aspire.Hosting.Azure.Storage)", formattedOutput);

// Verify Spectre can render this without throwing
var exception = Record.Exception(() => console.MarkupLine(formattedOutput));
Assert.Null(exception);

var outputString = output.ToString();
Assert.Contains("Azure Storage [Preview]", outputString);
Assert.Contains("Aspire.Hosting.Azure.Storage", outputString);
}
}
Loading