diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 9cad830834c..4989454366c 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -35,7 +35,8 @@ internal interface IDotNetCliRunner Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -770,7 +771,7 @@ public async Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvoc options: options, cancellationToken: cancellationToken); } - public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); @@ -784,16 +785,23 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN if (isSingleFileAppHost) { cliArgsList.AddRange(["package", "--file", projectFilePath.FullName]); - // For single-file AppHost, use packageName@version format + // For single-file AppHost, use packageName@version format (version required) + if (string.IsNullOrEmpty(packageVersion)) + { + throw new ArgumentException("Package version is required for single-file AppHost projects.", nameof(packageVersion)); + } cliArgsList.Add($"{packageName}@{packageVersion}"); } else { cliArgsList.AddRange([projectFilePath.FullName, "package"]); - // For non single-file scenarios, use separate --version flag cliArgsList.Add(packageName); - cliArgsList.Add("--version"); - cliArgsList.Add(packageVersion); + // Only add --version if a version is specified (for CPM, version comes from Directory.Packages.props) + if (!string.IsNullOrEmpty(packageVersion)) + { + cliArgsList.Add("--version"); + cliArgsList.Add(packageVersion); + } } if (string.IsNullOrEmpty(nugetSource)) @@ -831,6 +839,51 @@ public async Task AddPackageAsync(FileInfo projectFilePath, string packageN return result; } + public async Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + using var activity = telemetry.ActivitySource.StartActivity(); + + var cliArgsList = new List(); + + // For single-file AppHost (apphost.cs), use "dotnet package remove --file " + var isSingleFileAppHost = projectFilePath.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase); + if (isSingleFileAppHost) + { + cliArgsList.AddRange(["package", "remove", "--file", projectFilePath.FullName]); + cliArgsList.Add(packageName); + } + else + { + // For regular projects, use "dotnet remove package " + cliArgsList.AddRange(["remove", projectFilePath.FullName, "package"]); + cliArgsList.Add(packageName); + } + + string[] cliArgs = [.. cliArgsList]; + + logger.LogInformation("Removing package {PackageName} from project {ProjectFilePath}", packageName, projectFilePath.FullName); + + var result = await ExecuteAsync( + args: cliArgs, + env: null, + projectFile: projectFilePath, + workingDirectory: projectFilePath.Directory!, + backchannelCompletionSource: null, + options: options, + cancellationToken: cancellationToken); + + if (result != 0) + { + logger.LogError("Failed to remove package {PackageName} from project {ProjectFilePath}. See debug logs for more details.", packageName, projectFilePath.FullName); + } + else + { + logger.LogInformation("Package {PackageName} removed from project {ProjectFilePath}", packageName, projectFilePath.FullName); + } + + return result; + } + public async Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.ActivitySource.StartActivity(); diff --git a/src/Aspire.Cli/Packaging/PackageMigration.cs b/src/Aspire.Cli/Packaging/PackageMigration.cs new file mode 100644 index 00000000000..5ea6fcaec92 --- /dev/null +++ b/src/Aspire.Cli/Packaging/PackageMigration.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Semver; + +namespace Aspire.Cli.Packaging; + +/// +/// Provides package migration information for upgrading or downgrading packages +/// when switching between different Aspire versions. +/// +internal interface IPackageMigration +{ + /// + /// Gets the replacement package ID for a package when migrating to a specific target Aspire hosting version. + /// + /// The target Aspire hosting SDK version being migrated to. + /// The package ID to check for migration. + /// + /// The replacement package ID if a migration rule exists for the given package and version; + /// null if no migration is needed for this package. + /// + /// Thrown when multiple migration rules match for the same package. + string? GetMigration(SemVersion targetHostingVersion, string packageId); +} + +/// +/// Implementation of that provides package migration rules +/// for migrating packages between different Aspire versions. +/// +internal sealed class PackageMigration : IPackageMigration +{ + private readonly ILogger _logger; + private readonly List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> _migrationRules; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public PackageMigration(ILogger logger) + { + _logger = logger; + _migrationRules = CreateMigrationRules(); + } + + /// + /// Creates the list of migration rules. + /// Each rule contains a version predicate, the source package ID, and the target package ID. + /// Rules are evaluated based on the target hosting version. + /// + private static List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> CreateMigrationRules() + { + // Migration rules are defined here. Each rule specifies: + // - A predicate that determines if the rule applies based on the target version + // - The package ID being migrated from + // - The package ID being migrated to + // + // Both upwards (stable -> daily) and downwards (daily -> stable) migrations should be defined + // to support users switching between channels. + // + // Example migration rules (placeholder - replace with actual migrations): + // - When upgrading to version >= 10.0.0, migrate Aspire.Hosting.OldPackage to Aspire.Hosting.NewPackage + // - When downgrading to version < 10.0.0, migrate Aspire.Hosting.NewPackage to Aspire.Hosting.OldPackage + + var version13 = SemVersion.Parse("13.0.0", SemVersionStyles.Strict); + + return + [ + // Aspire.Hosting.NodeJs was renamed to Aspire.Hosting.JavaScript in 13.0.0 + (v => v.ComparePrecedenceTo(version13) >= 0, "Aspire.Hosting.NodeJs", "Aspire.Hosting.JavaScript"), + (v => v.ComparePrecedenceTo(version13) >= 0, "CommunityToolkit.Aspire.Hosting.NodeJs.Extensions", "CommunityToolkit.Aspire.Hosting.JavaScript.Extensions"), + (v => v.ComparePrecedenceTo(version13) < 0, "Aspire.Hosting.JavaScript", "Aspire.Hosting.NodeJs"), + (v => v.ComparePrecedenceTo(version13) < 0, "CommunityToolkit.Aspire.Hosting.JavaScript.Extensions", "CommunityToolkit.Aspire.Hosting.NodeJs.Extensions") + ]; + } + + /// + public string? GetMigration(SemVersion targetHostingVersion, string packageId) + { + _logger.LogDebug("Checking migration rules for package '{PackageId}' targeting version '{TargetVersion}'", packageId, targetHostingVersion); + + // Find all matching rules for the given package ID and version + var matchingRules = _migrationRules + .Where(rule => string.Equals(rule.FromPackageId, packageId, StringComparison.OrdinalIgnoreCase) + && rule.VersionPredicate(targetHostingVersion)) + .ToList(); + + if (matchingRules.Count == 0) + { + _logger.LogDebug("No migration rules found for package '{PackageId}'", packageId); + return null; + } + + if (matchingRules.Count > 1) + { + var toPackages = string.Join(", ", matchingRules.Select(r => r.ToPackageId)); + _logger.LogError( + "Multiple migration rules found for package '{PackageId}' at version '{TargetVersion}'. Target packages: {ToPackages}", + packageId, targetHostingVersion, toPackages); + + throw new PackageMigrationException( + $"Multiple migration rules match for package '{packageId}' at version '{targetHostingVersion}'. " + + $"This is a configuration error. Matching target packages: {toPackages}"); + } + + var matchingRule = matchingRules[0]; + _logger.LogInformation( + "Migration rule found: '{FromPackageId}' -> '{ToPackageId}' for target version '{TargetVersion}'", + matchingRule.FromPackageId, matchingRule.ToPackageId, targetHostingVersion); + + return matchingRule.ToPackageId; + } +} + +/// +/// Exception thrown when there is an error in package migration processing. +/// +internal sealed class PackageMigrationException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public PackageMigrationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public PackageMigrationException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index bd763009c46..b32b2852221 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -167,6 +167,7 @@ private static async Task BuildApplicationAsync(string[] args) builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 12622e530fe..55347bd5c82 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -23,7 +23,7 @@ internal interface IProjectUpdater Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default); } -internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater +internal sealed partial class ProjectUpdater(ILogger logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser, IPackageMigration packageMigration) : IProjectUpdater { public async Task UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default) { @@ -42,7 +42,7 @@ public async Task UpdateProjectAsync(FileInfo projectFile, // Group update steps by project for better visual organization var updateStepsByProject = updateSteps - .OfType() + .OfType() .GroupBy(step => step.ProjectFile.FullName) .ToList(); @@ -258,6 +258,13 @@ private async Task AnalyzeAppHostSdkAsync(UpdateContext context, CancellationTok var latestSdkPackage = await GetLatestVersionOfPackageAsync(context, "Aspire.AppHost.Sdk", cancellationToken); + // Set the target hosting version in the context for package migration decisions + if (latestSdkPackage is not null && SemVersion.TryParse(latestSdkPackage.Version, SemVersionStyles.Strict, out var targetVersion)) + { + context.TargetHostingVersion = targetVersion; + logger.LogDebug("Target hosting version set to: {TargetVersion}", targetVersion); + } + // Treat unparseable versions (including range expressions) like wildcards - always update them // Only skip if the version is a valid semantic version that matches the latest if (!string.IsNullOrEmpty(sdkVersion) && IsValidSemanticVersion(sdkVersion) && sdkVersion == latestSdkPackage?.Version) @@ -441,7 +448,7 @@ private async Task AnalyzeProjectAsync(FileInfo projectFile, UpdateContext conte private static bool IsUpdatablePackage(string packageId) { - return packageId.StartsWith("Aspire."); + return packageId.StartsWith("Aspire.") || packageId.StartsWith("CommunityToolkit.Aspire."); } private static CentralPackageManagementInfo DetectCentralPackageManagement(FileInfo projectFile) @@ -461,6 +468,22 @@ private static CentralPackageManagementInfo DetectCentralPackageManagement(FileI private async Task AnalyzePackageForTraditionalManagementAsync(string packageId, string packageVersion, FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken) { + // Check if this package needs migration to a different package + if (context.TargetHostingVersion is not null) + { + var migrationTarget = packageMigration.GetMigration(context.TargetHostingVersion, packageId); + if (migrationTarget is not null) + { + logger.LogInformation("Migration detected: '{FromPackage}' -> '{ToPackage}' for target version '{TargetVersion}'", + packageId, migrationTarget, context.TargetHostingVersion); + + // Add steps to remove old package and add new package + await AddMigrationStepsForTraditionalManagementAsync( + packageId, packageVersion, migrationTarget, projectFile, context, cancellationToken); + return; + } + } + var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken); // Treat unparseable versions (including range expressions) like wildcards - always update them @@ -481,6 +504,29 @@ private async Task AnalyzePackageForTraditionalManagementAsync(string packageId, context.UpdateSteps.Enqueue(updateStep); } + private async Task AddMigrationStepsForTraditionalManagementAsync( + string fromPackageId, string fromVersion, string toPackageId, + FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken) + { + // Get the latest version of the target package + var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken); + + // Create a single migration step that removes the old package and adds the new one + var migrationStep = new PackageMigrationStep( + string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.MigratePackageFormat, fromPackageId, toPackageId), + async () => + { + await RemovePackageFromProject(projectFile, fromPackageId, cancellationToken); + await UpdatePackageReferenceInProject(projectFile, latestTargetPackage!, cancellationToken); + }, + fromPackageId, + fromVersion, + toPackageId, + latestTargetPackage!.Version, + projectFile); + context.UpdateSteps.Enqueue(migrationStep); + } + private async Task AnalyzePackageForCentralPackageManagementAsync(string packageId, FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken) { var currentVersion = await GetPackageVersionFromDirectoryPackagesPropsAsync(packageId, directoryPackagesPropsFile, projectFile, cancellationToken); @@ -491,6 +537,22 @@ private async Task AnalyzePackageForCentralPackageManagementAsync(string package return; } + // Check if this package needs migration to a different package + if (context.TargetHostingVersion is not null) + { + var migrationTarget = packageMigration.GetMigration(context.TargetHostingVersion, packageId); + if (migrationTarget is not null) + { + logger.LogInformation("Migration detected: '{FromPackage}' -> '{ToPackage}' for target version '{TargetVersion}'", + packageId, migrationTarget, context.TargetHostingVersion); + + // Add steps to remove old package and add new package + await AddMigrationStepsForCentralPackageManagementAsync( + packageId, currentVersion, migrationTarget, projectFile, directoryPackagesPropsFile, context, cancellationToken); + return; + } + } + var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken); // Treat unparseable versions (including range expressions) like wildcards - always update them @@ -511,6 +573,104 @@ private async Task AnalyzePackageForCentralPackageManagementAsync(string package context.UpdateSteps.Enqueue(updateStep); } + private async Task AddMigrationStepsForCentralPackageManagementAsync( + string fromPackageId, string fromVersion, string toPackageId, + FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken) + { + // Get the latest version of the target package + var latestTargetPackage = await GetLatestVersionOfPackageAsync(context, toPackageId, cancellationToken); + + // Create a single migration step that removes the old package and adds the new one + // For CPM, we need to update both Directory.Packages.props AND the project file's PackageReference + var migrationStep = new PackageMigrationStep( + string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.MigratePackageFormat, fromPackageId, toPackageId), + async () => + { + // Update Directory.Packages.props + await RemovePackageFromDirectoryPackagesProps(fromPackageId, directoryPackagesPropsFile); + await AddPackageToDirectoryPackagesProps(toPackageId, latestTargetPackage!.Version, directoryPackagesPropsFile); + + // Update the project file's PackageReference + await RemovePackageFromProject(projectFile, fromPackageId, cancellationToken); + await AddPackageReferenceToProject(projectFile, toPackageId, cancellationToken); + }, + fromPackageId, + fromVersion, + toPackageId, + latestTargetPackage!.Version, + projectFile); + context.UpdateSteps.Enqueue(migrationStep); + } + + private async Task RemovePackageFromProject(FileInfo projectFile, string packageId, CancellationToken cancellationToken) + { + return await runner.RemovePackageAsync(projectFile, packageId, new(), cancellationToken); + } + + private async Task AddPackageReferenceToProject(FileInfo projectFile, string packageId, CancellationToken cancellationToken) + { + // For CPM projects, we add a PackageReference without a version (version comes from Directory.Packages.props) + var exitCode = await runner.AddPackageAsync( + projectFilePath: projectFile, + packageName: packageId, + packageVersion: null, + nugetSource: null, + options: new(), + cancellationToken: cancellationToken); + + if (exitCode != 0) + { + throw new ProjectUpdaterException(string.Format(CultureInfo.InvariantCulture, UpdateCommandStrings.FailedUpdatePackageReferenceFormat, packageId, projectFile.FullName)); + } + } + + private static Task RemovePackageFromDirectoryPackagesProps(string packageId, FileInfo directoryPackagesPropsFile) + { + var doc = new XmlDocument { PreserveWhitespace = true }; + doc.Load(directoryPackagesPropsFile.FullName); + + var packageVersionNode = doc.SelectSingleNode($"/Project/ItemGroup/PackageVersion[@Include='{packageId}']"); + if (packageVersionNode?.ParentNode is not null) + { + packageVersionNode.ParentNode.RemoveChild(packageVersionNode); + doc.Save(directoryPackagesPropsFile.FullName); + } + + return Task.CompletedTask; + } + + private Task AddPackageToDirectoryPackagesProps(string packageId, string version, FileInfo directoryPackagesPropsFile) + { + var doc = new XmlDocument { PreserveWhitespace = true }; + doc.Load(directoryPackagesPropsFile.FullName); + + // Find the ItemGroup that contains PackageVersion elements + var itemGroup = doc.SelectSingleNode("/Project/ItemGroup[PackageVersion]"); + if (itemGroup is null) + { + // Create a new ItemGroup if one doesn't exist + var project = doc.SelectSingleNode("/Project"); + if (project is null) + { + logger.LogWarning("Could not find element in {DirectoryPackagesPropsFile}. Package '{PackageId}' will not be added.", + directoryPackagesPropsFile.FullName, packageId); + return Task.CompletedTask; + } + + itemGroup = doc.CreateElement("ItemGroup"); + project.AppendChild(itemGroup); + } + + // Create the new PackageVersion element + var packageVersionElement = doc.CreateElement("PackageVersion"); + packageVersionElement.SetAttribute("Include", packageId); + packageVersionElement.SetAttribute("Version", version); + itemGroup.AppendChild(packageVersionElement); + + doc.Save(directoryPackagesPropsFile.FullName); + return Task.CompletedTask; + } + private async Task GetPackageVersionFromDirectoryPackagesPropsAsync(string packageId, FileInfo directoryPackagesPropsFile, FileInfo projectFile, CancellationToken cancellationToken) { try @@ -884,6 +1044,11 @@ internal sealed class UpdateContext(FileInfo appHostProjectFile, PackageChannel public ConcurrentQueue AnalyzeSteps { get; } = new(); public HashSet VisitedProjects { get; } = new(); public bool FallbackParsing { get; set; } + /// + /// The target Aspire Hosting SDK version being updated to. + /// This is set during SDK analysis and used for package migration decisions. + /// + public SemVersion? TargetHostingVersion { get; set; } } internal abstract record UpdateStep(string Description, Func Callback) @@ -894,6 +1059,14 @@ internal abstract record UpdateStep(string Description, Func Callback) public virtual string GetFormattedDisplayText() => Description; } +/// +/// Represents an update step associated with a specific project file. +/// +internal abstract record ProjectUpdateStep( + string Description, + Func Callback, + FileInfo ProjectFile) : UpdateStep(Description, Callback); + /// /// Represents an update step for a package reference, containing package and project information. /// @@ -903,7 +1076,7 @@ internal record PackageUpdateStep( string PackageId, string CurrentVersion, string NewVersion, - FileInfo ProjectFile) : UpdateStep(Description, Callback) + FileInfo ProjectFile) : ProjectUpdateStep(Description, Callback, ProjectFile) { public override string GetFormattedDisplayText() { @@ -911,6 +1084,24 @@ public override string GetFormattedDisplayText() } } +/// +/// Represents a migration step that replaces one package with another during package migration. +/// +internal record PackageMigrationStep( + string Description, + Func Callback, + string FromPackageId, + string FromVersion, + string ToPackageId, + string ToVersion, + FileInfo ProjectFile) : ProjectUpdateStep(Description, Callback, ProjectFile) +{ + public override string GetFormattedDisplayText() + { + return $"[bold yellow]{FromPackageId}[/] [bold green]{FromVersion.EscapeMarkup()}[/] to [bold yellow]{ToPackageId}[/] [bold green]{ToVersion.EscapeMarkup()}[/]"; + } +} + internal record AnalyzeStep(string Description, Func Callback); internal sealed class ProjectUpdaterException : System.Exception diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index 11324520d48..c0fb9742c87 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -105,6 +105,9 @@ internal static string ProjectArgumentDescription { internal static string ChannelOptionDescriptionWithStaging => ResourceManager.GetString("ChannelOptionDescriptionWithStaging", resourceCulture); internal static string QualityOptionDescription => ResourceManager.GetString("QualityOptionDescription", resourceCulture); internal static string QualityOptionDescriptionWithStaging => ResourceManager.GetString("QualityOptionDescriptionWithStaging", resourceCulture); - internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture); + internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture)!; + internal static string RemovePackageFormat => ResourceManager.GetString("RemovePackageFormat", resourceCulture)!; + internal static string AddPackageFormat => ResourceManager.GetString("AddPackageFormat", resourceCulture)!; + internal static string MigratePackageFormat => ResourceManager.GetString("MigratePackageFormat", resourceCulture)!; } } diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 2b40fb44d18..541ddb549c0 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -135,4 +135,13 @@ To update the Aspire CLI when installed as a .NET tool, run: + + Remove package {0} + + + Add package {0} version {1} + + + Migrate package {0} to {1} + diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index 98a9616bf3e..d50573d81b1 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Přidáno: {0} @@ -112,6 +117,11 @@ Mapování: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Nenašel se žádný projekt Aspire AppHost. Chcete místo toho aktualizovat rozhraní CLI Aspire? @@ -167,6 +177,11 @@ Úroveň kvality, na kterou se má aktualizovat (stabilní, příprava, denní) + + Remove package {0} + Remove package {0} + + Removed: {0} Odebráno: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index 2908cd3090b..3a6e9d62409 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Hinzugefügt: {0} @@ -112,6 +117,11 @@ Zuordnung: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Es wurde kein Aspire-AppHost-Projekt gefunden. Möchten Sie stattdessen die Aspire-CLI aktualisieren? @@ -167,6 +177,11 @@ Qualitätsstufe, auf die aktualisiert werden soll (stable, staging, daily) + + Remove package {0} + Remove package {0} + + Removed: {0} Entfernt: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index 4412cf5c975..52916e92e19 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Agregado: {0} @@ -112,6 +117,11 @@ Asignación: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? No se encontró ningún proyecto de AppHost. ¿Quiere actualizar la CLI de Aspire en su lugar? @@ -167,6 +177,11 @@ Nivel de calidad al que se va a actualizar (estable, de ensayo, diario) + + Remove package {0} + Remove package {0} + + Removed: {0} Quitado: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index 333130a4982..b125dee1c77 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Ajouté : {0} @@ -112,6 +117,11 @@ Mappage : {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Aucun projet Aspire AppHost trouvé. Voulez-vous plutôt mettre à jour l’interface CLI Aspire ? @@ -167,6 +177,11 @@ Niveau de qualité vers lequel effectuer une mise à jour (stable, gestion intermédiaire, quotidien) + + Remove package {0} + Remove package {0} + + Removed: {0} Supprimé : {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index ce822b23102..3949881fbec 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Aggiunto: {0} @@ -112,6 +117,11 @@ Mapping: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Nessun progetto Aspire AppHost trovato. Aggiornare invece l'interfaccia della riga di comando Aspire? @@ -167,6 +177,11 @@ Livello di qualità da aggiornare a (stabile, staging, giornaliero) + + Remove package {0} + Remove package {0} + + Removed: {0} Rimosso: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index fad84c2aeca..8c4ee6dfde8 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} 追加済み: {0} @@ -112,6 +117,11 @@ マッピング: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Aspire AppHost プロジェクトが見つかりませんでした。代わりに Aspire CLI を更新しますか? @@ -167,6 +177,11 @@ 更新先の品質レベル (安定、ステージング、毎日) + + Remove package {0} + Remove package {0} + + Removed: {0} 削除済み: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index f13f9893e70..9f051698439 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} 추가됨: {0} @@ -112,6 +117,11 @@ 매핑: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Azure AppHost 프로젝트를 찾을 수 없습니다. 대신 Aspire CLI를 업데이트하시겠어요? @@ -167,6 +177,11 @@ 업데이트할 품질 수준(안정, 스테이징, 데일리) + + Remove package {0} + Remove package {0} + + Removed: {0} 제거됨: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index 62293be966c..05b929ff6c0 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Dodano: {0} @@ -112,6 +117,11 @@ Mapowanie: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Nie znaleziono projektu Aspire AppHost. Czy chcesz zamiast tego zaktualizować narzędzie Aspire CLI? @@ -167,6 +177,11 @@ Poziom jakości, do którego należy zaktualizować (stabilny, przejściowy, dzienny) + + Remove package {0} + Remove package {0} + + Removed: {0} Usunięto: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index bfc7e7358b7..d42833d8ef6 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Adicionado: {0} @@ -112,6 +117,11 @@ Mapeamento: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Não foram encontrados projetos AppHost do Aspire. Você quer atualizar a CLI do Aspire em vez disso? @@ -167,6 +177,11 @@ Nível de qualidade para o qual atualizar (estável, preparo, diariamente) + + Remove package {0} + Remove package {0} + + Removed: {0} Removido: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index 0b96df9baee..8394b9b3f82 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Добавлено: {0} @@ -112,6 +117,11 @@ Сопоставление: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Проект Aspire AppHost не найден. Хотите вместо этого обновить Aspire CLI? @@ -167,6 +177,11 @@ Уровень качества для обновления (стабильный, промежуточный, ежедневный) + + Remove package {0} + Remove package {0} + + Removed: {0} Удалено: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index 6c9851d5974..d67d8d6f60d 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} Eklendi: {0} @@ -112,6 +117,11 @@ Eşleme: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? Aspire AppHost projesi bulunamadı. Bunun yerine Aspire CLI'yı güncelleştirmek ister misiniz? @@ -167,6 +177,11 @@ Güncelleştirilecek kalite seviyesi (kararlı, hazırlama, günlük) + + Remove package {0} + Remove package {0} + + Removed: {0} Kaldırıldı: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index b29ce7b9284..ca900fdb01a 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} 已添加: {0} @@ -112,6 +117,11 @@ 映射: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? 找不到 Aspire AppHost 项目。是否改为更新 Aspire CLI? @@ -167,6 +177,11 @@ 要更新到的质量级别(稳定、暂存、每日) + + Remove package {0} + Remove package {0} + + Removed: {0} 已移除: {0} diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index 2be7eaa110a..466d3e616f0 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -2,6 +2,11 @@ + + Add package {0} version {1} + Add package {0} version {1} + + Added: {0} 已新增: {0} @@ -112,6 +117,11 @@ 對應: {0} + + Migrate package {0} to {1} + Migrate package {0} to {1} + + No Aspire AppHost project found. Would you like to update the Aspire CLI instead? 找不到任何 Aspire AppHost 專案。您是否要改為更新 Aspire CLI? @@ -167,6 +177,11 @@ 要更新的品質等級 (穩定、暫存、每日) + + Remove package {0} + Remove package {0} + + Removed: {0} 已移除: {0} diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs new file mode 100644 index 00000000000..cdbfb4b2d01 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Packaging/PackageMigrationTests.cs @@ -0,0 +1,477 @@ +// 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.Packaging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Semver; + +namespace Aspire.Cli.Tests.Packaging; + +public class PackageMigrationTests +{ + private static ILogger CreateTestLogger() + { + return NullLogger.Instance; + } + + /// + /// Helper method to create a version predicate for versions >= threshold + /// + private static Func VersionAtLeast(string threshold) + { + var thresholdVersion = SemVersion.Parse(threshold, SemVersionStyles.Strict); + return v => SemVersion.ComparePrecedence(v, thresholdVersion) >= 0; + } + + /// + /// Helper method to create a version predicate for versions < threshold + /// + private static Func VersionBelow(string threshold) + { + var thresholdVersion = SemVersion.Parse(threshold, SemVersionStyles.Strict); + return v => SemVersion.ComparePrecedence(v, thresholdVersion) < 0; + } + + /// + /// Helper method to create a version predicate for versions in a range [min, max) + /// + private static Func VersionInRange(string minInclusive, string maxExclusive) + { + var minVersion = SemVersion.Parse(minInclusive, SemVersionStyles.Strict); + var maxVersion = SemVersion.Parse(maxExclusive, SemVersionStyles.Strict); + return v => SemVersion.ComparePrecedence(v, minVersion) >= 0 + && SemVersion.ComparePrecedence(v, maxVersion) < 0; + } + + #region GetMigration - Basic functionality tests + + [Fact] + public void GetMigration_WhenNoMatchingRule_ReturnsNull() + { + // Arrange + var migration = new PackageMigration(CreateTestLogger()); + var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NonExistentPackage"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetMigration_WhenPackageIdDoesNotExist_ReturnsNull() + { + // Arrange + var migration = new PackageMigration(CreateTestLogger()); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "SomeOtherPackage.NotAspire"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetMigration_WithEmptyPackageId_ReturnsNull() + { + // Arrange + var migration = new PackageMigration(CreateTestLogger()); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, string.Empty); + + // Assert + Assert.Null(result); + } + + #endregion + + #region GetMigration - Version matching tests + + [Fact] + public void GetMigration_WithPrereleaseVersion_EvaluatesPredicateCorrectly() + { + // Arrange + var migration = new PackageMigration(CreateTestLogger()); + var prereleaseVersion = SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict); + + // Act - testing that prerelease versions are handled without throwing + var result = migration.GetMigration(prereleaseVersion, "Aspire.Hosting.SomePackage"); + + // Assert + Assert.Null(result); // No migration rules defined, so should be null + } + + [Fact] + public void GetMigration_WithDailyBuildVersion_EvaluatesPredicateCorrectly() + { + // Arrange + var migration = new PackageMigration(CreateTestLogger()); + var dailyVersion = SemVersion.Parse("10.0.0-daily.12345.1", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(dailyVersion, "Aspire.Hosting.SomePackage"); + + // Assert + Assert.Null(result); // No migration rules defined, so should be null + } + + #endregion + + #region GetMigration - Case insensitivity tests + + [Fact] + public void GetMigration_PackageIdComparison_IsCaseInsensitive() + { + // Arrange - Using a testable migration service with predefined rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var lowerCaseResult = migration.GetMigration(targetVersion, "aspire.hosting.oldpackage"); + var upperCaseResult = migration.GetMigration(targetVersion, "ASPIRE.HOSTING.OLDPACKAGE"); + var mixedCaseResult = migration.GetMigration(targetVersion, "Aspire.Hosting.OldPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.NewPackage", lowerCaseResult); + Assert.Equal("Aspire.Hosting.NewPackage", upperCaseResult); + Assert.Equal("Aspire.Hosting.NewPackage", mixedCaseResult); + } + + #endregion + + #region Upward migration tests (stable -> daily/preview) + + [Fact] + public void GetMigration_UpwardMigration_WhenTargetVersionMeetsThreshold_ReturnsMigratedPackage() + { + // Arrange - Simulating an upgrade scenario + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.ModernPackage", result); + } + + [Fact] + public void GetMigration_UpwardMigration_WhenTargetVersionExceedsThreshold_ReturnsMigratedPackage() + { + // Arrange - Simulating an upgrade scenario with version beyond threshold + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage") + ]); + var targetVersion = SemVersion.Parse("10.5.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.ModernPackage", result); + } + + [Fact] + public void GetMigration_UpwardMigration_WhenTargetVersionBelowThreshold_ReturnsNull() + { + // Arrange - Simulating scenario where version doesn't meet threshold + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.LegacyPackage", "Aspire.Hosting.ModernPackage") + ]); + var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.LegacyPackage"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Downward migration tests (daily/preview -> stable) + + [Fact] + public void GetMigration_DownwardMigration_WhenTargetVersionBelowThreshold_ReturnsMigratedPackage() + { + // Arrange - Simulating a downgrade scenario (daily -> stable) + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage") + ]); + var targetVersion = SemVersion.Parse("9.5.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NewPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.OldPackage", result); + } + + [Fact] + public void GetMigration_DownwardMigration_WhenTargetVersionMeetsThreshold_ReturnsNull() + { + // Arrange - Simulating scenario where version doesn't require downgrade migration + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var result = migration.GetMigration(targetVersion, "Aspire.Hosting.NewPackage"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region Bidirectional migration tests + + [Fact] + public void GetMigration_BidirectionalMigrations_UpwardMigrationWorks() + { + // Arrange - Setup bidirectional migration rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + // Upward migration: when upgrading to 10.0.0+, migrate OldPackage -> NewPackage + (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"), + // Downward migration: when downgrading to < 10.0.0, migrate NewPackage -> OldPackage + (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage") + ]); + + // Act - Test upward migration + var upgradeResult = migration.GetMigration( + SemVersion.Parse("10.0.0", SemVersionStyles.Strict), + "Aspire.Hosting.OldPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.NewPackage", upgradeResult); + } + + [Fact] + public void GetMigration_BidirectionalMigrations_DownwardMigrationWorks() + { + // Arrange - Setup bidirectional migration rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + // Upward migration + (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"), + // Downward migration + (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage") + ]); + + // Act - Test downward migration + var downgradeResult = migration.GetMigration( + SemVersion.Parse("9.5.0", SemVersionStyles.Strict), + "Aspire.Hosting.NewPackage"); + + // Assert + Assert.Equal("Aspire.Hosting.OldPackage", downgradeResult); + } + + [Fact] + public void GetMigration_BidirectionalMigrations_SamePackageNoMigrationNeeded() + { + // Arrange - Setup bidirectional migration rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + // Upward migration + (VersionAtLeast("10.0.0"), "Aspire.Hosting.OldPackage", "Aspire.Hosting.NewPackage"), + // Downward migration + (VersionBelow("10.0.0"), "Aspire.Hosting.NewPackage", "Aspire.Hosting.OldPackage") + ]); + + // Act - Old package at old version - no migration needed + var oldPackageAtOldVersion = migration.GetMigration( + SemVersion.Parse("9.5.0", SemVersionStyles.Strict), + "Aspire.Hosting.OldPackage"); + + // Act - New package at new version - no migration needed + var newPackageAtNewVersion = migration.GetMigration( + SemVersion.Parse("10.0.0", SemVersionStyles.Strict), + "Aspire.Hosting.NewPackage"); + + // Assert + Assert.Null(oldPackageAtOldVersion); + Assert.Null(newPackageAtNewVersion); + } + + #endregion + + #region Multiple rule match error tests + + [Fact] + public void GetMigration_WhenMultipleRulesMatch_ThrowsPackageMigrationException() + { + // Arrange - Setup conflicting rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.SomePackage", "Aspire.Hosting.TargetA"), + (VersionAtLeast("9.5.0"), "Aspire.Hosting.SomePackage", "Aspire.Hosting.TargetB") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act & Assert + var exception = Assert.Throws(() => + migration.GetMigration(targetVersion, "Aspire.Hosting.SomePackage")); + + Assert.Contains("Multiple migration rules match", exception.Message); + Assert.Contains("Aspire.Hosting.SomePackage", exception.Message); + Assert.Contains("10.0.0", exception.Message); + } + + [Fact] + public void GetMigration_WhenMultipleRulesMatch_ExceptionIncludesTargetPackages() + { + // Arrange - Setup conflicting rules + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (_ => true, "Aspire.Hosting.ConflictingPackage", "Aspire.Hosting.Target1"), + (_ => true, "Aspire.Hosting.ConflictingPackage", "Aspire.Hosting.Target2") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act & Assert + var exception = Assert.Throws(() => + migration.GetMigration(targetVersion, "Aspire.Hosting.ConflictingPackage")); + + Assert.Contains("Target1", exception.Message); + Assert.Contains("Target2", exception.Message); + } + + #endregion + + #region Complex version predicate tests + + [Fact] + public void GetMigration_WithVersionRangePredicate_MatchesCorrectly() + { + // Arrange - Setup rule that only applies to specific version range [10.0.0, 11.0.0) + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionInRange("10.0.0", "11.0.0"), "Aspire.Hosting.RangePackage", "Aspire.Hosting.RangeTarget") + ]); + + // Act + var belowRange = migration.GetMigration(SemVersion.Parse("9.5.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage"); + var inRange = migration.GetMigration(SemVersion.Parse("10.5.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage"); + var aboveRange = migration.GetMigration(SemVersion.Parse("11.0.0", SemVersionStyles.Strict), "Aspire.Hosting.RangePackage"); + + // Assert + Assert.Null(belowRange); + Assert.Equal("Aspire.Hosting.RangeTarget", inRange); + Assert.Null(aboveRange); + } + + [Fact] + public void GetMigration_WithPrereleaseVersionPredicate_MatchesCorrectly() + { + // Arrange - Setup rule that includes prerelease versions + var previewThreshold = SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict); + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (v => SemVersion.ComparePrecedence(v, previewThreshold) >= 0, + "Aspire.Hosting.PreviewPackage", "Aspire.Hosting.PreviewTarget") + ]); + + // Act + var stableBelow = migration.GetMigration(SemVersion.Parse("9.5.0", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage"); + var previewMatch = migration.GetMigration(SemVersion.Parse("10.0.0-preview.1", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage"); + var previewAbove = migration.GetMigration(SemVersion.Parse("10.0.0-preview.5", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage"); + var stableAbove = migration.GetMigration(SemVersion.Parse("10.0.0", SemVersionStyles.Strict), "Aspire.Hosting.PreviewPackage"); + + // Assert + Assert.Null(stableBelow); + Assert.Equal("Aspire.Hosting.PreviewTarget", previewMatch); + Assert.Equal("Aspire.Hosting.PreviewTarget", previewAbove); + Assert.Equal("Aspire.Hosting.PreviewTarget", stableAbove); + } + + #endregion + + #region Multiple packages migration tests + + [Fact] + public void GetMigration_WithMultiplePackageMigrations_EachPackageResolvesCorrectly() + { + // Arrange - Setup rules for multiple packages + var migration = new TestablePackageMigration(CreateTestLogger(), [ + (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageA", "Aspire.Hosting.NewPackageA"), + (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageB", "Aspire.Hosting.NewPackageB"), + (VersionAtLeast("10.0.0"), "Aspire.Hosting.PackageC", "Aspire.Hosting.NewPackageC") + ]); + var targetVersion = SemVersion.Parse("10.0.0", SemVersionStyles.Strict); + + // Act + var resultA = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageA"); + var resultB = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageB"); + var resultC = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageC"); + var resultUnknown = migration.GetMigration(targetVersion, "Aspire.Hosting.PackageD"); + + // Assert + Assert.Equal("Aspire.Hosting.NewPackageA", resultA); + Assert.Equal("Aspire.Hosting.NewPackageB", resultB); + Assert.Equal("Aspire.Hosting.NewPackageC", resultC); + Assert.Null(resultUnknown); + } + + #endregion + + #region Helper class for testing with custom rules + + /// + /// A testable version of PackageMigration that allows injecting custom migration rules for testing. + /// + private sealed class TestablePackageMigration : IPackageMigration + { + private readonly ILogger _logger; + private readonly List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> _migrationRules; + + public TestablePackageMigration( + ILogger logger, + List<(Func VersionPredicate, string FromPackageId, string ToPackageId)> migrationRules) + { + _logger = logger; + _migrationRules = migrationRules; + } + + public string? GetMigration(SemVersion targetHostingVersion, string packageId) + { + _logger.LogDebug("Checking migration rules for package '{PackageId}' targeting version '{TargetVersion}'", packageId, targetHostingVersion); + + var matchingRules = _migrationRules + .Where(rule => string.Equals(rule.FromPackageId, packageId, StringComparison.OrdinalIgnoreCase) + && rule.VersionPredicate(targetHostingVersion)) + .ToList(); + + if (matchingRules.Count == 0) + { + _logger.LogDebug("No migration rules found for package '{PackageId}'", packageId); + return null; + } + + if (matchingRules.Count > 1) + { + var toPackages = string.Join(", ", matchingRules.Select(r => r.ToPackageId)); + _logger.LogError( + "Multiple migration rules found for package '{PackageId}' at version '{TargetVersion}'. Target packages: {ToPackages}", + packageId, targetHostingVersion, toPackages); + + throw new PackageMigrationException( + $"Multiple migration rules match for package '{packageId}' at version '{targetHostingVersion}'. " + + $"This is a configuration error. Matching target packages: {toPackages}"); + } + + var matchingRule = matchingRules[0]; + _logger.LogInformation( + "Migration rule found: '{FromPackageId}' -> '{ToPackageId}' for target version '{TargetVersion}'", + matchingRule.FromPackageId, matchingRule.ToPackageId, targetHostingVersion); + + return matchingRule.ToPackageId; + } + } + + #endregion +} diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 3e40fb823ee..62dd01b2ed0 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -152,7 +152,7 @@ await File.WriteAllTextAsync( """); - var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>(); + var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config => { config.DotNetCliRunnerFactory = (sp) => @@ -289,7 +289,7 @@ await File.WriteAllTextAsync( """); - var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>(); + var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config => { config.DotNetCliRunnerFactory = (sp) => @@ -448,7 +448,7 @@ await File.WriteAllTextAsync( """); - var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>(); + var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config => { config.DotNetCliRunnerFactory = (sp) => @@ -1476,7 +1476,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); // Should not throw ProjectUpdaterException; should produce update steps including AppHost SDK @@ -1578,7 +1578,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); // Should discover package reference (version may be absent) and not crash @@ -1657,7 +1657,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); // Should throw ProjectUpdaterException due to invalid XML await Assert.ThrowsAsync(() => @@ -1739,7 +1739,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); // Normal path unaffected - no updates needed since version is already current @@ -1818,7 +1818,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); Assert.True(updateResult.UpdatedApplied); @@ -1901,7 +1901,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); Assert.True(updateResult.UpdatedApplied); @@ -1989,7 +1989,7 @@ await File.WriteAllTextAsync( var channels = await packagingService.GetChannelsAsync(); var selectedChannel = channels.Single(c => c.Name == "default"); - var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, new PackageMigration(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); // This should not throw and should handle the * version gracefully var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); @@ -2302,7 +2302,7 @@ await File.WriteAllTextAsync( """); - var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string PackageVersion, string? PackageSource)>(); + var packagesAddsExecuted = new List<(FileInfo ProjectFile, string PackageId, string? PackageVersion, string? PackageSource)>(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, config => { diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index f8a2cfe33f1..5fc84e657ed 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -411,7 +411,10 @@ public Task NewProjectAsync(string templateName, string projectName, string public Task BuildAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddPackageAsync(FileInfo projectFile, string packageName, string? version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + public Task RemovePackageAsync(FileInfo projectFile, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 0d4a63f112a..c644a06faa4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -11,7 +11,8 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestDotNetCliRunner : IDotNetCliRunner { - public Func? AddPackageAsyncCallback { get; set; } + public Func? AddPackageAsyncCallback { get; set; } + public Func? RemovePackageAsyncCallback { get; set; } public Func? AddProjectToSolutionAsyncCallback { get; set; } public Func? BuildAsyncCallback { get; set; } public Func? CheckHttpCertificateAsyncCallback { get; set; } @@ -26,13 +27,20 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } public Func? AddProjectReferenceAsyncCallback { get; set; } - public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task AddPackageAsync(FileInfo projectFilePath, string packageName, string? packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return AddPackageAsyncCallback != null ? Task.FromResult(AddPackageAsyncCallback(projectFilePath, packageName, packageVersion, nugetSource, options, cancellationToken)) : throw new NotImplementedException(); } + public Task RemovePackageAsync(FileInfo projectFilePath, string packageName, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + return RemovePackageAsyncCallback != null + ? Task.FromResult(RemovePackageAsyncCallback(projectFilePath, packageName, options, cancellationToken)) + : Task.FromResult(0); // If not overridden, just return success. + } + public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return AddProjectToSolutionAsyncCallback != null diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 554d9b59a58..85013933096 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -89,6 +89,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.CliUpdateNotifierFactory); services.AddSingleton(options.DotNetSdkInstallerFactory); services.AddSingleton(options.PackagingServiceFactory); + services.AddSingleton(options.PackageMigrationFactory); services.AddSingleton(options.CliExecutionContextFactory); services.AddSingleton(options.DiskCacheFactory); services.AddSingleton(options.CliHostEnvironmentFactory); @@ -239,7 +240,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var cache = serviceProvider.GetRequiredService(); var executionContext = serviceProvider.GetRequiredService(); var fallbackParser = serviceProvider.GetRequiredService(); - return new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); + var packageMigration = serviceProvider.GetRequiredService(); + return new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser, packageMigration); }; public Func CliHostEnvironmentFactory { get; set; } = (IServiceProvider serviceProvider) => @@ -340,6 +342,12 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new PackagingService(executionContext, nuGetPackageCache, features, configuration); }; + public Func PackageMigrationFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var logger = serviceProvider.GetRequiredService>(); + return new PackageMigration(logger); + }; + public Func DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache(); public Func CliDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) =>