File: Projects\ProjectUpdater.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.Collections.Concurrent;
using System.Text.Json;
using System.Xml;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Shared;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Semver;
 
namespace Aspire.Cli.Projects;
 
internal interface IProjectUpdater
{
    Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default);
}
 
internal sealed class ProjectUpdater(ILogger<ProjectUpdater> logger, IDotNetCliRunner runner, IInteractionService interactionService, IMemoryCache cache, CliExecutionContext executionContext, FallbackProjectParser fallbackParser) : IProjectUpdater
{
    public async Task<ProjectUpdateResult> UpdateProjectAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken = default)
    {
        logger.LogDebug("Fetching '{AppHostPath}' items and properties.", projectFile.FullName);
 
        var (updateSteps, fallbackUsed) = await interactionService.ShowStatusAsync(UpdateCommandStrings.AnalyzingProjectStatus, () => GetUpdateStepsAsync(projectFile, channel, cancellationToken));
 
        if (!updateSteps.Any())
        {
            logger.LogInformation("No updates required for project: {ProjectFile}", projectFile.FullName);
            interactionService.DisplayMessage("check_mark", UpdateCommandStrings.ProjectUpToDateMessage);
            return new ProjectUpdateResult { UpdatedApplied = false };
        }
 
        interactionService.DisplayEmptyLine();
 
        // Group update steps by project for better visual organization
        var updateStepsByProject = updateSteps
            .OfType<PackageUpdateStep>()
            .GroupBy(step => step.ProjectFile.FullName)
            .ToList();
 
        // Display package updates grouped by project
        foreach (var projectGroup in updateStepsByProject)
        {
            var projectName = new FileInfo(projectGroup.Key).Name;
            if (updateStepsByProject.Count > 1)
            {
                interactionService.DisplayMessage("file_folder", $"[bold cyan]{projectName}[/]:");
            }
 
            foreach (var packageStep in projectGroup)
            {
                interactionService.DisplayMessage("package", packageStep.GetFormattedDisplayText());
            }
 
            interactionService.DisplayEmptyLine();
        }
 
        // Display warning if fallback XML parsing was used
        if (fallbackUsed)
        {
            interactionService.DisplayMessage("warning", "[yellow]Note: Update plan generated using fallback XML parsing due to unresolvable AppHost SDK. Dependency analysis may have reduced accuracy.[/]");
            interactionService.DisplayEmptyLine();
        }
 
        if (!await interactionService.ConfirmAsync(UpdateCommandStrings.PerformUpdatesPrompt, true, cancellationToken))
        {
            return new ProjectUpdateResult { UpdatedApplied = false };
        }
 
        if (channel.Type == PackageChannelType.Explicit)
        {
            var (configPathsExitCode, configPaths) = await runner.GetNuGetConfigPathsAsync(projectFile.Directory!, new(), cancellationToken);
 
            if (configPathsExitCode != 0 || configPaths is null || configPaths.Length == 0)
            {
                throw new ProjectUpdaterException(UpdateCommandStrings.FailedDiscoverNuGetConfig);
            }
 
            var configPathDirectories = configPaths.Select(Path.GetDirectoryName).ToArray();
            var fallbackNuGetConfigDirectory = executionContext.WorkingDirectory.FullName;
 
            // If there is one or zero config paths we assume that we should use
            // the fallback (there should always be one, but just for exhaustivenss).
            // If there is more than one we just make sure that the first on in the list
            // isn't a global config (on Windows with .NET and VS installed you'll have 3
            // global config files but the first one should be the NuGet in AppData).
            // The final rule should never ever be invoked, its just to get around CS8846
            // which does not evaluate when statements for exhaustiveness.
            var recommendedNuGetConfigFileDirectory = configPathDirectories switch
            {
                { Length: 0 or 1 } => fallbackNuGetConfigDirectory,
                var p when p.Length > 1 => IsGlobalNuGetConfig(p[0]!) ? fallbackNuGetConfigDirectory : p[0],
 
                // CS8846 error if we don't put this rule here even though we do "when"
                // above - this is corner case in C# evalutation of switch statements.
                _ => throw new InvalidOperationException(UpdateCommandStrings.UnexpectedCodePath)
            };
 
            interactionService.DisplayEmptyLine();
 
            var selectedPathForNewNuGetConfigFile = await interactionService.PromptForStringAsync(
                promptText: UpdateCommandStrings.WhichDirectoryNuGetConfigPrompt,
                defaultValue: recommendedNuGetConfigFileDirectory,
                validator: null,
                isSecret: false,
                required: true,
                cancellationToken: cancellationToken);
 
            var nugetConfigDirectory = new DirectoryInfo(selectedPathForNewNuGetConfigFile);
            await NuGetConfigMerger.CreateOrUpdateAsync(nugetConfigDirectory, channel);
        }
 
        interactionService.DisplayEmptyLine();
 
        foreach (var updateStep in updateSteps)
        {
            interactionService.DisplaySubtleMessage(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.ExecutingUpdateStepFormat, updateStep.Description));
            await updateStep.Callback();
        }
 
        interactionService.DisplayEmptyLine();
 
        interactionService.DisplaySuccess(UpdateCommandStrings.UpdateSuccessfulMessage);
        return new ProjectUpdateResult { UpdatedApplied = true };
    }
 
    private static bool IsGlobalNuGetConfig(string path)
    {
        if (Environment.OSVersion.Platform == PlatformID.Win32NT)
        {
            return path.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
        }
        else
        {
            var globalNuGetFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget");
            return path.StartsWith(globalNuGetFolder);
        }
    }
 
    private async Task<(IEnumerable<UpdateStep> UpdateSteps, bool FallbackUsed)> GetUpdateStepsAsync(FileInfo projectFile, PackageChannel channel, CancellationToken cancellationToken)
    {
        var context = new UpdateContext(projectFile, channel);
 
        var appHostAnalyzeStep = new AnalyzeStep(UpdateCommandStrings.AnalyzeAppHost, () => AnalyzeAppHostAsync(context, cancellationToken));
        context.AnalyzeSteps.Enqueue(appHostAnalyzeStep);
 
        while (context.AnalyzeSteps.TryDequeue(out var analyzeStep))
        {
            await analyzeStep.Callback();
        }
 
        return (context.UpdateSteps, context.FallbackXmlParsing);
    }
 
    private const string ItemsAndPropertiesCacheKeyPrefix = "ItemsAndProperties";
 
    private async Task<JsonDocument> GetItemsAndPropertiesAsync(FileInfo projectFile, CancellationToken cancellationToken)
    {
        return await GetItemsAndPropertiesAsync(projectFile, ["PackageReference", "ProjectReference"], ["AspireHostingSDKVersion"], cancellationToken);
    }
 
    private async Task<JsonDocument> GetItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, CancellationToken cancellationToken)
    {
        // Create a cache key that includes the project file and the requested items/properties
        var itemsKey = string.Join(",", items.OrderBy(x => x));
        var propertiesKey = string.Join(",", properties.OrderBy(x => x));
        var cacheKey = $"{ItemsAndPropertiesCacheKeyPrefix}_{projectFile.FullName}_{itemsKey}_{propertiesKey}";
        
        var (exitCode, document) = await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            return await runner.GetProjectItemsAndPropertiesAsync(projectFile, items, properties, new(), cancellationToken);
        });
 
        if (exitCode != 0 || document is null)
        {
            throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.FailedFetchItemsAndPropertiesFormat, projectFile.FullName));
        }
 
        return document;
    }
 
    private async Task<JsonDocument> GetItemsAndPropertiesWithFallbackAsync(FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken)
    {
        return await GetItemsAndPropertiesWithFallbackAsync(projectFile, ["PackageReference", "ProjectReference"], ["AspireHostingSDKVersion"], context, cancellationToken);
    }
 
    private async Task<JsonDocument> GetItemsAndPropertiesWithFallbackAsync(FileInfo projectFile, string[] items, string[] properties, UpdateContext context, CancellationToken cancellationToken)
    {
        try
        {
            // Try normal MSBuild evaluation first
            return await GetItemsAndPropertiesAsync(projectFile, items, properties, cancellationToken);
        }
        catch (ProjectUpdaterException ex) when (IsAppHostProject(projectFile, context))
        {
            // Only use fallback for AppHost projects
            logger.LogWarning("Falling back to XML parsing for '{ProjectFile}'. Reason: {Message}", projectFile.FullName, ex.Message);
            
            if (!context.FallbackXmlParsing)
            {
                context.FallbackXmlParsing = true;
                logger.LogWarning("Update plan will be generated using fallback XML parsing; dependency accuracy may be reduced.");
            }
 
            return fallbackParser.ParseProject(projectFile);
        }
    }
 
    private static bool IsAppHostProject(FileInfo projectFile, UpdateContext context)
    {
        return string.Equals(projectFile.FullName, context.AppHostProjectFile.FullName, StringComparison.OrdinalIgnoreCase);
    }
 
    private Task AnalyzeAppHostAsync(UpdateContext context, CancellationToken cancellationToken)
    {
        var appHostSdkAnalyzeStep = new AnalyzeStep(UpdateCommandStrings.AnalyzeAppHostSdk, () => AnalyzeAppHostSdkAsync(context, cancellationToken));
        context.AnalyzeSteps.Enqueue(appHostSdkAnalyzeStep);
 
        var appHostProjectAnalyzeStep = new AnalyzeStep(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.AnalyzeProjectFormat, context.AppHostProjectFile.FullName), () => AnalyzeProjectAsync(context.AppHostProjectFile, context, cancellationToken));
        context.AnalyzeSteps.Enqueue(appHostProjectAnalyzeStep);
 
        return Task.CompletedTask;
    }
 
    private async Task<NuGetPackageCli> GetLatestVersionOfPackageAsync(UpdateContext context, string packageId, CancellationToken cancellationToken)
    {
        var cacheKey = $"LatestPackage-{packageId}";
        var latestPackage = await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            var packages = await context.Channel.GetPackagesAsync(packageId, context.AppHostProjectFile.Directory!, cancellationToken);
            var latestPackage = packages.OrderByDescending(p => SemVersion.Parse(p.Version), SemVersion.PrecedenceComparer).FirstOrDefault();
            return latestPackage;
        });
 
        return latestPackage ?? throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.NoPackageFoundFormat, packageId, context.Channel.Name));
    }
 
    private async Task AnalyzeAppHostSdkAsync(UpdateContext context, CancellationToken cancellationToken)
    {
        logger.LogDebug("Analyzing App Host SDK for: {AppHostFile}", context.AppHostProjectFile.FullName);
 
        var itemsAndPropertiesDocument = await GetItemsAndPropertiesWithFallbackAsync(context.AppHostProjectFile, context, cancellationToken);
        var propertiesElement = itemsAndPropertiesDocument.RootElement.GetProperty("Properties");
        var sdkVersionElement = propertiesElement.GetProperty("AspireHostingSDKVersion");
 
        var latestSdkPackage = await GetLatestVersionOfPackageAsync(context, "Aspire.AppHost.Sdk", cancellationToken);
 
        if (sdkVersionElement.GetString() == latestSdkPackage?.Version)
        {
            logger.LogInformation("App Host SDK is up to date.");
            return;
        }
 
        var sdkUpdateStep = new PackageUpdateStep(
            string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.UpdatePackageFormat, "Aspire.AppHost.Sdk", sdkVersionElement.GetString(), latestSdkPackage?.Version),
            () => UpdateSdkVersionInAppHostAsync(context.AppHostProjectFile, latestSdkPackage!),
            "Aspire.AppHost.Sdk",
            sdkVersionElement.GetString() ?? "unknown",
            latestSdkPackage?.Version ?? "unknown",
            context.AppHostProjectFile);
        context.UpdateSteps.Enqueue(sdkUpdateStep);
    }
 
    private static async Task UpdateSdkVersionInAppHostAsync(FileInfo projectFile, NuGetPackageCli package)
    {
        var projectDocument = new XmlDocument();
        projectDocument.PreserveWhitespace = true;
 
        projectDocument.Load(projectFile.FullName);
 
        var projectNode = projectDocument.SelectSingleNode("/Project");
        if (projectNode is null)
        {
            throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.CouldNotFindRootProjectElementFormat, projectFile.FullName));
        }
 
        var sdkNode = projectNode.SelectSingleNode("Sdk[@Name='Aspire.AppHost.Sdk']");
        if (sdkNode is null)
        {
            throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.CouldNotFindSdkElementFormat, projectFile.FullName));
        }
 
        sdkNode.Attributes?["Version"]?.Value = package.Version;
 
        projectDocument.Save(projectFile.FullName);
 
        await Task.CompletedTask;
    }
 
    private async Task AnalyzeProjectAsync(FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken)
    {
        if (!context.VisitedProjects.Add(projectFile.FullName))
        {
            // Project already analyzed, skip
            return;
        }
 
        // Detect if this project uses Central Package Management
        var cpmInfo = DetectCentralPackageManagement(projectFile);
 
        // Use fallback wrapper for AppHost project, normal method for others
        var itemsAndPropertiesDocument = IsAppHostProject(projectFile, context)
            ? await GetItemsAndPropertiesWithFallbackAsync(projectFile, context, cancellationToken)
            : await GetItemsAndPropertiesAsync(projectFile, cancellationToken);
 
        var itemsElement = itemsAndPropertiesDocument.RootElement.GetProperty("Items");
 
        // Handle ProjectReference items (may not exist if project has no project references)
        if (itemsElement.TryGetProperty("ProjectReference", out var projectReferencesElement))
        {
            foreach (var projectReference in projectReferencesElement.EnumerateArray())
            {
                var referencedProjectPath = projectReference.GetProperty("FullPath").GetString() ?? throw new ProjectUpdaterException(UpdateCommandStrings.ProjectReferenceNoFullPath);
                var referencedProjectFile = new FileInfo(referencedProjectPath);
                context.AnalyzeSteps.Enqueue(new AnalyzeStep(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.AnalyzeProjectFormat, referencedProjectFile.FullName), () => AnalyzeProjectAsync(referencedProjectFile, context, cancellationToken)));
            }
        }
 
        // Handle PackageReference items (may not exist if project has no package references)
        if (itemsElement.TryGetProperty("PackageReference", out var packageReferencesElement))
        {
            foreach (var packageReference in packageReferencesElement.EnumerateArray())
            {
            var packageId = packageReference.GetProperty("Identity").GetString() ?? throw new ProjectUpdaterException(UpdateCommandStrings.PackageReferenceNoIdentity);
 
            if (!IsUpdatablePackage(packageId))
            {
                continue;
            }
 
            if (cpmInfo.UsesCentralPackageManagement)
            {
                await AnalyzePackageForCentralPackageManagementAsync(packageId, projectFile, cpmInfo.DirectoryPackagesPropsFile!, context, cancellationToken);
            }
            else
            {
                // Traditional package management - Version should be in PackageReference
                if (!packageReference.TryGetProperty("Version", out var versionElement) || versionElement.GetString() is null)
                {
                    throw new ProjectUpdaterException(UpdateCommandStrings.PackageReferenceNoVersion);
                }
                
                var packageVersion = versionElement.GetString()!;
                await AnalyzePackageForTraditionalManagementAsync(packageId, packageVersion, projectFile, context, cancellationToken);
            }
        }
    }
    }
 
    private static bool IsUpdatablePackage(string packageId)
    {
        return packageId.StartsWith("Aspire.")
            || packageId.StartsWith("Microsoft.Extensions.ServiceDiscovery.")
            || packageId.Equals("Microsoft.Extensions.ServiceDiscovery");
    }
 
    private static CentralPackageManagementInfo DetectCentralPackageManagement(FileInfo projectFile)
    {
        // Look for Directory.Packages.props in directory tree.
        for (var current = projectFile.Directory; current is not null; current = current.Parent)
        {
            var directoryPackagesPropsPath = Path.Combine(current.FullName, "Directory.Packages.props");
            if (File.Exists(directoryPackagesPropsPath))
            {
                return new CentralPackageManagementInfo(true, new FileInfo(directoryPackagesPropsPath));
            }
        }
 
        return new CentralPackageManagementInfo(false, null);
    }
 
    private async Task AnalyzePackageForTraditionalManagementAsync(string packageId, string packageVersion, FileInfo projectFile, UpdateContext context, CancellationToken cancellationToken)
    {
        var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken);
 
        if (packageVersion == latestPackage?.Version)
        {
            logger.LogInformation("Package '{PackageId}' is up to date.", packageId);
            return;
        }
 
        var updateStep = new PackageUpdateStep(
            string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.UpdatePackageFormat, packageId, packageVersion, latestPackage!.Version),
            () => UpdatePackageReferenceInProject(projectFile, latestPackage, cancellationToken),
            packageId,
            packageVersion,
            latestPackage!.Version,
            projectFile);
        context.UpdateSteps.Enqueue(updateStep);
    }
 
    private async Task AnalyzePackageForCentralPackageManagementAsync(string packageId, FileInfo projectFile, FileInfo directoryPackagesPropsFile, UpdateContext context, CancellationToken cancellationToken)
    {
        var currentVersion = await GetPackageVersionFromDirectoryPackagesPropsAsync(packageId, directoryPackagesPropsFile, projectFile, cancellationToken);
        
        if (currentVersion is null)
        {
            logger.LogInformation("Package '{PackageId}' not found in Directory.Packages.props, skipping.", packageId);
            return;
        }
 
        var latestPackage = await GetLatestVersionOfPackageAsync(context, packageId, cancellationToken);
 
        if (currentVersion == latestPackage?.Version)
        {
            logger.LogInformation("Package '{PackageId}' is up to date.", packageId);
            return;
        }
 
        var updateStep = new PackageUpdateStep(
            string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.UpdatePackageFormat, packageId, currentVersion, latestPackage!.Version),
            () => UpdatePackageVersionInDirectoryPackagesProps(packageId, latestPackage!.Version, directoryPackagesPropsFile),
            packageId,
            currentVersion,
            latestPackage!.Version,
            projectFile);
        context.UpdateSteps.Enqueue(updateStep);
    }
 
    private async Task<string?> GetPackageVersionFromDirectoryPackagesPropsAsync(string packageId, FileInfo directoryPackagesPropsFile, FileInfo projectFile, CancellationToken cancellationToken)
    {
        try
        {
            var doc = new XmlDocument { PreserveWhitespace = true };
            doc.Load(directoryPackagesPropsFile.FullName);
            var packageVersionNode = doc.SelectSingleNode($"/Project/ItemGroup/PackageVersion[@Include='{packageId}']");
            var versionAttribute = packageVersionNode?.Attributes?["Version"]?.Value;
            
            if (versionAttribute is null)
            {
                return null;
            }
 
            // Check if this is an MSBuild property expression like $(AspireVersion)
            if (IsMSBuildPropertyExpression(versionAttribute))
            {
                var propertyName = ExtractPropertyNameFromExpression(versionAttribute);
                if (propertyName is not null)
                {
                    var resolvedValue = await ResolveMSBuildPropertyAsync(propertyName, projectFile, cancellationToken);
                    if (resolvedValue is not null && IsValidSemanticVersion(resolvedValue))
                    {
                        return resolvedValue;
                    }
                    else
                    {
                        throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, 
                            "Unable to resolve MSBuild property '{0}' to a valid semantic version. Expression: '{1}', Resolved value: '{2}'",
                            propertyName, versionAttribute, resolvedValue ?? "null"));
                    }
                }
                else
                {
                    throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture,
                        "Invalid MSBuild property expression in package version: '{0}'", versionAttribute));
                }
            }
 
            return versionAttribute;
        }
        catch (ProjectUpdaterException)
        {
            // Re-throw our custom exceptions
            throw;
        }
        catch (Exception ex)
        {
            // Ignore parse errors.
            logger.LogInformation(ex, "Ignoring parsing error in Directory.Packages.props '{DirectoryPackagesPropsFile}' for project '{ProjectFile}'", directoryPackagesPropsFile.FullName, projectFile.FullName);
            return null;
        }
    }
 
    private static bool IsMSBuildPropertyExpression(string value)
    {
        return value.StartsWith("$(") && value.EndsWith(")") && value.Length > 3;
    }
 
    private static string? ExtractPropertyNameFromExpression(string expression)
    {
        if (!IsMSBuildPropertyExpression(expression))
        {
            return null;
        }
 
        // Extract property name from $(PropertyName)
        return expression.Substring(2, expression.Length - 3);
    }
 
    private async Task<string?> ResolveMSBuildPropertyAsync(string propertyName, FileInfo projectFile, CancellationToken cancellationToken)
    {
        try
        {
            var document = await GetItemsAndPropertiesAsync(
                projectFile, 
                Array.Empty<string>(), // No items needed
                [propertyName], // Just the property we want
                cancellationToken);
 
            var propertiesElement = document.RootElement.GetProperty("Properties");
            if (propertiesElement.TryGetProperty(propertyName, out var propertyElement))
            {
                return propertyElement.GetString();
            }
 
            return null;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "Exception while resolving MSBuild property '{PropertyName}' for project '{ProjectFile}'", propertyName, projectFile.FullName);
            return null;
        }
    }
 
    private static bool IsValidSemanticVersion(string version)
    {
        try
        {
            SemVersion.Parse(version);
            return true;
        }
        catch
        {
            return false;
        }
    }
 
    private static async Task UpdatePackageVersionInDirectoryPackagesProps(string packageId, string newVersion, FileInfo directoryPackagesPropsFile)
    {
        var doc = new XmlDocument { PreserveWhitespace = true };
        doc.Load(directoryPackagesPropsFile.FullName);
        
        var packageVersionNode = doc.SelectSingleNode($"/Project/ItemGroup/PackageVersion[@Include='{packageId}']");
        if (packageVersionNode?.Attributes?["Version"] is null)
        {
            throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.CouldNotFindPackageVersionInDirectoryPackagesProps, packageId, directoryPackagesPropsFile.FullName));
        }
 
        packageVersionNode.Attributes["Version"]!.Value = newVersion;
        doc.Save(directoryPackagesPropsFile.FullName);
 
        await Task.CompletedTask;
    }
 
    private async Task UpdatePackageReferenceInProject(FileInfo projectFile, NuGetPackageCli package, CancellationToken cancellationToken)
    {
        var exitCode = await runner.AddPackageAsync(
            projectFilePath: projectFile,
            packageName: package.Id,
            packageVersion: package.Version,
            nugetSource: null, // When source is null we append --no-restore.
            options: new(),
            cancellationToken: cancellationToken);
 
        if (exitCode != 0)
        {
            throw new ProjectUpdaterException(string.Format(System.Globalization.CultureInfo.InvariantCulture, UpdateCommandStrings.FailedUpdatePackageReferenceFormat, package.Id, projectFile.FullName));
        }
    }
}
 
internal sealed class ProjectUpdateResult
{
    public bool UpdatedApplied { get; set; }
}
 
internal sealed class UpdateContext(FileInfo appHostProjectFile, PackageChannel channel)
{
    public FileInfo AppHostProjectFile { get; } = appHostProjectFile;
    public PackageChannel Channel { get; } = channel;
    public ConcurrentQueue<UpdateStep> UpdateSteps { get; } = new();
    public ConcurrentQueue<AnalyzeStep> AnalyzeSteps { get; } = new();
    public HashSet<string> VisitedProjects { get; } = new();
    public bool FallbackXmlParsing { get; set; }
}
 
internal abstract record UpdateStep(string Description, Func<Task> Callback)
{
    /// <summary>
    /// Gets the formatted display text using Spectre Console markup for enhanced visual presentation.
    /// </summary>
    public virtual string GetFormattedDisplayText() => Description;
}
 
/// <summary>
/// Represents an update step for a package reference, containing package and project information.
/// </summary>
internal record PackageUpdateStep(
    string Description, 
    Func<Task> Callback,
    string PackageId,
    string CurrentVersion,
    string NewVersion,
    FileInfo ProjectFile) : UpdateStep(Description, Callback)
{
    public override string GetFormattedDisplayText()
    {
        return $"[bold yellow]{PackageId}[/] [bold green]{CurrentVersion}[/] to [bold green]{NewVersion}[/]";
    }
}
 
internal record AnalyzeStep(string Description, Func<Task> Callback);
 
internal sealed class ProjectUpdaterException : System.Exception
{
    public ProjectUpdaterException(string message) : base(message) { }
    public ProjectUpdaterException(string message, System.Exception inner) : base(message, inner) { }
}
 
internal record CentralPackageManagementInfo(bool UsesCentralPackageManagement, FileInfo? DirectoryPackagesPropsFile);