From 0793b16a9f8a1a8028f511c153949636a25bc470 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:17:15 +0000 Subject: [PATCH 1/8] Add aspire ls command Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/969a4da7-cf61-4d7b-9283-27480e82bbb5 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/LsCommand.cs | 104 +++++++++++++ src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Program.cs | 1 + src/Aspire.Cli/Projects/ProjectLocator.cs | 43 +++++- .../Commands/LsCommandTests.cs | 141 ++++++++++++++++++ .../TestServices/TestProjectLocator.cs | 13 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + 7 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 src/Aspire.Cli/Commands/LsCommand.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs new file mode 100644 index 00000000000..30e5274ca72 --- /dev/null +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +internal sealed class CandidateAppHostDisplayInfo +{ + public required string AppHostPath { get; init; } +} + +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class LsCommandJsonContext : JsonSerializerContext +{ + private static LsCommandJsonContext? s_relaxedEscaping; + + public static LsCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); +} + +internal sealed class LsCommand : BaseCommand +{ + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + + private readonly IInteractionService _interactionService; + private readonly IProjectLocator _projectLocator; + private readonly CliExecutionContext _executionContext; + + private static readonly Option s_formatOption = new("--format") + { + Description = PsCommandStrings.JsonOptionDescription + }; + + public LsCommand( + IInteractionService interactionService, + IProjectLocator projectLocator, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry) + : base("ls", "List candidate AppHosts", features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _projectLocator = projectLocator; + _executionContext = executionContext; + + Options.Add(s_formatOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var format = parseResult.GetValue(s_formatOption); + var appHosts = await _projectLocator.FindAppHostProjectFilesAsync(_executionContext.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var appHostInfos = appHosts.Select(a => new CandidateAppHostDisplayInfo { AppHostPath = a.FullName }).ToList(); + + if (format == OutputFormat.Json) + { + var json = JsonSerializer.Serialize(appHostInfos, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); + } + else if (appHostInfos.Count == 0) + { + _interactionService.DisplayMessage(KnownEmojis.Information, "No candidate AppHosts found."); + } + else + { + DisplayTable(appHostInfos); + } + + return ExitCodeConstants.Success; + } + + private void DisplayTable(List appHosts) + { + var shortPaths = FileSystemHelper.ShortenPaths(appHosts.Select(a => a.AppHostPath).ToList()); + + var table = new Table(); + table.AddBoldColumn(PsCommandStrings.HeaderPath); + + foreach (var appHost in appHosts) + { + table.AddRow(Markup.Escape(shortPaths[appHost.AppHostPath])); + } + + _interactionService.DisplayRenderable(table); + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 5ffec201cbc..58a338bdcec 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -113,6 +113,7 @@ public RootCommand( StopCommand stopCommand, StartCommand startCommand, WaitCommand waitCommand, + LsCommand lsCommand, ResourceCommand commandCommand, PsCommand psCommand, DescribeCommand describeCommand, @@ -205,6 +206,7 @@ public RootCommand( Subcommands.Add(stopCommand); Subcommands.Add(startCommand); Subcommands.Add(waitCommand); + Subcommands.Add(lsCommand); Subcommands.Add(commandCommand); Subcommands.Add(psCommand); Subcommands.Add(describeCommand); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 9b79dbfb782..bf533d141a3 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -477,6 +477,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 30ed87dfaa8..90b729e2d4c 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -18,6 +18,7 @@ namespace Aspire.Cli.Projects; internal interface IProjectLocator { + Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken); @@ -41,17 +42,29 @@ internal sealed class ProjectLocator( AspireCliTelemetry telemetry) : IProjectLocator { + public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + { + var allCandidates = await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, cancellationToken); + var candidates = allCandidates.BuildableAppHost.Concat(allCandidates.UnbuildableSuspectedAppHostProjects).ToList(); + candidates.Sort((x, y) => x.FullName.CompareTo(y.FullName)); + return candidates; + } + public async Task> FindAppHostProjectFilesAsync(string searchDirectory, CancellationToken cancellationToken) { - var allCandidates = await FindAppHostProjectFilesAsync(new DirectoryInfo(searchDirectory), stopAfterMultipleBuildableAppHosts: false, cancellationToken); - return [..allCandidates.BuildableAppHost, ..allCandidates.UnbuildableSuspectedAppHostProjects]; + return await FindAppHostProjectFilesAsync(new DirectoryInfo(searchDirectory), cancellationToken); } private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, CancellationToken cancellationToken) + { + return await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts, displayProgress: true, cancellationToken); + } + + private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, bool displayProgress, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); - return await interactionService.ShowStatusAsync(InteractionServiceStrings.FindingAppHosts, async () => + async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostsAsync() { var appHostProjects = new List(); var unbuildableSuspectedAppHostProjects = new List(); @@ -133,7 +146,10 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand { logger.LogDebug("Found {Language} apphost {CandidateFile}", handler.DisplayName, candidateFile.FullName); var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName); - interactionService.DisplaySubtleMessage(relativePath); + if (displayProgress) + { + interactionService.DisplaySubtleMessage(relativePath); + } lock (lockObject) { appHostProjects.Add(candidateFile); @@ -147,14 +163,20 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand else if (validationResult.IsUnsupported) { var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName); - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath)); + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileUnsupportedInCurrentEnvironment, relativePath)); + } logger.LogDebug("Skipping unsupported project {CandidateFile}", candidateFile.FullName); hasUnsupportedProjects = true; } else if (validationResult.IsPossiblyUnbuildable) { var relativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, candidateFile.FullName); - interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, relativePath)); + if (displayProgress) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectFileMayBeUnbuildableAppHost, relativePath)); + } lock (lockObject) { unbuildableSuspectedAppHostProjects.Add(candidateFile); @@ -176,7 +198,14 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand appHostProjects.Sort((x, y) => x.FullName.CompareTo(y.FullName)); return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects); - }); + } + + if (displayProgress) + { + return await interactionService.ShowStatusAsync(InteractionServiceStrings.FindingAppHosts, FindAppHostsAsync); + } + + return await FindAppHostsAsync(); } /// diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs new file mode 100644 index 00000000000..500b3c10148 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class LsCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task LsCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task LsCommand_WhenNoCandidates_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData("json")] + [InlineData("Json")] + [InlineData("JSON")] + public async Task LsCommand_FormatOption_IsCaseInsensitive(string format) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"ls --format {format}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task LsCommand_FormatOption_RejectsInvalidValue() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format invalid"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task LsCommand_JsonFormat_ReturnsCandidateAppHosts() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + var appHostPath1 = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"); + var appHostPath2 = Path.Combine(workspace.WorkspaceRoot.FullName, "App2", "App2.AppHost.csproj"); + var projectLocator = new TestProjectLocator + { + FindAppHostProjectFilesAsyncCallback = (_, _) => Task.FromResult(new List + { + new(appHostPath1), + new(appHostPath2) + }) + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.ProjectLocatorFactory = _ => projectLocator; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + var appHosts = JsonSerializer.Deserialize(jsonOutput, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + Assert.NotNull(appHosts); + + Assert.Collection(appHosts, + first => Assert.Equal(appHostPath1, first.AppHostPath), + second => Assert.Equal(appHostPath2, second.AppHostPath)); + } + + [Fact] + public async Task LsCommand_JsonFormat_WhenNoCandidates_ReturnsEmptyArray() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ls --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + using var document = JsonDocument.Parse(jsonOutput); + Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); + Assert.Equal(0, document.RootElement.GetArrayLength()); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index ebb53915f80..7fb3d439d3c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs @@ -14,6 +14,18 @@ internal sealed class TestProjectLocator : IProjectLocator public Func>? GetAppHostFromSettingsAsyncCallback { get; set; } + public Func>>? FindAppHostProjectFilesAsyncCallback { get; set; } + + public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + { + if (FindAppHostProjectFilesAsyncCallback != null) + { + return await FindAppHostProjectFilesAsyncCallback(searchDirectory, cancellationToken); + } + + return []; + } + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken) { if (UseOrFindAppHostProjectFileAsyncCallback != null) @@ -59,4 +71,3 @@ public async Task UseOrFindAppHostProjectFileAsync(F return null; } } - diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5fbf637dd8d..a600b7efee8 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -192,6 +192,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); From ea24edfc88b6ab8d54f37eb30f5f8f8ba31f839b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:21:08 +0000 Subject: [PATCH 2/8] Address aspire ls review feedback Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/969a4da7-cf61-4d7b-9283-27480e82bbb5 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/LsCommand.cs | 8 +++---- src/Aspire.Cli/Projects/ProjectLocator.cs | 15 ++++++++++++ .../SharedCommandStrings.Designer.cs | 24 +++++++++++++++++++ .../Resources/SharedCommandStrings.resx | 12 ++++++++++ .../Resources/xlf/SharedCommandStrings.cs.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.de.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.es.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.fr.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.it.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.ja.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.ko.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.pl.xlf | 20 ++++++++++++++++ .../xlf/SharedCommandStrings.pt-BR.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.ru.xlf | 20 ++++++++++++++++ .../Resources/xlf/SharedCommandStrings.tr.xlf | 20 ++++++++++++++++ .../xlf/SharedCommandStrings.zh-Hans.xlf | 20 ++++++++++++++++ .../xlf/SharedCommandStrings.zh-Hant.xlf | 20 ++++++++++++++++ 17 files changed, 315 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index 30e5274ca72..f0c9a315e9e 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -43,7 +43,7 @@ internal sealed class LsCommand : BaseCommand private static readonly Option s_formatOption = new("--format") { - Description = PsCommandStrings.JsonOptionDescription + Description = SharedCommandStrings.LsFormatOptionDescription }; public LsCommand( @@ -53,7 +53,7 @@ public LsCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) - : base("ls", "List candidate AppHosts", features, updateNotifier, executionContext, interactionService, telemetry) + : base("ls", SharedCommandStrings.LsCommandDescription, features, updateNotifier, executionContext, interactionService, telemetry) { _interactionService = interactionService; _projectLocator = projectLocator; @@ -77,7 +77,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } else if (appHostInfos.Count == 0) { - _interactionService.DisplayMessage(KnownEmojis.Information, "No candidate AppHosts found."); + _interactionService.DisplayMessage(KnownEmojis.Information, SharedCommandStrings.LsNoCandidateAppHostsFound); } else { @@ -92,7 +92,7 @@ private void DisplayTable(List appHosts) var shortPaths = FileSystemHelper.ShortenPaths(appHosts.Select(a => a.AppHostPath).ToList()); var table = new Table(); - table.AddBoldColumn(PsCommandStrings.HeaderPath); + table.AddBoldColumn(SharedCommandStrings.HeaderPath); foreach (var appHost in appHosts) { diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 90b729e2d4c..445e9fb5d10 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -18,6 +18,9 @@ namespace Aspire.Cli.Projects; internal interface IProjectLocator { + /// + /// Finds all candidate AppHost project files in the specified search directory. + /// Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken); @@ -42,6 +45,12 @@ internal sealed class ProjectLocator( AspireCliTelemetry telemetry) : IProjectLocator { + /// + /// Finds all candidate AppHost project files in the specified search directory. + /// + /// The directory to search recursively. + /// The cancellation token. + /// A list of candidate AppHost project files sorted by full path. public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { var allCandidates = await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, cancellationToken); @@ -50,6 +59,12 @@ public async Task> FindAppHostProjectFilesAsync(DirectoryInfo sea return candidates; } + /// + /// Finds all candidate AppHost project files in the specified search directory. + /// + /// The directory to search recursively. + /// The cancellation token. + /// A list of candidate AppHost project files sorted by full path. public async Task> FindAppHostProjectFilesAsync(string searchDirectory, CancellationToken cancellationToken) { return await FindAppHostProjectFilesAsync(new DirectoryInfo(searchDirectory), cancellationToken); diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index afb05bc7640..ddb0ad8ea03 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -87,6 +87,30 @@ internal static string FormatOptionDescription { } } + internal static string LsCommandDescription { + get { + return ResourceManager.GetString("LsCommandDescription", resourceCulture); + } + } + + internal static string LsFormatOptionDescription { + get { + return ResourceManager.GetString("LsFormatOptionDescription", resourceCulture); + } + } + + internal static string LsNoCandidateAppHostsFound { + get { + return ResourceManager.GetString("LsNoCandidateAppHostsFound", resourceCulture); + } + } + + internal static string HeaderPath { + get { + return ResourceManager.GetString("HeaderPath", resourceCulture); + } + } + internal static string IsolatedOptionDescription { get { return ResourceManager.GetString("IsolatedOptionDescription", resourceCulture); diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index 54a95f9e84c..be7dc6ad82b 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -139,6 +139,18 @@ Output format for detached AppHost results + + List candidate AppHosts + + + Output format (Table or Json) + + + No candidate AppHosts found. + + + Path + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index 5b6f5ca0ac3..b1032aec2b5 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 10f8ff925a5..3f87710bf75 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index 58311911980..c5f7db1ee3d 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 2b728b4e22c..b0a7464199b 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index aa928d48b3b..9522f8ff7ab 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 180dc8699be..45de199f793 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 7329b3e5dd4..769ddff5b9a 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index b54c6cc3418..86d7141cc47 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 88e0d26cb38..fe95be71e15 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 89dfe72a9e3..6c081fb8459 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index 022fa095900..a7681aa4d6e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index b81bf4ff2d1..10ef43ab780 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index abc7c7961ea..d853bf7e516 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -22,11 +22,31 @@ Output format for detached AppHost results + + Path + Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously + + List candidate AppHosts + List candidate AppHosts + + + + Output format (Table or Json) + Output format (Table or Json) + + + + No candidate AppHosts found. + No candidate AppHosts found. + + Multiple running AppHosts found in the current directory. Select from running AppHosts. Multiple running AppHosts found in the current directory. Select from running AppHosts. From 08a291c6fa403be1d24302347b25ba069bbf3b97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:54:25 +0000 Subject: [PATCH 3/8] Expose ls relative path and language Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/a216c5c3-0292-4383-a126-10c503e483e9 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/LsCommand.cs | 24 ++++++--- src/Aspire.Cli/Projects/ProjectLocator.cs | 49 +++++++++++++------ .../SharedCommandStrings.Designer.cs | 12 +++++ .../Resources/SharedCommandStrings.resx | 6 +++ .../Resources/xlf/SharedCommandStrings.cs.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.de.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.es.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.fr.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.it.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.ja.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.ko.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.pl.xlf | 10 ++++ .../xlf/SharedCommandStrings.pt-BR.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.ru.xlf | 10 ++++ .../Resources/xlf/SharedCommandStrings.tr.xlf | 10 ++++ .../xlf/SharedCommandStrings.zh-Hans.xlf | 10 ++++ .../xlf/SharedCommandStrings.zh-Hant.xlf | 10 ++++ .../Commands/LsCommandTests.cs | 21 ++++++-- .../TestServices/TestProjectLocator.cs | 18 +++++++ 19 files changed, 234 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index f0c9a315e9e..a5d6729cc82 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -16,7 +16,11 @@ namespace Aspire.Cli.Commands; internal sealed class CandidateAppHostDisplayInfo { - public required string AppHostPath { get; init; } + public required string RelativePath { get; init; } + + public required string Path { get; init; } + + public required string Language { get; init; } } [JsonSerializable(typeof(List))] @@ -67,8 +71,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = Telemetry.StartDiagnosticActivity(Name); var format = parseResult.GetValue(s_formatOption); - var appHosts = await _projectLocator.FindAppHostProjectFilesAsync(_executionContext.WorkingDirectory, cancellationToken).ConfigureAwait(false); - var appHostInfos = appHosts.Select(a => new CandidateAppHostDisplayInfo { AppHostPath = a.FullName }).ToList(); + var appHosts = await _projectLocator.FindAppHostProjectsAsync(_executionContext.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var appHostInfos = appHosts.Select(a => new CandidateAppHostDisplayInfo + { + RelativePath = System.IO.Path.GetRelativePath(_executionContext.WorkingDirectory.FullName, a.AppHostFile.FullName), + Path = a.AppHostFile.FullName, + Language = a.Language + }).ToList(); if (format == OutputFormat.Json) { @@ -89,14 +98,17 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell private void DisplayTable(List appHosts) { - var shortPaths = FileSystemHelper.ShortenPaths(appHosts.Select(a => a.AppHostPath).ToList()); - var table = new Table(); + table.AddBoldColumn(SharedCommandStrings.HeaderRelativePath); table.AddBoldColumn(SharedCommandStrings.HeaderPath); + table.AddBoldColumn(SharedCommandStrings.HeaderLanguage); foreach (var appHost in appHosts) { - table.AddRow(Markup.Escape(shortPaths[appHost.AppHostPath])); + table.AddRow( + Markup.Escape(appHost.RelativePath), + Markup.Escape(appHost.Path), + Markup.Escape(appHost.Language)); } _interactionService.DisplayRenderable(table); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 445e9fb5d10..22f9258fefa 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -18,6 +18,11 @@ namespace Aspire.Cli.Projects; internal interface IProjectLocator { + /// + /// Finds all candidate AppHost projects in the specified search directory. + /// + Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); + /// /// Finds all candidate AppHost project files in the specified search directory. /// @@ -34,6 +39,8 @@ internal interface IProjectLocator Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default); } +internal sealed record AppHostProjectCandidate(FileInfo AppHostFile, string Language); + internal sealed class ProjectLocator( ILogger logger, CliExecutionContext executionContext, @@ -51,14 +58,26 @@ internal sealed class ProjectLocator( /// The directory to search recursively. /// The cancellation token. /// A list of candidate AppHost project files sorted by full path. - public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + public async Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { var allCandidates = await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, cancellationToken); var candidates = allCandidates.BuildableAppHost.Concat(allCandidates.UnbuildableSuspectedAppHostProjects).ToList(); - candidates.Sort((x, y) => x.FullName.CompareTo(y.FullName)); + candidates.Sort((x, y) => x.AppHostFile.FullName.CompareTo(y.AppHostFile.FullName)); return candidates; } + /// + /// Finds all candidate AppHost project files in the specified search directory. + /// + /// The directory to search recursively. + /// The cancellation token. + /// A list of candidate AppHost project files sorted by full path. + public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + { + var candidates = await FindAppHostProjectsAsync(searchDirectory, cancellationToken); + return candidates.Select(c => c.AppHostFile).ToList(); + } + /// /// Finds all candidate AppHost project files in the specified search directory. /// @@ -70,19 +89,19 @@ public async Task> FindAppHostProjectFilesAsync(string searchDire return await FindAppHostProjectFilesAsync(new DirectoryInfo(searchDirectory), cancellationToken); } - private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, CancellationToken cancellationToken) + private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, CancellationToken cancellationToken) { return await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts, displayProgress: true, cancellationToken); } - private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, bool displayProgress, CancellationToken cancellationToken) + private async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, bool stopAfterMultipleBuildableAppHosts, bool displayProgress, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); - async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostsAsync() + async Task<(List BuildableAppHost, List UnbuildableSuspectedAppHostProjects, bool HasUnsupportedProjects)> FindAppHostsAsync() { - var appHostProjects = new List(); - var unbuildableSuspectedAppHostProjects = new List(); + var appHostProjects = new List(); + var unbuildableSuspectedAppHostProjects = new List(); var hasUnsupportedProjects = false; var lockObject = new object(); logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName); @@ -167,7 +186,7 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand } lock (lockObject) { - appHostProjects.Add(candidateFile); + appHostProjects.Add(new(candidateFile, handler.LanguageId)); if (stopAfterMultipleBuildableAppHosts && appHostProjects.Count >= 2) { @@ -194,7 +213,7 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand } lock (lockObject) { - unbuildableSuspectedAppHostProjects.Add(candidateFile); + unbuildableSuspectedAppHostProjects.Add(new(candidateFile, handler.LanguageId)); } } else @@ -210,7 +229,7 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand // This sort is done here to make results deterministic since we get all the app // host information in parallel and the order may vary. - appHostProjects.Sort((x, y) => x.FullName.CompareTo(y.FullName)); + appHostProjects.Sort((x, y) => x.AppHostFile.FullName.CompareTo(y.AppHostFile.FullName)); return (appHostProjects, unbuildableSuspectedAppHostProjects, hasUnsupportedProjects); } @@ -365,7 +384,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F directory, stopAfterMultipleBuildableAppHosts: multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Throw, cancellationToken); - var appHostProjects = searchResults.BuildableAppHost; + var appHostProjects = searchResults.BuildableAppHost.Select(c => c.AppHostFile).ToList(); interactionService.DisplayEmptyLine(); @@ -513,7 +532,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F } else if (results.BuildableAppHost.Count == 1) { - selectedAppHost = settingsAppHost ?? results.BuildableAppHost[0]; + selectedAppHost = settingsAppHost ?? results.BuildableAppHost[0].AppHostFile; } else if (results.BuildableAppHost.Count > 1) { @@ -525,7 +544,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F : StringComparison.Ordinal; if (settingsAppHost is not null - && results.BuildableAppHost.Any(f => string.Equals(f.FullName, settingsAppHost.FullName, pathComparison))) + && results.BuildableAppHost.Any(c => string.Equals(c.AppHostFile.FullName, settingsAppHost.FullName, pathComparison))) { logger.LogDebug("Using previously-selected AppHost from settings: {AppHost}", settingsAppHost.FullName); selectedAppHost = settingsAppHost; @@ -536,7 +555,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F selectedAppHost = multipleAppHostProjectsFoundBehavior switch { MultipleAppHostProjectsFoundBehavior.Throw => throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound, ProjectLocatorFailureReason.MultipleProjectFilesFound), - MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken: cancellationToken), + MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost.Select(c => c.AppHostFile).ToList(), projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken: cancellationToken), MultipleAppHostProjectsFoundBehavior.None => null, _ => selectedAppHost }; @@ -552,7 +571,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F // can rely on SelectedProjectFile being present in AllProjectFileCandidates. This // covers cases where the configured settings AppHost is selected but lives outside // the discovered candidate set (e.g. parent directory or excluded by enumeration). - var allCandidates = results.BuildableAppHost; + var allCandidates = results.BuildableAppHost.Select(c => c.AppHostFile).ToList(); if (selectedAppHost is not null && !allCandidates.Any(f => string.Equals(f.FullName, selectedAppHost.FullName, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) { diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index ddb0ad8ea03..7378570b998 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -111,6 +111,18 @@ internal static string HeaderPath { } } + internal static string HeaderRelativePath { + get { + return ResourceManager.GetString("HeaderRelativePath", resourceCulture); + } + } + + internal static string HeaderLanguage { + get { + return ResourceManager.GetString("HeaderLanguage", resourceCulture); + } + } + internal static string IsolatedOptionDescription { get { return ResourceManager.GetString("IsolatedOptionDescription", resourceCulture); diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index be7dc6ad82b..041813f5430 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -151,6 +151,12 @@ Path + + Relative Path + + + Language + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index b1032aec2b5..9691115bc0f 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 3f87710bf75..e743cc8a7b4 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index c5f7db1ee3d..2f7239a3ff5 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index b0a7464199b..0db0229a277 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 9522f8ff7ab..278768e8789 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index 45de199f793..b130830eb45 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 769ddff5b9a..1cc0c205540 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 86d7141cc47..ebfa774fd14 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index fe95be71e15..e5df4f35f49 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 6c081fb8459..b1466d1efd4 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index a7681aa4d6e..e9ea560ecf3 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index 10ef43ab780..52375c644b9 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index d853bf7e516..3f9e27d113c 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -22,11 +22,21 @@ Output format for detached AppHost results + + Language + Language + + Path Path + + Relative Path + Relative Path + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 500b3c10148..babcd8caad4 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Aspire.Cli.Commands; +using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -84,10 +85,10 @@ public async Task LsCommand_JsonFormat_ReturnsCandidateAppHosts() var appHostPath2 = Path.Combine(workspace.WorkspaceRoot.FullName, "App2", "App2.AppHost.csproj"); var projectLocator = new TestProjectLocator { - FindAppHostProjectFilesAsyncCallback = (_, _) => Task.FromResult(new List + FindAppHostProjectsAsyncCallback = (_, _) => Task.FromResult(new List { - new(appHostPath1), - new(appHostPath2) + new(new FileInfo(appHostPath1), KnownLanguageId.CSharp), + new(new FileInfo(appHostPath2), KnownLanguageId.TypeScript) }) }; @@ -110,8 +111,18 @@ public async Task LsCommand_JsonFormat_ReturnsCandidateAppHosts() Assert.NotNull(appHosts); Assert.Collection(appHosts, - first => Assert.Equal(appHostPath1, first.AppHostPath), - second => Assert.Equal(appHostPath2, second.AppHostPath)); + first => + { + Assert.Equal(Path.Combine("App1", "App1.AppHost.csproj"), first.RelativePath); + Assert.Equal(appHostPath1, first.Path); + Assert.Equal(KnownLanguageId.CSharp, first.Language); + }, + second => + { + Assert.Equal(Path.Combine("App2", "App2.AppHost.csproj"), second.RelativePath); + Assert.Equal(appHostPath2, second.Path); + Assert.Equal(KnownLanguageId.TypeScript, second.Language); + }); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index 7fb3d439d3c..b6333730325 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs @@ -14,8 +14,26 @@ internal sealed class TestProjectLocator : IProjectLocator public Func>? GetAppHostFromSettingsAsyncCallback { get; set; } + public Func>>? FindAppHostProjectsAsyncCallback { get; set; } + public Func>>? FindAppHostProjectFilesAsyncCallback { get; set; } + public async Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + { + if (FindAppHostProjectsAsyncCallback != null) + { + return await FindAppHostProjectsAsyncCallback(searchDirectory, cancellationToken); + } + + if (FindAppHostProjectFilesAsyncCallback != null) + { + var appHostFiles = await FindAppHostProjectFilesAsyncCallback(searchDirectory, cancellationToken); + return appHostFiles.Select(f => new AppHostProjectCandidate(f, KnownLanguageId.CSharp)).ToList(); + } + + return []; + } + public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { if (FindAppHostProjectFilesAsyncCallback != null) From 4c47ab9e85eb88239a3add93cb4de63e45ca42f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:55:48 +0000 Subject: [PATCH 4/8] Address ls review feedback Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/a216c5c3-0292-4383-a126-10c503e483e9 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectLocator.cs | 2 +- tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 22f9258fefa..7bd78b0401a 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -24,7 +24,7 @@ internal interface IProjectLocator Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); /// - /// Finds all candidate AppHost project files in the specified search directory. + /// Finds all candidate AppHost project files in the specified search directory, without language metadata. /// Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index babcd8caad4..7cf2ba764a5 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -107,10 +107,10 @@ public async Task LsCommand_JsonFormat_ReturnsCandidateAppHosts() Assert.Equal(ExitCodeConstants.Success, exitCode); var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); - Assert.NotNull(appHosts); + var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + Assert.NotNull(candidateAppHosts); - Assert.Collection(appHosts, + Assert.Collection(candidateAppHosts, first => { Assert.Equal(Path.Combine("App1", "App1.AppHost.csproj"), first.RelativePath); From 8c651ed27fab4f75f0851013507481b6897e286d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:57:06 +0000 Subject: [PATCH 5/8] Clarify ls candidate metadata docs Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/a216c5c3-0292-4383-a126-10c503e483e9 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectLocator.cs | 10 ++++++++-- .../TestServices/TestProjectLocator.cs | 6 ------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 7bd78b0401a..205160d585d 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -21,11 +21,17 @@ internal interface IProjectLocator /// /// Finds all candidate AppHost projects in the specified search directory. /// + /// The directory to search recursively. + /// The cancellation token. + /// A list of candidate AppHost projects with language metadata sorted by full path. Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); /// /// Finds all candidate AppHost project files in the specified search directory, without language metadata. /// + /// The directory to search recursively. + /// The cancellation token. + /// A list of candidate AppHost project files sorted by full path. Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken); @@ -53,11 +59,11 @@ internal sealed class ProjectLocator( { /// - /// Finds all candidate AppHost project files in the specified search directory. + /// Finds all candidate AppHost projects in the specified search directory with language metadata. /// /// The directory to search recursively. /// The cancellation token. - /// A list of candidate AppHost project files sorted by full path. + /// A list of candidate AppHost projects with language metadata sorted by full path. public async Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) { var allCandidates = await FindAppHostProjectFilesAsync(searchDirectory, stopAfterMultipleBuildableAppHosts: false, displayProgress: false, cancellationToken); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index b6333730325..67fb61fef82 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs @@ -25,12 +25,6 @@ public async Task> FindAppHostProjectsAsync(Direct return await FindAppHostProjectsAsyncCallback(searchDirectory, cancellationToken); } - if (FindAppHostProjectFilesAsyncCallback != null) - { - var appHostFiles = await FindAppHostProjectFilesAsyncCallback(searchDirectory, cancellationToken); - return appHostFiles.Select(f => new AppHostProjectCandidate(f, KnownLanguageId.CSharp)).ToList(); - } - return []; } From bee708a391e793471dad5dc763813b5ce519a8db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 03:03:07 +0000 Subject: [PATCH 6/8] Use shared JSON context for ls Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/e5c5bda1-664f-4e53-a85b-7256df9bcb13 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/LsCommand.cs | 35 ++++++------------- src/Aspire.Cli/JsonSourceGenerationContext.cs | 1 + .../Commands/LsCommandTests.cs | 2 +- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Cli/Commands/LsCommand.cs b/src/Aspire.Cli/Commands/LsCommand.cs index a5d6729cc82..4420551bb94 100644 --- a/src/Aspire.Cli/Commands/LsCommand.cs +++ b/src/Aspire.Cli/Commands/LsCommand.cs @@ -3,7 +3,6 @@ using System.CommandLine; using System.Text.Json; -using System.Text.Json.Serialization; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; @@ -14,29 +13,6 @@ namespace Aspire.Cli.Commands; -internal sealed class CandidateAppHostDisplayInfo -{ - public required string RelativePath { get; init; } - - public required string Path { get; init; } - - public required string Language { get; init; } -} - -[JsonSerializable(typeof(List))] -[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal sealed partial class LsCommandJsonContext : JsonSerializerContext -{ - private static LsCommandJsonContext? s_relaxedEscaping; - - public static LsCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }); -} - internal sealed class LsCommand : BaseCommand { internal override HelpGroup HelpGroup => HelpGroup.AppCommands; @@ -81,7 +57,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (format == OutputFormat.Json) { - var json = JsonSerializer.Serialize(appHostInfos, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + var json = JsonSerializer.Serialize(appHostInfos, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else if (appHostInfos.Count == 0) @@ -114,3 +90,12 @@ private void DisplayTable(List appHosts) _interactionService.DisplayRenderable(table); } } + +internal sealed class CandidateAppHostDisplayInfo +{ + public required string RelativePath { get; init; } + + public required string Path { get; init; } + + public required string Language { get; init; } +} diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index ad2fcbd35cb..2c83e329052 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -46,6 +46,7 @@ namespace Aspire.Cli; [JsonSerializable(typeof(ApiListItem[]))] [JsonSerializable(typeof(ApiSearchResult[]))] [JsonSerializable(typeof(ApiContent))] +[JsonSerializable(typeof(List))] internal partial class JsonSourceGenerationContext : JsonSerializerContext { private static JsonSourceGenerationContext? s_relaxedEscaping; diff --git a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs index 7cf2ba764a5..c21d4abd74a 100644 --- a/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LsCommandTests.cs @@ -107,7 +107,7 @@ public async Task LsCommand_JsonFormat_ReturnsCandidateAppHosts() Assert.Equal(ExitCodeConstants.Success, exitCode); var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, LsCommandJsonContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); + var candidateAppHosts = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.RelaxedEscaping.ListCandidateAppHostDisplayInfo); Assert.NotNull(candidateAppHosts); Assert.Collection(candidateAppHosts, From 9396260769f37b04ed555be23b813e2248107ef6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 03:04:24 +0000 Subject: [PATCH 7/8] Clarify project locator docs Agent-Logs-Url: https://github.com/microsoft/aspire/sessions/e5c5bda1-664f-4e53-a85b-7256df9bcb13 Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectLocator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 205160d585d..8ead95e542a 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -23,7 +23,7 @@ internal interface IProjectLocator /// /// The directory to search recursively. /// The cancellation token. - /// A list of candidate AppHost projects with language metadata sorted by full path. + /// A list of candidate AppHost projects with language metadata sorted by full path, or an empty list from the default implementation used by test fakes. Task> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); /// @@ -31,7 +31,7 @@ internal interface IProjectLocator /// /// The directory to search recursively. /// The cancellation token. - /// A list of candidate AppHost project files sorted by full path. + /// A list of candidate AppHost project files sorted by full path, or an empty list from the default implementation used by test fakes. Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) => Task.FromResult>([]); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken); @@ -73,9 +73,9 @@ public async Task> FindAppHostProjectsAsync(Direct } /// - /// Finds all candidate AppHost project files in the specified search directory. + /// Finds all candidate AppHost project files in the specified search directory path. /// - /// The directory to search recursively. + /// The directory path to search recursively. /// The cancellation token. /// A list of candidate AppHost project files sorted by full path. public async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) From 00caaf9fc89c2847512f0a0c44b1228c7d46775d Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 2 May 2026 19:32:58 -0700 Subject: [PATCH 8/8] Optimize aspire ls AppHost discovery Use a single globbing pass when finding candidate AppHosts and avoid materializing every file under the search root. Suppress per-probe .NET CLI telemetry and workload update notification overhead for internal MSBuild metadata queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 7 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 183 +++++++++++++++--- src/Shared/KnownConfigNames.cs | 2 + .../DotNet/DotNetCliRunnerTests.cs | 5 +- .../Projects/ProjectLocatorTests.cs | 27 +++ 6 files changed, 194 insertions(+), 31 deletions(-) diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 181cb8b8eba..02465017357 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -69,6 +69,7 @@ + diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 1e430cfc3f2..65cf19d6eb1 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -417,6 +417,11 @@ private async Task StartBackchannelAsync(IProcessExecution? execution, string so cliArgsList.Add(projectFile.FullName); string[] cliArgs = [.. cliArgsList]; + var env = new Dictionary + { + [KnownConfigNames.DotnetCliTelemetryOptOut] = "1", + [KnownConfigNames.DotnetCliWorkloadUpdateNotifyDisable] = "1" + }; var existingStandardOutputCallback = options.StandardOutputCallback; var existingStandardErrorCallback = options.StandardErrorCallback; @@ -440,7 +445,7 @@ private async Task StartBackchannelAsync(IProcessExecution? execution, string so var exitCode = await ExecuteAsync( args: cliArgs, - env: null, + env: env, projectFile: projectFile, workingDirectory: projectFile.Directory!, backchannelCompletionSource: null, diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 8ead95e542a..e8d0c575244 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.IO.Enumeration; using System.Text.Json; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; @@ -11,6 +10,8 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting.Utils; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.FileSystemGlobbing.Abstractions; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -137,27 +138,29 @@ public async Task> FindAppHostProjectFilesAsync(string searchDire var nugetCachePath = GetNuGetPackagesCachePath(); logger.LogDebug("NuGet cache path to exclude: {NuGetCachePath}", nugetCachePath ?? "(none)"); - // Collect all candidates with their handlers across all patterns + // Collect all candidates with their handlers across all patterns. var candidatesWithHandlers = new List<(FileInfo File, IAppHostProject Handler)>(); + var (candidateFiles, candidateCountsByPattern) = FindMatchingFiles(searchDirectory, allPatterns, enumerationOptions, nugetCachePath); foreach (var pattern in allPatterns) { - var candidateFiles = FindMatchingFiles(searchDirectory, pattern, enumerationOptions, nugetCachePath); - logger.LogDebug("Found {CandidateCount} files matching pattern '{Pattern}'", candidateFiles.Length, pattern); + logger.LogDebug("Found {CandidateCount} files matching pattern '{Pattern}'", candidateCountsByPattern[pattern], pattern); + } - foreach (var candidateFile in candidateFiles) - { - logger.LogDebug("Checking candidate file {CandidateFile}", candidateFile.FullName); + logger.LogDebug("Found {CandidateCount} unique candidate files matching AppHost detection patterns", candidateFiles.Length); - var handler = projectFactory.TryGetProject(candidateFile); - if (handler is null) - { - logger.LogTrace("No handler found for {CandidateFile}", candidateFile.FullName); - continue; - } + foreach (var candidateFile in candidateFiles) + { + logger.LogDebug("Checking candidate file {CandidateFile}", candidateFile.FullName); - candidatesWithHandlers.Add((candidateFile, handler)); + var handler = projectFactory.TryGetProject(candidateFile); + if (handler is null) + { + logger.LogTrace("No handler found for {CandidateFile}", candidateFile.FullName); + continue; } + + candidatesWithHandlers.Add((candidateFile, handler)); } // If any candidates are .NET projects, ensure the SDK is available @@ -705,32 +708,67 @@ private void MigrateLegacySettings(DirectoryInfo settingsRootDirectory) _ = AspireConfigFile.LoadOrCreate(settingsRootDirectory.FullName); } - private static FileInfo[] FindMatchingFiles(DirectoryInfo searchDirectory, string pattern, EnumerationOptions options, string? excludePath) + private static (FileInfo[] Files, Dictionary CountsByPattern) FindMatchingFiles(DirectoryInfo searchDirectory, IReadOnlyList patterns, EnumerationOptions options, string? excludePath) { + if (patterns.Count == 0) + { + return ([], new Dictionary(StringComparer.Ordinal)); + } + var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var enumerable = new FileSystemEnumerable( - searchDirectory.FullName, - (ref FileSystemEntry entry) => new FileInfo(entry.ToFullPath()), - options) + var matcher = CreateMatcher(patterns); + + var directory = new MatcherDirectoryInfo(searchDirectory, options, excludePath, pathComparison); + var matchedFilePaths = matcher.Execute(directory).Files.Select(match => match.Path).ToArray(); + + var matchedFiles = matchedFilePaths + .Select(path => new FileInfo(Path.Combine(searchDirectory.FullName, path.Replace('/', Path.DirectorySeparatorChar)))) + .ToArray(); + + var countsByPattern = patterns.ToDictionary(pattern => pattern, _ => 0, StringComparer.Ordinal); + var matchersByPattern = patterns.Select(pattern => (Pattern: pattern, Matcher: CreateMatcher(pattern))).ToArray(); + foreach (var matchedFilePath in matchedFilePaths) { - ShouldIncludePredicate = (ref FileSystemEntry entry) => - !entry.IsDirectory && FileSystemName.MatchesSimpleExpression(pattern, entry.FileName), - ShouldRecursePredicate = (ref FileSystemEntry entry) => + foreach (var (pattern, patternMatcher) in matchersByPattern) { - if (excludePath is null) + if (patternMatcher.Match(matchedFilePath).HasMatches) { - return true; + countsByPattern[pattern]++; } - var dirPath = entry.ToFullPath(); - return !dirPath.Equals(excludePath, pathComparison) - && !dirPath.StartsWith(excludePath + Path.DirectorySeparatorChar, pathComparison); } - }; + } + + return (matchedFiles, countsByPattern); + } - return enumerable.ToArray(); + private static string ToRecursiveGlobPattern(string pattern) + { + var normalizedPattern = pattern.Replace(Path.DirectorySeparatorChar, '/'); + if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) + { + normalizedPattern = normalizedPattern.Replace(Path.AltDirectorySeparatorChar, '/'); + } + + return normalizedPattern.Contains('/', StringComparison.Ordinal) + ? normalizedPattern + : $"**/{normalizedPattern}"; + } + + private static Matcher CreateMatcher(IEnumerable patterns) + { + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + matcher.AddIncludePatterns(patterns.Select(ToRecursiveGlobPattern)); + return matcher; + } + + private static Matcher CreateMatcher(string pattern) + { + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + matcher.AddInclude(ToRecursiveGlobPattern(pattern)); + return matcher; } private string? GetNuGetPackagesCachePath() @@ -749,6 +787,93 @@ private static FileInfo[] FindMatchingFiles(DirectoryInfo searchDirectory, strin return null; } + + private sealed class MatcherDirectoryInfo(DirectoryInfo directory, EnumerationOptions options, string? excludePath, StringComparison pathComparison) : DirectoryInfoBase + { + private readonly DirectoryInfo _directory = directory; + private readonly EnumerationOptions _options = options; + private readonly string? _excludePath = excludePath; + private readonly StringComparison _pathComparison = pathComparison; + + public override string Name => _directory.Name; + + public override string FullName => _directory.FullName; + + public override DirectoryInfoBase ParentDirectory => _directory.Parent is { } parent + ? new MatcherDirectoryInfo(parent, _options, _excludePath, _pathComparison) + : null!; + + public override IEnumerable EnumerateFileSystemInfos() + { + foreach (var entry in _directory.EnumerateFileSystemInfos("*", CreateTopDirectoryOnlyOptions(_options))) + { + if (entry is DirectoryInfo childDirectory) + { + if (!ShouldExcludeDirectory(childDirectory)) + { + yield return new MatcherDirectoryInfo(childDirectory, _options, _excludePath, _pathComparison); + } + } + else if (entry is FileInfo childFile) + { + yield return new MatcherFileInfo(childFile, _options, _excludePath, _pathComparison); + } + } + } + + public override DirectoryInfoBase GetDirectory(string path) + { + return new MatcherDirectoryInfo(new DirectoryInfo(Path.Combine(_directory.FullName, path)), _options, _excludePath, _pathComparison); + } + + public override FileInfoBase GetFile(string path) + { + return new MatcherFileInfo(new FileInfo(Path.Combine(_directory.FullName, path)), _options, _excludePath, _pathComparison); + } + + private bool ShouldExcludeDirectory(DirectoryInfo directory) + { + if (_excludePath is null) + { + return false; + } + + var directoryPath = Path.GetFullPath(directory.FullName); + return directoryPath.Equals(_excludePath, _pathComparison) + || directoryPath.StartsWith(_excludePath + Path.DirectorySeparatorChar, _pathComparison); + } + } + + private sealed class MatcherFileInfo(FileInfo file, EnumerationOptions options, string? excludePath, StringComparison pathComparison) : FileInfoBase + { + private readonly FileInfo _file = file; + private readonly EnumerationOptions _options = options; + private readonly string? _excludePath = excludePath; + private readonly StringComparison _pathComparison = pathComparison; + + public override string Name => _file.Name; + + public override string FullName => _file.FullName; + + public override DirectoryInfoBase ParentDirectory => _file.Directory is { } parent + ? new MatcherDirectoryInfo(parent, _options, _excludePath, _pathComparison) + : null!; + } + + private static EnumerationOptions CreateTopDirectoryOnlyOptions(EnumerationOptions options) + { + return new EnumerationOptions + { + AttributesToSkip = options.AttributesToSkip, + BufferSize = options.BufferSize, + IgnoreInaccessible = options.IgnoreInaccessible, + MatchCasing = options.MatchCasing, + MatchType = options.MatchType, + MaxRecursionDepth = options.MaxRecursionDepth, + RecurseSubdirectories = false, + ReturnSpecialDirectories = options.ReturnSpecialDirectories + }; + } } internal class ProjectLocatorException(string message, ProjectLocatorFailureReason failureReason) : System.Exception(message) diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 7f18e0e034b..3f9608c1058 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -42,6 +42,8 @@ internal static class KnownConfigNames public const string LocaleOverride = "ASPIRE_LOCALE_OVERRIDE"; public const string DotnetCliUiLanguage = "DOTNET_CLI_UI_LANGUAGE"; + public const string DotnetCliTelemetryOptOut = "DOTNET_CLI_TELEMETRY_OPTOUT"; + public const string DotnetCliWorkloadUpdateNotifyDisable = "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"; public const string MsBuildTerminalLogger = "MSBUILDTERMINALLOGGER"; public const string ExtensionEndpoint = "ASPIRE_EXTENSION_ENDPOINT"; diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index 4e03b603973..5891a420248 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -1228,11 +1228,14 @@ public async Task GetProjectItemsAndPropertiesAsync_UsesMsBuild_ForCsProjFile() var runner = DotNetCliRunnerTestHelper.Create( provider, executionContext, - (args, _, _, invocationOptions) => + (args, env, _, invocationOptions) => { // Verify that "msbuild" command is used for .csproj files Assert.Contains("msbuild", args); Assert.DoesNotContain("build", args); + Assert.NotNull(env); + Assert.Equal("1", env["DOTNET_CLI_TELEMETRY_OPTOUT"]); + Assert.Equal("1", env["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"]); // Provide valid JSON output invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}"); diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 89ebc02377d..7b8581bd53a 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -864,6 +864,33 @@ await File.WriteAllTextAsync( Assert.Contains(appHostFile.FullName, foundPaths); } + [Fact] + public async Task FindAppHostProjectFilesAsync_DoesNotDuplicateFilesThatMatchMultiplePatterns() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var overlappingLanguage = new LanguageInfo( + LanguageId: new LanguageId("overlapping"), + DisplayName: "Overlapping", + PackageName: "", + DetectionPatterns: ["AppHost.csproj"], + CodeGenerator: "", + AppHostFileName: "AppHost.csproj"); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectLocator = CreateProjectLocator( + executionContext, + languageDiscovery: new TestLanguageDiscovery(overlappingLanguage)); + + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); + + var foundFile = Assert.Single(foundFiles); + Assert.Equal(projectFile.FullName, foundFile.FullName); + } + [Fact] public async Task UseOrFindAppHostProjectFileAsync_AcceptsExplicitSingleFileAppHost() {