| File: CargoMetadata.cs | Web Access |
| Project: src\src\Aspire.Hosting.Rust\Aspire.Hosting.Rust.csproj (Aspire.Hosting.Rust) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; namespace Aspire.Hosting.Rust; /// <summary> /// The subset of <c>cargo metadata --format-version 1 --no-deps</c> output that Aspire needs to work out /// which file a cargo build produces. /// </summary> internal sealed class CargoMetadata { /// <summary> /// Every package in the workspace (or the single package for a non-workspace crate). /// </summary> public required IReadOnlyList<CargoPackage> Packages { get; init; } /// <summary> /// The package ids cargo would build when no <c>-p</c>/<c>--package</c> is given. /// </summary> public required IReadOnlyList<string> DefaultMemberIds { get; init; } /// <summary> /// The absolute path of the directory cargo writes build output to. /// </summary> /// <remarks> /// This is not always <c><crate>/target</c>: <c>CARGO_TARGET_DIR</c>, <c>build.target-dir</c> in /// <c>.cargo/config.toml</c>, and <c>--target-dir</c> all move it, and a workspace member shares the /// workspace root's directory. Cargo resolves all of that and reports the answer, so debugging uses this /// rather than assuming the default layout. /// </remarks> public required string TargetDirectory { get; init; } /// <summary> /// Parses the JSON emitted by <c>cargo metadata --format-version 1 --no-deps</c>. /// </summary> /// <remarks> /// The document looks like this (trimmed to the fields used here): /// <code> /// { /// "packages": [ /// { /// "name": "my-app", /// "id": "path+file:///app#my-app@0.1.0", /// "default_run": "server", /// "rust_version": "1.89", /// "targets": [ /// { "kind": ["bin"], "crate_types": ["bin"], "name": "server", "src_path": "/app/src/bin/server.rs" }, /// { "kind": ["lib"], "crate_types": ["lib"], "name": "my_app", "src_path": "/app/src/lib.rs" } /// ] /// } /// ], /// "workspace_members": ["path+file:///app#my-app@0.1.0"], /// "workspace_default_members": ["path+file:///app#my-app@0.1.0"], /// "resolve": null, /// "target_directory": "/app/target" /// } /// </code> /// Notes on the shape that the parser has to tolerate: /// <list type="bullet"> /// <item><c>default_run</c> is <see langword="null"/> (or absent on old cargo) unless the manifest sets it.</item> /// <item><c>rust_version</c> is likewise absent unless the manifest declares an MSRV, and may be written /// with one, two, or three components.</item> /// <item><c>workspace_default_members</c> only exists from cargo 1.71. Older output is rejected because /// falling back to every workspace member would ignore an explicit <c>[workspace] default-members</c>.</item> /// <item>Package id syntax changed in cargo 1.77 from <c>"my-app 0.1.0 (path+file:///app)"</c> to /// <c>"path+file:///app#my-app@0.1.0"</c>. Ids are only ever compared against other ids from the same /// document, so both forms work without being parsed.</item> /// <item>A target's <c>kind</c> is an array because a single target can be several crate types at once /// (for example <c>["lib", "cdylib"]</c>), so a bin target is one whose kind array contains "bin".</item> /// </list> /// </remarks> public static CargoMetadata Parse(string json) { using var document = JsonDocument.Parse(json); var root = document.RootElement; var packages = new List<CargoPackage>(); if (root.TryGetProperty("packages", out var packagesElement) && packagesElement.ValueKind == JsonValueKind.Array) { foreach (var packageElement in packagesElement.EnumerateArray()) { packages.Add(ParsePackage(packageElement)); } } if (!root.TryGetProperty("workspace_default_members", out var defaultMembersElement) || defaultMembersElement.ValueKind != JsonValueKind.Array) { throw new DistributedApplicationException( "Aspire.Hosting.Rust requires Cargo 1.71 or later because this 'cargo metadata' output does not " + "include 'workspace_default_members'. Update the Rust toolchain and try again."); } return new CargoMetadata { Packages = packages, DefaultMemberIds = ReadStringArray(defaultMembersElement), TargetDirectory = root.TryGetProperty("target_directory", out var targetDirectoryElement) ? targetDirectoryElement.GetString() ?? string.Empty : string.Empty }; } private static CargoPackage ParsePackage(JsonElement element) { var binTargets = new List<string>(); if (element.TryGetProperty("targets", out var targetsElement) && targetsElement.ValueKind == JsonValueKind.Array) { foreach (var targetElement in targetsElement.EnumerateArray()) { if (IsBinTarget(targetElement) && targetElement.TryGetProperty("name", out var targetName) && targetName.GetString() is { Length: > 0 } name) { binTargets.Add(name); } } } return new CargoPackage { Name = element.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty, Id = element.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? string.Empty : string.Empty, DefaultRun = element.TryGetProperty("default_run", out var defaultRunElement) && defaultRunElement.ValueKind == JsonValueKind.String ? defaultRunElement.GetString() : null, BinTargetNames = binTargets }; } private static bool IsBinTarget(JsonElement targetElement) { if (!targetElement.TryGetProperty("kind", out var kindElement) || kindElement.ValueKind != JsonValueKind.Array) { return false; } foreach (var kind in kindElement.EnumerateArray()) { if (kind.ValueKind == JsonValueKind.String && kind.GetString() == "bin") { return true; } } return false; } private static List<string> ReadStringArray(JsonElement element) { var values = new List<string>(); if (element.ValueKind == JsonValueKind.Array) { foreach (var item in element.EnumerateArray()) { if (item.ValueKind == JsonValueKind.String && item.GetString() is { Length: > 0 } value) { values.Add(value); } } } return values; } } /// <summary> /// A single package from <c>cargo metadata</c> output. /// </summary> internal sealed class CargoPackage { /// <summary> /// The <c>[package] name</c> from the manifest. /// </summary> public required string Name { get; init; } /// <summary> /// The opaque cargo package id, used to match against the workspace's default members. /// </summary> public required string Id { get; init; } /// <summary> /// The <c>[package] default-run</c> value, if the manifest declares one. /// </summary> /// <remarks> /// <c>cargo run</c> honours <c>default-run</c> while <c>cargo build</c> ignores it, so publishing has to /// resolve it here to produce the same binary the resource runs locally. /// See https://doc.rust-lang.org/cargo/reference/manifest.html#the-default-run-field /// </remarks> public string? DefaultRun { get; init; } /// <summary> /// The names of the package's <c>bin</c> targets, in the order cargo reported them. /// </summary> /// <remarks> /// Binary target names are used verbatim as file names, so hyphens are NOT translated to underscores the /// way they are for library targets. /// See https://doc.rust-lang.org/cargo/reference/cargo-targets.html#binaries /// </remarks> public required IReadOnlyList<string> BinTargetNames { get; init; } }