File: Layout\LayoutDiscovery.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 Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Layout;
 
/// <summary>
/// Service for discovering and loading Aspire bundle layouts.
/// Uses priority-based resolution: environment variables > relative paths from CLI location.
/// </summary>
public interface ILayoutDiscovery
{
    /// <summary>
    /// Attempts to discover a valid layout configuration.
    /// </summary>
    /// <param name="projectDirectory">Optional project directory (unused, kept for API compatibility).</param>
    /// <returns>Layout configuration if found and valid, null otherwise.</returns>
    LayoutConfiguration? DiscoverLayout(string? projectDirectory = null);
 
    /// <summary>
    /// Gets the path to a specific component, checking environment variable overrides first.
    /// </summary>
    string? GetComponentPath(LayoutComponent component, string? projectDirectory = null);
 
    /// <summary>
    /// Checks if bundle mode is available and should be used.
    /// </summary>
    bool IsBundleModeAvailable(string? projectDirectory = null);
}
 
/// <summary>
/// Implementation of layout discovery with priority-based resolution.
/// </summary>
public sealed class LayoutDiscovery : ILayoutDiscovery
{
    private readonly ILogger<LayoutDiscovery> _logger;
    private readonly IEnvironment _environment;
 
    public LayoutDiscovery(ILogger<LayoutDiscovery> logger, IEnvironment environment)
    {
        _logger = logger;
        _environment = environment;
    }
 
    /// <summary>
    /// Overrides <see cref="Environment.ProcessPath"/> for relative-layout discovery.
    /// Used in tests to simulate the CLI executable living at an arbitrary path.
    /// </summary>
    internal string? ProcessPathOverride { get; init; }
 
    public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null)
    {
        // 1. Try environment variable for layout path
        var envLayoutPath = _environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar);
        if (!string.IsNullOrEmpty(envLayoutPath))
        {
            _logger.LogDebug("Found ASPIRE_LAYOUT_PATH: {Path}", envLayoutPath);
            var config = TryLoadLayoutFromPath(envLayoutPath);
            if (config is not null)
            {
                return LogEnvironmentOverrides(config);
            }
        }
 
        // 2. Try relative paths from CLI executable
        var relativeLayout = TryDiscoverRelativeLayout();
        if (relativeLayout is not null)
        {
            _logger.LogDebug("Discovered layout relative to CLI: {Path}", relativeLayout.LayoutPath);
            return LogEnvironmentOverrides(relativeLayout);
        }
 
        // 3. Try the Aspire home directory. This is the auto-extract destination
        // for sidecar-less installs (e.g. CLI binaries in read-only locations
        // like a Nix store), so the bundle the CLI just extracted has to be
        // discoverable here too — otherwise post-extract validation fails and
        // every command that depends on the bundle reports extraction failed.
        // Keep this as the last probe so colocated installs (winget, brew,
        // dotnet-tool, script, pr, localhive) are never shadowed by a stale
        // home-directory layout.
        var aspireHomeLayout = TryDiscoverAspireHomeLayout();
        if (aspireHomeLayout is not null)
        {
            _logger.LogDebug("Discovered layout in Aspire home: {Path}", aspireHomeLayout.LayoutPath);
            return LogEnvironmentOverrides(aspireHomeLayout);
        }
 
        _logger.LogDebug("No bundle layout discovered");
        return null;
    }
 
    private LayoutConfiguration? TryDiscoverAspireHomeLayout()
    {
        string aspireHome;
        try
        {
            aspireHome = CliPathHelper.GetDefaultAspireHomeDirectory();
        }
        catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException)
        {
            _logger.LogDebug(ex, "TryDiscoverAspireHomeLayout: could not resolve Aspire home directory");
            return null;
        }
 
        if (string.IsNullOrEmpty(aspireHome) || !Directory.Exists(aspireHome))
        {
            return null;
        }
 
        _logger.LogDebug("TryDiscoverAspireHomeLayout: Checking Aspire home {Path}...", aspireHome);
        return TryInferLayout(aspireHome);
    }
 
    public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null)
    {
        // Check environment variable overrides first
        var envPath = component switch
        {
            LayoutComponent.Dcp => _environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar),
            LayoutComponent.Managed => _environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar),
            _ => null
        };
 
        if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
        {
            return envPath;
        }
 
        // Fall back to layout configuration
        var layout = DiscoverLayout(projectDirectory);
        return layout?.GetComponentPath(component);
    }
 
    public bool IsBundleModeAvailable(string? projectDirectory = null)
    {
        // Check if user explicitly wants SDK mode
        var useSdk = _environment.GetEnvironmentVariable(BundleDiscovery.UseGlobalDotNetEnvVar);
        if (string.Equals(useSdk, "true", StringComparison.OrdinalIgnoreCase) ||
            string.Equals(useSdk, "1", StringComparison.OrdinalIgnoreCase))
        {
            _logger.LogDebug("SDK mode forced via {EnvVar}", BundleDiscovery.UseGlobalDotNetEnvVar);
            return false;
        }
 
        var layout = DiscoverLayout(projectDirectory);
        if (layout is null)
        {
            return false;
        }
 
        // Validate that essential components exist
        return ValidateLayout(layout);
    }
 
    private LayoutConfiguration? TryLoadLayoutFromPath(string layoutPath)
    {
        _logger.LogDebug("TryLoadLayoutFromPath: {Path}", layoutPath);
 
        if (!Directory.Exists(layoutPath))
        {
            _logger.LogDebug("Layout path does not exist: {Path}", layoutPath);
            return null;
        }
 
        _logger.LogDebug("Layout path exists, checking directory structure...");
 
        // Log directory contents for debugging
        try
        {
            var entries = Directory.GetFileSystemEntries(layoutPath).Select(Path.GetFileName).ToArray();
            _logger.LogDebug("Layout directory contents: {Contents}", string.Join(", ", entries));
        }
        catch (Exception ex)
        {
            _logger.LogDebug("Could not list directory contents: {Error}", ex.Message);
        }
 
        // Infer layout from directory structure (well-known relative paths)
        return TryInferLayout(layoutPath);
    }
 
    private LayoutConfiguration? TryDiscoverRelativeLayout()
    {
        var cliPath = ProcessPathOverride ?? Environment.ProcessPath;
        if (string.IsNullOrEmpty(cliPath))
        {
            _logger.LogDebug("TryDiscoverRelativeLayout: ProcessPath is null or empty");
            return null;
        }
 
        var resolvedCliPath = CliPathHelper.ResolveSymlinkOrOriginalPath(cliPath, _logger);
        if (!string.Equals(resolvedCliPath, cliPath, StringComparison.Ordinal))
        {
            _logger.LogDebug("TryDiscoverRelativeLayout: Resolved CLI path {RawPath} -> {ResolvedPath}", cliPath, resolvedCliPath);
 
            var resolvedLayout = TryDiscoverRelativeLayout(resolvedCliPath);
            if (resolvedLayout is not null)
            {
                return resolvedLayout;
            }
 
            _logger.LogDebug("TryDiscoverRelativeLayout: No layout found relative to resolved CLI path; trying raw path {Path}.", cliPath);
        }
 
        return TryDiscoverRelativeLayout(cliPath);
    }
 
    private LayoutConfiguration? TryDiscoverRelativeLayout(string cliPath)
    {
        var cliDir = Path.GetDirectoryName(cliPath);
        if (string.IsNullOrEmpty(cliDir))
        {
            _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from process path {Path}", cliPath);
            return null;
        }
 
        _logger.LogDebug("TryDiscoverRelativeLayout: CLI at {Path}, checking for layout...", cliDir);
 
        // Check if CLI is in a bundle layout
        // First, check if components are siblings of the CLI (flat layout):
        //   {layout}/aspire + {layout}/bundle/ -> {layout}/versions/{id}/
        var layout = TryInferLayout(cliDir);
        if (layout is not null)
        {
            return layout;
        }
 
        // Next, check the parent directory (bin/ layout where CLI is in a subdirectory):
        //   {layout}/bin/aspire + {layout}/bundle/ -> {layout}/versions/{id}/
        var parentDir = Path.GetDirectoryName(cliDir);
        if (!string.IsNullOrEmpty(parentDir))
        {
            _logger.LogDebug("TryDiscoverRelativeLayout: Checking parent directory {Path}...", parentDir);
            layout = TryInferLayout(parentDir);
            if (layout is not null)
            {
                return layout;
            }
        }
 
        return null;
    }
 
    private LayoutConfiguration? TryInferLayout(string layoutPath)
    {
        // New layout: a single bundle/ link whose target contains managed/ and dcp/.
        var bundlePath = Path.Combine(layoutPath, BundleDiscovery.BundleDirectoryName);
        var bundleManagedPath = Path.Combine(bundlePath, BundleDiscovery.ManagedDirectoryName);
        var bundleDcpPath = Path.Combine(bundlePath, BundleDiscovery.DcpDirectoryName);
        var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName);
        var bundleDcpExe = BundleDiscovery.GetDcpExecutablePath(bundleDcpPath);
 
        _logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath);
        _logger.LogDebug("  {Dir}/{Managed}/: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, Directory.Exists(bundleManagedPath) ? "exists" : "MISSING");
        _logger.LogDebug("  {Dir}/{Dcp}/: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName, Directory.Exists(bundleDcpPath) ? "exists" : "MISSING");
 
        if (Directory.Exists(bundleManagedPath) && Directory.Exists(bundleDcpPath))
        {
            var bundleManagedExe = Path.Combine(bundleManagedPath, managedExeName);
            _logger.LogDebug("  {Dir}/{Managed}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName, managedExeName, File.Exists(bundleManagedExe) ? "exists" : "MISSING");
            _logger.LogDebug("  {Dir}/{Dcp}/{Exe}: {Exists}", BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName, Path.GetFileName(bundleDcpExe), File.Exists(bundleDcpExe) ? "exists" : "MISSING");
 
            if (File.Exists(bundleManagedExe) && File.Exists(bundleDcpExe))
            {
                _logger.LogDebug("TryInferLayout: New bundle/ layout is valid");
                return new LayoutConfiguration
                {
                    LayoutPath = layoutPath,
                    Components = new LayoutComponents
                    {
                        Dcp = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName),
                        Managed = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName),
                    }
                };
            }
        }
 
        // Legacy layout: top-level managed/ and dcp/ directories (or reparse points).
        var managedPath = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName);
        var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName);
        var dcpExePath = BundleDiscovery.GetDcpExecutablePath(dcpPath);
 
        _logger.LogDebug("  {Dir}/: {Exists}", BundleDiscovery.ManagedDirectoryName, Directory.Exists(managedPath) ? "exists" : "MISSING");
        _logger.LogDebug("  {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING");
 
        if (!Directory.Exists(managedPath) || !Directory.Exists(dcpPath))
        {
            _logger.LogDebug("TryInferLayout: Layout rejected - missing required directories");
            return null;
        }
 
        // Check for aspire-managed executable
        var managedExePath = Path.Combine(managedPath, managedExeName);
        _logger.LogDebug("  managed/{ManagedExe}: {Exists}", managedExeName, File.Exists(managedExePath) ? "exists" : "MISSING");
        _logger.LogDebug("  dcp/{DcpExe}: {Exists}", Path.GetFileName(dcpExePath), File.Exists(dcpExePath) ? "exists" : "MISSING");
 
        if (!File.Exists(managedExePath) || !File.Exists(dcpExePath))
        {
            _logger.LogDebug("TryInferLayout: Layout rejected - required executable not found");
            return null;
        }
 
        _logger.LogDebug("TryInferLayout: Legacy layout is valid");
 
        // Infer a basic layout configuration
        return new LayoutConfiguration
        {
            LayoutPath = layoutPath,
            Components = new LayoutComponents()
        };
    }
 
    private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config)
    {
        // Environment variables for specific components take precedence
        // These will be checked at GetComponentPath time, but we note them here for logging
 
        if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar)))
        {
            _logger.LogDebug("DCP path override from {EnvVar}", BundleDiscovery.DcpPathEnvVar);
        }
        if (!string.IsNullOrEmpty(_environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar)))
        {
            _logger.LogDebug("Managed path override from {EnvVar}", BundleDiscovery.ManagedPathEnvVar);
        }
 
        return config;
    }
 
    private bool ValidateLayout(LayoutConfiguration layout)
    {
        // Check that aspire-managed exists
        var managedPath = layout.GetManagedPath();
        if (managedPath is null || !File.Exists(managedPath))
        {
            _logger.LogDebug("Layout validation failed: aspire-managed not found at {Path}", managedPath);
            return false;
        }
 
        // Require DCP for valid layouts
        var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp);
        if (dcpPath is null ||
            !Directory.Exists(dcpPath) ||
            !File.Exists(BundleDiscovery.GetDcpExecutablePath(dcpPath)))
        {
            _logger.LogDebug("Layout validation failed: DCP not found");
            return false;
        }
 
        return true;
    }
}