File: Projects\ProjectLocator.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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 System.Text.Json;
using Aspire.Cli.Interaction;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Projects;
 
internal interface IProjectLocator
{
    Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default);
}
 
internal sealed class ProjectLocator(ILogger<ProjectLocator> logger, IDotNetCliRunner runner, DirectoryInfo currentDirectory, IInteractionService interactionService) : IProjectLocator
{
    private readonly ActivitySource _activitySource = new(nameof(ProjectLocator));
 
    private async Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, CancellationToken cancellationToken)
    {
        using var activity = _activitySource.StartActivity();
 
        return await interactionService.ShowStatusAsync("Searching", async () =>
        {
            var appHostProjects = new List<FileInfo>();
            logger.LogDebug("Searching for project files in {SearchDirectory}", searchDirectory.FullName);
            var enumerationOptions = new EnumerationOptions
            {
                RecurseSubdirectories = true,
                IgnoreInaccessible = true
            };
 
            interactionService.DisplayMessage("magnifying_glass_tilted_left", "Finding app hosts...");
            var projectFiles = searchDirectory.GetFiles("*.csproj", enumerationOptions);
            logger.LogDebug("Found {ProjectFileCount} project files in {SearchDirectory}", projectFiles.Length, searchDirectory.FullName);
 
            var parallelOptions = new ParallelOptions
            {
                CancellationToken = cancellationToken,
                MaxDegreeOfParallelism = Environment.ProcessorCount
            };
 
            await Parallel.ForEachAsync(projectFiles, async (projectFile, ct) =>
            {
                logger.LogDebug("Checking project file {ProjectFile}", projectFile.FullName);
                var information = await runner.GetAppHostInformationAsync(projectFile, new DotNetCliRunnerInvocationOptions(), ct);
 
                if (information.ExitCode == 0 && information.IsAspireHost)
                {
                    logger.LogDebug("Found AppHost project file {ProjectFile} in {SearchDirectory}", projectFile.FullName, searchDirectory.FullName);
                    var relativePath = Path.GetRelativePath(currentDirectory.FullName, projectFile.FullName);
                    interactionService.DisplaySubtleMessage(relativePath);
                    appHostProjects.Add(projectFile);
                }
                else
                {
                    logger.LogTrace("Project file {ProjectFile} in {SearchDirectory} is not an Aspire host", projectFile.FullName, searchDirectory.FullName);
                }
            });
 
            // 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));
 
            return appHostProjects;
        });
    }
 
    private async Task<FileInfo?> 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<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, CancellationToken cancellationToken = default)
    {
        logger.LogDebug("Finding project file in {CurrentDirectory}", currentDirectory);
 
        if (projectFile is not null)
        {
            // If the project file is passed, just use it.
            if (!projectFile.Exists)
            {
                logger.LogError("Project file {ProjectFile} does not exist.", projectFile.FullName);
                throw new ProjectLocatorException($"Project file does not exist.");
            }
 
            logger.LogDebug("Using project file {ProjectFile}", projectFile.FullName);
            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(currentDirectory, cancellationToken);
        interactionService.DisplayEmptyLine();
 
        logger.LogDebug("Found {ProjectFileCount} project files.", appHostProjects.Count);
 
        FileInfo? selectedAppHost = null;
 
        if (appHostProjects.Count == 0)
        {
            throw new ProjectLocatorException("No project file found.");
        }
        else if (appHostProjects.Count == 1)
        {
            selectedAppHost = appHostProjects[0];
        }
        else if (appHostProjects.Count > 1)
        {
            selectedAppHost = await interactionService.PromptForSelectionAsync(
                "Select app host to run",
                appHostProjects,
                projectFile => $"{projectFile.Name} ({Path.GetRelativePath(currentDirectory.FullName, projectFile.FullName)})",
                cancellationToken
                );
        }
 
        interactionService.DisplayEmptyLine();
        await CreateSettingsFileAsync(selectedAppHost!, cancellationToken);
        return selectedAppHost;
    }
 
    private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationToken cancellationToken)
    {
        var defaultSettingsFilePath = Path.Combine(currentDirectory.FullName, ".aspire", "settings.json");
        var settingsFilePath = await interactionService.PromptForStringAsync(
            "Creating settings file",
            defaultSettingsFilePath,
            (path) =>
            {
                if (!path.EndsWith($"{Path.DirectorySeparatorChar}.aspire{Path.DirectorySeparatorChar}settings.json"))
                {
                    return ValidationResult.Error("Settings file must end with '/.aspire/settings.json'");
                }
 
                return ValidationResult.Success();
            },
            cancellationToken);
 
        if (!Path.IsPathRooted(settingsFilePath))
        {
            settingsFilePath = Path.Combine(currentDirectory.FullName, settingsFilePath);
        }
 
        var settingsFile = new FileInfo(settingsFilePath);
        logger.LogDebug("Creating settings file at {SettingsFilePath}", settingsFile.FullName);
 
        if (!settingsFile.Directory!.Exists)
        {
            settingsFile.Directory.Create();
        }
 
        var relativePathToProjectFile = Path.GetRelativePath(settingsFile.Directory.FullName, projectFile.FullName).Replace(Path.DirectorySeparatorChar, '/');
 
        // Get the relative path and normalize it to use '/' as the separator
        var settings = new CliSettings
        {
            AppHostPath = relativePathToProjectFile
        };
 
        using var stream = settingsFile.OpenWrite();
        await JsonSerializer.SerializeAsync(stream, settings, JsonSourceGenerationContext.Default.CliSettings, cancellationToken);
        
        var relativeSettingsFilePath = Path.GetRelativePath(currentDirectory.FullName, settingsFile.FullName).Replace(Path.DirectorySeparatorChar, '/');
        var relativeProjectFilePath = Path.GetRelativePath(currentDirectory.FullName, projectFile.FullName).Replace(Path.DirectorySeparatorChar, '/');
        interactionService.DisplayMessage("file_cabinet", $"Created settings file at [bold]'{settingsFile.FullName}'[/] for project [bold]'{relativeProjectFilePath}'[/].");
    }
}
 
internal class ProjectLocatorException : System.Exception
{
    public ProjectLocatorException(string message) : base(message) { }
}