feat: validate toolchain license/version/platform and add a rustup import recipe
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
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)
+}