diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 07f7e3216e4..89d3d582402 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -19,6 +19,13 @@ internal interface IProjectLocator { Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken = default); Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken); + + /// + /// Resolves the AppHost project file from .aspire/settings.json only, without any + /// user interaction or recursive filesystem scanning. Returns null when no settings + /// file or appHostPath entry is found. + /// + Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default); } internal sealed class ProjectLocator( @@ -152,7 +159,18 @@ await Parallel.ForEachAsync(candidatesWithHandlers, parallelOptions, async (cand }); } + /// + public async Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) + { + return await GetAppHostProjectFileFromSettingsAsync(silent: true, cancellationToken); + } + private async Task GetAppHostProjectFileFromSettingsAsync(CancellationToken cancellationToken) + { + return await GetAppHostProjectFileFromSettingsAsync(silent: false, cancellationToken); + } + + private async Task GetAppHostProjectFileFromSettingsAsync(bool silent, CancellationToken cancellationToken) { var searchDirectory = executionContext.WorkingDirectory; @@ -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; } } diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DotNetSdkCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DotNetSdkCheck.cs index 17c4d765d13..941e3a339f6 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DotNetSdkCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DotNetSdkCheck.cs @@ -2,6 +2,7 @@ // 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; @@ -9,7 +10,16 @@ namespace Aspire.Cli.Utils.EnvironmentChecker; /// /// Checks if the .NET SDK is installed and meets the minimum version requirement. /// -internal sealed class DotNetSdkCheck(IDotNetSdkInstaller sdkInstaller, ILogger logger) : IEnvironmentCheck +/// +/// 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. +/// +internal sealed class DotNetSdkCheck( + IDotNetSdkInstaller sdkInstaller, + IProjectLocator projectLocator, + ILanguageDiscovery languageDiscovery, + CliExecutionContext executionContext, + ILogger logger) : IEnvironmentCheck { public int Order => 30; // File system check - slightly more expensive @@ -17,6 +27,12 @@ public async Task> CheckAsync(Cancellation { 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) @@ -64,4 +80,47 @@ public async Task> CheckAsync(Cancellation }]; } } + + /// + /// Determines whether a .NET AppHost is positively detected, meaning the .NET SDK check should run. + /// Only returns true 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. + /// + private async Task 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; + } + } } diff --git a/src/Aspire.Cli/Utils/FileSystemHelper.cs b/src/Aspire.Cli/Utils/FileSystemHelper.cs index 833c84f7369..d1c96303722 100644 --- a/src/Aspire.Cli/Utils/FileSystemHelper.cs +++ b/src/Aspire.Cli/Utils/FileSystemHelper.cs @@ -51,4 +51,55 @@ internal static void CopyDirectory(string sourceDir, string destinationDir, bool } } } + + /// + /// Recursively searches for the first file matching any of the given patterns. + /// Stops immediately when a match is found. + /// + /// Root folder to start search + /// Maximum directory depth to search. Use 0 to search only the root, or -1 for unlimited depth. + /// File name patterns, e.g., "*.csproj", "apphost.cs" + /// Full path to first matching file, or null if none found + 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; + } } diff --git a/tests/Aspire.Cli.Tests/Commands/DotNetSdkCheckTests.cs b/tests/Aspire.Cli.Tests/Commands/DotNetSdkCheckTests.cs new file mode 100644 index 00000000000..aa8c086ffae --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/DotNetSdkCheckTests.cs @@ -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.Instance); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs index a435baef7ee..0f38143cbf2 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs @@ -177,6 +177,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class MultipleProjectFilesProjectLocator : Aspire.Cli.Projects.IProjectLocator @@ -190,6 +192,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator @@ -203,5 +207,7 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } } diff --git a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs index 34004df126a..8a3377c50c1 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs @@ -243,6 +243,8 @@ public Task UseOrFindServiceProjectFileAsync( { throw new NotImplementedException(); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class MultipleProjectsProjectLocator : IProjectLocator @@ -296,6 +298,8 @@ public Task UseOrFindServiceProjectFileAsync( { throw new NotImplementedException(); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class NoProjectFileProjectLocator : IProjectLocator @@ -341,6 +345,8 @@ public Task UseOrFindServiceProjectFileAsync( { throw new NotImplementedException(); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class ThrowingProjectLocator : IProjectLocator @@ -386,5 +392,7 @@ public Task UseOrFindServiceProjectFileAsync( { throw new NotImplementedException(); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 03ff8aad9bf..c9be9da0286 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -172,6 +172,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } [Fact] @@ -225,6 +227,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class MultipleProjectFilesProjectLocator : IProjectLocator @@ -238,6 +242,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider @@ -1266,6 +1272,8 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf // Return a .cs file to simulate single file AppHost return Task.FromResult(new FileInfo("/tmp/apphost.cs")); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/NoProjectFileProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/NoProjectFileProjectLocator.cs index 1fabc438d91..907b9982a7f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/NoProjectFileProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/NoProjectFileProjectLocator.cs @@ -16,4 +16,6 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf { throw new ProjectLocatorException("No project file found.", ProjectLocatorFailureReason.NoProjectFileFound); } + + public Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestLanguageDiscovery.cs b/tests/Aspire.Cli.Tests/TestServices/TestLanguageDiscovery.cs index e406f8e73e3..ae78422cf70 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestLanguageDiscovery.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestLanguageDiscovery.cs @@ -7,10 +7,11 @@ namespace Aspire.Cli.Tests.TestServices; /// /// Test implementation of that includes C# support for testing. +/// Optionally accepts additional languages for polyglot scenarios. /// internal sealed class TestLanguageDiscovery : ILanguageDiscovery { - private static readonly LanguageInfo[] s_allLanguages = + private static readonly LanguageInfo[] s_defaultLanguages = [ new LanguageInfo( LanguageId: new LanguageId(KnownLanguageId.CSharp), @@ -21,19 +22,26 @@ internal sealed class TestLanguageDiscovery : ILanguageDiscovery AppHostFileName: null), ]; + private readonly LanguageInfo[] _allLanguages; + + public TestLanguageDiscovery(params LanguageInfo[] additionalLanguages) + { + _allLanguages = [.. s_defaultLanguages, .. additionalLanguages]; + } + public Task> GetAvailableLanguagesAsync(CancellationToken cancellationToken = default) - => Task.FromResult>(s_allLanguages); + => Task.FromResult>(_allLanguages); public Task GetPackageForLanguageAsync(LanguageId languageId, CancellationToken cancellationToken = default) { - var language = s_allLanguages.FirstOrDefault(l => + var language = _allLanguages.FirstOrDefault(l => string.Equals(l.LanguageId.Value, languageId.Value, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(language?.PackageName); } public Task DetectLanguageAsync(DirectoryInfo directory, CancellationToken cancellationToken = default) { - foreach (var language in s_allLanguages) + foreach (var language in _allLanguages) { foreach (var pattern in language.DetectionPatterns) { @@ -60,13 +68,13 @@ public Task> GetAvailableLanguagesAsync(CancellationTo public LanguageInfo? GetLanguageById(LanguageId languageId) { - return s_allLanguages.FirstOrDefault(l => + return _allLanguages.FirstOrDefault(l => string.Equals(l.LanguageId.Value, languageId.Value, StringComparison.OrdinalIgnoreCase)); } public LanguageInfo? GetLanguageByFile(FileInfo file) { - return s_allLanguages.FirstOrDefault(l => + return _allLanguages.FirstOrDefault(l => l.DetectionPatterns.Any(p => MatchesPattern(file.Name, p))); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index 634ffc24a17..ebb53915f80 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs @@ -12,6 +12,8 @@ internal sealed class TestProjectLocator : IProjectLocator public Func>? UseOrFindAppHostProjectFileWithBehaviorAsyncCallback { get; set; } + public Func>? GetAppHostFromSettingsAsyncCallback { get; set; } + public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken) { if (UseOrFindAppHostProjectFileAsyncCallback != null) @@ -45,5 +47,16 @@ public async Task UseOrFindAppHostProjectFileAsync(F return new AppHostProjectSearchResult(appHostFile, [appHostFile]); } + + public async Task GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) + { + if (GetAppHostFromSettingsAsyncCallback != null) + { + return await GetAppHostFromSettingsAsyncCallback(cancellationToken); + } + + // Default: no settings file found + return null; + } }