From cedf2fdbe7f404650fb9b98dca10a24451daec93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Wed, 8 May 2024 21:12:57 +0200 Subject: [PATCH] [WIP] New nugraph global tool to create dependency graphs from the command line --- Chisel.sln | 7 + src/nugraph/Dotnet.cs | 70 ++++++++ src/nugraph/GraphCommand.cs | 188 ++++++++++++++++++++++ src/nugraph/GraphCommandSettings.cs | 93 +++++++++++ src/nugraph/JsonPipeTarget.cs | 42 +++++ src/nugraph/Mermaid.cs | 35 ++++ src/nugraph/NuGetPackageResolver.cs | 121 ++++++++++++++ src/nugraph/Program.cs | 28 ++++ src/nugraph/RedirectionFriendlyConsole.cs | 47 ++++++ src/nugraph/SpectreLogger.cs | 38 +++++ src/nugraph/nugraph.csproj | 36 +++++ 11 files changed, 705 insertions(+) create mode 100644 src/nugraph/Dotnet.cs create mode 100644 src/nugraph/GraphCommand.cs create mode 100644 src/nugraph/GraphCommandSettings.cs create mode 100644 src/nugraph/JsonPipeTarget.cs create mode 100644 src/nugraph/Mermaid.cs create mode 100644 src/nugraph/NuGetPackageResolver.cs create mode 100644 src/nugraph/Program.cs create mode 100644 src/nugraph/RedirectionFriendlyConsole.cs create mode 100644 src/nugraph/SpectreLogger.cs create mode 100644 src/nugraph/nugraph.csproj diff --git a/Chisel.sln b/Chisel.sln index aa41324..e697f4a 100644 --- a/Chisel.sln +++ b/Chisel.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlClientSample", "samples\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Client", "samples\Microsoft.Identity.Client\Microsoft.Identity.Client.csproj", "{F40FA01A-EB18-4785-9A3C-379F2E7A7A02}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "nugraph", "src\nugraph\nugraph.csproj", "{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +62,10 @@ Global {F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Debug|Any CPU.Build.0 = Debug|Any CPU {F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Release|Any CPU.ActiveCfg = Release|Any CPU {F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Release|Any CPU.Build.0 = Release|Any CPU + {7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {845EDA2A-5207-4C6D-ABE9-9635F4630D90} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7} @@ -68,5 +74,6 @@ Global {8B1B3D6A-7100-4DFB-97C9-CF5ACF1A3B08} = {AC8C6685-EDF9-443A-BAF6-A5E7CF777B2A} {611D4DE0-F729-48A6-A496-2EA3B5DF8EC6} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7} {F40FA01A-EB18-4785-9A3C-379F2E7A7A02} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7} + {7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C} = {89268D80-B21D-4C76-AF7F-796AAD1E00D9} EndGlobalSection EndGlobal diff --git a/src/nugraph/Dotnet.cs b/src/nugraph/Dotnet.cs new file mode 100644 index 0000000..6330bde --- /dev/null +++ b/src/nugraph/Dotnet.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using CliWrap; + +namespace nugraph; + +internal static partial class Dotnet +{ + private static readonly Dictionary EnvironmentVariables = new() { ["DOTNET_NOLOGO"] = "true" }; + + public static async Task RestoreAsync(FileSystemInfo? source) + { + var arguments = new List { "restore" }; // may use "build" instead of "restore" if the project is an exe + if (source != null) + { + arguments.Add(source.FullName); + } + // !!! Requires a recent .NET SDK (see https://github.com/dotnet/msbuild/issues/3911) + // arguments.Add("--target:ResolvePackageAssets"); // may enable if the project is an exe in order to get RuntimeCopyLocalItems + NativeCopyLocalItems + arguments.Add($"--getProperty:{nameof(Property.ProjectAssetsFile)}"); + arguments.Add($"--getProperty:{nameof(Property.TargetFramework)}"); + arguments.Add($"--getProperty:{nameof(Property.TargetFrameworks)}"); + arguments.Add($"--getItem:{nameof(Item.RuntimeCopyLocalItems)}"); + arguments.Add($"--getItem:{nameof(Item.NativeCopyLocalItems)}"); + + var dotnet = Cli.Wrap("dotnet").WithArguments(arguments).WithEnvironmentVariables(EnvironmentVariables).WithValidation(CommandResultValidation.None); + var jsonPipe = new JsonPipeTarget(SourceGenerationContext.Default.Result, () => new Exception($"Running \"{dotnet}\" in \"{dotnet.WorkingDirPath}\" returned a literal 'null' JSON payload")); + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + var commandResult = await dotnet + .WithStandardOutputPipe(PipeTarget.Merge(jsonPipe, PipeTarget.ToStringBuilder(stdout))) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stderr)).ExecuteAsync(); + + if (!commandResult.IsSuccess) + { + var message = stderr.Length > 0 ? stderr.ToString() : stdout.ToString(); + throw new Exception($"Running \"{dotnet}\" in \"{dotnet.WorkingDirPath}\" failed with exit code {commandResult.ExitCode}.{Environment.NewLine}{message}"); + } + + var properties = jsonPipe.Result.Properties; + var items = jsonPipe.Result.Items; + var copyLocalPackages = items.RuntimeCopyLocalItems.Concat(items.NativeCopyLocalItems).Select(e => e.NuGetPackageId).ToHashSet(); + return new ProjectInfo(properties.ProjectAssetsFile, properties.GetTargetFrameworks(), copyLocalPackages); + } + + public record ProjectInfo(string ProjectAssetsFile, IReadOnlyCollection TargetFrameworks, IReadOnlyCollection CopyLocalPackages); + + [JsonSerializable(typeof(Result))] + private partial class SourceGenerationContext : JsonSerializerContext; + + private record Result(Property Properties, Item Items); + + private record Property(string ProjectAssetsFile, string TargetFramework, string TargetFrameworks) + { + public IReadOnlyCollection GetTargetFrameworks() + { + var targetFrameworks = TargetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(); + return targetFrameworks.Count > 0 ? targetFrameworks : [TargetFramework]; + } + } + + private record Item(CopyLocalItem[] RuntimeCopyLocalItems, CopyLocalItem[] NativeCopyLocalItems); + + private record CopyLocalItem(string NuGetPackageId); +} \ No newline at end of file diff --git a/src/nugraph/GraphCommand.cs b/src/nugraph/GraphCommand.cs new file mode 100644 index 0000000..b086a4f --- /dev/null +++ b/src/nugraph/GraphCommand.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Chisel; +using NuGet.Commands; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Frameworks; +using NuGet.LibraryModel; +using NuGet.ProjectModel; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +using OneOf; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace nugraph; + +[GenerateOneOf] +public partial class FileOrPackages : OneOfBase +{ + public override string ToString() => Match(file => file?.FullName ?? Environment.CurrentDirectory, ids => string.Join(", ", ids)); +} + +[Description("Generates dependency graphs for .NET projects and NuGet packages.")] +internal class GraphCommand(IAnsiConsole console) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext commandContext, GraphCommandSettings settings) + { + if (settings.PrintVersion) + { + console.WriteLine($"nugraph {GetVersion()}"); + return 0; + } + + var source = settings.GetSource(); + var graphUrl = await console.Status().StartAsync($"Generating dependency graph for {source}", async _ => + { + var graph = await source.Match( + f => ComputeDependencyGraphAsync(f, settings), + f => ComputeDependencyGraphAsync(f, settings, new SpectreLogger(console, LogLevel.Debug), CancellationToken.None) + ); + return await WriteGraphAsync(graph, settings); + }); + + if (graphUrl != null) + { + var url = graphUrl.ToString(); + console.WriteLine(url); + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + else if (settings.OutputFile != null) + { + console.MarkupLineInterpolated($"The {source} dependency graph has been written to [lime]{new Uri(settings.OutputFile.FullName)}[/]"); + } + + return 0; + } + + private static string GetVersion() + { + var assembly = typeof(GraphCommand).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion ?? assembly.GetCustomAttribute()?.Version; + if (version == null) + return "0.0.0"; + + return SemanticVersion.TryParse(version, out var semanticVersion) ? semanticVersion.ToNormalizedString() : version; + } + + private static async Task ComputeDependencyGraphAsync(FileSystemInfo? source, GraphCommandSettings settings) + { + var projectInfo = await Dotnet.RestoreAsync(source); + var targetFramework = settings.Framework ?? projectInfo.TargetFrameworks.First(); + var lockFile = new LockFileFormat().Read(projectInfo.ProjectAssetsFile); + Predicate filter = projectInfo.CopyLocalPackages.Count > 0 ? package => projectInfo.CopyLocalPackages.Contains(package.Name) : _ => true; + var (packages, roots) = lockFile.ReadPackages(targetFramework, settings.RuntimeIdentifier, filter); + return new DependencyGraph(packages, roots, ignores: settings.GraphIgnore); + } + + private static async Task ComputeDependencyGraphAsync(string[] packageIds, GraphCommandSettings settings, ILogger logger, CancellationToken cancellationToken) + { + var nugetSettings = Settings.LoadDefaultSettings(null); + using var sourceCacheContext = new SourceCacheContext(); + var packageSources = GetPackageSources(nugetSettings, logger); + var packageIdentityResolver = new NuGetPackageResolver(nugetSettings, logger, packageSources, sourceCacheContext); + + var packageInformation = new ConcurrentBag(); + var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = settings.MaxDegreeOfParallelism, CancellationToken = cancellationToken }; + await Parallel.ForEachAsync(packageIds, parallelOptions, async (packageId, ct) => + { + var packageInfo = await packageIdentityResolver.ResolvePackageInfoAsync(packageId, ct); + packageInformation.Add(packageInfo); + }); + + var dependencyGraphSpec = new DependencyGraphSpec(isReadOnly: true); + var projectName = $"dependency graph of {string.Join(", ", packageInformation.Select(e => e.PackageIdentity.Id))}"; + // TODO: Figure out how to best guess which framework to use if none is specified. + var targetFramework = packageInformation.First().DependencyGroups.Select(e => e.TargetFramework).OrderBy(e => e, NuGetFrameworkSorter.Instance).ToList(); + var framework = settings.Framework == null ? targetFramework.First() : NuGetFramework.Parse(settings.Framework); + IList targetFrameworks = [ new TargetFrameworkInformation { FrameworkName = framework } ]; + var projectSpec = new PackageSpec(targetFrameworks) + { + FilePath = projectName, + Name = projectName, + RestoreMetadata = new ProjectRestoreMetadata + { + ProjectName = projectName, + ProjectPath = projectName, + ProjectUniqueName = Guid.NewGuid().ToString(), + ProjectStyle = ProjectStyle.PackageReference, + // The output path is required, else we get NuGet.Commands.RestoreSpecException: Invalid restore input. Missing required property 'OutputPath' for project type 'PackageReference'. + // But it won't be used anyway since restore is performed with RestoreRunner.RunWithoutCommit instead of RestoreRunner.RunAsync + OutputPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.InternetCache), "nugraph"), + OriginalTargetFrameworks = targetFrameworks.Select(e => e.ToString()).ToList(), + Sources = packageSources, + }, + Dependencies = packageInformation.Select(e => new LibraryDependency(new LibraryRange(e.PackageIdentity.Id, new VersionRange(e.PackageIdentity.Version), LibraryDependencyTarget.Package))).ToList(), + }; + dependencyGraphSpec.AddProject(projectSpec); + dependencyGraphSpec.AddRestore(projectSpec.RestoreMetadata.ProjectUniqueName); + + var restoreCommandProvidersCache = new RestoreCommandProvidersCache(); + var dependencyGraphSpecRequestProvider = new DependencyGraphSpecRequestProvider(restoreCommandProvidersCache, dependencyGraphSpec, nugetSettings); + var restoreContext = new RestoreArgs + { + CacheContext = sourceCacheContext, + Log = logger, + GlobalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings), + PreLoadedRequestProviders = [ dependencyGraphSpecRequestProvider ], + }; + + var requests = await RestoreRunner.GetRequests(restoreContext); + // TODO: Single() => how can I be sure? If only one request? And how can I be sure that there's only one request created out of the restore context? + var restoreResultPair = (await RestoreRunner.RunWithoutCommit(requests, restoreContext)).Single(); + // TODO: filter log messages, only those with LogLevel == Error ? + if (!restoreResultPair.Result.Success) + throw new Exception(string.Join(Environment.NewLine, restoreResultPair.Result.LogMessages.Select(e => $"[{e.Code}] {e.Message}"))); + + var lockFile = restoreResultPair.Result.LockFile; + // TODO: build the package and roots out of restoreResultPair.Result.RestoreGraphs instead of the lock file? + var (packages, roots) = lockFile.ReadPackages(targetFrameworks.First().TargetAlias, settings.RuntimeIdentifier); + return new DependencyGraph(packages, roots, settings.GraphIgnore); + } + + private static IList GetPackageSources(ISettings settings, ILogger logger) + { + var packageSourceProvider = new PackageSourceProvider(settings); + var packageSources = packageSourceProvider.LoadPackageSources().Where(e => e.IsEnabled).Distinct().ToList(); + + if (packageSources.Count == 0) + { + var officialPackageSource = new PackageSource(NuGetConstants.V3FeedUrl, NuGetConstants.NuGetHostName); + packageSources.Add(officialPackageSource); + var configFilePaths = settings.GetConfigFilePaths().Distinct(); + logger.LogWarning($"No NuGet sources could be found in {string.Join(", ", configFilePaths)}. Using {officialPackageSource}"); + } + + return packageSources; + } + + private static async Task WriteGraphAsync(DependencyGraph graph, GraphCommandSettings settings) + { + await using var fileStream = settings.OutputFile?.OpenWrite(); + await using var memoryStream = fileStream == null ? new MemoryStream(capacity: 2048) : null; + var stream = (fileStream ?? memoryStream as Stream)!; + await using (var streamWriter = new StreamWriter(stream, leaveOpen: true)) + { + var isMermaid = fileStream == null || Path.GetExtension(fileStream.Name) is ".mmd" or ".mermaid"; + var graphWriter = isMermaid ? GraphWriter.Mermaid(streamWriter) : GraphWriter.Graphviz(streamWriter); + var graphOptions = new GraphOptions + { + Direction = settings.GraphDirection, + IncludeVersions = settings.GraphIncludeVersions, + WriteIgnoredPackages = settings.GraphWriteIgnoredPackages, + }; + graphWriter.Write(graph, graphOptions); + } + + return memoryStream == null ? null : Mermaid.GetLiveEditorUri(memoryStream.GetBuffer().AsSpan(0, Convert.ToInt32(memoryStream.Position)), settings.MermaidEditorMode); + } +} \ No newline at end of file diff --git a/src/nugraph/GraphCommandSettings.cs b/src/nugraph/GraphCommandSettings.cs new file mode 100644 index 0000000..2fe189a --- /dev/null +++ b/src/nugraph/GraphCommandSettings.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Chisel; +using Spectre.Console.Cli; + +namespace nugraph; + +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global", Justification = "Required for Spectre.Console.Cli binding")] +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global", Justification = "Required for Spectre.Console.Cli binding")] +internal class GraphCommandSettings : CommandSettings +{ + // TODO: Support only one package? Users can create a project with multiple package reference. + [CommandArgument(0, "[SOURCE]")] + [Description("The source of the graph. Can be either a directory containing a .NET project, a .NET project file (csproj) or names of NuGet packages.")] + public string[] Sources { get; init; } = []; + + // TODO: perform of NuGet package id (including version) in the Validate method + internal FileOrPackages GetSource() + { + if (Sources.Length == 0) + return (FileSystemInfo?)null; + + if (Sources.Length == 1) + { + var file = new FileInfo(Sources[0]); + if (file.Exists) + { + return file; + } + + var directory = new DirectoryInfo(Sources[0]); + if (directory.Exists) + { + return directory; + } + } + + return Sources; + } + + [CommandOption("-V|--version")] + [Description("Prints version information")] + public bool PrintVersion { get; init; } + + [CommandOption("-o|--output ")] + [Description("The path to the dependency graph output file. If not specified, the dependency graph URL is written on the standard output and opened in the browser.")] + public FileInfo? OutputFile { get; init; } + + [CommandOption("-f|--framework ")] + [Description("The target framework to consider when building the dependency graph.")] + public string? Framework { get; init; } + + [CommandOption("-r|--runtime ")] + [Description("The target runtime to consider when building the dependency graph.")] + public string? RuntimeIdentifier { get; init; } + + // TODO: option to choose Mermaid with https://mermaid.live vs Graphviz/DOT with https://edotor.net + + // TODO: option to disable opening the url in the default web browser in case (thus only printing the URL on stdout) + + [CommandOption("-m|--mode ")] + [Description($"The mode to use for the Mermaid Live Editor (https://mermaid.live). Possible values are [b]{nameof(MermaidEditorMode.View)}[/] and [b]{nameof(MermaidEditorMode.Edit)}[/]. " + + $"Used only when no output path is specified.")] + [DefaultValue(MermaidEditorMode.View)] + public MermaidEditorMode MermaidEditorMode { get; init; } + + [CommandOption("-d|--direction ")] + [Description($"The direction of the dependency graph. Possible values are [b]{nameof(GraphDirection.LeftToRight)}[/] and [b]{nameof(GraphDirection.TopToBottom)}[/]")] + [DefaultValue(GraphDirection.LeftToRight)] + public GraphDirection GraphDirection { get; init; } + + [CommandOption("-v|--include-version")] + [Description("Include package versions in the dependency graph. E.g. [b]Serilog/3.1.1[/] instead of [b]Serilog[/]")] + [DefaultValue(false)] + public bool GraphIncludeVersions { get; init; } + + [CommandOption("-i|--ignore")] + [Description("Packages to ignore in the dependency graph. May be used multiple times.")] + public string[] GraphIgnore { get; init; } = []; + + // TODO: Add an option to control the minimum log level, default to Warning + + [CommandOption("--include-ignored-packages", IsHidden = true)] + [Description("Include ignored packages in the dependency graph. Used for debugging.")] + [DefaultValue(false)] + public bool GraphWriteIgnoredPackages { get; init; } + + [CommandOption("--parallel", IsHidden = true)] + [Description("The maximum degree of parallelism.")] + [DefaultValue(16)] + public int MaxDegreeOfParallelism { get; init; } +} \ No newline at end of file diff --git a/src/nugraph/JsonPipeTarget.cs b/src/nugraph/JsonPipeTarget.cs new file mode 100644 index 0000000..66c35fc --- /dev/null +++ b/src/nugraph/JsonPipeTarget.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using CliWrap; + +namespace nugraph; + +internal class JsonPipeTarget(JsonTypeInfo jsonTypeInfo, Func exception) : PipeTarget +{ + private T? _result; + private JsonException? _exception; + + public override async Task CopyFromAsync(Stream stream, CancellationToken cancellationToken = default) + { + try + { + _result = await JsonSerializer.DeserializeAsync(stream, jsonTypeInfo, cancellationToken) ?? throw exception(); + } + catch (JsonException jsonException) + { + _exception = jsonException; + } + } + + public T Result + { + get + { + if (_result == null) + { + if (_exception != null) + throw _exception; + + throw new InvalidOperationException($"Result is only available after {nameof(CopyFromAsync)} has executed."); + } + return _result; + } + } +} \ No newline at end of file diff --git a/src/nugraph/Mermaid.cs b/src/nugraph/Mermaid.cs new file mode 100644 index 0000000..0e44d1d --- /dev/null +++ b/src/nugraph/Mermaid.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text.Json; + +namespace nugraph; + +internal enum MermaidEditorMode +{ + Edit, + View, +} + +internal static class Mermaid +{ + public static Uri GetLiveEditorUri(ReadOnlySpan data, MermaidEditorMode mode) + { + using var memoryStream = new MemoryStream(capacity: 2048); + using (var zlibStream = new ZLibStream(memoryStream, CompressionLevel.SmallestSize, leaveOpen: true)) + using (var writer = new Utf8JsonWriter(zlibStream)) + { + // See https://github.com/mermaid-js/mermaid-live-editor/blob/dc72838036719637f3947a7c16c0cbbdeba0d73b/src/lib/types.d.ts#L21-L31 + // And https://github.com/mermaid-js/mermaid-live-editor/blob/dc72838036719637f3947a7c16c0cbbdeba0d73b/src/lib/util/state.ts#L10-L23 + writer.WriteStartObject(); + writer.WriteString("code"u8, data); + writer.WriteString("mermaid"u8, """{"theme":"default"}"""u8); + writer.WriteBoolean("panZoom"u8, true); + writer.WriteEndObject(); + } + + // See https://github.com/mermaid-js/mermaid-live-editor/discussions/1291 + var payload = Convert.ToBase64String(memoryStream.GetBuffer().AsSpan(0, Convert.ToInt32(memoryStream.Position))).Replace("/", "_").Replace("+", "-"); + return new Uri($"https://mermaid.live/{mode.ToString().ToLowerInvariant()}#pako:{payload}"); + } +} \ No newline at end of file diff --git a/src/nugraph/NuGetPackageResolver.cs b/src/nugraph/NuGetPackageResolver.cs new file mode 100644 index 0000000..fa03fcd --- /dev/null +++ b/src/nugraph/NuGetPackageResolver.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Packaging.Core; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace nugraph; + +public class NuGetPackageResolver +{ + private readonly ISettings _settings; + private readonly ILogger _logger; + private readonly IList _packageSources; + private readonly SourceCacheContext _sourceCacheContext; + + public NuGetPackageResolver(ISettings settings, ILogger logger, IList packageSources, SourceCacheContext sourceCacheContext) + { + _settings = settings; + _logger = logger; + _packageSources = packageSources; + _sourceCacheContext = sourceCacheContext; + } + + /// + /// Resolves a NuGet package by searching the configured package sources. + /// + /// The NuGet package identifier. Can optionally contain a version, for example: Serilog/3.1.1. + /// The that can be used to cancel the operation. + /// + /// The package identity. If no version is specified in the then the latest non-prerelease version of the package is used. + /// If the package only has prerelease versions, then the latest prerelease version is used. + /// Returns if no package is found in any of the configured package sources. + /// + public async Task ResolvePackageInfoAsync(string packageId, CancellationToken cancellationToken) + { + var request = GetPackageIdentityRequest(packageId); + var packageSources = GetPackageSources(request); + + using var sourceCacheContext = new SourceCacheContext(); + foreach (var sourceRepository in packageSources.Select(e => Repository.Factory.GetCoreV3(e))) + { + var packageIdentity = await GetPackageIdentityAsync(request, sourceRepository, cancellationToken); + if (packageIdentity != null) + { + var findPackageByIdResource = await sourceRepository.GetResourceAsync(cancellationToken); + return await findPackageByIdResource.GetDependencyInfoAsync(packageIdentity.Id, packageIdentity.Version, sourceCacheContext, _logger, cancellationToken); + } + } + + if (packageSources.Count == 1) + { + throw new Exception($"Package {packageId} was not found in {packageSources[0]}"); + } + + throw new Exception($"Package {packageId} was not found. The following sources were searched {string.Join(", ", packageSources.Select(e => e.ToString()))}"); + } + + private IList GetPackageSources(PackageIdentityRequest request) + { + var packageSourceMapping = PackageSourceMapping.GetPackageSourceMapping(_settings); + if (packageSourceMapping.IsEnabled) + { + var sourceNames = packageSourceMapping.GetConfiguredPackageSources(request.Id); + return _packageSources.Where(e => sourceNames.Contains(e.Name)).ToList(); + } + + return _packageSources; + } + + private async Task GetPackageIdentityAsync(PackageIdentityRequest request, SourceRepository sourceRepository, CancellationToken cancellationToken) + { + var metadataResource = await sourceRepository.GetResourceAsync(cancellationToken); + if (request.Version != null) + { + var identity = new PackageIdentity(request.Id, request.Version); + _logger.LogDebug($"Verifying if {request} exists in {sourceRepository.PackageSource}"); + var exists = await metadataResource.Exists(identity, _sourceCacheContext, _logger, cancellationToken); + _logger.LogDebug($" => {request} {(exists ? "found" : "not found")}"); + return exists ? identity : null; + } + + _logger.LogDebug($"Getting last release version of {request} in {sourceRepository.PackageSource}"); + var latestReleaseVersion = await metadataResource.GetLatestVersion(request.Id, includePrerelease: false, includeUnlisted: false, _sourceCacheContext, _logger, cancellationToken); + _logger.LogDebug($" => {request}{(latestReleaseVersion == null ? " not found" : $"/{latestReleaseVersion}")}"); + if (latestReleaseVersion is not null) + { + return new PackageIdentity(request.Id, latestReleaseVersion); + } + _logger.LogDebug($"Getting last pre-release version of {request} in {sourceRepository.PackageSource}"); + var latestPrereleaseVersion = await metadataResource.GetLatestVersion(request.Id, includePrerelease: true, includeUnlisted: false, _sourceCacheContext, _logger, cancellationToken); + _logger.LogDebug($" => {request}{(latestPrereleaseVersion == null ? " not found" : $"/{latestPrereleaseVersion}")}"); + return latestPrereleaseVersion is null ? null : new PackageIdentity(request.Id, latestPrereleaseVersion); + } + + private static PackageIdentityRequest GetPackageIdentityRequest(string packageId) + { + var parts = packageId.Split('/'); + if (parts.Length == 2) + { + if (NuGetVersion.TryParse(parts[1], out var version)) + { + return new PackageIdentityRequest(parts[0], version); + } + + throw new ArgumentException($"Version {parts[1]} for package {parts[0]} is not a valid NuGet version."); + } + + return new PackageIdentityRequest(packageId, Version: null); + } + + private record PackageIdentityRequest(string Id, NuGetVersion? Version) + { + public override string ToString() => Version == null ? Id : $"{Id}/{Version}"; + } +} \ No newline at end of file diff --git a/src/nugraph/Program.cs b/src/nugraph/Program.cs new file mode 100644 index 0000000..dc8738d --- /dev/null +++ b/src/nugraph/Program.cs @@ -0,0 +1,28 @@ +using nugraph; +using Spectre.Console; +using Spectre.Console.Cli; + +var app = new CommandApp(); +app.Configure(config => +{ + // TODO: add some more examples + config.AddExample("spectre.console/src/Spectre.Console.Cli/Spectre.Console.Cli.csproj", "-v"); + config.AddExample("Serilog.Sinks.MSSqlServer", "--ignore", "Microsoft.Data.SqlClient"); +#if DEBUG + config.ValidateExamples(); +#endif + config.ConfigureConsole(RedirectionFriendlyConsole.Out); + config.SetExceptionHandler((exception, _) => + { + if (exception is CommandAppException commandAppException) + { + RedirectionFriendlyConsole.Error.Write(commandAppException.Pretty ?? Markup.FromInterpolated($"[red]Error:[/] {exception.Message}\n")); + app.Run(["--help"]); + return 64; // EX_USAGE -- The command was used incorrectly, e.g., with the wrong number of arguments, a bad flag, a bad syntax in a parameter, or whatever. + } + + RedirectionFriendlyConsole.Error.WriteException(exception); + return 70; // EX_SOFTWARE -- An internal software error has been detected. + }); +}); +return app.Run(args); diff --git a/src/nugraph/RedirectionFriendlyConsole.cs b/src/nugraph/RedirectionFriendlyConsole.cs new file mode 100644 index 0000000..4079907 --- /dev/null +++ b/src/nugraph/RedirectionFriendlyConsole.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using Spectre.Console; + +namespace nugraph; + +public static class RedirectionFriendlyConsole +{ + public static IAnsiConsole Out { get; } = CreateRedirectionFriendlyConsole(Console.Out); + public static IAnsiConsole Error { get; } = CreateRedirectionFriendlyConsole(Console.Error); + + private static IAnsiConsole CreateRedirectionFriendlyConsole(TextWriter textWriter) + { + var output = new RedirectionFriendlyAnsiConsoleOutput(new AnsiConsoleOutput(textWriter)); + var settings = new AnsiConsoleSettings + { + Out = output, + Ansi = output.IsTerminal ? AnsiSupport.Detect : AnsiSupport.No, + }; + return AnsiConsole.Create(settings); + } + + private sealed class RedirectionFriendlyAnsiConsoleOutput(IAnsiConsoleOutput ansiConsoleOutput) : IAnsiConsoleOutput + { + public TextWriter Writer + { + get + { + var count = 0; + while (ansiConsoleOutput.Width == 80 && Environment.GetEnvironmentVariable("IDEA_INITIAL_DIRECTORY") != null && count < 100) + { + // Because of https://youtrack.jetbrains.com/issue/IJPL-112721/Terminal-width-is-applied-asynchronously-which-leads-to-inconsistent-line-breaking-on-Windows + Thread.Sleep(millisecondsTimeout: 10); + count++; + } + return ansiConsoleOutput.Writer; + } + } + + public void SetEncoding(Encoding encoding) => ansiConsoleOutput.SetEncoding(encoding); + public bool IsTerminal => ansiConsoleOutput.IsTerminal; + public int Width => IsTerminal ? ansiConsoleOutput.Width : 320; + public int Height => IsTerminal ? ansiConsoleOutput.Height : 240; + } +} \ No newline at end of file diff --git a/src/nugraph/SpectreLogger.cs b/src/nugraph/SpectreLogger.cs new file mode 100644 index 0000000..03a1489 --- /dev/null +++ b/src/nugraph/SpectreLogger.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using NuGet.Common; +using Spectre.Console; + +namespace nugraph; + +internal class SpectreLogger(IAnsiConsole console, LogLevel minimumLevel) : LoggerBase +{ + public override void Log(ILogMessage message) + { + if (message.Level < minimumLevel) + return; + + var color = GetColor(message.Level); + console.WriteLine(message.Code == NuGetLogCode.Undefined ? message.Message : $"[{message.Code}] {message.Message}", color); + } + + public override Task LogAsync(ILogMessage message) + { + Log(message); + return Task.CompletedTask; + } + + private static Color GetColor(LogLevel level) + { + return level switch + { + LogLevel.Debug => Color.Grey74, + LogLevel.Verbose => Color.Grey58, + LogLevel.Information => Color.Black, + LogLevel.Minimal => Color.Black, + LogLevel.Warning => Color.Orange1, + LogLevel.Error => Color.Red, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, $"The value of argument '{nameof(level)}' ({level}) is invalid for enum type '{nameof(LogLevel)}'.") + }; + } +} \ No newline at end of file diff --git a/src/nugraph/nugraph.csproj b/src/nugraph/nugraph.csproj new file mode 100644 index 0000000..1cbabb9 --- /dev/null +++ b/src/nugraph/nugraph.csproj @@ -0,0 +1,36 @@ + + + + Exe + net8.0 + enable + + + + true + nugraph + LatestMajor + + + + + + + + + + + + + + + + + + + + + + + +