| File: Templating\OutputPathHelper.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.Globalization; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Spectre.Console; namespace Aspire.Cli.Templating; internal static class OutputPathHelper { /// <summary> /// Resolves the output path by prompting if not provided, validating if explicit, /// and converting to an absolute path. /// </summary> internal static async Task<string?> ResolveOutputPathAsync( string? outputPath, string workingDirectory, Func<Task<string>> promptCallback, IInteractionService interactionService) { if (string.IsNullOrWhiteSpace(outputPath)) { outputPath = await promptCallback(); } else { var earlyError = ValidateOutputPath(outputPath, workingDirectory); if (earlyError is not null) { interactionService.DisplayError(earlyError); return null; } } return Path.GetFullPath(outputPath, workingDirectory); } /// <summary> /// Returns a unique default output path based on the given base name. /// Invalid path characters are stripped from <paramref name="baseName"/>. /// If all characters are invalid, <c>"output"</c> is used as the fallback name. /// If <c>./{baseName}</c> already exists and is non-empty, appends /// a numeric suffix (<c>-2</c>, <c>-3</c>, …) until an available name is found. /// </summary> internal static string GetUniqueDefaultOutputPath(string baseName, string workingDirectory) { baseName = SanitizeBaseName(baseName); var baseCandidate = $"./{baseName}"; if (!IsNonEmptyDirectory(Path.GetFullPath(baseCandidate, workingDirectory))) { return baseCandidate; } for (var i = 2; i < 1000; i++) { var candidate = $"{baseCandidate}-{i}"; if (!IsNonEmptyDirectory(Path.GetFullPath(candidate, workingDirectory))) { return candidate; } } // Fallback — extremely unlikely to reach here. return baseCandidate; } /// <summary> /// Creates a validator that checks whether the given output path contains invalid characters /// or refers to a non-empty existing directory. /// </summary> internal static Func<string, ValidationResult> CreateOutputPathValidator(string workingDirectory) { return path => { var validationResult = ValidateOutputPath(path, workingDirectory, isExplicitOutput: false); if (validationResult is not null) { return ValidationResult.Error(validationResult); } return ValidationResult.Success(); }; } internal static async Task<bool> PromptExtensionCreateProjectNameSubdirectoryAsync( IInteractionService interactionService, bool isExtensionHost, bool isExplicitOutput, string projectName, CancellationToken cancellationToken) { if (!isExtensionHost || isExplicitOutput || !CanCreateProjectNameSubdirectory(projectName)) { return false; } var subdirectoryChoice = string.Format(CultureInfo.CurrentCulture, NewCommandStrings.CreateProjectNameSubdirectoryChoice, projectName); var selectedChoice = await interactionService.PromptForSelectionAsync( NewCommandStrings.SelectProjectCreationLocation, [subdirectoryChoice, NewCommandStrings.CreateProjectDirectlyInSelectedFolderChoice], static choice => choice, cancellationToken: cancellationToken); return string.Equals(selectedChoice, subdirectoryChoice, StringComparison.Ordinal); } internal static Func<string, string>? CreateProjectNameSubdirectoryOutputPathResolver(bool createProjectNameSubdirectory, string projectName) { if (!createProjectNameSubdirectory || !CanCreateProjectNameSubdirectory(projectName)) { return null; } return outputPath => AppendProjectNameSubdirectory(outputPath, projectName); } /// <summary> /// Validates a (possibly relative) output path before resolution. Returns an error message if the path /// contains invalid characters or targets a non-empty existing directory, or <see langword="null"/> if valid. /// </summary> internal static string? ValidateOutputPath(string path, string workingDirectory, bool isExplicitOutput = true) { // Validate the path before calling Path.GetFullPath to avoid exceptions from invalid characters. if (ContainsInvalidPathChars(path)) { return string.Format(CultureInfo.CurrentCulture, NewCommandStrings.OutputPathContainsInvalidCharacters, path); } var fullPath = Path.GetFullPath(path, workingDirectory); if (IsNonEmptyDirectory(fullPath)) { var format = isExplicitOutput ? NewCommandStrings.OutputDirectoryNotEmptyNonInteractive : NewCommandStrings.OutputDirectoryNotEmptyInteractive; return string.Format(CultureInfo.CurrentCulture, format, fullPath); } return null; } private static string AppendProjectNameSubdirectory(string outputPath, string projectName) { var normalizedOutputPath = Path.TrimEndingDirectorySeparator(outputPath); return string.Equals(Path.GetFileName(normalizedOutputPath), projectName, StringComparison.OrdinalIgnoreCase) ? normalizedOutputPath : Path.Combine(normalizedOutputPath, projectName); } private static bool CanCreateProjectNameSubdirectory(string projectName) => !projectName.Equals(".", StringComparison.Ordinal) && !projectName.Equals("..", StringComparison.Ordinal); private static string SanitizeBaseName(string baseName) { var invalidChars = Path.GetInvalidPathChars(); var sanitized = string.Concat(baseName.Where(c => !invalidChars.Contains(c))); return sanitized.Length > 0 ? sanitized : "output"; } private static bool ContainsInvalidPathChars(string path) { return path.AsSpan().IndexOfAny(Path.GetInvalidPathChars()) >= 0; } private static bool IsNonEmptyDirectory(string fullPath) { return Path.IsPathRooted(fullPath) && Directory.Exists(fullPath) && Directory.EnumerateFileSystemEntries(fullPath).Any(); } }