File: Program.cs
Web Access
Project: BuildCache.csproj (dotnet-build-cache)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.CommandLine;
using System.Globalization;
using System.IO;
using Microsoft.NET.Build.Tasks;

namespace BuildCache;

internal static class Program
{
    private static readonly string[] s_cutoffFormats = { "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz" };

    private static int Main(string[] args) => Run(args, Console.Out, Console.Error);

    internal static int Run(string[] args, TextWriter output, TextWriter error)
    {
        var command = new RootCommand("Experimental build invocation cache maintenance. Commands and cache formats may change without notice.");
        var cache = new Option<string?>("--cache")
        {
            Description = "Named cache: illink or crossgen2. Uses its path environment variable or platform default."
        };
        var directory = new Option<DirectoryInfo>("--cache-directory")
        {
            Description = "Explicit cache root, overriding named-cache settings. No builds may use this cache while purging."
        }.AcceptExistingOnly();
        var before = new Option<DateTimeOffset>("--before")
        {
            Description = "Delete entries last used before this UTC ISO 8601 timestamp (Z or +00:00).",
            Required = true,
            CustomParser = result =>
            {
                string value = result.Tokens[0].Value;
                if (DateTimeOffset.TryParseExact(value, s_cutoffFormats,
                    CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset cutoff) &&
                    cutoff.Offset == TimeSpan.Zero)
                {
                    return cutoff;
                }

                result.AddError("--before must be a UTC ISO 8601 timestamp ending in Z or +00:00.");
                return default;
            }
        };
        var purge = new Command("purge", "Delete unused entries from an idle invocation cache.");
        purge.Options.Add(cache);
        purge.Options.Add(directory);
        purge.Options.Add(before);
        purge.SetAction(result =>
        {
            string? name = result.GetValue(cache);
            InvocationCacheConfiguration? configuration = name switch
            {
                "illink" => new("ILLink", "ILLINK_EXPERIMENTAL_CACHE", "ILLINK_EXPERIMENTAL_CACHE_PATH"),
                "crossgen2" => new("Crossgen2", "CROSSGEN2_EXPERIMENTAL_CACHE", "CROSSGEN2_EXPERIMENTAL_CACHE_PATH"),
                _ => null
            };
            if (name is not null && configuration is null)
            {
                error.WriteLine("--cache must be 'illink' or 'crossgen2'.");
                return 1;
            }

            string? path = result.GetValue(directory)?.FullName;
            if (path is null && configuration is null)
            {
                error.WriteLine("Specify --cache or --cache-directory.");
                return 1;
            }

            try
            {
                if (path is null && configuration is not null)
                {
                    path = Environment.GetEnvironmentVariable(configuration.PathEnvironmentVariable);
                    if (string.IsNullOrEmpty(path))
                        path = InvocationCacheConfiguration.GetDefaultCacheDirectory(configuration.ToolName);
                }
                if (path is null)
                {
                    error.WriteLine("No default cache directory is available; specify --cache-directory.");
                    return 1;
                }

                path = Path.GetFullPath(path);
                if (!Directory.Exists(path))
                {
                    error.WriteLine($"Cache directory '{path}' does not exist or cannot be accessed.");
                    return 1;
                }
            }
            catch (Exception ex) when (ex is ArgumentException or NotSupportedException or IOException or UnauthorizedAccessException)
            {
                error.WriteLine($"Could not resolve cache directory '{path}': {ex.Message}");
                return 1;
            }

            return InvocationCachePurge.Run(path, InvocationCacheEntry.VersionDirectory,
                configuration?.ToolName ?? "Build", result.GetValue(before), output, error);
        });
        command.Subcommands.Add(purge);
        return command.Parse(args).Invoke(new()
        {
            EnableDefaultExceptionHandler = false,
            Output = output,
            Error = error
        });
    }
}