git-ents.gitmain
⌘K
foforge
commit e7fb0aa
feat: validate toolchain license/version/platform and add a rustup import recipe

Toolchain::license is now a required SPDX license expression instead of an arbitrary string, and two new required fields, version (semver) and platform (target triple), are validated the same way at import time — the closest available standard for each, since there is no SPDX-equivalent registry for platforms. git ents toolchain import --from rustup --spec <name> derives bin/src/license/version/platform from a local rustup install instead of requiring them by hand, the first entry in what is meant to grow into a small recipe registry for common toolchains.

feat: add version and platform fields to Toolchain, validated via spdx/semver/target-lexicon feat: add --from/--spec recipe support to git ents toolchain import feat: add a rustup import recipe deriving bin/src/license/version/platform via rustc -vV docs: describe the new toolchain fields and import --from in storage.adoc and cli.adoc Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1518,6 +1518,9 @@ "facet-git-tree", "git-store", "gix", + "semver", + "spdx", + "target-lexicon", "tempfile", "thiserror 2.0.18", ] @@ -3773,6 +3776,15 @@ "windows-sys 0.61.2", ] +[[package]] +name = "spdx" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8da593e30beb790fc9424502eb898320b44e5eb30367dbda1c1edde8e2f32d7" +dependencies = [ + "smallvec 1.15.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3880,6 +3892,12 @@ "yaml-rust", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0"
Cargo.toml @@ -66,6 +66,9 @@ maud = { version = "0.27", features = ["axum"] } pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } rstest = "0.26" +semver = "1" +spdx = "0.13" +target-lexicon = "0.13" tempfile = "3" thiserror = "2" tokio = { version = "1", features = [
crates/git-toolchain/Cargo.toml @@ -10,6 +10,9 @@ facet-git-tree = { workspace = true } git-store = { workspace = true } gix = { workspace = true } +semver = { workspace = true } +spdx = { workspace = true } +target-lexicon = { workspace = true } thiserror = { workspace = true } [dev-dependencies]
docs/spec/cli.adoc @@ -69,14 +69,22 @@ The CLI MUST provide, under `git ents toolchain`: * `import` — write a local `bin` directory (and, optionally, a `src` - directory) plus a license to `refs/meta/toolchains/<name>` on a remote and - push it, creating the ref when absent. `bin` MUST be non-empty and - `license` MUST be non-empty. + directory) plus a license, version, and platform to + `refs/meta/toolchains/<name>` on a remote and push it, creating the ref + when absent. `bin` MUST be non-empty; `license` MUST be a valid SPDX + license expression; `version` MUST be a valid semver version; `platform` + MUST be a valid target triple. +* `import --from <recipe>` — derive `bin`/`src`/`license`/`version`/ + `platform` from a local toolchain install instead of supplying them by + hand, via a named recipe (currently only `rustup`, selected further by + `--spec <name>`, e.g. `stable`); any of the fields also passed explicitly + override the recipe's value. * `list` — render every toolchain configured on a remote with its `bin` - tree id and license. + tree id, version, platform, and license. * `export` — recreate a remote's named toolchain's `bin` (and `src`, if present) under a local destination directory, restoring the executable bit - and symlinks, and print the license; read-only, per <<cli.remote-admin>>. + and symlinks, and print the version, platform, and license; read-only, per + <<cli.remote-admin>>. * `remove` — delete the toolchain's ref on a remote. --
docs/spec/storage.adoc @@ -66,13 +66,18 @@ the identity rule cannot drift between collections. `toolchains/<name>` (`git-toolchain`) is a named collection like `member/<username>`, and its item is a `Facet` document like any other — a `bin` directory tree, an - optional `src` directory tree, and a license — but `bin` and `src` are each + optional `src` directory tree, an SPDX license expression, a semver + version, and a target-triple platform — but `bin` and `src` are each captured whole as a `facet_git_tree::RawTree`, a raw-passthrough field wrapping an already-written tree's object id rather than a directory - layout `Facet` could model field-by-field. `Store::store_tree`/`ref_tree` - write and read the document's root tree directly, since the two - directories underneath must be written into the object database before the - document that references them can be assembled. + layout `Facet` could model field-by-field. `license`, `version`, and + `platform` are plain strings validated against a real parser (`spdx`, + `semver`, `target-lexicon`) at import time rather than carried as a parsed + type, since nothing downstream needs more than the canonical string back. + `Store::store_tree`/`ref_tree` write and read the document's root tree + directly, since the two directories underneath must be written into the + object database before the document that references them can be + assembled. Authored collections:: A collection whose documents treat the commit as the record
crates/git-ents/src/main.rs @@ -13,6 +13,7 @@ mod debug_session; mod interactive; +mod registry; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -230,22 +231,38 @@ #[repr(u8)] enum ToolchainAction { /// Import a local directory as toolchain `name` on a remote and push it. - /// Prompts for any field left unset when run at an interactive terminal. + /// Prompts for any field left unset when run at an interactive terminal, + /// unless `--from` supplies it via a recipe. Import { /// Name to record the toolchain under (`toolchains/<name>`). #[facet(args::positional, default)] name: Option<String>, /// Directory of executables to import, activated on `PATH` when a - /// check requests this toolchain. + /// check requests this toolchain. Not needed with `--from`. #[facet(args::positional, default)] bin: Option<String>, /// Directory of source to import alongside `bin`, if any — kept for /// provenance, never activated on `PATH`. #[facet(args::named)] src: Option<String>, - /// License covering `bin` (and `src`, if given). + /// SPDX license expression covering `bin` (and `src`, if given). #[facet(args::named, default)] license: Option<String>, + /// Semver version of the toolchain being imported. + #[facet(args::named, default)] + version: Option<String>, + /// Target triple the toolchain runs on (`x86_64-unknown-linux-gnu`, + /// ...). + #[facet(args::named, default)] + platform: Option<String>, + /// Recipe to derive `bin`/`src`/`license`/`version`/`platform` from + /// instead of supplying them by hand (currently only `rustup`). + #[facet(args::named)] + from: Option<String>, + /// Recipe-specific selector (for `--from rustup`, the toolchain + /// name `rustup` itself knows, e.g. `stable`; defaults to `stable`). + #[facet(args::named)] + spec: Option<String>, }, /// List the toolchains configured on a remote. List, @@ -460,7 +477,13 @@ bin, src, license, - } => toolchain_import(name, bin, src, license, remote), + version, + platform, + from, + spec, + } => toolchain_import( + name, bin, src, license, version, platform, from, spec, remote, + ), ToolchainAction::List => toolchain_list(remote), ToolchainAction::Export { name, dest } => toolchain_export(&name, &dest, remote), ToolchainAction::Remove { name } => toolchain_remove(&name, remote), @@ -469,18 +492,54 @@ /// Import `bin`'s (and, optionally, `src`'s) contents as toolchain `name` on /// `remote` and push it. Prompts for any field left unset when run at an -/// interactive terminal. +/// interactive terminal, unless `from` names a recipe (`registry::resolve`) +/// to derive `bin`/`src`/`license`/`version`/`platform` from instead; +/// explicit flags still win over a recipe's values. +#[expect(clippy::too_many_arguments, reason = "one flag per import field")] fn toolchain_import( name: Option<String>, bin: Option<String>, src: Option<String>, license: Option<String>, + version: Option<String>, + platform: Option<String>, + from: Option<String>, + spec: Option<String>, remote: &str, ) -> Result<(), String> { let name = interactive::text_or(name, "Toolchain name")?; - let bin = interactive::text_or(bin, "Directory of executables to import")?; - let src = interactive::optional_text_or(src, "Directory of source to import (optional)")?; - let license = interactive::text_or(license, "License")?; + + let recipe = from + .map(|recipe| registry::resolve(&recipe, spec.as_deref().unwrap_or("stable"))) + .transpose()?; + + let bin = match bin.or_else(|| recipe.as_ref().map(|r| r.bin.display().to_string())) { + Some(bin) => bin, + None => interactive::text_or(None, "Directory of executables to import")?, + }; + let src = src.or_else(|| { + recipe + .as_ref() + .and_then(|r| r.src.as_ref().map(|s| s.display().to_string())) + }); + let src = if src.is_some() { + src + } else { + interactive::optional_text_or(None, "Directory of source to import (optional)")? + }; + let license = match license.or_else(|| recipe.as_ref().map(|r| r.license.clone())) { + Some(license) => license, + None => interactive::text_or(None, "License (SPDX expression)")?, + }; + let version = match version.or_else(|| recipe.as_ref().map(|r| r.version.clone())) { + Some(version) => version, + None => interactive::text_or(None, "Version (semver)")?, + }; + let platform = match platform.or_else(|| recipe.as_ref().map(|r| r.platform.clone())) { + Some(platform) => platform, + None => interactive::text_or(None, "Platform (target triple)")?, + }; + let refname = format!("{TOOLCHAINS_NS}/{name}"); let expected = sync(remote, &refname)?; let repo = repo()?; @@ -490,6 +549,8 @@ Path::new(&bin), src.as_deref().map(Path::new), &license, + &version, + &platform, ) .map_err(|error| error.to_string())?; push_signed(remote, &refname, expected.as_deref())?; @@ -497,7 +558,8 @@ Ok(()) } -/// Print every toolchain configured on `remote` as `<name> <bin> <license>`. +/// Print every toolchain configured on `remote` as +/// `<name> <bin> <version> <platform> <license>`. fn toolchain_list(remote: &str) -> Result<(), String> { let repo = repo()?; sync_namespace(remote, TOOLCHAINS_NS)?; @@ -508,8 +570,10 @@ } for (name, toolchain) in toolchains { println!( - "{name} {} {}", + "{name} {} {} {} {}", short_id(&toolchain.bin.oid().to_string()), + toolchain.version, + toolchain.platform, toolchain.license ); } @@ -525,8 +589,8 @@ let toolchain = git_toolchain::export(&repo, name, Path::new(dest)).map_err(|error| error.to_string())?; println!( - "exported toolchain {name} to {dest} (license: {})", - toolchain.license + "exported toolchain {name} to {dest} (version: {}, platform: {}, license: {})", + toolchain.version, toolchain.platform, toolchain.license ); Ok(()) }
crates/git-toolchain/src/lib.rs @@ -24,6 +24,7 @@ use std::fs; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use facet::Facet; use facet_git_tree::RawTree; @@ -39,12 +40,18 @@ pub const TOOLCHAINS_NS: &str = "refs/meta/toolchains"; /// A toolchain: an executable `bin` directory, an optional `src` directory, -/// and the license covering them — the document stored at the tip of -/// `refs/meta/toolchains/<name>`. +/// its license, version, and target platform — the document stored at the +/// tip of `refs/meta/toolchains/<name>`. /// /// `bin` and `src` are [`RawTree`]: each is captured as a single opaque git /// tree by [`import`], not walked field-by-field, since a toolchain's /// on-disk layout has no fixed shape for `Facet` to model. +/// +/// `license`, `version`, and `platform` are stored as plain strings — like +/// `license` before them, `version` and `platform` are validated against a +/// real parser (`semver`, `target-lexicon`) at [`import`] time rather than +/// carried as a parsed type, since nothing downstream needs more than the +/// canonical string back. #[derive(Debug, Clone, PartialEq, Facet)] pub struct Toolchain { /// The toolchain's executables, activated on `PATH` when a check @@ -53,8 +60,16 @@ /// The toolchain's source, if imported — not activated on `PATH`, kept /// only for provenance. pub src: Option<RawTree>, - /// The license covering `bin` (and `src`, if present). + /// The license covering `bin` (and `src`, if present), an SPDX license + /// expression (`MIT`, `Apache-2.0 WITH LLVM-exception`, ...). pub license: String, + /// The toolchain's version, a semver string. + pub version: String, + /// The toolchain's target platform, an LLVM/autotools-style target + /// triple (`x86_64-unknown-linux-gnu`, ...) — the closest thing to a + /// standard platform identifier; there is no SPDX-equivalent registry + /// for platforms. + pub platform: String, } /// A failure importing, resolving, listing, exporting, or removing a @@ -88,29 +103,44 @@ /// activates nothing on `PATH` is not a toolchain. #[error("{0} contains nothing importable; a toolchain's bin directory must not be empty")] EmptyBin(PathBuf), - /// [`import`]'s `license` argument was empty. - #[error("toolchain license must not be empty")] - EmptyLicense, + /// [`import`]'s `license` argument was not a valid SPDX license + /// expression. + #[error("{0:?} is not a valid SPDX license expression: {1}")] + InvalidLicense(String, spdx::ParseError), + /// [`import`]'s `version` argument was not a valid semver version. + #[error("{0:?} is not a valid semver version: {1}")] + InvalidVersion(String, semver::Error), + /// [`import`]'s `platform` argument was not a valid target triple. + #[error("{0:?} is not a valid target triple")] + InvalidPlatform(String), } /// Import `bin_dir` (and, optionally, `src_dir`) into `repo` as the /// toolchain `name`: write each directory tree bottom-up into the object -/// database, assemble a [`Toolchain`] document over them and `license`, and -/// fast-forward `refs/meta/toolchains/<name>` to a commit over it. Returns -/// the document's root tree object id. +/// database, assemble a [`Toolchain`] document over them, `license`, +/// `version`, and `platform`, and fast-forward `refs/meta/toolchains/<name>` +/// to a commit over it. Returns the document's root tree object id. +/// +/// `license` MUST be a valid SPDX license expression, `version` a valid +/// semver version, and `platform` a valid target triple. pub fn import( repo: &Path, name: &str, bin_dir: &Path, src_dir: Option<&Path>, license: &str, + version: &str, + platform: &str, ) -> Result<ObjectId, Error> { if !git_store::ref_segment_ok(name) { return Err(Error::InvalidName(name.to_owned())); } - if license.is_empty() { - return Err(Error::EmptyLicense); - } + spdx::Expression::parse(license) + .map_err(|error| Error::InvalidLicense(license.to_owned(), error))?; + semver::Version::parse(version) + .map_err(|error| Error::InvalidVersion(version.to_owned(), error))?; + target_lexicon::Triple::from_str(platform) + .map_err(|_error| Error::InvalidPlatform(platform.to_owned()))?; let odb = odb_at(repo)?; let bin_tree = build_tree(&odb, bin_dir)?; @@ -130,6 +160,8 @@ bin, src, license: license.to_owned(), + version: version.to_owned(), + platform: platform.to_owned(), }; let oid = facet_git_tree::serialize_into(&toolchain, &odb)?; @@ -363,6 +395,11 @@ use super::*; + /// A valid semver version and target triple, reused across tests that + /// only care about `license`. + const VERSION: &str = "1.0.0"; + const PLATFORM: &str = "x86_64-unknown-linux-gnu"; + /// A file, an executable, and (on unix) a symlink, plus an empty /// subdirectory — enough to exercise every branch of `write_entry`. fn populate(dir: &Path) { @@ -383,8 +420,26 @@ populate(a.path()); populate(b.path()); - let first = import(repo_dir.path(), "gcc", a.path(), None, "MIT").unwrap(); - let second = import(repo_dir.path(), "clang", b.path(), None, "MIT").unwrap(); + let first = import( + repo_dir.path(), + "gcc", + a.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); + let second = import( + repo_dir.path(), + "clang", + b.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); assert_eq!(first, second); } @@ -394,7 +449,16 @@ let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - import(repo_dir.path(), "gcc", dir.path(), None, "MIT").unwrap(); + import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); let toolchain = resolve(repo_dir.path(), "gcc").unwrap(); let odb = odb_at(repo_dir.path()).unwrap(); let mut buf = Vec::new(); @@ -408,7 +472,16 @@ let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - let oid = import(repo_dir.path(), "gcc", dir.path(), None, "MIT").unwrap(); + let oid = import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); let toolchain = resolve(repo_dir.path(), "gcc").unwrap(); assert_eq!(toolchain.license, "MIT"); assert!(toolchain.src.is_none()); @@ -425,7 +498,16 @@ let repo_dir = repo(); let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - import(repo_dir.path(), "gcc", dir.path(), None, "MIT").unwrap(); + import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); let dest = tempfile::tempdir().unwrap(); let dest_path = dest.path().join("out"); @@ -456,6 +538,8 @@ bin_dir.path(), Some(src_dir.path()), "MIT", + VERSION, + PLATFORM, ) .unwrap(); @@ -477,7 +561,16 @@ let repo_dir = repo(); let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - import(repo_dir.path(), "gcc", dir.path(), None, "MIT").unwrap(); + import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); let dest = tempfile::tempdir().unwrap(); fs::write(dest.path().join("already-here"), b"x").unwrap(); @@ -486,12 +579,54 @@ } #[test] - fn import_rejects_an_empty_license() { + fn import_rejects_an_invalid_license() { let repo_dir = repo(); let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - let result = import(repo_dir.path(), "gcc", dir.path(), None, ""); - assert!(matches!(result, Err(Error::EmptyLicense))); + let result = import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "not a license", + VERSION, + PLATFORM, + ); + assert!(matches!(result, Err(Error::InvalidLicense(_, _)))); + } + + #[test] + fn import_rejects_an_invalid_version() { + let repo_dir = repo(); + let dir = tempfile::tempdir().unwrap(); + populate(dir.path()); + let result = import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + "not-semver", + PLATFORM, + ); + assert!(matches!(result, Err(Error::InvalidVersion(_, _)))); + } + + #[test] + fn import_rejects_an_invalid_platform() { + let repo_dir = repo(); + let dir = tempfile::tempdir().unwrap(); + populate(dir.path()); + let result = import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + "not a platform!!", + ); + assert!(matches!(result, Err(Error::InvalidPlatform(_)))); } #[test] @@ -501,7 +636,15 @@ // Only an empty subdirectory: `build_tree` skips it, so `bin` ends up // with nothing importable. fs::create_dir(dir.path().join("empty")).unwrap(); - let result = import(repo_dir.path(), "gcc", dir.path(), None, "MIT"); + let result = import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ); assert!(matches!(result, Err(Error::EmptyBin(_)))); } @@ -513,8 +656,26 @@ populate(a.path()); fs::write(b.path().join("distinct"), b"x").unwrap(); - import(repo_dir.path(), "gcc", a.path(), None, "MIT").unwrap(); - import(repo_dir.path(), "clang", b.path(), None, "Apache-2.0").unwrap(); + import( + repo_dir.path(), + "gcc", + a.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); + import( + repo_dir.path(), + "clang", + b.path(), + None, + "Apache-2.0", + VERSION, + PLATFORM, + ) + .unwrap(); let mut listed = list(repo_dir.path()).unwrap(); listed.sort_by(|(a, _), (b, _)| a.cmp(b)); @@ -529,7 +690,16 @@ let repo_dir = repo(); let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - import(repo_dir.path(), "gcc", dir.path(), None, "MIT").unwrap(); + import( + repo_dir.path(), + "gcc", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ) + .unwrap(); remove(repo_dir.path(), "gcc").unwrap(); let _ = resolve(repo_dir.path(), "gcc").unwrap_err(); @@ -540,7 +710,15 @@ let repo_dir = repo(); let dir = tempfile::tempdir().unwrap(); populate(dir.path()); - let result = import(repo_dir.path(), "not/valid", dir.path(), None, "MIT"); + let result = import( + repo_dir.path(), + "not/valid", + dir.path(), + None, + "MIT", + VERSION, + PLATFORM, + ); assert!(matches!(result, Err(Error::InvalidName(_)))); } }
crates/git-ents/src/registry.rs @@ -1,0 +1,89 @@ +//! Recipes for `git ents toolchain import --from <recipe>`. +//! +//! A recipe derives `bin`/`src`/`license`/`version`/`platform` from a local +//! toolchain install a user already has (rustup, ...) instead of requiring +//! them to hand-supply paths and metadata `git-toolchain` itself has no way +//! to discover. This module only locates and describes what's already on +//! disk; it never installs a toolchain. + +use std::path::PathBuf; +use std::process::Command; + +/// What a recipe resolved from a local toolchain install, ready to hand to +/// `git_toolchain::import`. +pub struct Resolved { + pub bin: PathBuf, + pub src: Option<PathBuf>, + pub license: String, + pub version: String, + pub platform: String, +} + +/// Resolve `recipe` against `spec` (a recipe-specific selector, e.g. a +/// rustup toolchain name). The only recipe today is `rustup`. +pub fn resolve(recipe: &str, spec: &str) -> Result<Resolved, String> { + match recipe { + "rustup" => rustup(spec), + other => Err(format!( + "unknown toolchain recipe {other:?} (known: rustup)" + )), + } +} + +/// Resolve a rustup-managed toolchain named `spec` (e.g. `stable`, +/// `1.75.0`, `nightly`) via `rustc +<spec> -vV`, which reports the +/// toolchain's own `release` (its version) and `host` (its target platform) +/// without needing rustup's own metadata format. `bin` is `<sysroot>/bin`; +/// `src` is `<sysroot>/lib/rustlib/src/rust` when the `rust-src` component +/// is installed, else omitted. Rust's own toolchain is dual-licensed +/// `MIT OR Apache-2.0`. +fn rustup(spec: &str) -> Result<Resolved, String> { + let toolchain_arg = format!("+{spec}"); + let sysroot = rustc(&toolchain_arg, &["--print", "sysroot"])?; + let sysroot = PathBuf::from(sysroot.trim()); + + let verbose = rustc(&toolchain_arg, &["-vV"])?; + let version = verbose_field(&verbose, "release") + .ok_or_else(|| format!("rustc +{spec} -vV did not report a release"))?; + let platform = verbose_field(&verbose, "host") + .ok_or_else(|| format!("rustc +{spec} -vV did not report a host"))?; + + let bin = sysroot.join("bin"); + let src = sysroot.join("lib/rustlib/src/rust"); + let src = src.is_dir().then_some(src); + + Ok(Resolved { + bin, + src, + license: "MIT OR Apache-2.0".to_owned(), + version, + platform, + }) +} + +/// Run `rustc <toolchain_arg> <args>` and return its stdout, so a missing +/// toolchain or missing `rustc`/`rustup` shim surfaces as a plain error +/// rather than a panic. +fn rustc(toolchain_arg: &str, args: &[&str]) -> Result<String, String> { + let output = Command::new("rustc") + .arg(toolchain_arg) + .args(args) + .output() + .map_err(|error| format!("could not run rustc: {error}"))?; + if !output.status.success() { + return Err(format!( + "rustc {toolchain_arg} {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + )); + } + String::from_utf8(output.stdout).map_err(|_error| "rustc output was not valid UTF-8".to_owned()) +} + +/// Extract `<name>: <value>` from `rustc -vV`'s line-oriented output. +fn verbose_field(output: &str, name: &str) -> Option<String> { + output + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}: "))) + .map(str::to_owned) +}