File: ToolPackage\LocalToolsResolverCache.cs
Web Access
Project: src\src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Configurer;
using Microsoft.Extensions.EnvironmentAbstractions;
using NuGet.Frameworks;
using NuGet.Versioning;

namespace Microsoft.DotNet.Cli.ToolPackage;

internal partial class LocalToolsResolverCache : ILocalToolsResolverCache
{
    private readonly DirectoryPath _cacheVersionedDirectory;
    private readonly IFileSystem _fileSystem;
    private const int LocalToolResolverCacheVersion = 1;

    public LocalToolsResolverCache(IFileSystem fileSystem = null,
        DirectoryPath? cacheDirectory = null,
        int version = LocalToolResolverCacheVersion)
    {
        _fileSystem = fileSystem ?? new FileSystemWrapper();
        DirectoryPath appliedCacheDirectory =
            cacheDirectory ?? new DirectoryPath(Path.Combine(CliFolderPathCalculator.ToolsResolverCachePath));
        _cacheVersionedDirectory = appliedCacheDirectory.WithSubDirectories(version.ToString());
    }

    public void Save(
        IDictionary<RestoredCommandIdentifier, ToolCommand> restoredCommandMap)
    {
        EnsureFileStorageExists();

        foreach (var distinctPackageIdAndRestoredCommandMap in restoredCommandMap.GroupBy(x => x.Key.PackageId))
        {
            PackageId distinctPackageId = distinctPackageIdAndRestoredCommandMap.Key;
            string packageCacheFile = GetCacheFile(distinctPackageId);
            if (_fileSystem.File.Exists(packageCacheFile))
            {
                var existingCacheTable = GetCacheTable(packageCacheFile);

                var diffedRow = distinctPackageIdAndRestoredCommandMap
                    .Where(pair => !TryGetMatchingRestoredCommand(
                        pair.Key,
                        existingCacheTable, out _))
                    .Select(pair => ConvertToCacheRow(pair.Key, pair.Value));

                _fileSystem.File.WriteAllText(
                    packageCacheFile,
                    JsonSerializer.Serialize(existingCacheTable.Concat(diffedRow), LocalToolsCacheJsonSerializerContext.Default.IEnumerableCacheRow));
            }
            else
            {
                var rowsToAdd =
                    distinctPackageIdAndRestoredCommandMap
                        .Select(mapWithSamePackageId
                            => ConvertToCacheRow(
                                mapWithSamePackageId.Key,
                                mapWithSamePackageId.Value));

                _fileSystem.File.WriteAllText(
                    packageCacheFile,
                    JsonSerializer.Serialize(rowsToAdd, LocalToolsCacheJsonSerializerContext.Default.IEnumerableCacheRow));
            }
        }
    }

    public bool TryLoad(
        RestoredCommandIdentifier restoredCommandIdentifier,
        out ToolCommand toolCommand)
    {
        string packageCacheFile = GetCacheFile(restoredCommandIdentifier.PackageId);
        if (_fileSystem.File.Exists(packageCacheFile))
        {
            if (TryGetMatchingRestoredCommand(
                restoredCommandIdentifier,
                GetCacheTable(packageCacheFile),
                out toolCommand))
            {
                return true;
            }
        }

        toolCommand = null;
        return false;
    }

    private CacheRow[] GetCacheTable(string packageCacheFile)
    {
        CacheRow[] cacheTable = [];

        try
        {
            cacheTable =
                JsonSerializer.Deserialize(_fileSystem.File.ReadAllText(packageCacheFile), LocalToolsCacheJsonSerializerContext.Default.CacheRowArray);
        }
        catch (JsonException)
        {
            // if file is corrupted, treat it as empty since it is not the source of truth
        }

        return cacheTable;
    }

    private string GetCacheFile(PackageId packageId)
    {
        return _cacheVersionedDirectory.WithFile(packageId.ToString()).Value;
    }

    private void EnsureFileStorageExists()
    {
        _fileSystem.Directory.CreateDirectory(_cacheVersionedDirectory.Value);
    }

    private static CacheRow ConvertToCacheRow(
        RestoredCommandIdentifier restoredCommandIdentifier,
        ToolCommand toolCommand)
    {
        return new CacheRow
        {
            Version = restoredCommandIdentifier.Version.ToNormalizedString(),
            TargetFramework = restoredCommandIdentifier.TargetFramework.GetShortFolderName(),
            RuntimeIdentifier = restoredCommandIdentifier.RuntimeIdentifier.ToLowerInvariant(),
            Name = restoredCommandIdentifier.CommandName.Value,
            Runner = toolCommand.Runner,
            PathToExecutable = toolCommand.Executable.Value
        };
    }

    private static
        (RestoredCommandIdentifier restoredCommandIdentifier,
        ToolCommand toolCommand)
        Convert(
            PackageId packageId,
            CacheRow cacheRow)
    {
        RestoredCommandIdentifier restoredCommandIdentifier =
            new(
                packageId,
                NuGetVersion.Parse(cacheRow.Version),
                NuGetFramework.Parse(cacheRow.TargetFramework),
                cacheRow.RuntimeIdentifier,
                new ToolCommandName(cacheRow.Name));

        ToolCommand toolCommand =
            new(
                new ToolCommandName(cacheRow.Name),
                cacheRow.Runner,
                new FilePath(cacheRow.PathToExecutable));

        return (restoredCommandIdentifier, toolCommand);
    }

    private static bool TryGetMatchingRestoredCommand(
        RestoredCommandIdentifier restoredCommandIdentifier,
        CacheRow[] cacheTable,
        out ToolCommand toolCommandList)
    {
        (RestoredCommandIdentifier restoredCommandIdentifier, ToolCommand toolCommand)[] matchingRow =
            [.. cacheTable
                .Select(c => Convert(restoredCommandIdentifier.PackageId, c))
                .Where(candidate => candidate.restoredCommandIdentifier == restoredCommandIdentifier)];

        if (matchingRow.Length >= 2)
        {
            throw new ResolverCacheInconsistentException(
                $"more than one row for {restoredCommandIdentifier.DebugToString()}");
        }

        if (matchingRow.Length == 1)
        {
            toolCommandList = matchingRow[0].toolCommand;
            return true;
        }

        toolCommandList = null;
        return false;
    }

    private class CacheRow
    {
        public string Version { get; set; }
        public string TargetFramework { get; set; }
        public string RuntimeIdentifier { get; set; }
        public string Name { get; set; }
        public string Runner { get; set; }
        public string PathToExecutable { get; set; }
    }

    [JsonSerializable(typeof(CacheRow[]))]
    [JsonSerializable(typeof(IEnumerable<CacheRow>))]
    private partial class LocalToolsCacheJsonSerializerContext : JsonSerializerContext;
}