From 5a08733d2c04808e6ffabff3b1c8840140373254 Mon Sep 17 00:00:00 2001 From: "Christopher S. Aondona" Date: Sat, 26 Sep 2026 13:53:25 +0100 Subject: [PATCH 1/2] feat: list real build-time executables from metadata buildtrace-cargo inventory resolves the target graph with cargo_metadata (read-only, never builds), detects custom-build and proc-macro targets, and pins checksum-or-revision via cargo-lock. Adds path-only fixture workspaces (build script, proc macro, neither) with tests through the inventory seam. --- .agents/rules/technology-stack.md | 2 +- Cargo.lock | 432 ++++++++++++++++++ crates/buildtrace-cargo/Cargo.toml | 2 + crates/buildtrace-cargo/src/lib.rs | 202 +++++++- tests/fixtures/has-build-script/Cargo.lock | 14 + tests/fixtures/has-build-script/Cargo.toml | 9 + tests/fixtures/has-build-script/build.rs | 3 + .../has-build-script/fixture-plain/Cargo.toml | 4 + .../has-build-script/fixture-plain/src/lib.rs | 3 + tests/fixtures/has-build-script/src/main.rs | 1 + tests/fixtures/has-neither/Cargo.lock | 7 + tests/fixtures/has-neither/Cargo.toml | 6 + tests/fixtures/has-neither/src/main.rs | 1 + tests/fixtures/has-proc-macro/Cargo.lock | 7 + tests/fixtures/has-proc-macro/Cargo.toml | 9 + tests/fixtures/has-proc-macro/src/lib.rs | 6 + 16 files changed, 694 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/has-build-script/Cargo.lock create mode 100644 tests/fixtures/has-build-script/Cargo.toml create mode 100644 tests/fixtures/has-build-script/build.rs create mode 100644 tests/fixtures/has-build-script/fixture-plain/Cargo.toml create mode 100644 tests/fixtures/has-build-script/fixture-plain/src/lib.rs create mode 100644 tests/fixtures/has-build-script/src/main.rs create mode 100644 tests/fixtures/has-neither/Cargo.lock create mode 100644 tests/fixtures/has-neither/Cargo.toml create mode 100644 tests/fixtures/has-neither/src/main.rs create mode 100644 tests/fixtures/has-proc-macro/Cargo.lock create mode 100644 tests/fixtures/has-proc-macro/Cargo.toml create mode 100644 tests/fixtures/has-proc-macro/src/lib.rs diff --git a/.agents/rules/technology-stack.md b/.agents/rules/technology-stack.md index 30bcd61..201e5f4 100644 --- a/.agents/rules/technology-stack.md +++ b/.agents/rules/technology-stack.md @@ -6,7 +6,7 @@ | Layout | Cargo workspace (`crates/`): `buildtrace-cli` (commands, output, exit codes), `buildtrace-core` (report model, rendering), `buildtrace-cargo` (manifest reading, attribution); `buildtrace-linux` + `buildtrace-policy` arrive with `record`/`propose` | | CLI | `clap` (derive) — binary name `cargo-buildtrace` so `cargo buildtrace` dispatch works | | Serialization | `serde` + `serde_json` | -| Cargo metadata | `cargo_metadata` (metadata only — never execute dependency code) | +| Cargo metadata | `cargo_metadata` (read-only `cargo metadata` resolution — never build commands, never executes dependency code) + `cargo-lock` (checksum-or-revision from the lockfile, never a hand-rolled parser) | | Formatter | `rustfmt` via `cargo fmt` | | Linter | Clippy via `cargo clippy -- -D warnings` | | Testing | `cargo test` (in-module units, `tests/` integration, snapshots) | diff --git a/Cargo.lock b/Cargo.lock index a5c0e34..634baea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,8 @@ name = "buildtrace-cargo" version = "0.0.1" dependencies = [ "buildtrace-core", + "cargo-lock", + "cargo_metadata", ] [[package]] @@ -76,6 +78,51 @@ dependencies = [ "serde_json", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-lock" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50524592e6bfbb1bf6f94e8184a786f637faeaf1cf37bbe65502b9a2c5c48939" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "clap" version = "4.6.7" @@ -122,12 +169,158 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -140,6 +333,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "memchr" version = "2.8.3" @@ -152,6 +351,21 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -170,6 +384,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.229" @@ -213,6 +437,27 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -230,12 +475,110 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -257,6 +600,95 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/buildtrace-cargo/Cargo.toml b/crates/buildtrace-cargo/Cargo.toml index 13be791..7d8e655 100644 --- a/crates/buildtrace-cargo/Cargo.toml +++ b/crates/buildtrace-cargo/Cargo.toml @@ -6,3 +6,5 @@ license = "MIT OR Apache-2.0" [dependencies] buildtrace-core = { path = "../buildtrace-core" } +cargo-lock = "11.1.0" +cargo_metadata = "0.23.1" diff --git a/crates/buildtrace-cargo/src/lib.rs b/crates/buildtrace-cargo/src/lib.rs index ce62b72..385fa8f 100644 --- a/crates/buildtrace-cargo/src/lib.rs +++ b/crates/buildtrace-cargo/src/lib.rs @@ -1,6 +1,9 @@ use std::path::Path; -use buildtrace_core::{Report, sort_report}; +use buildtrace_core::{ExecutableKind, PackageEntry, Report, sort_report}; +use cargo_lock::Lockfile; +use cargo_metadata::camino::Utf8PathBuf; +use cargo_metadata::{Metadata, MetadataCommand, Package, TargetKind}; #[derive(Debug)] pub struct InventoryError(String); @@ -11,18 +14,135 @@ impl std::fmt::Display for InventoryError { } } -/// The single test seam: everything (CLI and tests) goes through here. +/// Read-only resolution query via the maintained `cargo_metadata` crate. /// -/// v0.0.1 skeleton: validates the manifest is readable and returns an empty -/// report. Real package identities arrive with metadata support. +/// `cargo metadata` resolves the dependency graph from manifests, the +/// lockfile, and cached sources. It never compiles or executes dependency +/// code and writes no build output. Build commands (`build`, `check`, +/// `test`, `run`) are never invoked — that is the "never run a `cargo` +/// command" guarantee in ADR-0002. +fn load_metadata(manifest_path: &Path) -> Result { + MetadataCommand::new() + .manifest_path(manifest_path) + .exec() + .map_err(|err| { + InventoryError(format!( + "cannot read manifest {}: {err}", + manifest_path.display() + )) + }) +} + +struct LockRow { + name: String, + version: String, + source: Option, + checksum: Option, +} + +/// Checksums and revisions come from the lockfile next to the target +/// workspace root, parsed with the maintained `cargo-lock` crate — never a +/// hand-rolled parser, per `plan.md`. A missing lockfile means checksums are +/// unknown, not an error; an unreadable one fails loudly. +fn load_lock_rows(workspace_root: &Utf8PathBuf) -> Result, InventoryError> { + let lock_path = workspace_root.join("Cargo.lock"); + if !lock_path.is_file() { + return Ok(Vec::new()); + } + let lockfile = Lockfile::load(lock_path.as_std_path()) + .map_err(|err| InventoryError(format!("cannot parse lockfile {lock_path}: {err}")))?; + Ok(lockfile + .packages + .iter() + .map(|package| LockRow { + name: package.name.to_string(), + version: package.version.to_string(), + source: package.source.as_ref().map(ToString::to_string), + checksum: package.checksum.as_ref().map(ToString::to_string), + }) + .collect()) +} + +/// Best-effort checksum-or-revision for one package: an exact +/// (name, version, source) lockfile hit wins, otherwise the first checksum +/// recorded for that name and version. A Git revision embedded in the source +/// string (after `#`) is the fallback when the lockfile has nothing. +fn checksum_for(rows: &[LockRow], name: &str, version: &str, source: &str) -> Option { + let mut fallback = None; + for row in rows + .iter() + .filter(|row| row.name == name && row.version == version) + { + let Some(checksum) = row.checksum.clone() else { + continue; + }; + if row.source.as_deref() == Some(source) { + return Some(checksum); + } + if fallback.is_none() { + fallback = Some(checksum); + } + } + fallback.or_else(|| git_revision(source)) +} + +fn git_revision(source: &str) -> Option { + source.split('#').nth(1).map(str::to_string) +} + +/// Exact source string. Workspace members carry no registry/Git/path source +/// in metadata, so their identity falls back to the manifest directory as a +/// `path+file://` URL — still exact, still local. +fn source_of(package: &Package) -> String { + if let Some(source) = &package.source { + return source.repr.clone(); + } + match package.manifest_path.parent() { + Some(dir) => format!("path+file://{dir}"), + None => "path+unknown".to_string(), + } +} + +/// Which build-time executable kinds a package contributes, if any. +/// Detection only: custom build targets for build scripts, `proc-macro` +/// target kinds for procedural macros. Nothing is compiled or executed. +fn kinds_of(package: &Package) -> Vec { + let mut kinds = Vec::new(); + let has = |want: TargetKind| { + package + .targets + .iter() + .any(|target| target.kind.contains(&want)) + }; + if has(TargetKind::CustomBuild) { + kinds.push(ExecutableKind::BuildScript); + } + if has(TargetKind::ProcMacro) { + kinds.push(ExecutableKind::ProcMacro); + } + kinds +} + +/// The single test seam: everything (CLI and tests) goes through here. pub fn inventory(manifest_path: &Path) -> Result { - std::fs::read(manifest_path).map_err(|err| { - InventoryError(format!( - "cannot read manifest {}: {err}", - manifest_path.display() - )) - })?; + let metadata = load_metadata(manifest_path)?; + let lock_rows = load_lock_rows(&metadata.workspace_root)?; let mut report = Report::default(); + for package in &metadata.packages { + let name = package.name.to_string(); + let version = package.version.to_string(); + let source = source_of(package); + let checksum = checksum_for(&lock_rows, &name, &version, &source); + for kind in kinds_of(package) { + report.packages.push(PackageEntry { + name: name.clone(), + version: version.clone(), + source: source.clone(), + checksum: checksum.clone(), + kind, + }); + } + } sort_report(&mut report); Ok(report) } @@ -32,14 +152,22 @@ mod tests { use super::*; use std::path::PathBuf; + use buildtrace_core::ExecutableKind; + fn crate_manifest() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml") } + fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures") + .join(name) + .join("Cargo.toml") + } + #[test] - fn inventory_accepts_readable_manifest() { - let report = inventory(&crate_manifest()).expect("crate manifest reads"); - assert!(report.packages.is_empty()); + fn inventory_succeeds_on_readable_manifest() { + inventory(&crate_manifest()).expect("crate manifest reads"); } #[test] @@ -47,4 +175,52 @@ mod tests { let err = inventory(Path::new("does-not-exist-Cargo.toml")).expect_err("must fail"); assert!(err.to_string().contains("cannot read manifest")); } + + #[test] + fn build_script_fixture_lists_build_script_with_identity() { + let report = inventory(&fixture("has-build-script")).expect("fixture reads"); + assert_eq!(report.packages.len(), 1); + let entry = &report.packages[0]; + assert_eq!(entry.name, "fixture-build-script"); + assert_eq!(entry.version, "0.1.0"); + assert!( + entry.source.starts_with("path+"), + "source: {}", + entry.source + ); + assert_eq!(entry.checksum, None); + assert_eq!(entry.kind, ExecutableKind::BuildScript); + } + + #[test] + fn packages_without_build_targets_are_excluded() { + let report = inventory(&fixture("has-build-script")).expect("fixture reads"); + assert!( + report.packages.iter().all(|e| e.name != "fixture-plain"), + "plain lib must be excluded: {:?}", + report.packages + ); + } + + #[test] + fn proc_macro_fixture_lists_proc_macro_with_identity() { + let report = inventory(&fixture("has-proc-macro")).expect("fixture reads"); + assert_eq!(report.packages.len(), 1); + let entry = &report.packages[0]; + assert_eq!(entry.name, "fixture-proc-macro"); + assert_eq!(entry.version, "0.1.0"); + assert!( + entry.source.starts_with("path+"), + "source: {}", + entry.source + ); + assert_eq!(entry.checksum, None); + assert_eq!(entry.kind, ExecutableKind::ProcMacro); + } + + #[test] + fn neither_fixture_reports_empty() { + let report = inventory(&fixture("has-neither")).expect("fixture reads"); + assert!(report.packages.is_empty()); + } } diff --git a/tests/fixtures/has-build-script/Cargo.lock b/tests/fixtures/has-build-script/Cargo.lock new file mode 100644 index 0000000..d763248 --- /dev/null +++ b/tests/fixtures/has-build-script/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fixture-build-script" +version = "0.1.0" +dependencies = [ + "fixture-plain", +] + +[[package]] +name = "fixture-plain" +version = "0.1.0" diff --git a/tests/fixtures/has-build-script/Cargo.toml b/tests/fixtures/has-build-script/Cargo.toml new file mode 100644 index 0000000..75c9636 --- /dev/null +++ b/tests/fixtures/has-build-script/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fixture-build-script" +version = "0.1.0" +edition = "2021" + +[dependencies] +fixture-plain = { path = "fixture-plain" } + +[workspace] diff --git a/tests/fixtures/has-build-script/build.rs b/tests/fixtures/has-build-script/build.rs new file mode 100644 index 0000000..9233dfa --- /dev/null +++ b/tests/fixtures/has-build-script/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo::rerun-if-changed=build.rs"); +} diff --git a/tests/fixtures/has-build-script/fixture-plain/Cargo.toml b/tests/fixtures/has-build-script/fixture-plain/Cargo.toml new file mode 100644 index 0000000..636186e --- /dev/null +++ b/tests/fixtures/has-build-script/fixture-plain/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "fixture-plain" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/has-build-script/fixture-plain/src/lib.rs b/tests/fixtures/has-build-script/fixture-plain/src/lib.rs new file mode 100644 index 0000000..9f157ee --- /dev/null +++ b/tests/fixtures/has-build-script/fixture-plain/src/lib.rs @@ -0,0 +1,3 @@ +pub fn helper() -> bool { + true +} diff --git a/tests/fixtures/has-build-script/src/main.rs b/tests/fixtures/has-build-script/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/tests/fixtures/has-build-script/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/tests/fixtures/has-neither/Cargo.lock b/tests/fixtures/has-neither/Cargo.lock new file mode 100644 index 0000000..326430a --- /dev/null +++ b/tests/fixtures/has-neither/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fixture-neither" +version = "0.1.0" diff --git a/tests/fixtures/has-neither/Cargo.toml b/tests/fixtures/has-neither/Cargo.toml new file mode 100644 index 0000000..f371db9 --- /dev/null +++ b/tests/fixtures/has-neither/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "fixture-neither" +version = "0.1.0" +edition = "2021" + +[workspace] diff --git a/tests/fixtures/has-neither/src/main.rs b/tests/fixtures/has-neither/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/tests/fixtures/has-neither/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/tests/fixtures/has-proc-macro/Cargo.lock b/tests/fixtures/has-proc-macro/Cargo.lock new file mode 100644 index 0000000..301aba9 --- /dev/null +++ b/tests/fixtures/has-proc-macro/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fixture-proc-macro" +version = "0.1.0" diff --git a/tests/fixtures/has-proc-macro/Cargo.toml b/tests/fixtures/has-proc-macro/Cargo.toml new file mode 100644 index 0000000..3ce30af --- /dev/null +++ b/tests/fixtures/has-proc-macro/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fixture-proc-macro" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[workspace] diff --git a/tests/fixtures/has-proc-macro/src/lib.rs b/tests/fixtures/has-proc-macro/src/lib.rs new file mode 100644 index 0000000..9fba6fc --- /dev/null +++ b/tests/fixtures/has-proc-macro/src/lib.rs @@ -0,0 +1,6 @@ +use proc_macro::TokenStream; + +#[proc_macro_attribute] +pub fn traced(_attr: TokenStream, item: TokenStream) -> TokenStream { + item +} From 248656a69712eaa0ac129a2b31349b8bf7abed06 Mon Sep 17 00:00:00 2001 From: "Christopher S. Aondona" Date: Sat, 26 Sep 2026 13:56:56 +0100 Subject: [PATCH 2/2] test: drive CLI binary against fixtures; pin exact fixture sources Addresses review on 5a08733: integration tests in crates/buildtrace-cli/tests/inventory.rs drive the built binary against all three fixtures (exit codes, human and JSON markers), and unit asserts now pin the fixture directory inside source URLs. No-execution, determinism, and snapshot tests stay with ticket 08. Tracker: .scratch/v0-0-1-minimal-release/issues/07-real-package-identities.md --- crates/buildtrace-cargo/src/lib.rs | 46 +++++++++------- crates/buildtrace-cli/tests/inventory.rs | 70 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 crates/buildtrace-cli/tests/inventory.rs diff --git a/crates/buildtrace-cargo/src/lib.rs b/crates/buildtrace-cargo/src/lib.rs index 385fa8f..5da9666 100644 --- a/crates/buildtrace-cargo/src/lib.rs +++ b/crates/buildtrace-cargo/src/lib.rs @@ -179,17 +179,12 @@ mod tests { #[test] fn build_script_fixture_lists_build_script_with_identity() { let report = inventory(&fixture("has-build-script")).expect("fixture reads"); - assert_eq!(report.packages.len(), 1); - let entry = &report.packages[0]; - assert_eq!(entry.name, "fixture-build-script"); - assert_eq!(entry.version, "0.1.0"); - assert!( - entry.source.starts_with("path+"), - "source: {}", - entry.source + assert_single_entry( + &report, + "fixture-build-script", + "has-build-script", + ExecutableKind::BuildScript, ); - assert_eq!(entry.checksum, None); - assert_eq!(entry.kind, ExecutableKind::BuildScript); } #[test] @@ -205,22 +200,35 @@ mod tests { #[test] fn proc_macro_fixture_lists_proc_macro_with_identity() { let report = inventory(&fixture("has-proc-macro")).expect("fixture reads"); + assert_single_entry( + &report, + "fixture-proc-macro", + "has-proc-macro", + ExecutableKind::ProcMacro, + ); + } + + #[test] + fn neither_fixture_reports_empty() { + let report = inventory(&fixture("has-neither")).expect("fixture reads"); + assert!(report.packages.is_empty()); + } + + /// Path-only fixtures carry no lockfile checksum, so exactness here is + /// name, version, the fixture directory inside the source URL, and kind. + /// Registry/Git checksums resolve through `checksum_for` against real + /// lockfiles, which v0.0.1 cannot fixture without network (spec). + fn assert_single_entry(report: &Report, name: &str, fixture_dir: &str, kind: ExecutableKind) { assert_eq!(report.packages.len(), 1); let entry = &report.packages[0]; - assert_eq!(entry.name, "fixture-proc-macro"); + assert_eq!(entry.name, name); assert_eq!(entry.version, "0.1.0"); assert!( - entry.source.starts_with("path+"), + entry.source.starts_with("path+") && entry.source.contains(fixture_dir), "source: {}", entry.source ); assert_eq!(entry.checksum, None); - assert_eq!(entry.kind, ExecutableKind::ProcMacro); - } - - #[test] - fn neither_fixture_reports_empty() { - let report = inventory(&fixture("has-neither")).expect("fixture reads"); - assert!(report.packages.is_empty()); + assert_eq!(entry.kind, kind); } } diff --git a/crates/buildtrace-cli/tests/inventory.rs b/crates/buildtrace-cli/tests/inventory.rs new file mode 100644 index 0000000..ffca1b2 --- /dev/null +++ b/crates/buildtrace-cli/tests/inventory.rs @@ -0,0 +1,70 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn binary() -> PathBuf { + Path::new(env!("CARGO_BIN_EXE_cargo-buildtrace")).to_path_buf() +} + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures") + .join(name) + .join("Cargo.toml") +} + +fn inventory(args: &[&str]) -> Output { + Command::new(binary()) + .arg("inventory") + .args(args) + .output() + .expect("inventory runs") +} + +fn stdout_text(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout is utf8") +} + +#[test] +fn build_script_fixture_lists_package_and_exits_zero() { + let manifest = fixture("has-build-script"); + let output = inventory(&["--manifest-path", &manifest.to_string_lossy()]); + assert!(output.status.success()); + let text = stdout_text(&output); + assert!(text.contains("Build scripts")); + assert!(text.contains("fixture-build-script 0.1.0")); + assert!(!text.contains("fixture-plain")); +} + +#[test] +fn proc_macro_fixture_json_is_versioned_and_exits_zero() { + let manifest = fixture("has-proc-macro"); + let output = inventory(&[ + "--format", + "json", + "--manifest-path", + &manifest.to_string_lossy(), + ]); + assert!(output.status.success()); + let text = stdout_text(&output); + assert!(text.contains("\"version\": 1")); + assert!(text.contains("fixture-proc-macro")); + assert!(text.contains("proc-macro")); +} + +#[test] +fn neither_fixture_reports_empty_and_exits_zero() { + let manifest = fixture("has-neither"); + let output = inventory(&["--manifest-path", &manifest.to_string_lossy()]); + assert!(output.status.success()); + let text = stdout_text(&output); + assert!(text.contains("Procedural macros")); + assert!(text.contains("(none)")); +} + +#[test] +fn missing_manifest_exits_two_with_diagnostic() { + let output = inventory(&["--manifest-path", "does-not-exist-Cargo.toml"]); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("stderr is utf8"); + assert!(stderr.contains("cannot read manifest")); +}