From 054c237a342c31ad450614dfb44107536d7a05e9 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 20 Apr 2025 05:56:22 +0000 Subject: [PATCH 1/8] Add covering tests for existing behavior. --- src/Aspire.Cli/Projects/ProjectLocator.cs | 2 - .../Projects/ProjectLocatorTests.cs | 100 ++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 823e16e1af7..d517f789e52 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -44,7 +44,5 @@ internal sealed class ProjectLocator(ILogger logger, string curr internal class ProjectLocatorException : System.Exception { - public ProjectLocatorException() { } public ProjectLocatorException(string message) : base(message) { } - public ProjectLocatorException(string message, System.Exception inner) : base(message, inner) { } } \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs new file mode 100644 index 00000000000..2559b286d98 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Projects; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Cli.Tests.Projects; + +public class ProjectLocatorTests +{ + [Fact] + public void UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotExist() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + var projectFile = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); + var projectLocator = new ProjectLocator(logger, projectDirectory); + + var ex = Assert.Throws(() =>{ + projectLocator.UseOrFindAppHostProjectFile(projectFile); + }); + + Assert.Equal("Project file does not exist.", ex.Message); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + var projectFile1 = new FileInfo(Path.Combine(projectDirectory, "AppHost1.csproj")); + await File.WriteAllTextAsync(projectFile1.FullName, "Not a real project file."); + + var projectFile2 = new FileInfo(Path.Combine(projectDirectory, "AppHost2.csproj")); + await File.WriteAllTextAsync(projectFile2.FullName, "Not a real project file."); + + var projectLocator = new ProjectLocator(logger, projectDirectory); + + var ex = Assert.Throws(() =>{ + projectLocator.UseOrFindAppHostProjectFile(null); + }); + + Assert.Equal("Multiple project files found.", ex.Message); + } + + [Fact] + public void UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + + var projectLocator = new ProjectLocator(logger, projectDirectory); + + var ex = Assert.Throws(() =>{ + projectLocator.UseOrFindAppHostProjectFile(null); + }); + + Assert.Equal("No project file found.", ex.Message); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndProvided() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var projectLocator = new ProjectLocator(logger, projectDirectory); + + var returnedProjectFile = projectLocator.UseOrFindAppHostProjectFile(projectFile); + + Assert.Equal(projectFile, returnedProjectFile); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotExplicitlyProvided() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var projectLocator = new ProjectLocator(logger, projectDirectory); + + var returnedProjectFile = projectLocator.UseOrFindAppHostProjectFile(null); + Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); + } +} \ No newline at end of file From cfd5ff740d7dd14499c05fe5d3d46de5fc606b78 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 20 Apr 2025 12:11:48 +0000 Subject: [PATCH 2/8] GitRootLocator. --- src/Aspire.Cli/Git/GitRootLocator.cs | 43 ++++++++++++ tests/Aspire.Cli.Tests/Git/GitRootLocator.cs | 62 +++++++++++++++++ tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs | 67 +++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/Aspire.Cli/Git/GitRootLocator.cs create mode 100644 tests/Aspire.Cli.Tests/Git/GitRootLocator.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs diff --git a/src/Aspire.Cli/Git/GitRootLocator.cs b/src/Aspire.Cli/Git/GitRootLocator.cs new file mode 100644 index 00000000000..f408a6d2812 --- /dev/null +++ b/src/Aspire.Cli/Git/GitRootLocator.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Git; + +internal interface IGitRootLocator +{ + DirectoryInfo? FindGitRoot(DirectoryInfo startDirectory); +} + +internal sealed class GitRootLocator(ILogger logger) : IGitRootLocator +{ + private readonly ActivitySource _activitySource = new ActivitySource(nameof(GitRootLocator)); + + public DirectoryInfo? FindGitRoot(DirectoryInfo startDirectory) + { + using var activity = _activitySource.StartActivity(); + + logger.LogTrace("Starting search for Git root from directory: {Directory}", startDirectory.FullName); + + var currentDirectory = startDirectory; + + while (currentDirectory != null) + { + logger.LogTrace("Checking directory: {Directory}", currentDirectory.FullName); + + var gitFolder = new DirectoryInfo(Path.Combine(currentDirectory.FullName, ".git")); + if (gitFolder.Exists) + { + logger.LogTrace("Found Git root at directory: {Directory}", currentDirectory.FullName); + return currentDirectory; + } + + currentDirectory = currentDirectory.Parent; + } + + logger.LogTrace("No Git root found starting from directory: {Directory}", startDirectory.FullName); + return null; + } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs b/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs new file mode 100644 index 00000000000..700beb9245d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Git; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Cli.Tests.Git; + +public class GitRootLocatorTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void FindGitRootReturnsNullIfNoGitRootFound() + { + var logger = new NullLogger(); + + // Create a repo but don't initialize it. + using var tempRepo = TemporaryRepo.Create(outputHelper); + + var gitRootLocator = new GitRootLocator(logger); + + var result = gitRootLocator.FindGitRoot(tempRepo.RootDirectory); + + Assert.Null(result); + } + + [Fact] + public async Task FindGitRootReturnsRepoRootWhenSearchStartsInRepoRoot() + { + var logger = new NullLogger(); + + // Create a repo but don't initialize it. + using var tempRepo = TemporaryRepo.Create(outputHelper); + await tempRepo.InitializeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + + var gitRootLocator = new GitRootLocator(logger); + + var result = gitRootLocator.FindGitRoot(tempRepo.RootDirectory); + + Assert.Equal(tempRepo.RootDirectory.FullName, result?.FullName); + } + + [Fact] + public async Task FindGitRootReturnsRepoRootWhenSearchStartsInNestedDirectories() + { + var logger = new NullLogger(); + + // Create a repo but don't initialize it. + using var tempRepo = TemporaryRepo.Create(outputHelper); + await tempRepo.InitializeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var dir1 = tempRepo.CreateDirectory("dir1"); + var dir2 = dir1.CreateSubdirectory("dir2"); + var dir3 = dir2.CreateSubdirectory("dir3"); + + var gitRootLocator = new GitRootLocator(logger); + + var result = gitRootLocator.FindGitRoot(dir3); + + Assert.Equal(tempRepo.RootDirectory.FullName, result?.FullName); + } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs b/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs new file mode 100644 index 00000000000..0b88c5ffaa1 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Xunit; + +namespace Aspire.Cli.Tests.Utils; + +internal sealed class TemporaryRepo(ITestOutputHelper outputHelper, DirectoryInfo repoDirectory) : IDisposable +{ + public DirectoryInfo RootDirectory => repoDirectory; + + public DirectoryInfo CreateDirectory(string name) + { + return repoDirectory.CreateSubdirectory(name); + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + outputHelper.WriteLine($"Initializing git repository at: {repoDirectory.FullName}"); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "init", + WorkingDirectory = repoDirectory.FullName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException($"Failed to initialize git repository: {error}"); + } + } + + public void Dispose() + { + try + { + repoDirectory.Delete(true); + } + catch (Exception ex) + { + Console.WriteLine($"Error disposing TemporaryRepo: {ex.Message}"); + } + } + + internal static TemporaryRepo Create(ITestOutputHelper outputHelper) + { + var tempPath = Path.GetTempPath(); + var path = Path.Combine(tempPath, "Aspire.Cli.Tests", "TemporaryRepo", Guid.NewGuid().ToString()); + var repoDirectory = Directory.CreateDirectory(path); + outputHelper.WriteLine($"Temporary repo created at: {repoDirectory.FullName}"); + + return new TemporaryRepo(outputHelper, repoDirectory); + } +} From 96814a9fa66d63a4918c37f3ae10da40e9c28766 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Sun, 20 Apr 2025 13:59:08 +0000 Subject: [PATCH 3/8] WIP --- src/Aspire.Cli/Projects/ProjectLocator.cs | 51 +++++++++++++++---- .../Projects/ProjectLocatorTests.cs | 20 ++++---- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index d517f789e52..7b92c7b44ee 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -1,18 +1,50 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; internal interface IProjectLocator { - FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile); + Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default); } -internal sealed class ProjectLocator(ILogger logger, string currentDirectory) : IProjectLocator +internal sealed class ProjectLocator(ILogger logger, IDotNetCliRunner runner, string currentDirectory) : IProjectLocator { - public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + private readonly ActivitySource _activitySource = new(nameof(ProjectLocator)); + + private async Task> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken) + { + using var activity = _activitySource.StartActivity(); + + var appHostProjects = new List(); + + logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName); + var projectFiles = searchDirectory.GetFiles("*.csproj", SearchOption.AllDirectories); + logger.LogDebug("Found {ProjectFileCount} project files in {SearchDirectory}", projectFiles.Length, searchDirectory.FullName); + + foreach (var projectFile in projectFiles) + { + logger.LogDebug("Checking project file {ProjectFile}", projectFile.FullName); + var information = await runner.GetAppHostInformationAsync(projectFile, cancellationToken); + + if (information.ExitCode == 0 && information.IsAspireHost) + { + logger.LogDebug("Found AppHost project file {ProjectFile} in {SearchDirectory}", projectFile.FullName, searchDirectory.FullName); + appHostProjects.Add(projectFile); + } + else + { + logger.LogTrace("Project file {ProjectFile} in {SearchDirectory} is not an Aspire host", projectFile.FullName, searchDirectory.FullName); + } + } + + return appHostProjects; + } + + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default) { logger.LogDebug("Finding project file in {CurrentDirectory}", currentDirectory); @@ -30,14 +62,15 @@ internal sealed class ProjectLocator(ILogger logger, string curr } logger.LogDebug("No project file specified, searching for *.csproj files in {CurrentDirectory}", currentDirectory); - var projectFilePaths = Directory.GetFiles(currentDirectory, "*.csproj"); + var appHostProjects = await FindAppHostProjectFilesAsync(new DirectoryInfo(currentDirectory), cancellationToken); - logger.LogDebug("Found {ProjectFileCount} project files.", projectFilePaths.Length); + logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count); - return projectFilePaths switch { - { Length: 0 } => throw new ProjectLocatorException("No project file found."), - { Length: > 1 } => throw new ProjectLocatorException("Multiple project files found."), - { Length: 1 } => new FileInfo(projectFilePaths[0]), + return appHostProjects.Count switch { + 0 => throw new ProjectLocatorException("No project file found."), + > 1 => throw new ProjectLocatorException("Multiple project files found."), + 1 => appHostProjects[0], + _ => throw new ProjectLocatorException("Unexpected number of project files found.") }; } } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 2559b286d98..de94c345501 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -10,7 +10,7 @@ namespace Aspire.Cli.Tests.Projects; public class ProjectLocatorTests { [Fact] - public void UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotExist() + public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotExist() { var logger = NullLogger.Instance; var tempDirectory = Path.GetTempPath(); @@ -19,8 +19,8 @@ public void UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotExist() var projectFile = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); var projectLocator = new ProjectLocator(logger, projectDirectory); - var ex = Assert.Throws(() =>{ - projectLocator.UseOrFindAppHostProjectFile(projectFile); + var ex = await Assert.ThrowsAsync(async () => { + await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); }); Assert.Equal("Project file does not exist.", ex.Message); @@ -41,15 +41,15 @@ public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() var projectLocator = new ProjectLocator(logger, projectDirectory); - var ex = Assert.Throws(() =>{ - projectLocator.UseOrFindAppHostProjectFile(null); + var ex = await Assert.ThrowsAsync(async () => { + await projectLocator.UseOrFindAppHostProjectFileAsync(null); }); Assert.Equal("Multiple project files found.", ex.Message); } [Fact] - public void UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() + public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() { var logger = NullLogger.Instance; var tempDirectory = Path.GetTempPath(); @@ -58,8 +58,8 @@ public void UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() var projectLocator = new ProjectLocator(logger, projectDirectory); - var ex = Assert.Throws(() =>{ - projectLocator.UseOrFindAppHostProjectFile(null); + var ex = await Assert.ThrowsAsync(async () =>{ + await projectLocator.UseOrFindAppHostProjectFileAsync(null); }); Assert.Equal("No project file found.", ex.Message); @@ -77,7 +77,7 @@ public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndPr var projectLocator = new ProjectLocator(logger, projectDirectory); - var returnedProjectFile = projectLocator.UseOrFindAppHostProjectFile(projectFile); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); Assert.Equal(projectFile, returnedProjectFile); } @@ -94,7 +94,7 @@ public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotE var projectLocator = new ProjectLocator(logger, projectDirectory); - var returnedProjectFile = projectLocator.UseOrFindAppHostProjectFile(null); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); } } \ No newline at end of file From d693ef9340084510bd7058b211369538a3760e79 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 21 Apr 2025 00:12:58 +0000 Subject: [PATCH 4/8] WIP --- src/Aspire.Cli/Commands/AddCommand.cs | 2 +- src/Aspire.Cli/Commands/PublishCommand.cs | 2 +- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/Program.cs | 3 ++- .../Commands/RunCommandTests.cs | 11 ++++++----- .../Projects/ProjectLocatorTests.cs | 18 +++++++++++++----- .../TestServices/TestProjectLocator.cs | 8 ++++---- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 ++- 8 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 18fc0aa1437..0598357c09a 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -65,7 +65,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var integrationName = parseResult.GetValue("integration"); var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile); + var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); if (effectiveAppHostProjectFile is null) { diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 4c01f7ec212..4702b529c1f 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -74,7 +74,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = _activitySource.StartActivity(); var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile); + var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); if (effectiveAppHostProjectFile is null) { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 2ff4b7790e8..90fe1f14462 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -56,7 +56,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = _activitySource.StartActivity(); var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = _projectLocator.UseOrFindAppHostProjectFile(passedAppHostProjectFile); + var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); if (effectiveAppHostProjectFile is null) { diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 1a2c59428dd..74f7c2be2bf 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -114,7 +114,8 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider) private static IProjectLocator BuildProjectLocator(IServiceProvider serviceProvider) { var logger = serviceProvider.GetRequiredService>(); - return new ProjectLocator(logger, Directory.GetCurrentDirectory()); + var runner = serviceProvider.GetRequiredService(); + return new ProjectLocator(logger, runner, Directory.GetCurrentDirectory()); } public static async Task Main(string[] args) diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index f9c0f010d4e..d5fa6f80a92 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; @@ -77,7 +78,7 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator { - public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken) { throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist."); } @@ -107,17 +108,17 @@ public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, Cancellation } } - private sealed class NoProjectFileProjectLocator : Aspire.Cli.Projects.IProjectLocator + private sealed class NoProjectFileProjectLocator : IProjectLocator { - public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken) { throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found."); } } - private sealed class MultipleProjectFilesProjectLocator : Aspire.Cli.Projects.IProjectLocator + private sealed class MultipleProjectFilesProjectLocator : IProjectLocator { - public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken) { throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found."); } diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index de94c345501..f66f7824e91 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Projects; +using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -17,7 +18,9 @@ public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotE var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); Directory.CreateDirectory(projectDirectory); var projectFile = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); - var projectLocator = new ProjectLocator(logger, projectDirectory); + + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var ex = await Assert.ThrowsAsync(async () => { await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); @@ -39,7 +42,9 @@ public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() var projectFile2 = new FileInfo(Path.Combine(projectDirectory, "AppHost2.csproj")); await File.WriteAllTextAsync(projectFile2.FullName, "Not a real project file."); - var projectLocator = new ProjectLocator(logger, projectDirectory); + var runner = new TestDotNetCliRunner(); + + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var ex = await Assert.ThrowsAsync(async () => { await projectLocator.UseOrFindAppHostProjectFileAsync(null); @@ -56,7 +61,8 @@ public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); Directory.CreateDirectory(projectDirectory); - var projectLocator = new ProjectLocator(logger, projectDirectory); + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var ex = await Assert.ThrowsAsync(async () =>{ await projectLocator.UseOrFindAppHostProjectFileAsync(null); @@ -75,7 +81,8 @@ public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndPr var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); - var projectLocator = new ProjectLocator(logger, projectDirectory); + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); @@ -92,7 +99,8 @@ public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotE var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); - var projectLocator = new ProjectLocator(logger, projectDirectory); + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index f223db6e05c..014914d212d 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs @@ -7,13 +7,13 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestProjectLocator : IProjectLocator { - public Func? UseOrFindAppHostProjectFileCallback { get; set; } + public Func>? UseOrFindAppHostProjectFileAsyncCallback { get; set; } - public FileInfo? UseOrFindAppHostProjectFile(FileInfo? projectFile) + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken) { - if (UseOrFindAppHostProjectFileCallback != null) + if (UseOrFindAppHostProjectFileAsyncCallback != null) { - return UseOrFindAppHostProjectFileCallback(projectFile); + return await UseOrFindAppHostProjectFileAsyncCallback(projectFile, cancellationToken); } // Fallback behavior if not overridden. diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index a0d05ae8461..a32d35cda8c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -79,7 +79,8 @@ internal sealed class CliServiceCollectionTestOptions(ITestOutputHelper outputHe public Func ProjectLocatorFactory { get; set; } = (IServiceProvider serviceProvider) => { var logger = serviceProvider.GetRequiredService>(); - return new ProjectLocator(logger, Directory.GetCurrentDirectory()); + var runner = serviceProvider.GetRequiredService(); + return new ProjectLocator(logger, runner, Directory.GetCurrentDirectory()); }; public Func InteractionServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { From aaf11bc27289a4d247a6a731d62135ccaccd3d66 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 21 Apr 2025 12:09:34 +1000 Subject: [PATCH 5/8] WIP --- src/Aspire.Cli/Commands/RunCommand.cs | 7 ++- src/Aspire.Cli/Git/GitRootLocator.cs | 43 ------------- src/Aspire.Cli/Program.cs | 7 +++ tests/Aspire.Cli.Tests/Git/GitRootLocator.cs | 62 ------------------- .../Projects/ProjectLocatorTests.cs | 40 ++++++++++-- 5 files changed, 48 insertions(+), 111 deletions(-) delete mode 100644 src/Aspire.Cli/Git/GitRootLocator.cs delete mode 100644 tests/Aspire.Cli.Tests/Git/GitRootLocator.cs diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 90fe1f14462..0a3111354b0 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -55,8 +55,11 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = _activitySource.StartActivity(); - var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); + var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () => + { + var passedAppHostProjectFile = parseResult.GetValue("--project"); + return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); + }); if (effectiveAppHostProjectFile is null) { diff --git a/src/Aspire.Cli/Git/GitRootLocator.cs b/src/Aspire.Cli/Git/GitRootLocator.cs deleted file mode 100644 index f408a6d2812..00000000000 --- a/src/Aspire.Cli/Git/GitRootLocator.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Git; - -internal interface IGitRootLocator -{ - DirectoryInfo? FindGitRoot(DirectoryInfo startDirectory); -} - -internal sealed class GitRootLocator(ILogger logger) : IGitRootLocator -{ - private readonly ActivitySource _activitySource = new ActivitySource(nameof(GitRootLocator)); - - public DirectoryInfo? FindGitRoot(DirectoryInfo startDirectory) - { - using var activity = _activitySource.StartActivity(); - - logger.LogTrace("Starting search for Git root from directory: {Directory}", startDirectory.FullName); - - var currentDirectory = startDirectory; - - while (currentDirectory != null) - { - logger.LogTrace("Checking directory: {Directory}", currentDirectory.FullName); - - var gitFolder = new DirectoryInfo(Path.Combine(currentDirectory.FullName, ".git")); - if (gitFolder.Exists) - { - logger.LogTrace("Found Git root at directory: {Directory}", currentDirectory.FullName); - return currentDirectory; - } - - currentDirectory = currentDirectory.Parent; - } - - logger.LogTrace("No Git root found starting from directory: {Directory}", startDirectory.FullName); - return null; - } -} \ No newline at end of file diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 74f7c2be2bf..5bb4489e9a4 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Spectre.Console; +using Microsoft.Extensions.Configuration; + #if DEBUG using OpenTelemetry; @@ -28,6 +30,11 @@ public class Program { private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Program)); + private static void AddConfigurationFiles(HostApplicationBuilder builder) + { + + } + private static IHost BuildApplication(string[] args) { var builder = Host.CreateApplicationBuilder(); diff --git a/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs b/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs deleted file mode 100644 index 700beb9245d..00000000000 --- a/tests/Aspire.Cli.Tests/Git/GitRootLocator.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Cli.Git; -using Aspire.Cli.Tests.Utils; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Aspire.Cli.Tests.Git; - -public class GitRootLocatorTests(ITestOutputHelper outputHelper) -{ - [Fact] - public void FindGitRootReturnsNullIfNoGitRootFound() - { - var logger = new NullLogger(); - - // Create a repo but don't initialize it. - using var tempRepo = TemporaryRepo.Create(outputHelper); - - var gitRootLocator = new GitRootLocator(logger); - - var result = gitRootLocator.FindGitRoot(tempRepo.RootDirectory); - - Assert.Null(result); - } - - [Fact] - public async Task FindGitRootReturnsRepoRootWhenSearchStartsInRepoRoot() - { - var logger = new NullLogger(); - - // Create a repo but don't initialize it. - using var tempRepo = TemporaryRepo.Create(outputHelper); - await tempRepo.InitializeAsync().WaitAsync(CliTestConstants.DefaultTimeout); - - var gitRootLocator = new GitRootLocator(logger); - - var result = gitRootLocator.FindGitRoot(tempRepo.RootDirectory); - - Assert.Equal(tempRepo.RootDirectory.FullName, result?.FullName); - } - - [Fact] - public async Task FindGitRootReturnsRepoRootWhenSearchStartsInNestedDirectories() - { - var logger = new NullLogger(); - - // Create a repo but don't initialize it. - using var tempRepo = TemporaryRepo.Create(outputHelper); - await tempRepo.InitializeAsync().WaitAsync(CliTestConstants.DefaultTimeout); - var dir1 = tempRepo.CreateDirectory("dir1"); - var dir2 = dir1.CreateSubdirectory("dir2"); - var dir3 = dir2.CreateSubdirectory("dir3"); - - var gitRootLocator = new GitRootLocator(logger); - - var result = gitRootLocator.FindGitRoot(dir3); - - Assert.Equal(tempRepo.RootDirectory.FullName, result?.FullName); - } -} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index f66f7824e91..480ace5198f 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -38,12 +39,12 @@ public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() Directory.CreateDirectory(projectDirectory); var projectFile1 = new FileInfo(Path.Combine(projectDirectory, "AppHost1.csproj")); await File.WriteAllTextAsync(projectFile1.FullName, "Not a real project file."); - + var projectFile2 = new FileInfo(Path.Combine(projectDirectory, "AppHost2.csproj")); await File.WriteAllTextAsync(projectFile2.FullName, "Not a real project file."); - + var runner = new TestDotNetCliRunner(); - + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); var ex = await Assert.ThrowsAsync(async () => { @@ -53,6 +54,37 @@ public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() Assert.Equal("Multiple project files found.", ex.Message); } + [Fact] + public async Task UseOrFindAppHostProjectFileOnlyConsidersValidAppHostProjects() + { + var logger = NullLogger.Instance; + var tempDirectory = Path.GetTempPath(); + var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); + Directory.CreateDirectory(projectDirectory); + var appHostProject = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostProject.FullName, "Not a real apphost project."); + + var webProject = new FileInfo(Path.Combine(projectDirectory, "WebProject.csproj")); + await File.WriteAllTextAsync(webProject.FullName, "Not a real web project."); + + var runner = new TestDotNetCliRunner(); + runner.GetAppHostInformationAsyncCallback = (projectFile, cancellationToken) => { + if (projectFile.FullName == appHostProject.FullName) + { + return (0, true, VersionHelper.GetDefaultTemplateVersion()); + } + else + { + return (0, false, null); + } + }; + + var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null); + Assert.Equal(appHostProject.FullName, foundAppHost?.FullName); + } + [Fact] public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() { @@ -105,4 +137,4 @@ public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotE var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); } -} \ No newline at end of file +} From 4d516c9d57843f9377e6e3bb3e3911ee269bf2bd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 21 Apr 2025 15:52:57 +1000 Subject: [PATCH 6/8] WIP --- src/Aspire.Cli/Program.cs | 38 ++++- src/Aspire.Cli/Projects/ProjectLocator.cs | 55 +++++++- .../Projects/ProjectLocatorTests.cs | 131 +++++++++++++----- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 7 +- tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs | 14 +- 5 files changed, 195 insertions(+), 50 deletions(-) diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 5bb4489e9a4..2d7252ac396 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -15,7 +15,6 @@ using Spectre.Console; using Microsoft.Extensions.Configuration; - #if DEBUG using OpenTelemetry; using OpenTelemetry.Resources; @@ -30,14 +29,45 @@ public class Program { private static readonly ActivitySource s_activitySource = new ActivitySource(nameof(Program)); - private static void AddConfigurationFiles(HostApplicationBuilder builder) + /// + /// This method walks up the directory tree looking for the .aspire/settings.json files + /// and then adds them to the host as a configuration source. This means that the settings + /// architecture for the CLI will just be standard .NET configuraiton. + /// + private static void SetupAppHostOptions(HostApplicationBuilder builder) { - + var settingsFiles = new List(); + var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); + + while (true) + { + var settingsFilePath = Path.Combine(currentDirectory.FullName, ".aspire", "settings.json"); + + if (File.Exists(settingsFilePath)) + { + var settingsFile = new FileInfo(settingsFilePath); + settingsFiles.Add(settingsFile); + } + + if (currentDirectory.Parent is null) + { + break; + } + + currentDirectory = currentDirectory.Parent; + } + + settingsFiles.Reverse(); + foreach (var settingsFile in settingsFiles) + { + builder.Configuration.AddJsonFile(settingsFile.FullName); + } } private static IHost BuildApplication(string[] args) { var builder = Host.CreateApplicationBuilder(); + SetupAppHostOptions(builder); builder.Logging.ClearProviders(); @@ -122,7 +152,7 @@ private static IProjectLocator BuildProjectLocator(IServiceProvider serviceProvi { var logger = serviceProvider.GetRequiredService>(); var runner = serviceProvider.GetRequiredService(); - return new ProjectLocator(logger, runner, Directory.GetCurrentDirectory()); + return new ProjectLocator(logger, runner, new DirectoryInfo(Directory.GetCurrentDirectory())); } public static async Task Main(string[] args) diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 7b92c7b44ee..a6060e9c6a5 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Text.Json; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -11,7 +12,7 @@ internal interface IProjectLocator Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default); } -internal sealed class ProjectLocator(ILogger logger, IDotNetCliRunner runner, string currentDirectory) : IProjectLocator +internal sealed class ProjectLocator(ILogger logger, IDotNetCliRunner runner, DirectoryInfo currentDirectory) : IProjectLocator { private readonly ActivitySource _activitySource = new(nameof(ProjectLocator)); @@ -44,6 +45,47 @@ private async Task> FindAppHostProjectFilesAsync(DirectoryInfo se return appHostProjects; } + private async Task GetAppHostProjectFileFromSettingsAsync(CancellationToken cancellationToken) + { + var searchDirectory = currentDirectory; + + while (true) + { + var settingsFile = new FileInfo(Path.Combine(searchDirectory.FullName, ".aspire", "settings.json")); + + if (settingsFile.Exists) + { + using var stream = settingsFile.OpenRead(); + var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + if (json.RootElement.TryGetProperty("appHostPath", out var appHostPathProperty) && appHostPathProperty.GetString() is { } appHostPath ) + { + + var qualifiedAppHostPath = Path.IsPathRooted(appHostPath) ? appHostPath : Path.Combine(settingsFile.Directory!.FullName, appHostPath); + var appHostFile = new FileInfo(qualifiedAppHostPath); + + if (appHostFile.Exists) + { + return appHostFile; + } + else + { + throw new ProjectLocatorException($"AppHost file was specified in '{settingsFile.FullName}' but it does not exist."); + } + } + } + + if (searchDirectory.Parent is not null) + { + searchDirectory = searchDirectory.Parent; + } + else + { + return null; + } + } + } + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default) { logger.LogDebug("Finding project file in {CurrentDirectory}", currentDirectory); @@ -61,8 +103,15 @@ private async Task> FindAppHostProjectFilesAsync(DirectoryInfo se return projectFile; } + projectFile = await GetAppHostProjectFileFromSettingsAsync(cancellationToken); + + if (projectFile is not null) + { + return projectFile; + } + logger.LogDebug("No project file specified, searching for *.csproj files in {CurrentDirectory}", currentDirectory); - var appHostProjects = await FindAppHostProjectFilesAsync(new DirectoryInfo(currentDirectory), cancellationToken); + var appHostProjects = await FindAppHostProjectFilesAsync(currentDirectory, cancellationToken); logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count); @@ -78,4 +127,4 @@ private async Task> FindAppHostProjectFilesAsync(DirectoryInfo se internal class ProjectLocatorException : System.Exception { public ProjectLocatorException(string message) : base(message) { } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 480ace5198f..a62c8b9b111 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -1,27 +1,29 @@ // 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.Projects; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Cli.Tests.Projects; -public class ProjectLocatorTests +public class ProjectLocatorTests(ITestOutputHelper outputHelper) { [Fact] public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotExist() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); - var projectFile = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); - + + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + var runner = new TestDotNetCliRunner(); - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var ex = await Assert.ThrowsAsync(async () => { await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); @@ -30,22 +32,89 @@ public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotE Assert.Equal("Project file does not exist.", ex.Message); } + [Fact] + public async Task UseOrFindAppHostProjectFileUsesAppHostSpecifiedInSettings() + { + var logger = NullLogger.Instance; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var targetAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("TargetAppHost"); + var targetAppHostProjectFile = new FileInfo(Path.Combine(targetAppHostDirectory.FullName, "TargetAppHost.csproj")); + await File.WriteAllTextAsync(targetAppHostProjectFile.FullName, "Not a real apphost"); + + var otherAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("OtherAppHost"); + var otherAppHostProjectFile = new FileInfo(Path.Combine(otherAppHostDirectory.FullName, "OtherAppHost.csproj")); + await File.WriteAllTextAsync(targetAppHostProjectFile.FullName, "Not a real apphost"); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + + using var writer = aspireSettingsFile.OpenWrite(); + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = Path.GetRelativePath(aspireSettingsFile.Directory!.FullName, targetAppHostProjectFile.FullName) + }); + writer.Close(); + + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); + + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null); + + Assert.Equal(targetAppHostProjectFile.FullName, foundAppHost?.FullName); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileUsesAppHostSpecifiedInSettingsWalksTree() + { + var logger = NullLogger.Instance; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var dir1 = workspace.WorkspaceRoot.CreateSubdirectory("dir1"); + var dir2 = dir1.CreateSubdirectory("dir2"); + + var targetAppHostDirectory = dir2.CreateSubdirectory("TargetAppHost"); + var targetAppHostProjectFile = new FileInfo(Path.Combine(targetAppHostDirectory.FullName, "TargetAppHost.csproj")); + await File.WriteAllTextAsync(targetAppHostProjectFile.FullName, "Not a real apphost"); + + var otherAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("OtherAppHost"); + var otherAppHostProjectFile = new FileInfo(Path.Combine(otherAppHostDirectory.FullName, "OtherAppHost.csproj")); + await File.WriteAllTextAsync(targetAppHostProjectFile.FullName, "Not a real apphost"); + + var workspaceSettingsDirectory = workspace.CreateDirectory(".aspire"); + var aspireSettingsFile = new FileInfo(Path.Combine(workspaceSettingsDirectory.FullName, "settings.json")); + + using var writer = aspireSettingsFile.OpenWrite(); + await JsonSerializer.SerializeAsync(writer, new + { + appHostPath = Path.GetRelativePath(aspireSettingsFile.Directory!.FullName, targetAppHostProjectFile.FullName) + }); + writer.Close(); + + var runner = new TestDotNetCliRunner(); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); + + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null); + + Assert.Equal(targetAppHostProjectFile.FullName, foundAppHost?.FullName); + } + [Fact] public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); - var projectFile1 = new FileInfo(Path.Combine(projectDirectory, "AppHost1.csproj")); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile1 = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost1.csproj")); await File.WriteAllTextAsync(projectFile1.FullName, "Not a real project file."); - var projectFile2 = new FileInfo(Path.Combine(projectDirectory, "AppHost2.csproj")); + var projectFile2 = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost2.csproj")); await File.WriteAllTextAsync(projectFile2.FullName, "Not a real project file."); var runner = new TestDotNetCliRunner(); - - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var ex = await Assert.ThrowsAsync(async () => { await projectLocator.UseOrFindAppHostProjectFileAsync(null); @@ -58,13 +127,12 @@ public async Task UseOrFindAppHostProjectFileThrowsTwoProjectFilesFound() public async Task UseOrFindAppHostProjectFileOnlyConsidersValidAppHostProjects() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); - var appHostProject = new FileInfo(Path.Combine(projectDirectory, "AppHost.csproj")); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostProject = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); await File.WriteAllTextAsync(appHostProject.FullName, "Not a real apphost project."); - var webProject = new FileInfo(Path.Combine(projectDirectory, "WebProject.csproj")); + var webProject = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "WebProject.csproj")); await File.WriteAllTextAsync(webProject.FullName, "Not a real web project."); var runner = new TestDotNetCliRunner(); @@ -79,8 +147,7 @@ public async Task UseOrFindAppHostProjectFileOnlyConsidersValidAppHostProjects() } }; - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); - + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null); Assert.Equal(appHostProject.FullName, foundAppHost?.FullName); } @@ -89,12 +156,10 @@ public async Task UseOrFindAppHostProjectFileOnlyConsidersValidAppHostProjects() public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); + using var workspace = TemporaryWorkspace.Create(outputHelper); var runner = new TestDotNetCliRunner(); - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var ex = await Assert.ThrowsAsync(async () =>{ await projectLocator.UseOrFindAppHostProjectFileAsync(null); @@ -107,14 +172,12 @@ public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndProvided() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); - var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); + 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 runner = new TestDotNetCliRunner(); - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile); @@ -125,14 +188,12 @@ public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndPr public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotExplicitlyProvided() { var logger = NullLogger.Instance; - var tempDirectory = Path.GetTempPath(); - var projectDirectory = Path.Combine(tempDirectory, "Aspire.Cli.Tests", "Projects", Guid.NewGuid().ToString()); - Directory.CreateDirectory(projectDirectory); - var projectFile = new FileInfo(Path.Combine(projectDirectory, "MalformedProjectFile.csproj")); + 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 runner = new TestDotNetCliRunner(); - var projectLocator = new ProjectLocator(logger, runner, projectDirectory); + var projectLocator = new ProjectLocator(logger, runner, workspace.WorkspaceRoot); var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index a32d35cda8c..9b0c20f0dd6 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -22,6 +23,10 @@ public static IServiceCollection CreateServiceCollection(ITestOutputHelper outpu configure?.Invoke(options); var services = new ServiceCollection(); + + var configuration = new ConfigurationBuilder().Build(); + services.AddSingleton(configuration); + services.AddLogging(); services.AddSingleton(options.AnsiConsoleFactory); @@ -80,7 +85,7 @@ internal sealed class CliServiceCollectionTestOptions(ITestOutputHelper outputHe public Func ProjectLocatorFactory { get; set; } = (IServiceProvider serviceProvider) => { var logger = serviceProvider.GetRequiredService>(); var runner = serviceProvider.GetRequiredService(); - return new ProjectLocator(logger, runner, Directory.GetCurrentDirectory()); + return new ProjectLocator(logger, runner, new DirectoryInfo(Directory.GetCurrentDirectory())); }; public Func InteractionServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { diff --git a/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs b/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs index 0b88c5ffaa1..ffe7c0d9352 100644 --- a/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs +++ b/tests/Aspire.Cli.Tests/Utils/TemporaryRepo.cs @@ -6,16 +6,16 @@ namespace Aspire.Cli.Tests.Utils; -internal sealed class TemporaryRepo(ITestOutputHelper outputHelper, DirectoryInfo repoDirectory) : IDisposable +internal sealed class TemporaryWorkspace(ITestOutputHelper outputHelper, DirectoryInfo repoDirectory) : IDisposable { - public DirectoryInfo RootDirectory => repoDirectory; + public DirectoryInfo WorkspaceRoot => repoDirectory; public DirectoryInfo CreateDirectory(string name) { return repoDirectory.CreateSubdirectory(name); } - public async Task InitializeAsync(CancellationToken cancellationToken = default) + public async Task InitializeGitAsync(CancellationToken cancellationToken = default) { outputHelper.WriteLine($"Initializing git repository at: {repoDirectory.FullName}"); @@ -55,13 +55,13 @@ public void Dispose() } } - internal static TemporaryRepo Create(ITestOutputHelper outputHelper) + internal static TemporaryWorkspace Create(ITestOutputHelper outputHelper) { var tempPath = Path.GetTempPath(); - var path = Path.Combine(tempPath, "Aspire.Cli.Tests", "TemporaryRepo", Guid.NewGuid().ToString()); + var path = Path.Combine(tempPath, "Aspire.Cli.Tests", "TemporaryWorkspaces", Guid.NewGuid().ToString()); var repoDirectory = Directory.CreateDirectory(path); - outputHelper.WriteLine($"Temporary repo created at: {repoDirectory.FullName}"); + outputHelper.WriteLine($"Temporary workspace created at: {repoDirectory.FullName}"); - return new TemporaryRepo(outputHelper, repoDirectory); + return new TemporaryWorkspace(outputHelper, repoDirectory); } } From b77cd40fd2344385e56bf02d81e4a57792f96eee Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 21 Apr 2025 16:05:42 +1000 Subject: [PATCH 7/8] Clean up error messages. --- src/Aspire.Cli/Commands/AddCommand.cs | 13 ++++++++----- src/Aspire.Cli/Commands/PublishCommand.cs | 13 ++++++++----- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 0598357c09a..9a69e887510 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -64,9 +64,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var integrationName = parseResult.GetValue("integration"); - var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); - + var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () => + { + var passedAppHostProjectFile = parseResult.GetValue("--project"); + return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); + }); + if (effectiveAppHostProjectFile is null) { return ExitCodeConstants.FailedToFindProject; @@ -145,9 +148,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _interactionService.DisplayError("The --project option specified a project that does not exist."); return ExitCodeConstants.FailedToFindProject; } - catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files")) + catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found.")) { - _interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected."); + _interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected."); return ExitCodeConstants.FailedToFindProject; } catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file")) diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 4702b529c1f..a23be453ebd 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -73,9 +73,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = _activitySource.StartActivity(); - var passedAppHostProjectFile = parseResult.GetValue("--project"); - var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); - + var effectiveAppHostProjectFile = await _interactionService.ShowStatusAsync("Locating app host project...", async () => + { + var passedAppHostProjectFile = parseResult.GetValue("--project"); + return await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, cancellationToken); + }); + if (effectiveAppHostProjectFile is null) { return ExitCodeConstants.FailedToFindProject; @@ -273,9 +276,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _interactionService.DisplayError("The --project option specified a project that does not exist."); return ExitCodeConstants.FailedToFindProject; } - catch (ProjectLocatorException ex) when (ex.Message.Contains("Nultiple project files")) + catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files found.")) { - _interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected."); + _interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected."); return ExitCodeConstants.FailedToFindProject; } catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file")) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 0a3111354b0..f83739e06a1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -226,7 +226,7 @@ await _ansiConsole.Live(table).StartAsync(async context => } catch (ProjectLocatorException ex) when (ex.Message.Contains("Multiple project files")) { - _interactionService.DisplayError("The --project option was not specified and multiple *.csproj files were detected."); + _interactionService.DisplayError("The --project option was not specified and multiple app host project files were detected."); return ExitCodeConstants.FailedToFindProject; } catch (ProjectLocatorException ex) when (ex.Message.Contains("No project file")) From a6993d8188e5d729956000e91b301a406f48adf0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 21 Apr 2025 19:05:44 +1000 Subject: [PATCH 8/8] Write config file based on current working path. --- src/Aspire.Cli/Aspire.Cli.csproj | 3 ++- src/Aspire.Cli/CliSettings.cs | 12 +++++++++ src/Aspire.Cli/JsonSourceGenerationContext.cs | 12 +++++++++ src/Aspire.Cli/Projects/ProjectLocator.cs | 26 ++++++++++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/Aspire.Cli/CliSettings.cs create mode 100644 src/Aspire.Cli/JsonSourceGenerationContext.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index fc2edc69483..f89c70adb7a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -1,4 +1,4 @@ - + Exe @@ -34,6 +34,7 @@ + diff --git a/src/Aspire.Cli/CliSettings.cs b/src/Aspire.Cli/CliSettings.cs new file mode 100644 index 00000000000..3d5454cacad --- /dev/null +++ b/src/Aspire.Cli/CliSettings.cs @@ -0,0 +1,12 @@ +// 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.Serialization; + +namespace Aspire.Cli; + +internal class CliSettings +{ + [JsonPropertyName("appHostPath")] + public string? AppHostPath { get; set; } +} diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs new file mode 100644 index 00000000000..41c8a8c8f22 --- /dev/null +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -0,0 +1,12 @@ +// 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.Serialization; + +namespace Aspire.Cli; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(CliSettings))] +internal partial class JsonSourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index a6060e9c6a5..80e52e736d5 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -115,12 +115,36 @@ private async Task> FindAppHostProjectFilesAsync(DirectoryInfo se logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count); - return appHostProjects.Count switch { + var selectedAppHost = appHostProjects.Count switch { 0 => throw new ProjectLocatorException("No project file found."), > 1 => throw new ProjectLocatorException("Multiple project files found."), 1 => appHostProjects[0], _ => throw new ProjectLocatorException("Unexpected number of project files found.") }; + + await CreateSettingsFileIfNotExistsAsync(selectedAppHost, cancellationToken); + return selectedAppHost; + } + + private async Task CreateSettingsFileIfNotExistsAsync(FileInfo projectFile, CancellationToken cancellationToken) + { + var settingsFile = new FileInfo(Path.Combine(currentDirectory.FullName, ".aspire", "settings.json")); + + if (!settingsFile.Exists) + { + if (!settingsFile.Directory!.Exists) + { + settingsFile.Directory.Create(); + } + + var settings = new CliSettings + { + AppHostPath = Path.GetRelativePath(settingsFile.Directory.FullName, projectFile.FullName) + }; + + using var stream = settingsFile.OpenWrite(); + await JsonSerializer.SerializeAsync(stream, settings, JsonSourceGenerationContext.Default.CliSettings, cancellationToken); + } } }