Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/Aspire.Cli/Projects/ProjectLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ internal interface IProjectLocator
{
Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default);
Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken);

/// <summary>
/// Resolves the AppHost project file from <c>.aspire/settings.json</c> only, without any
/// user interaction or recursive filesystem scanning. Returns <c>null</c> when no settings
/// file or <c>appHostPath</c> entry is found.
/// </summary>
Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default);
}

internal sealed class ProjectLocator(
Expand Down Expand Up @@ -152,7 +159,18 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand
});
}

/// <inheritdoc />
public async Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default)
{
return await GetAppHostProjectFileFromSettingsAsync(silent: true, cancellationToken);
}

private async Task<FileInfo?> GetAppHostProjectFileFromSettingsAsync(CancellationToken cancellationToken)
{
return await GetAppHostProjectFileFromSettingsAsync(silent: false, cancellationToken);
}

private async Task<FileInfo?> GetAppHostProjectFileFromSettingsAsync(bool silent, CancellationToken cancellationToken)
{
var searchDirectory = executionContext.WorkingDirectory;

Expand All @@ -178,7 +196,10 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand
else
{
// AppHost file was specified but doesn't exist, return null to trigger fallback logic
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath));
if (!silent)
{
interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.AppHostWasSpecifiedButDoesntExist, settingsFile.FullName, qualifiedAppHostPath));
}
return null;
}
}
Expand Down
61 changes: 60 additions & 1 deletion src/Aspire.Cli/Utils/EnvironmentChecker/DotNetSdkCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,37 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.DotNet;
using Aspire.Cli.Projects;
using Microsoft.Extensions.Logging;

namespace Aspire.Cli.Utils.EnvironmentChecker;

/// <summary>
/// Checks if the .NET SDK is installed and meets the minimum version requirement.
/// </summary>
internal sealed class DotNetSdkCheck(IDotNetSdkInstaller sdkInstaller, ILogger<DotNetSdkCheck> logger) : IEnvironmentCheck
/// <remarks>
/// This check is skipped when the detected AppHost is a non-.NET project (e.g., TypeScript, Python, Go),
/// since .NET SDK is not required for polyglot scenarios.
/// </remarks>
internal sealed class DotNetSdkCheck(
IDotNetSdkInstaller sdkInstaller,
IProjectLocator projectLocator,
ILanguageDiscovery languageDiscovery,
CliExecutionContext executionContext,
ILogger<DotNetSdkCheck> logger) : IEnvironmentCheck
{
public int Order => 30; // File system check - slightly more expensive

public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
{
try
{
if (!await IsDotNetAppHostAsync(cancellationToken))
{
logger.LogDebug("Skipping .NET SDK check because no .NET AppHost was detected");
return [];
}

var (success, highestVersion, minimumRequiredVersion) = await sdkInstaller.CheckAsync(cancellationToken);

if (!success)
Expand Down Expand Up @@ -64,4 +80,47 @@ public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(Cancellation
}];
}
}

/// <summary>
/// Determines whether a .NET AppHost is positively detected, meaning the .NET SDK check should run.
/// Only returns <c>true</c> when a settings file is found and the apphost is a .NET project.
/// When no settings file exists or the apphost is non-.NET, the check is skipped.
/// </summary>
private async Task<bool> IsDotNetAppHostAsync(CancellationToken cancellationToken)
{
try
{
// Use the silent settings-only lookup to find the apphost without
// emitting interaction output or performing recursive filesystem scans.
var appHostFile = await projectLocator.GetAppHostFromSettingsAsync(cancellationToken);

if (appHostFile is not null && languageDiscovery.GetLanguageByFile(appHostFile) is { } language)
{
return language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase);
}

// No apphost configured in settings. Look for possible .NET app hosts on file system (projects or apphost.cs)
// This doesn't guarantee the apphost is .NET, but it's a signal that it might be and worth checking for .NET SDK.
var csharp = languageDiscovery.GetLanguageById(KnownLanguageId.CSharp);
if (csharp is null)
{
return false;
}

// Scan file system directly instead of using ProjectLocator for performance.
// Limit recursive scan to avoid expensive file system operations in large workspaces.
// We don't want a complete list of all possible project files, just a quick signal that a .NET apphost is probably present.
var match = FileSystemHelper.FindFirstFile(executionContext.WorkingDirectory.FullName, recurseLimit: 5, csharp.DetectionPatterns);
return match is not null;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error detecting AppHost language, skipping .NET SDK check");
return false;
}
}
}
51 changes: 51 additions & 0 deletions src/Aspire.Cli/Utils/FileSystemHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,55 @@ internal static void CopyDirectory(string sourceDir, string destinationDir, bool
}
}
}

/// <summary>
/// Recursively searches for the first file matching any of the given patterns.
/// Stops immediately when a match is found.
/// </summary>
/// <param name="root">Root folder to start search</param>
/// <param name="recurseLimit">Maximum directory depth to search. Use 0 to search only the root, or -1 for unlimited depth.</param>
/// <param name="patterns">File name patterns, e.g., "*.csproj", "apphost.cs"</param>
/// <returns>Full path to first matching file, or null if none found</returns>
public static string? FindFirstFile(string root, int recurseLimit = -1, params string[] patterns)
{
if (!Directory.Exists(root) || patterns.Length == 0)
{
return null;
}

var dirs = new Stack<(string Path, int Depth)>();
dirs.Push((root, 0));

while (dirs.Count > 0)
{
var (dir, depth) = dirs.Pop();

try
{
// Check for each pattern in this directory
foreach (var pattern in patterns)
{
foreach (var file in Directory.EnumerateFiles(dir, pattern))
{
return file; // first match, exit immediately
}
}

// Push subdirectories for further search if within depth limit
if (recurseLimit < 0 || depth < recurseLimit)
{
foreach (var sub in Directory.EnumerateDirectories(dir))
{
dirs.Push((sub, depth + 1));
}
}
}
catch
{
// Skip directories we can't access (permissions, etc.)
}
}

return null;
}
}
168 changes: 168 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/DotNetSdkCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// 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 Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Cli.Tests.Commands;

public class DotNetSdkCheckTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task CheckAsync_SkipsCheck_WhenNoAppHostFound()
{
// No apphost in settings — skip .NET SDK check entirely
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace,
sdkCheckResult: (false, null, "10.0.100"),
languageDiscovery: new TestLanguageDiscovery());

var results = await check.CheckAsync().DefaultTimeout();

Assert.Empty(results);
}

[Fact]
public async Task CheckAsync_SkipsCheck_WhenNonDotNetAppHostFound()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace,
appHostFileName: "apphost.ts",
sdkCheckResult: (false, null, "10.0.100"));

var results = await check.CheckAsync().DefaultTimeout();

Assert.Empty(results);
}

[Fact]
public async Task CheckAsync_RunsCheck_WhenDotNetAppHostFound()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace, appHostFileName: "MyAppHost.csproj");

var results = await check.CheckAsync().DefaultTimeout();
}

[Fact]
public async Task CheckAsync_ReturnsFail_WhenDotNetAppHostFound_AndSdkNotInstalled()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace,
appHostFileName: "MyAppHost.csproj",
sdkCheckResult: (false, null, "10.0.100"));

var results = await check.CheckAsync().DefaultTimeout();

Assert.Single(results);
Assert.Equal(EnvironmentCheckStatus.Fail, results[0].Status);
Assert.Contains(".NET SDK not found", results[0].Message);
}

[Fact]
public async Task CheckAsync_SkipsCheck_WhenNoSettingsFileExists()
{
// No settings.json — can't determine language, skip .NET check
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace);

var results = await check.CheckAsync().DefaultTimeout();

Assert.Empty(results);
}

[Fact]
public async Task CheckAsync_SkipsCheck_WhenLanguageNotRecognized()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace, appHostFileName: "unknown.xyz");

var results = await check.CheckAsync().DefaultTimeout();

// Unrecognized file — skip .NET check
Assert.Empty(results);
}

[Theory]
[InlineData("MyAppHost.csproj", true)]
[InlineData("apphost.cs", true)]
[InlineData("readme.txt", false)]
[InlineData("src/MyAppHost.csproj", true)]
[InlineData("src/AppHost/apphost.cs", true)]
[InlineData("src/deep/nested/readme.txt", false)]
[InlineData("a/b/c/d/e/f/apphost.cs", false)]
public async Task CheckAsync_FallsBackToFileSystemScan_WhenNoSettingsFile(string relativePath, bool shouldRunCheck)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var check = CreateDotNetSdkCheck(workspace);

// Create the file on disk (with nested directories) so FindFirstFile can discover it
var filePath = Path.Combine(workspace.WorkspaceRoot.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
await File.WriteAllTextAsync(filePath, "");

var results = await check.CheckAsync().DefaultTimeout();

if (shouldRunCheck)
{
Assert.Single(results);
Assert.Equal(EnvironmentCheckStatus.Pass, results[0].Status);
}
else
{
Assert.Empty(results);
}
}

private static readonly LanguageInfo s_typeScriptLanguage = new(
LanguageId: new LanguageId(KnownLanguageId.TypeScript),
DisplayName: "TypeScript (Node.js)",
PackageName: "Aspire.Hosting.CodeGeneration.TypeScript",
DetectionPatterns: ["apphost.ts"],
CodeGenerator: "TypeScript",
AppHostFileName: "apphost.ts");

private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace) =>
new(
workingDirectory: workspace.WorkspaceRoot,
hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-hives"),
cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-cache"),
sdksDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-sdks"),
logsDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire-logs"),
logFilePath: "test.log");

private static DotNetSdkCheck CreateDotNetSdkCheck(
TemporaryWorkspace workspace,
string? appHostFileName = null,
(bool Success, string? HighestVersion, string MinimumRequired)? sdkCheckResult = null,
ILanguageDiscovery? languageDiscovery = null)
{
var appHostFile = appHostFileName is not null
? new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, appHostFileName))
: null;

var sdkInstaller = new TestDotNetSdkInstaller();
if (sdkCheckResult is var (success, highest, minimum))
{
sdkInstaller.CheckAsyncCallback = _ => (success, highest, minimum);
}

var projectLocator = new TestProjectLocator
{
GetAppHostFromSettingsAsyncCallback = _ => Task.FromResult(appHostFile)
};

var executionContext = CreateExecutionContext(workspace);

return new DotNetSdkCheck(
sdkInstaller,
projectLocator,
languageDiscovery ?? new TestLanguageDiscovery(s_typeScriptLanguage),
executionContext,
NullLogger<DotNetSdkCheck>.Instance);
}
}
6 changes: 6 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInf
{
throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound);
}

public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
}

private sealed class MultipleProjectFilesProjectLocator : Aspire.Cli.Projects.IProjectLocator
Expand All @@ -190,6 +192,8 @@ public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInf
{
throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound);
}

public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
}

private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator
Expand All @@ -203,5 +207,7 @@ public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInf
{
throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist);
}

public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
}
}
Loading
Loading