File: NuGet\NuGetPackageCache.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.Frozen;
using System.Globalization;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Microsoft.Extensions.Caching.Memory;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.NuGet;
 
internal interface INuGetPackageCache
{
    Task<NuGetPackageOperationConfiguration> CreateChannelConfigurationAsync(DirectoryInfo workingDirectory, IReadOnlyList<PackageMapping>? channelMappings, CancellationToken cancellationToken);
    Task<NuGetPackageOperationConfiguration> CreateSourceRestrictedConfigurationAsync(DirectoryInfo workingDirectory, PackageMapping[] mappings, CancellationToken cancellationToken);
    Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(NuGetPackageOperationConfiguration configuration, bool prerelease, CancellationToken cancellationToken);
    Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(NuGetPackageOperationConfiguration configuration, bool prerelease, CancellationToken cancellationToken);
    Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(NuGetPackageOperationConfiguration configuration, bool prerelease, CancellationToken cancellationToken);
    Task<IEnumerable<NuGetPackage>> GetPackagesAsync(NuGetPackageOperationConfiguration configuration, string packageId, Func<string, bool>? filter, bool prerelease, bool useCache, CancellationToken cancellationToken);
    Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(NuGetPackageOperationConfiguration configuration, string exactPackageId, bool prerelease, bool useCache, CancellationToken cancellationToken);
 
    Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        => GetTemplatePackagesAsync(NuGetPackageOperationConfiguration.FromExistingConfiguration(workingDirectory, nugetConfigFile), prerelease, cancellationToken);
 
    Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        => GetIntegrationPackagesAsync(NuGetPackageOperationConfiguration.FromExistingConfiguration(workingDirectory, nugetConfigFile), prerelease, cancellationToken);
 
    Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        => GetCliPackagesAsync(NuGetPackageOperationConfiguration.FromExistingConfiguration(workingDirectory, nugetConfigFile), prerelease, cancellationToken);
 
    Task<IEnumerable<NuGetPackage>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
        => GetPackagesAsync(NuGetPackageOperationConfiguration.FromExistingConfiguration(workingDirectory, nugetConfigFile), packageId, filter, prerelease, useCache, cancellationToken);
 
    Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
        => GetPackageVersionsAsync(NuGetPackageOperationConfiguration.FromExistingConfiguration(workingDirectory, nugetConfigFile), exactPackageId, prerelease, useCache, cancellationToken);
}
 
/// <summary>
/// Packages that have been superseded and should be hidden from integration listings by default.
/// </summary>
internal static class DeprecatedPackages
{
    private static readonly FrozenSet<string> s_all = new[]
    {
        "Aspire.Azure.AI.Inference",
        "Aspire.Azure.AI.OpenAI",
        "Aspire.Hosting.Dapr",
        "Aspire.Hosting.GitHub.Models",
        "Aspire.Hosting.NodeJs"
    }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
 
    public static bool IsDeprecated(string packageId) => s_all.Contains(packageId);
}
 
internal static class PackageIdFilters
{
    /// <summary>
    /// Identifies official hosting integration package IDs for display priority.
    /// </summary>
    public static bool IsOfficialIntegrationPackageId(string packageId)
    {
        return packageId.StartsWith("Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) &&
            !IsExcludedHostingPackage(packageId);
    }
 
    public static bool IsOfficialOrCommunityToolkitPackage(string packageId)
    {
        var isHostingOrCommunityToolkitNamespaced = packageId.StartsWith("Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) ||
            packageId.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase) ||
            packageId.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase);
 
        return isHostingOrCommunityToolkitNamespaced && !IsExcludedHostingPackage(packageId);
    }
 
    public static bool IsIntegrationPackageId(string packageId)
    {
        var isHostingOrCommunityToolkitNamespaced = packageId.StartsWith("Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.OrdinalIgnoreCase);
 
        return isHostingOrCommunityToolkitNamespaced && !IsExcludedHostingPackage(packageId);
    }
 
    private static bool IsExcludedHostingPackage(string packageId)
    {
        return packageId.StartsWith("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("Aspire.Hosting.Sdk", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("Aspire.Hosting.Orchestration", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("Aspire.Hosting.Testing", StringComparison.OrdinalIgnoreCase) ||
            packageId.StartsWith("Aspire.Hosting.Msi", StringComparison.OrdinalIgnoreCase) ||
            packageId.Equals("Aspire.Hosting.Integration.Analyzers", StringComparison.OrdinalIgnoreCase);
    }
}
 
internal sealed class NuGetPackageCache(
    IDotNetCliRunner cliRunner,
    IMemoryCache memoryCache,
    AspireCliTelemetry telemetry,
    IFeatures features,
    BundleNuGetService nuGetService) : INuGetPackageCache
{
    private const int SearchPageSize = 1000;
 
    public Task<NuGetPackageOperationConfiguration> CreateChannelConfigurationAsync(
        DirectoryInfo workingDirectory,
        IReadOnlyList<PackageMapping>? channelMappings,
        CancellationToken cancellationToken)
        => nuGetService.CreatePackageOperationConfigurationAsync(
            workingDirectory,
            channelMappings,
            restrictToSelectedSources: false,
            cancellationToken);
 
    public Task<NuGetPackageOperationConfiguration> CreateSourceRestrictedConfigurationAsync(
        DirectoryInfo workingDirectory,
        PackageMapping[] mappings,
        CancellationToken cancellationToken)
        => nuGetService.CreatePackageOperationConfigurationAsync(workingDirectory, mappings, restrictToSelectedSources: true, cancellationToken);
 
    public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(
        NuGetPackageOperationConfiguration configuration,
        bool prerelease,
        CancellationToken cancellationToken)
    {
        var key = $"TemplatePackages-{configuration.OriginalWorkingDirectory.FullName}-{prerelease}-{configuration.CacheIdentity}";
 
        var packages = await memoryCache.GetOrCreateAsync(key, async (entry) =>
        {
            var packages = await GetPackagesAsync(configuration, "Aspire.ProjectTemplates", null, prerelease, true, cancellationToken);
            return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase));
 
        }) ?? throw new NuGetPackageCacheException(ErrorStrings.FailedToRetrieveCachedTemplatePackages);
 
        return packages;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetIntegrationPackagesAsync(
        NuGetPackageOperationConfiguration configuration,
        bool prerelease,
        CancellationToken cancellationToken)
    {
        return await GetPackagesAsync(configuration, "Aspire.Hosting", null, prerelease, true, cancellationToken);
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetCliPackagesAsync(
        NuGetPackageOperationConfiguration configuration,
        bool prerelease,
        CancellationToken cancellationToken)
    {
        var key = $"CliPackages-{configuration.OriginalWorkingDirectory.FullName}-{prerelease}-{configuration.CacheIdentity}";
 
        var packages = await memoryCache.GetOrCreateAsync(key, async (entry) =>
        {
            // Set cache expiration to 1 hour for CLI updates
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
            var packages = await GetPackagesAsync(configuration, "Aspire.Cli", null, prerelease, false, cancellationToken);
            return packages.Where(p => p.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase));
        }) ?? [];
 
        return packages;
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(
        NuGetPackageOperationConfiguration configuration,
        string query,
        Func<string, bool>? filter,
        bool prerelease,
        bool useCache,
        CancellationToken cancellationToken)
    {
        using var activity = telemetry.StartDiagnosticActivity();
 
        var collectedPackages = new List<NuGetPackage>();
        var skip = 0;
 
        bool continueFetching;
        do
        {
            // This search should pick up Aspire.Hosting.* and CommunityToolkit.Aspire.Hosting.*
            var result = await cliRunner.SearchPackagesAsync(
                configuration.EffectiveWorkingDirectory,
                query,
                exactMatch: false,
                prerelease,
                SearchPageSize,
                skip,
                configuration.ExplicitConfigFile,
                useCache, // Pass through the useCache parameter
                new ProcessInvocationOptions { SuppressLogging = true },
                cancellationToken
                );
 
            if (result.ExitCode != 0)
            {
                throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, result.ExitCode));
            }
            else
            {
                if (result.Packages?.Length > 0)
                {
                    collectedPackages.AddRange(result.Packages);
                }
 
                if (result.Packages?.Length < SearchPageSize)
                {
                    continueFetching = false;
                }
                else
                {
                    continueFetching = true;
                    skip += SearchPageSize;
                }
            }
        } while (continueFetching);
 
        // If no specific filter is specified we use the fallback filter which is useful in most circumstances
        // other that aspire update which really needs to see all the packages to work effectively.
        var showDeprecatedPackages = features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false);
        var effectiveFilter = (NuGetPackage p) =>
        {
            if (filter is not null)
            {
                return filter(p.Id);
            }
 
            var isOfficialPackage = PackageIdFilters.IsOfficialOrCommunityToolkitPackage(p.Id);
 
            // Apply deprecated package filter unless the user wants to show deprecated packages
            if (isOfficialPackage && !showDeprecatedPackages)
            {
                return !DeprecatedPackages.IsDeprecated(p.Id);
            }
 
            return isOfficialPackage;
        };
 
        return nuGetService.FilterPackageSearchResults(
            collectedPackages,
            configuration.ExplicitConfigFile?.FullName,
            configuration.EffectiveWorkingDirectory.FullName,
            cancellationToken).Where(effectiveFilter);
    }
 
    public async Task<IEnumerable<NuGetPackage>> GetPackageVersionsAsync(
        NuGetPackageOperationConfiguration configuration,
        string exactPackageId,
        bool prerelease,
        bool useCache,
        CancellationToken cancellationToken)
    {
        using var activity = telemetry.StartDiagnosticActivity();
 
        var collectedPackages = new List<NuGetPackage>();
 
        var result = await cliRunner.SearchPackagesAsync(
                configuration.EffectiveWorkingDirectory,
                exactPackageId,
                exactMatch: true,
                prerelease,
                take: 0,
                skip: 0, // skip and take parameters are ignored when exactMatch is true
                configuration.ExplicitConfigFile,
                useCache, // Pass through the useCache parameter
                new ProcessInvocationOptions { SuppressLogging = true },
                cancellationToken
                );
 
        if (result.ExitCode != 0)
        {
            throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, result.ExitCode));
        }
 
        if (result.Packages?.Length > 0)
        {
            collectedPackages.AddRange(result.Packages);
        }
 
        // If no specific filter is specified we use the fallback filter which is useful in most circumstances
        // other that aspire update which really needs to see all the packages to work effectively.
        var effectiveFilter = (NuGetPackage p) =>
        {
            // Apply deprecated package filter unless the user wants to show deprecated packages
            if (!features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false))
            {
                return !DeprecatedPackages.IsDeprecated(p.Id);
            }
            return true;
        };
 
        return nuGetService.FilterPackageSearchResults(
            collectedPackages,
            configuration.ExplicitConfigFile?.FullName,
            configuration.EffectiveWorkingDirectory.FullName,
            cancellationToken).Where(effectiveFilter);
    }
 
}
 
internal sealed class NuGetPackageCacheException : Exception
{
    public NuGetPackageCacheException(string message)
        : base(message)
    {
    }
 
    public NuGetPackageCacheException(string message, Exception innerException)
        : base(message, innerException)
    {
    }
}