diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index ea4978b0e58..1898d36d833 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -92,11 +92,18 @@ public NewCommand(IDotNetCliRunner runner, INuGetPackageCache nuGetPackageCache, private async Task GetProjectNameAsync(ParseResult parseResult, CancellationToken cancellationToken) { - if (parseResult.GetValue("--name") is not { } name || !ProjectNameValidator.IsProjectNameValid(name)) + if (parseResult.GetValue("--name") is not { } name) { var defaultName = new DirectoryInfo(Environment.CurrentDirectory).Name; name = await _prompter.PromptForProjectNameAsync(defaultName, cancellationToken); } + else if (!ProjectNameValidator.IsProjectNameValid(name)) + { + // Replace invalid characters with underscores + var sanitizedName = ProjectNameValidator.SanitizeProjectName(name); + _interactionService.DisplayWarning($"Project name '{name}' contains invalid characters. Using '{sanitizedName}' instead."); + name = sanitizedName; + } return name; } @@ -243,15 +250,26 @@ public virtual async Task PromptForOutputPath(string path, CancellationT public virtual async Task PromptForProjectNameAsync(string defaultName, CancellationToken cancellationToken) { - return await interactionService.PromptForStringAsync( + var name = await interactionService.PromptForStringAsync( "Enter the project name:", defaultValue: defaultName, - validator: (name) => { - return ProjectNameValidator.IsProjectNameValid(name) - ? ValidationResult.Success() - : ValidationResult.Error("Invalid project name."); + validator: (name) => + { + if (!ProjectNameValidator.IsProjectNameValid(name)) + { + var sanitizedName = ProjectNameValidator.SanitizeProjectName(name); + interactionService.DisplayWarning($"Invalid project name '{name}'. It will be modified to '{sanitizedName}'."); + } + return ValidationResult.Success(); }, cancellationToken: cancellationToken); + + if (!ProjectNameValidator.IsProjectNameValid(name)) + { + name = ProjectNameValidator.SanitizeProjectName(name); + } + + return name; } public virtual async Task<(string TemplateName, string TemplateDescription, Func PathDeriver)> PromptForTemplateAsync((string TemplateName, string TemplateDescription, Func PathDeriver)[] validTemplates, CancellationToken cancellationToken) @@ -267,12 +285,27 @@ public virtual async Task PromptForProjectNameAsync(string defaultName, internal static partial class ProjectNameValidator { - [GeneratedRegex(@"^[a-zA-Z0-9_][a-zA-Z0-9_.]{0,253}[a-zA-Z0-9_]$", RegexOptions.Compiled)] + [GeneratedRegex(@"^[a-zA-Z0-9_][a-zA-Z0-9_.]{0,98}[a-zA-Z0-9_]?$", RegexOptions.Compiled)] internal static partial Regex GetAssemblyNameRegex(); + [GeneratedRegex(@"[^a-zA-Z0-9_.]", RegexOptions.Compiled)] + internal static partial Regex GetInvalidCharsRegex(); + public static bool IsProjectNameValid(string projectName) { var regex = GetAssemblyNameRegex(); return regex.IsMatch(projectName); } -} \ No newline at end of file + + public static string SanitizeProjectName(string projectName) + { + var name = GetInvalidCharsRegex().Replace(projectName, "_"); + name = name.Substring(0, Math.Min(name.Length, 100)); + if (name[^1] == '.') + { + name = name[..^1] + "_"; + } + Debug.Assert(IsProjectNameValid(name), $"Sanitized project name '{name}' is still invalid."); + return name; + } +} diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 864348d138b..36d76af6b02 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -14,6 +14,7 @@ internal interface IInteractionService Task PromptForSelectionAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingSdkVersion); void DisplayError(string errorMessage); + void DisplayWarning(string warningMessage); void DisplayMessage(string emoji, string message); void DisplaySuccess(string message); void DisplayDashboardUrls((string BaseUrlWithLoginToken, string? CodespacesUrlWithLoginToken) dashboardUrls); diff --git a/src/Aspire.Cli/Interaction/InteractionService.cs b/src/Aspire.Cli/Interaction/InteractionService.cs index f286ab615ce..421bd1eec3e 100644 --- a/src/Aspire.Cli/Interaction/InteractionService.cs +++ b/src/Aspire.Cli/Interaction/InteractionService.cs @@ -87,6 +87,11 @@ public void DisplayError(string errorMessage) DisplayMessage("thumbs_down", $"[red bold]{errorMessage}[/]"); } + public void DisplayWarning(string warningMessage) + { + DisplayMessage("warning", $"[yellow bold]{warningMessage}[/]"); + } + public void DisplayMessage(string emoji, string message) { _ansiConsole.MarkupLine($":{emoji}: {message}"); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 55d34dca9a1..100613ae43b 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -641,6 +641,58 @@ public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode Assert.Equal(ExitCodeConstants.FailedToTrustCertificates, exitCode); } + [Fact] + public async Task NewCommand_SanitizesInvalidProjectName_WhenProvidedOnCommandLine() + { + string? sanitizedNameUsed = null; + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => { + // Set of options that we'll give when prompted. + options.NewCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + return new TestNewCommandPrompter(interactionService); + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, options, cancellationToken) => + { + var package = new NuGetPackage() + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { package } // Single package. + ); + }; + + runner.NewProjectAsyncCallback = (templateName, name, outputPath, options, cancellationToken) => + { + // This captures the sanitized name passed to the CLI + sanitizedNameUsed = name; + return 0; // Success + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("new --name Invalid@Name"); + + var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + + Assert.Equal(0, exitCode); + Assert.Equal("Invalid_Name", sanitizedNameUsed); + } + private sealed class ThrowingCertificateService : ICertificateService { public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Utils/ProjectNameValidatorTests.cs b/tests/Aspire.Cli.Tests/Utils/ProjectNameValidatorTests.cs new file mode 100644 index 00000000000..264e4e21399 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ProjectNameValidatorTests.cs @@ -0,0 +1,35 @@ +// 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.Commands; +using Xunit; + +namespace Aspire.Cli.Tests.Utils; + +public class ProjectNameValidatorTests +{ + [Theory] + [InlineData("validName", "validName")] + [InlineData("valid_name", "valid_name")] + [InlineData("valid.name", "valid.name")] + [InlineData("valid_name_1", "valid_name_1")] + [InlineData("invalid@name", "invalid_name")] + [InlineData("invalid$name", "invalid_name")] + [InlineData("invalid-name", "invalid_name")] + [InlineData("invalid+name", "invalid_name")] + [InlineData("invalid name", "invalid_name")] + [InlineData(".", "_")] + [InlineData( + "0123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790123456787901234567879", "0123456787901234567879012345678790123456787901234567879012345678790123456787901234567879012345678790")] + public void IsProjectNameValid_ReturnsExpectedResult(string projectName, string expectedSanitized) + { + // Valid names are recognized as valid + Assert.Equal(projectName == expectedSanitized, ProjectNameValidator.IsProjectNameValid(projectName)); + + // Sanitized names are recognized as valid + Assert.True(ProjectNameValidator.IsProjectNameValid(ProjectNameValidator.SanitizeProjectName(projectName))); + + // Sanitization should produce expected result + Assert.Equal(expectedSanitized, ProjectNameValidator.SanitizeProjectName(projectName)); + } +}