diff --git a/.agents/rules/code-style.md b/.agents/rules/code-style.md index 085234d..afba457 100644 --- a/.agents/rules/code-style.md +++ b/.agents/rules/code-style.md @@ -3,14 +3,14 @@ These are enforced by `rustfmt` and Clippy — do not fight them: - **Formatting**: `cargo fmt` defaults (4 spaces, 100-column soft width). - Run `cargo fmt --check` before committing. -- **Lints**: `cargo clippy --all-targets -- -D warnings` must be clean. Fix + Run `cargo fmt --all --check` before committing. +- **Lints**: `cargo clippy --workspace --all-targets -- -D warnings` must be clean. Fix lints — do not suppress them without a strong, documented reason. - **File length**: keep source files cohesive and modular (target `<= 500 LOC` per file); extract helpers or modules when approaching the limit. -- **Module boundaries**: keep them tidy — the single crate splits into - `crates/` when `record` lands, so code that will move together should - already live together. +- **Crate boundaries**: keep them aligned with `plan.md` — CLI output in + `buildtrace-cli`, report model in `buildtrace-core`, manifest reading in + `buildtrace-cargo`. Code that will move together should already live together. - **No dead code**: remove it or gate it behind the ticket that needs it. No commented-out code blocks and no leftover debug output. diff --git a/.agents/rules/development-commands.md b/.agents/rules/development-commands.md index fb51736..701580e 100644 --- a/.agents/rules/development-commands.md +++ b/.agents/rules/development-commands.md @@ -1,28 +1,30 @@ ## Development Commands -Run all commands from the **repo root** unless otherwise specified. +Run all commands from the **repo root** unless otherwise specified. The repo +is a Cargo workspace (`crates/`); `buildtrace-linux` and `buildtrace-policy` +arrive with `record`/`propose`. ```bash # Check formatting (must pass) -cargo fmt --check +cargo fmt --all --check # Lint with warnings denied (must pass) -cargo clippy --all-targets -- -D warnings +cargo clippy --workspace --all-targets -- -D warnings # Run the full test suite (must pass) -cargo test +cargo test --workspace # Run a single test by name -cargo test +cargo test --workspace -# Build the binary -cargo build +# Build all crates +cargo build --workspace # Run inventory against the current directory -cargo run -- inventory +cargo run -p buildtrace-cli -- inventory # Run inventory against another manifest -cargo run -- inventory --manifest-path +cargo run -p buildtrace-cli -- inventory --manifest-path ``` The installed binary is named `cargo-buildtrace`, so `cargo buildtrace diff --git a/.agents/rules/technology-stack.md b/.agents/rules/technology-stack.md index 49d2896..30bcd61 100644 --- a/.agents/rules/technology-stack.md +++ b/.agents/rules/technology-stack.md @@ -3,6 +3,7 @@ | Layer | Technology | |---|---| | Language | Rust, edition 2024 | +| 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) | diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6daaa6e..b40de01 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 - name: Check formatting - run: cargo fmt --check + run: cargo fmt --all --check - name: Clippy - run: cargo clippy -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: Build - run: cargo build --verbose + run: cargo build --workspace --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --workspace --verbose diff --git a/AGENTS.md b/AGENTS.md index c17bad8..2ce98cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ This is a single-context repository using `CONTEXT.md` and `docs/adr/`. See `doc BuildTrace is a Linux-first Cargo build capability recorder and policy-diff tool. - **Facts over assumptions**: inspect source code and verify contracts directly. Do not rely on unverified assumptions. -- **Layout**: single `buildtrace` crate now; split into `crates/` when `record` lands. Keep module boundaries tidy so the split stays mechanical. -- **Gates**: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` must pass before a task counts as done. -- **Local-only files**: `plan.md`, `CONTEXT.md`, `docs/adr/`, `docs/agents/`, and `.scratch/` stay untracked. Never commit them. +- **Layout**: Cargo workspace with `crates/` from day one per `plan.md` (`buildtrace-cli`, `buildtrace-core`, `buildtrace-cargo` now; `buildtrace-linux`, `buildtrace-policy` arrive with `record`/`propose`). Keep crate boundaries tidy. +- **Gates**: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` must pass before a task counts as done. +- **Local-only files**: Never commit them gitignored files. - **Clean git hygiene**: never commit secrets, API keys, `.env` files, or gitignored build output. ## Workspace Rules diff --git a/Cargo.lock b/Cargo.lock index 7867bd3..a5c0e34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,10 +53,25 @@ dependencies = [ ] [[package]] -name = "buildtrace" +name = "buildtrace-cargo" version = "0.0.1" dependencies = [ + "buildtrace-core", +] + +[[package]] +name = "buildtrace-cli" +version = "0.0.1" +dependencies = [ + "buildtrace-cargo", + "buildtrace-core", "clap", +] + +[[package]] +name = "buildtrace-core" +version = "0.0.1" +dependencies = [ "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4c6c075..aac3b05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,7 @@ -[package] -name = "buildtrace" -version = "0.0.1" -edition = "2024" -license = "MIT OR Apache-2.0" - -[[bin]] -name = "cargo-buildtrace" -path = "src/main.rs" - -[dependencies] -clap = { version = "4.6.7", features = ["derive"] } -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" +[workspace] +members = [ + "crates/buildtrace-core", + "crates/buildtrace-cargo", + "crates/buildtrace-cli", +] +resolver = "2" diff --git a/crates/buildtrace-cargo/Cargo.toml b/crates/buildtrace-cargo/Cargo.toml new file mode 100644 index 0000000..13be791 --- /dev/null +++ b/crates/buildtrace-cargo/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "buildtrace-cargo" +version = "0.0.1" +edition = "2024" +license = "MIT OR Apache-2.0" + +[dependencies] +buildtrace-core = { path = "../buildtrace-core" } diff --git a/crates/buildtrace-cargo/src/lib.rs b/crates/buildtrace-cargo/src/lib.rs new file mode 100644 index 0000000..ce62b72 --- /dev/null +++ b/crates/buildtrace-cargo/src/lib.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +use buildtrace_core::{Report, sort_report}; + +#[derive(Debug)] +pub struct InventoryError(String); + +impl std::fmt::Display for InventoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The single test seam: everything (CLI and tests) goes through here. +/// +/// v0.0.1 skeleton: validates the manifest is readable and returns an empty +/// report. Real package identities arrive with metadata support. +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 mut report = Report::default(); + sort_report(&mut report); + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn crate_manifest() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml") + } + + #[test] + fn inventory_accepts_readable_manifest() { + let report = inventory(&crate_manifest()).expect("crate manifest reads"); + assert!(report.packages.is_empty()); + } + + #[test] + fn inventory_rejects_missing_manifest() { + let err = inventory(Path::new("does-not-exist-Cargo.toml")).expect_err("must fail"); + assert!(err.to_string().contains("cannot read manifest")); + } +} diff --git a/crates/buildtrace-cli/Cargo.toml b/crates/buildtrace-cli/Cargo.toml new file mode 100644 index 0000000..8f8a740 --- /dev/null +++ b/crates/buildtrace-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "buildtrace-cli" +version = "0.0.1" +edition = "2024" +license = "MIT OR Apache-2.0" + +[[bin]] +name = "cargo-buildtrace" +path = "src/main.rs" + +[dependencies] +buildtrace-cargo = { path = "../buildtrace-cargo" } +buildtrace-core = { path = "../buildtrace-core" } +clap = { version = "4.6.7", features = ["derive"] } diff --git a/crates/buildtrace-cli/src/main.rs b/crates/buildtrace-cli/src/main.rs new file mode 100644 index 0000000..b5d2744 --- /dev/null +++ b/crates/buildtrace-cli/src/main.rs @@ -0,0 +1,59 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use buildtrace_cargo::{InventoryError, inventory}; +use buildtrace_core::{render_human, render_json}; +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command( + name = "cargo-buildtrace", + version, + about = "List dependency build scripts and procedural macros without executing them" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List build-time executables without executing them + Inventory { + /// Path to the manifest to inspect (defaults to ./Cargo.toml) + #[arg(long, default_value = "Cargo.toml")] + manifest_path: PathBuf, + /// Output format + #[arg(long, value_enum, default_value_t = Format::Human)] + format: Format, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum Format { + Human, + Json, +} + +fn run(cli: Cli) -> Result<(), InventoryError> { + let Command::Inventory { + manifest_path, + format, + } = cli.command; + let report = inventory(&manifest_path)?; + match format { + Format::Human => print!("{}", render_human(&report)), + Format::Json => println!("{}", render_json(&report)), + } + Ok(()) +} + +fn main() -> ExitCode { + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + ExitCode::from(2) + } + } +} diff --git a/crates/buildtrace-core/Cargo.toml b/crates/buildtrace-core/Cargo.toml new file mode 100644 index 0000000..e9a91f2 --- /dev/null +++ b/crates/buildtrace-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "buildtrace-core" +version = "0.0.1" +edition = "2024" +license = "MIT OR Apache-2.0" + +[dependencies] +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" diff --git a/crates/buildtrace-core/src/lib.rs b/crates/buildtrace-core/src/lib.rs new file mode 100644 index 0000000..cf11e21 --- /dev/null +++ b/crates/buildtrace-core/src/lib.rs @@ -0,0 +1,115 @@ +use serde::Serialize; + +pub const REPORT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecutableKind { + BuildScript, + ProcMacro, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PackageEntry { + pub name: String, + pub version: String, + pub source: String, + pub checksum: Option, + pub kind: ExecutableKind, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Report { + pub packages: Vec, +} + +pub fn sort_report(report: &mut Report) { + report + .packages + .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); +} + +pub fn render_human(report: &Report) -> String { + let mut out = String::from("Build-time executables in this dependency graph\n"); + for (title, kind) in [ + ("Build scripts", ExecutableKind::BuildScript), + ("Procedural macros", ExecutableKind::ProcMacro), + ] { + out.push_str(&format!("\n{title}\n")); + let mut any = false; + for entry in report.packages.iter().filter(|e| e.kind == kind) { + any = true; + out.push_str(&format!( + " {} {} {}\n", + entry.name, entry.version, entry.source + )); + } + if !any { + out.push_str(" (none)\n"); + } + } + out +} + +#[derive(Serialize)] +struct JsonReport<'a> { + version: u32, + packages: &'a [PackageEntry], +} + +pub fn render_json(report: &Report) -> String { + let payload = JsonReport { + version: REPORT_VERSION, + packages: &report.packages, + }; + serde_json::to_string_pretty(&payload).expect("report serializes") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn empty_report_renders_both_sections() { + let text = render_human(&Report::default()); + assert!(text.contains("Build scripts")); + assert!(text.contains("Procedural macros")); + } + + #[test] + fn empty_report_json_is_versioned_and_empty() { + let value: Value = + serde_json::from_str(&render_json(&Report::default())).expect("valid JSON"); + assert_eq!(value["version"], Value::from(REPORT_VERSION)); + assert_eq!(value["packages"], Value::Array(vec![])); + } + + #[test] + fn report_sorts_by_name_then_version() { + let mut report = Report { + packages: vec![ + entry("b", "2.0.0"), + entry("a", "1.1.0"), + entry("a", "1.0.0"), + ], + }; + sort_report(&mut report); + let names: Vec<(&str, &str)> = report + .packages + .iter() + .map(|e| (e.name.as_str(), e.version.as_str())) + .collect(); + assert_eq!(names, vec![("a", "1.0.0"), ("a", "1.1.0"), ("b", "2.0.0")]); + } + + fn entry(name: &str, version: &str) -> PackageEntry { + PackageEntry { + name: name.to_string(), + version: version.to_string(), + source: "path+test".to_string(), + checksum: None, + kind: ExecutableKind::BuildScript, + } + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 1addf95..0000000 --- a/src/main.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::ExitCode; - -use clap::{Parser, Subcommand, ValueEnum}; -use serde::Serialize; - -const REPORT_VERSION: u32 = 1; - -#[derive(Debug, Parser)] -#[command( - name = "cargo-buildtrace", - version, - about = "Record what dependency build scripts and procedural macros do during a build" -)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// List build-time executables without executing them - Inventory { - /// Path to the manifest to inspect (defaults to ./Cargo.toml) - #[arg(long, default_value = "Cargo.toml")] - manifest_path: PathBuf, - /// Output format - #[arg(long, value_enum, default_value_t = Format::Human)] - format: Format, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -enum Format { - Human, - Json, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum ExecutableKind { - BuildScript, - ProcMacro, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct PackageEntry { - name: String, - version: String, - source: String, - checksum: Option, - kind: ExecutableKind, -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct Report { - packages: Vec, -} - -#[derive(Debug)] -struct InventoryError(String); - -impl std::fmt::Display for InventoryError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -fn sort_report(report: &mut Report) { - report - .packages - .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); -} - -/// The single test seam: everything (CLI and tests) goes through here. -/// -/// v0.0.1 skeleton: validates the manifest is readable and returns an empty -/// report. Real package identities arrive with metadata support. -fn inventory(manifest_path: &Path) -> Result { - std::fs::read(manifest_path).map_err(|err| { - InventoryError(format!( - "cannot read manifest {}: {err}", - manifest_path.display() - )) - })?; - let mut report = Report::default(); - sort_report(&mut report); - Ok(report) -} - -fn render_human(report: &Report) -> String { - let mut out = String::from("Build-time executables in this dependency graph\n"); - for (title, kind) in [ - ("Build scripts", ExecutableKind::BuildScript), - ("Procedural macros", ExecutableKind::ProcMacro), - ] { - out.push_str(&format!("\n{title}\n")); - let mut any = false; - for entry in report.packages.iter().filter(|e| e.kind == kind) { - any = true; - out.push_str(&format!( - " {} {} {}\n", - entry.name, entry.version, entry.source - )); - } - if !any { - out.push_str(" (none)\n"); - } - } - out -} - -#[derive(Serialize)] -struct JsonReport<'a> { - version: u32, - packages: &'a [PackageEntry], -} - -fn render_json(report: &Report) -> String { - let payload = JsonReport { - version: REPORT_VERSION, - packages: &report.packages, - }; - serde_json::to_string_pretty(&payload).expect("report serializes") -} - -fn run(cli: Cli) -> Result<(), InventoryError> { - let Command::Inventory { - manifest_path, - format, - } = cli.command; - let report = inventory(&manifest_path)?; - match format { - Format::Human => print!("{}", render_human(&report)), - Format::Json => println!("{}", render_json(&report)), - } - Ok(()) -} - -fn main() -> ExitCode { - match run(Cli::parse()) { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("error: {err}"); - ExitCode::from(2) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::Value; - - #[test] - fn empty_report_renders_both_sections() { - let text = render_human(&Report::default()); - assert!(text.contains("Build scripts")); - assert!(text.contains("Procedural macros")); - } - - #[test] - fn empty_report_json_is_versioned_and_empty() { - let value: Value = - serde_json::from_str(&render_json(&Report::default())).expect("valid JSON"); - assert_eq!(value["version"], Value::from(REPORT_VERSION)); - assert_eq!(value["packages"], Value::Array(vec![])); - } - - #[test] - fn inventory_accepts_readable_manifest() { - let report = inventory(Path::new("Cargo.toml")).expect("repo manifest reads"); - assert!(report.packages.is_empty()); - } - - #[test] - fn inventory_rejects_missing_manifest() { - let err = inventory(Path::new("does-not-exist-Cargo.toml")).expect_err("must fail"); - assert!(err.to_string().contains("cannot read manifest")); - } - - #[test] - fn report_sorts_by_name_then_version() { - let mut report = Report { - packages: vec![ - entry("b", "2.0.0"), - entry("a", "1.1.0"), - entry("a", "1.0.0"), - ], - }; - sort_report(&mut report); - let names: Vec<(&str, &str)> = report - .packages - .iter() - .map(|e| (e.name.as_str(), e.version.as_str())) - .collect(); - assert_eq!(names, vec![("a", "1.0.0"), ("a", "1.1.0"), ("b", "2.0.0")]); - } - - fn entry(name: &str, version: &str) -> PackageEntry { - PackageEntry { - name: name.to_string(), - version: version.to_string(), - source: "path+test".to_string(), - checksum: None, - kind: ExecutableKind::BuildScript, - } - } -}