diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27554c7..dbd281b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,19 +10,34 @@ permissions: jobs: lint: - name: shellcheck + syntax + name: fmt + clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - # shellcheck is preinstalled on ubuntu-latest runners. + - name: rustfmt + run: cargo fmt --all -- --check + - name: clippy + run: cargo clippy --all-targets --all-features -- -D warnings + - name: unit tests + run: cargo test --locked --all-features - name: shellcheck - run: shellcheck -S warning zc tests/run.sh + run: shellcheck -S warning tests/run.sh - name: bash syntax - run: | - bash -n zc - bash -n tests/run.sh + run: bash -n tests/run.sh + + msrv: + name: MSRV build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install the declared MSRV toolchain + run: rustup toolchain install 1.82 --profile minimal --no-self-update + - name: check + run: cargo +1.82 check --locked --all-features --all-targets test: name: integration tests @@ -55,5 +70,9 @@ jobs: with: baseline: ${{ steps.fixture.outputs.baseline }} working-directory: action-fixture + - name: Build zc + run: cargo build --release --locked - name: Run integration tests + env: + ZC: ${{ github.workspace }}/target/release/zc run: tests/run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c7dec66 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,237 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zc" +version = "0.3.0" +dependencies = [ + "hex", + "regex", + "serde", + "serde_json", + "sha1", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c9fc534 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "zc" +version = "0.3.0" +edition = "2021" +rust-version = "1.82" +description = "Detect public API and dependency changes across all workspace crates between two git refs." +license = "MIT OR Apache-2.0" +repository = "https://github.com/ZcashFoundation/zc" + +[[bin]] +name = "zc" +path = "src/main.rs" + +[dependencies] +hex = "0.4" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha1 = "0.10" + +[profile.release] +debug = false diff --git a/README.md b/README.md index d99e944..2204fa6 100644 --- a/README.md +++ b/README.md @@ -81,23 +81,18 @@ cargo install cargo-public-api --version 0.52.0 --locked rustup toolchain install nightly-2026-07-18 --profile minimal ``` -Then get `zc` itself — it's a single Bash script. Install it onto your `PATH`: +Then install `zc` itself, a single Rust binary: ```sh -curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc -o ~/.local/bin/zc -chmod +x ~/.local/bin/zc +cargo install --git https://github.com/ZcashFoundation/zc --tag v0.3.0 --locked ``` -…or run a one-off without installing: +…or from a checkout: ```sh -curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc | bash -s -- main +cargo install --path . --locked ``` -> When run via `curl … | bash`, `--help` shows only a short usage (the script -> can't re-read its own source from a pipe); install it to a file for the full -> reference. - ## Usage ```sh @@ -242,9 +237,7 @@ Then ask Claude to "produce the changelog for PR #N" (or invoke `/zc N`). ## Requirements -- Bash 4+ (associative arrays). On macOS, zc tries to re-exec with `bash` from PATH, `/opt/homebrew/bin/bash`, or `/usr/local/bin/bash` before failing. Install it with `brew install bash`. - [`cargo-public-api`](https://github.com/cargo-public-api/cargo-public-api) -- `jq` - a `nightly` toolchain for rustdoc JSON builds ## Exit codes diff --git a/action.yml b/action.yml index 3a279ca..9a97ff8 100644 --- a/action.yml +++ b/action.yml @@ -30,13 +30,9 @@ inputs: runs: using: composite steps: - - name: Check system dependencies + - name: Build zc shell: bash - run: | - if ! command -v jq >/dev/null 2>&1; then - echo "::error title=Missing zc dependency::Install jq on the runner before using this action." - exit 64 - fi + run: cargo install --path "$GITHUB_ACTION_PATH" --locked --root "$RUNNER_TEMP/zc-bin" - name: Install the Rust nightly used by zc shell: bash @@ -90,4 +86,4 @@ runs: arguments+=("$INPUT_HEAD") fi - "$GITHUB_ACTION_PATH/zc" "${arguments[@]}" + "$RUNNER_TEMP/zc-bin/bin/zc" "${arguments[@]}" diff --git a/skills/zc/SKILL.md b/skills/zc/SKILL.md index 9cd6894..864e6e7 100644 --- a/skills/zc/SKILL.md +++ b/skills/zc/SKILL.md @@ -13,7 +13,7 @@ description: >- Run `zc --changelog`, curate its draft into [librustzcash](https://github.com/zcash/librustzcash)-style entries, and **write them into the repo's `CHANGELOG.md` files**. Needs `zc` on `PATH` (plus -its prereqs: `cargo-public-api`, `jq`, a nightly toolchain). +its prereqs: `cargo-public-api`, a nightly toolchain). ## Quick start diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..f8b9e42 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,406 @@ +//! Rustdoc JSON builds and per-crate public API analysis. + +use std::fs::{self, File, FileTimes}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +use serde_json::Value; + +use crate::ctx::Ctx; +use crate::model::{ApiError, CrateResult, CrateStatus, ErrorStage}; +use crate::pubdep::{self, PubdepTables}; + +/// Returns the installed cargo-public-api version string. +pub fn cargo_public_api_version() -> Option { + successful_stdout(Command::new("cargo").args(["public-api", "--version"])) +} + +/// Returns the selected nightly toolchain, preferring a non-empty `ZC_TOOLCHAIN`. +pub fn nightly_toolchain() -> Option { + if let Some(toolchain) = std::env::var_os("ZC_TOOLCHAIN") { + let toolchain = toolchain.to_string_lossy(); + if !toolchain.is_empty() { + return Some(toolchain.into_owned()); + } + } + + let output = Command::new("rustup") + .args(["toolchain", "list"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .find(|word| word.starts_with("nightly")) + .map(str::to_owned) +} + +/// Returns the rustc version reported by the selected toolchain. +pub fn rustc_version(toolchain: &str) -> Option { + successful_stdout( + Command::new("rustc") + .arg(format!("+{toolchain}")) + .arg("--version"), + ) +} + +fn successful_stdout(command: &mut Command) -> Option { + let output = command.output().ok()?; + output + .status + .success() + .then(|| trim_command_substitution(&String::from_utf8_lossy(&output.stdout))) +} + +fn trim_command_substitution(text: &str) -> String { + text.trim_end_matches('\n').to_owned() +} + +/// Builds or loads rustdoc JSON for one crate at one ref. +pub fn rustdoc_json( + ctx: &Ctx, + crate_name: &str, + worktree: &Path, + target: &Path, + ref_sha: &str, +) -> Result { + let cacheable = ctx.api_json_cacheable(ref_sha); + let cache_name = format!("{}.{}.{}.api.json", ref_sha, ctx.cache.api_fp, crate_name); + let cache_path = ctx.cache.path(&cache_name); + + if cacheable && valid_cached_json(&cache_path) { + touch(&cache_path); + return Ok(cache_path); + } + + let mut command = Command::new("cargo"); + command + .arg(format!("+{}", ctx.toolchain)) + .args(["rustdoc", "-q", "--manifest-path"]) + .arg(worktree.join("Cargo.toml")) + .args(["-p", crate_name, "--lib"]) + .args(&ctx.feature_args) + .args(["--", "-Z", "unstable-options", "--output-format", "json"]) + .env("CARGO_TARGET_DIR", target); + + let output = command.output().map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + + let json_path = target + .join("doc") + .join(format!("{}.json", crate_name.replace('-', "_"))); + if !json_path.is_file() { + return Err(format!("rustdoc did not produce {}", json_path.display())); + } + + if cacheable { + ctx.cache.copy_atomic(&cache_name, &json_path); + } + + Ok(json_path) +} + +fn valid_cached_json(path: &Path) -> bool { + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + if contents.is_empty() { + return false; + } + + serde_json::from_str::(&contents).is_ok_and(|json| { + json.get("format_version").is_some() + && json.get("root").is_some() + && json.get("index").is_some() + }) +} + +fn touch(path: &Path) { + let Ok(file) = File::open(path) else { + return; + }; + let times = FileTimes::new().set_modified(SystemTime::now()); + let _ = file.set_times(times); +} + +/// Analyzes every crate in input order. +pub fn analyze(ctx: &Ctx, crates: &[String], tables: &PubdepTables) -> Vec { + let mut results = Vec::with_capacity(crates.len()); + + for (index, crate_name) in crates.iter().enumerate() { + ctx.progress.set(&format!( + "public-api: [{}/{}] {}", + index + 1, + crates.len(), + crate_name + )); + + let base_json = match rustdoc_json( + ctx, + crate_name, + &ctx.baseline_worktree, + &ctx.baseline_target, + &ctx.refs.baseline_sha, + ) { + Ok(path) => path, + Err(stderr) => { + results.push(failure_result( + ctx, + crate_name, + ErrorStage::BaselineBuild, + &ctx.refs.baseline_label, + &ctx.refs.baseline_sha, + &stderr, + )); + continue; + } + }; + + let head_json = match rustdoc_json( + ctx, + crate_name, + &ctx.head_worktree, + &ctx.head_target, + &ctx.refs.head_sha, + ) { + Ok(path) => path, + Err(stderr) => { + results.push(failure_result( + ctx, + crate_name, + ErrorStage::HeadBuild, + &ctx.refs.head_label, + &ctx.refs.head_sha, + &stderr, + )); + continue; + } + }; + + let mut command = Command::new("cargo"); + command + .arg("public-api") + .args(&ctx.feature_args) + .args(["-p", crate_name, "-ss", "diff"]) + .arg(&base_json) + .arg(&head_json) + .current_dir(&ctx.tmp.dir); + + let output = match command.output() { + Ok(output) if output.status.success() => output, + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + results.push(failure_result( + ctx, + crate_name, + ErrorStage::Diff, + &format!("{}..{}", ctx.refs.baseline_label, ctx.refs.head_label), + &format!("{}..{}", ctx.refs.baseline_sha, ctx.refs.head_sha), + &stderr, + )); + continue; + } + Err(error) => { + results.push(failure_result( + ctx, + crate_name, + ErrorStage::Diff, + &format!("{}..{}", ctx.refs.baseline_label, ctx.refs.head_label), + &format!("{}..{}", ctx.refs.baseline_sha, ctx.refs.head_sha), + &error.to_string(), + )); + continue; + } + }; + + let parsed = parse_diff(&String::from_utf8_lossy(&output.stdout)); + results.push(CrateResult { + name: crate_name.clone(), + removed: parsed.removed.len(), + changed: parsed.changed_old_count, + added: parsed.added.len(), + removed_lines: parsed.removed, + changed_lines: parsed.changed, + added_lines: parsed.added, + status: CrateStatus::Ok, + error: None, + pubdep: pubdep::compute(ctx, tables, crate_name), + }); + } + + results +} + +fn failure_result( + ctx: &Ctx, + crate_name: &str, + stage: ErrorStage, + ref_label: &str, + ref_sha: &str, + stderr: &str, +) -> CrateResult { + let stderr = error_tail(stderr); + let command = error_command(ctx, crate_name, stage); + let hint = hint(&stderr); + + CrateResult { + name: crate_name.to_owned(), + removed: 0, + changed: 0, + added: 0, + removed_lines: Vec::new(), + changed_lines: Vec::new(), + added_lines: Vec::new(), + status: CrateStatus::Error, + error: Some(ApiError { + stage, + ref_label: ref_label.to_owned(), + ref_sha: ref_sha.to_owned(), + command, + stderr, + hint, + }), + pubdep: Vec::new(), + } +} + +fn error_tail(stderr: &str) -> String { + let cleaned = stderr.replace('\r', ""); + let lines: Vec<_> = cleaned.split_inclusive('\n').collect(); + let tail = lines[lines.len().saturating_sub(80)..] + .concat() + .trim_end_matches('\n') + .to_owned(); + if tail.is_empty() { + "public API analysis failed without writing stderr".to_owned() + } else { + tail + } +} + +fn error_command(ctx: &Ctx, crate_name: &str, stage: ErrorStage) -> String { + let features = ctx.feature_args.join(" "); + if stage != ErrorStage::Diff { + return format!("cargo public-api {features} -p {crate_name} -ss"); + } + + format!( + "run at {} ({}): cargo public-api {} -p {} -ss; run at {} ({}): cargo public-api {} -p {} -ss", + ctx.refs.baseline_label, + ctx.refs.baseline_sha, + features, + crate_name, + ctx.refs.head_label, + ctx.refs.head_sha, + features, + crate_name + ) +} + +/// Returns the first matching remediation hint for cargo-public-api stderr. +pub fn hint(stderr: &str) -> String { + let text = stderr.to_lowercase(); + if text.contains("protoc") || text.contains("protobuf-compiler") { + "Install protoc, for example brew install protobuf or apt-get install protobuf-compiler, then rerun zc." + } else if text.contains("custom build command") { + "A build script failed. Run the command shown above to inspect the crate's build requirements." + } else if text.contains("cargo.lock") + || text.contains("lock file") + || text.contains("lockfile") + { + "The lockfile or dependency resolution failed at this ref. Check Cargo.lock and rerun zc." + } else if text.contains("requires rustc") + || text + .find("rustc ") + .is_some_and(|start| text[start + "rustc ".len()..].contains("is not supported")) + { + "The selected Rust toolchain cannot build this ref. Install the required toolchain and rerun zc." + } else if text.contains("no library targets") + || text.contains("does not have a library target") + { + "cargo-public-api can only analyze library targets. Exclude this crate or add a library target." + } else if text.contains("could not compile") { + "The crate did not compile under the selected feature set. Fix the build or choose a supported feature policy." + } else { + "Run the command shown above and fix the failing crate build before trusting the API diff." + } + .to_owned() +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ParsedDiff { + pub(crate) removed: Vec, + pub(crate) changed: Vec, + pub(crate) added: Vec, + pub(crate) changed_old_count: usize, +} + +#[derive(Clone, Copy)] +enum DiffSection { + None, + Removed, + Changed, + Added, +} + +pub(crate) fn parse_diff(output: &str) -> ParsedDiff { + let mut section = DiffSection::None; + let mut removed = Vec::new(); + let mut changed_old = Vec::new(); + let mut changed_new = Vec::new(); + let mut added = Vec::new(); + + for line in output.split('\n') { + match line { + "Removed items from the public API" => section = DiffSection::Removed, + "Changed items in the public API" => section = DiffSection::Changed, + "Added items to the public API" => section = DiffSection::Added, + "" | "(none)" => {} + line if line.starts_with('=') => {} + line if line.starts_with('-') => match section { + DiffSection::Removed => removed.push(line[1..].to_owned()), + DiffSection::Changed => changed_old.push(line[1..].to_owned()), + DiffSection::None | DiffSection::Added => {} + }, + line if line.starts_with('+') => match section { + DiffSection::Changed => changed_new.push(line[1..].to_owned()), + DiffSection::Added => added.push(line[1..].to_owned()), + DiffSection::None | DiffSection::Removed => {} + }, + _ => {} + } + } + + let mut changed = Vec::with_capacity(changed_old.len() * 2); + let mut changed_old_count = 0; + for (index, old) in changed_old.iter().enumerate() { + if old.is_empty() { + continue; + } + changed_old_count += 1; + changed.push(format!(" - {old}")); + changed.push(format!( + " + {}", + changed_new.get(index).map(String::as_str).unwrap_or("") + )); + } + + removed.retain(|line| !line.is_empty()); + added.retain(|line| !line.is_empty()); + + ParsedDiff { + removed, + changed, + added, + changed_old_count, + } +} + +#[cfg(test)] +mod tests; diff --git a/src/api/tests.rs b/src/api/tests.rs new file mode 100644 index 0000000..dda4dc1 --- /dev/null +++ b/src/api/tests.rs @@ -0,0 +1,104 @@ +use super::{hint, parse_diff}; + +fn strings(lines: &[&str]) -> Vec { + lines.iter().map(|line| (*line).to_owned()).collect() +} + +#[test] +fn parses_all_diff_sections_and_interleaves_changed_pairs() { + let output = "\ +-outside section ++outside section + +Removed items from the public API +============================= +-old::gone() +(none) + +Changed items in the public API +============================= +-old::first() ++new::first() += unchanged::item() +-old::second() + +Added items to the public API +============================= ++new::added() +(none) + ++new::also_added() +"; + + let parsed = parse_diff(output); + + assert_eq!(parsed.removed, strings(&["old::gone()"])); + assert_eq!( + parsed.changed, + strings(&[ + " - old::first()", + " + new::first()", + " - old::second()", + " + ", + ]) + ); + assert_eq!( + parsed.added, + strings(&["new::added()", "new::also_added()"]) + ); + assert_eq!(parsed.removed.len(), 1); + assert_eq!(parsed.changed_old_count, 2); + assert_eq!(parsed.added.len(), 2); +} + +#[test] +fn selects_hints_case_insensitively_in_spec_order() { + const INSTALL_PROTOC: &str = concat!( + "Install protoc, for example brew install protobuf or apt-get install ", + "protobuf-compiler, then rerun zc." + ); + const BUILD_SCRIPT: &str = concat!( + "A build script failed. Run the command shown above to inspect the crate's build ", + "requirements." + ); + const LOCKFILE: &str = concat!( + "The lockfile or dependency resolution failed at this ref. Check Cargo.lock and ", + "rerun zc." + ); + const TOOLCHAIN: &str = concat!( + "The selected Rust toolchain cannot build this ref. Install the required toolchain ", + "and rerun zc." + ); + const LIBRARY: &str = concat!( + "cargo-public-api can only analyze library targets. Exclude this crate or add a ", + "library target." + ); + const COMPILE: &str = concat!( + "The crate did not compile under the selected feature set. Fix the build or choose a ", + "supported feature policy." + ); + const FALLBACK: &str = concat!( + "Run the command shown above and fix the failing crate build before trusting the API ", + "diff." + ); + + let cases = [ + ("PROTOC was not found", INSTALL_PROTOC), + ("install protobuf-compiler", INSTALL_PROTOC), + ("protoc: custom build command failed", INSTALL_PROTOC), + ("failed to run custom build command", BUILD_SCRIPT), + ("Cargo.lock needs to be updated", LOCKFILE), + ("failed to read lock file", LOCKFILE), + ("dependency lockfile is invalid", LOCKFILE), + ("package requires rustc 1.90", TOOLCHAIN), + ("rustc 1.70 is not supported", TOOLCHAIN), + ("package has no library targets", LIBRARY), + ("package does not have a library target", LIBRARY), + ("could not compile example", COMPILE), + ("an unrelated failure", FALLBACK), + ]; + + for (stderr, expected) in cases { + assert_eq!(hint(stderr), expected, "stderr: {stderr}"); + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..6557804 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,200 @@ +//! Persistent analysis cache and per-run temporary directories. + +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use sha1::{Digest, Sha1}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); +const API_JSON_MAX_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60); + +pub struct Cache { + pub dir: PathBuf, + pub script_hash: String, + pub api_fp: String, +} + +impl Cache { + pub fn new( + version: &str, + cargo_public_api_version: &str, + rustc_version: &str, + feature_args: &[String], + ) -> Result { + let target = std::env::var_os("CARGO_TARGET_DIR") + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| OsString::from("target")); + let cache_dir = PathBuf::from(target).join("zc-cache"); + fs::create_dir_all(&cache_dir) + .map_err(|_| "failed to resolve zc cache directory".to_string())?; + let dir = fs::canonicalize(cache_dir) + .map_err(|_| "failed to resolve zc cache directory".to_string())?; + + let script_hash = std::env::current_exe() + .ok() + .and_then(|path| fs::read(path).ok()) + .map(|bytes| sha1_short(&bytes)) + .unwrap_or_else(|| format!("v{version}")); + + let mut fingerprint = Vec::new(); + append_fingerprint_line(&mut fingerprint, cargo_public_api_version); + append_fingerprint_line(&mut fingerprint, rustc_version); + if feature_args.is_empty() { + append_fingerprint_line(&mut fingerprint, ""); + } + for arg in feature_args { + append_fingerprint_line(&mut fingerprint, arg); + } + let api_fp = sha1_short(&fingerprint); + + Ok(Cache { + dir, + script_hash, + api_fp, + }) + } + + pub fn path(&self, name: &str) -> PathBuf { + self.dir.join(name) + } + + pub fn read_if_present(&self, name: &str) -> Option { + let contents = fs::read_to_string(self.path(name)).ok()?; + (!contents.is_empty()).then_some(contents) + } + + pub fn write_atomic(&self, name: &str, contents: &str) { + let Ok((tmp, mut file)) = unique_file(&self.dir, ".zc-cache-write") else { + return; + }; + let wrote = file.write_all(contents.as_bytes()).is_ok(); + drop(file); + if !wrote || fs::rename(&tmp, self.path(name)).is_err() { + let _ = fs::remove_file(tmp); + } + } + + pub fn copy_atomic(&self, name: &str, src: &Path) { + let Ok((tmp, file)) = unique_file(&self.dir, ".zc-cache-copy") else { + return; + }; + drop(file); + if fs::copy(src, &tmp).is_err() || fs::rename(&tmp, self.path(name)).is_err() { + let _ = fs::remove_file(tmp); + } + } + + pub fn prune_old_api_json(&self) { + let Ok(entries) = fs::read_dir(&self.dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let is_api_json = entry.file_type().is_ok_and(|kind| kind.is_file()) + && entry + .file_name() + .to_str() + .is_some_and(|name| name.ends_with(".api.json")); + if !is_api_json { + continue; + } + let is_old = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age > API_JSON_MAX_AGE); + if is_old { + let _ = fs::remove_file(path); + } + } + } +} + +pub struct RunTmp { + pub dir: PathBuf, +} + +impl RunTmp { + pub fn new() -> Result { + unique_dir(&std::env::temp_dir(), "zc") + .map(|dir| RunTmp { dir }) + .map_err(|err| format!("failed to create zc run temp directory: {err}")) + } + + pub fn sub(&self, prefix: &str) -> Result { + unique_dir(&self.dir, prefix) + .map_err(|err| format!("failed to create zc temporary directory: {err}")) + } +} + +impl Drop for RunTmp { + fn drop(&mut self) { + if let Ok(entries) = fs::read_dir(&self.dir) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + crate::git::worktree_remove(&entry.path()); + } + } + } + let _ = fs::remove_dir_all(&self.dir); + } +} + +pub fn sha1_short(bytes: &[u8]) -> String { + let digest = Sha1::digest(bytes); + hex::encode(digest)[..12].to_string() +} + +fn append_fingerprint_line(fingerprint: &mut Vec, value: &str) { + fingerprint.extend_from_slice(value.as_bytes()); + fingerprint.push(b'\n'); +} + +fn unique_dir(parent: &Path, prefix: &str) -> std::io::Result { + for _ in 0..1_000 { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!("{prefix}.{}.{sequence}", std::process::id())); + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a unique directory name", + )) +} + +fn unique_file(parent: &Path, prefix: &str) -> std::io::Result<(PathBuf, File)> { + for _ in 0..1_000 { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!("{prefix}.{}.{sequence}", std::process::id())); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(file) => return Ok((path, file)), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a unique file name", + )) +} + +#[cfg(test)] +#[path = "cache/tests.rs"] +mod tests; diff --git a/src/cache/tests.rs b/src/cache/tests.rs new file mode 100644 index 0000000..b8944b3 --- /dev/null +++ b/src/cache/tests.rs @@ -0,0 +1,7 @@ +use super::sha1_short; + +#[test] +fn sha1_is_lowercase_and_truncated_to_twelve_hex_digits() { + assert_eq!(sha1_short(b""), "da39a3ee5e6b"); + assert_eq!(sha1_short(b"abc"), "a9993e364706"); +} diff --git a/src/cargo_meta.rs b/src/cargo_meta.rs new file mode 100644 index 0000000..56bf474 --- /dev/null +++ b/src/cargo_meta.rs @@ -0,0 +1,463 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; + +use crate::ctx::Ctx; +use crate::git; +use crate::model::{is_test_crate, DepKind, DepRecord, PerCrateDep, Scope}; + +#[derive(Deserialize)] +struct Metadata { + #[serde(default)] + packages: Vec, + #[serde(default)] + workspace_members: Vec, +} + +#[derive(Deserialize)] +struct Package { + id: String, + name: String, + rust_version: Option, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Deserialize)] +struct Dependency { + name: String, + rename: Option, + req: Option, + kind: Option, + optional: Option, + uses_default_features: Option, + features: Option>, +} + +#[derive(Serialize, Deserialize)] +struct CachedDep { + key: String, + ver: String, + kind: String, + optional: bool, + default_features: bool, + features: Vec, +} + +#[derive(Serialize, Deserialize)] +struct CachedPerCrateDep { + crate_name: String, + dep: String, + req: String, + scope: String, + pkg: String, +} + +struct Usage { + kind: DepKind, + optional: bool, + uses_default: bool, + features: Vec, +} + +struct Aggregate { + kind: DepKind, + optional: bool, + uses_default: bool, + features: BTreeSet, +} + +/// Lists the names of workspace-member crates in sorted order. +pub fn workspace_crate_names(workspace_dir: &Path) -> Vec { + let manifest = workspace_dir.join("Cargo.toml"); + let Ok(output) = Command::new("cargo") + .arg("metadata") + .arg("--manifest-path") + .arg(manifest) + .arg("--no-deps") + .arg("--format-version") + .arg("1") + .stderr(Stdio::null()) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let Ok(metadata) = serde_json::from_slice::(&output.stdout) else { + return Vec::new(); + }; + + let members: HashSet<_> = metadata.workspace_members.into_iter().collect(); + let mut names: Vec<_> = metadata + .packages + .into_iter() + .filter(|package| members.contains(&package.id)) + .map(|package| package.name) + .collect(); + names.sort(); + names +} + +/// Reads the merged external dependency table at a git ref. +pub fn dump_workspace_deps( + ctx: &Ctx, + git_ref: &str, +) -> Result, String> { + let sha = + git::rev_parse_verify(git_ref).map_err(|_| format!("cannot resolve ref '{git_ref}'"))?; + let cache_name = format!("{sha}.{}.tsv", ctx.cache.script_hash); + if let Some(contents) = ctx.cache.read_if_present(&cache_name) { + if let Some(cached) = decode_workspace_cache(&contents) { + return Ok(cached); + } + } + + let worktree = ctx.tmp.sub("deps")?; + if git::worktree_add(&worktree, &sha).is_err() { + let message = format!("failed to create worktree for '{git_ref}'"); + eprintln!("{}error:{} {message}", ctx.style.red, ctx.style.reset); + return Err(message); + } + + let output = locked_metadata(&worktree); + git::worktree_remove(&worktree); + let output = match output { + Ok(output) if output.status.success() => output, + _ => { + let message = + format!("lockfile out of sync at '{git_ref}' (cargo metadata --locked failed)"); + eprintln!("{}error:{} {message}", ctx.style.red, ctx.style.reset); + eprintln!( + "{} either update Cargo.lock at that ref, or re-run without --locked by editing this script{}", + ctx.style.dim, ctx.style.reset + ); + return Err(message); + } + }; + + let metadata = match serde_json::from_slice::(&output.stdout) { + Ok(metadata) => metadata, + Err(_) => { + let message = format!("jq failed processing metadata for '{git_ref}'"); + eprintln!("{}error:{} {message}", ctx.style.red, ctx.style.reset); + return Err(message); + } + }; + let records = classify_workspace_deps(metadata); + if let Ok(contents) = encode_workspace_cache(&records) { + ctx.cache.write_atomic(&cache_name, &contents); + } + Ok(records) +} + +/// Reads direct runtime and build dependencies for each workspace crate at a git ref. +pub fn dump_per_crate_deps(ctx: &Ctx, git_ref: &str) -> Result, String> { + let sha = + git::rev_parse_verify(git_ref).map_err(|_| format!("cannot resolve ref '{git_ref}'"))?; + let cache_name = format!("{sha}.{}.percrate-deps.tsv", ctx.cache.script_hash); + if let Some(contents) = ctx.cache.read_if_present(&cache_name) { + if let Some(cached) = decode_per_crate_cache(&contents) { + return Ok(cached); + } + } + + let worktree = ctx.tmp.sub("percrate-deps")?; + if git::worktree_add(&worktree, &sha).is_err() { + let message = format!("could not create worktree for '{git_ref}' (per-crate deps)"); + eprintln!("{}warning:{} {message}", ctx.style.yellow, ctx.style.reset); + return Err(message); + } + + let output = locked_metadata(&worktree); + git::worktree_remove(&worktree); + let output = match output { + Ok(output) if output.status.success() => output, + _ => { + let message = format!("cargo metadata failed at '{git_ref}' (per-crate deps)"); + eprintln!("{}warning:{} {message}", ctx.style.yellow, ctx.style.reset); + return Err(message); + } + }; + + let metadata = match serde_json::from_slice::(&output.stdout) { + Ok(metadata) => metadata, + Err(_) => { + let message = format!("jq failed processing per-crate deps for '{git_ref}'"); + eprintln!("{}warning:{} {message}", ctx.style.yellow, ctx.style.reset); + return Err(message); + } + }; + let records = classify_per_crate_deps(metadata); + if let Ok(contents) = encode_per_crate_cache(&records) { + ctx.cache.write_atomic(&cache_name, &contents); + } + Ok(records) +} + +fn locked_metadata(worktree: &Path) -> std::io::Result { + Command::new("cargo") + .arg("metadata") + .arg("--manifest-path") + .arg(worktree.join("Cargo.toml")) + .arg("--no-deps") + .arg("--format-version") + .arg("1") + .arg("--locked") + .stderr(Stdio::null()) + .output() +} + +fn classify_workspace_deps(metadata: Metadata) -> BTreeMap { + let member_ids: HashSet<_> = metadata + .workspace_members + .iter() + .map(String::as_str) + .collect(); + let workspace_names: HashSet<_> = metadata + .packages + .iter() + .filter(|package| member_ids.contains(package.id.as_str())) + .map(|package| package.name.clone()) + .collect(); + let mut declared: BTreeMap = BTreeMap::new(); + let mut usages: BTreeMap = BTreeMap::new(); + + for package in metadata.packages { + if is_test_crate(&package.name) { + continue; + } + for dependency in package.dependencies { + let key = dependency + .rename + .clone() + .unwrap_or_else(|| dependency.name.clone()); + declared.entry(key.clone()).or_insert_with(|| { + ( + dependency.name.clone(), + strip_caret(dependency.req.as_deref().unwrap_or("-")), + ) + }); + merge_usage( + &mut usages, + key, + Usage { + kind: dependency_kind(dependency.kind.as_deref()), + optional: dependency.optional.unwrap_or(false), + uses_default: dependency.uses_default_features.unwrap_or(true), + features: dependency.features.unwrap_or_default(), + }, + ); + } + } + + declared + .into_iter() + .filter(|(_, (real_name, _))| !workspace_names.contains(real_name)) + .map(|(key, (real_name, ver))| { + let aggregate = usages.remove(&key).unwrap_or_else(|| Aggregate { + kind: DepKind::Unused, + optional: false, + uses_default: true, + features: BTreeSet::new(), + }); + let display = if key == real_name { + key + } else { + format!("{key} (pkg: {real_name})") + }; + ( + display, + DepRecord { + ver, + kind: aggregate.kind, + optional: aggregate.optional, + default_features: aggregate.uses_default, + features: aggregate.features.into_iter().collect(), + }, + ) + }) + .collect() +} + +fn merge_usage(usages: &mut BTreeMap, key: String, usage: Usage) { + match usages.get_mut(&key) { + Some(aggregate) => { + if usage.kind.rank() > aggregate.kind.rank() { + aggregate.kind = usage.kind; + aggregate.optional = usage.optional; + } else if usage.kind == aggregate.kind { + aggregate.optional &= usage.optional; + } + aggregate.uses_default |= usage.uses_default; + aggregate.features.extend(usage.features); + } + None => { + usages.insert( + key, + Aggregate { + kind: usage.kind, + optional: usage.optional, + uses_default: usage.uses_default, + features: usage.features.into_iter().collect(), + }, + ); + } + } +} + +fn classify_per_crate_deps(metadata: Metadata) -> Vec { + let member_ids: HashSet<_> = metadata.workspace_members.into_iter().collect(); + let workspace_names: HashSet<_> = metadata + .packages + .iter() + .filter(|package| member_ids.contains(&package.id)) + .map(|package| package.name.clone()) + .collect(); + let packages: Vec<_> = metadata + .packages + .into_iter() + .filter(|package| member_ids.contains(&package.id) && !is_test_crate(&package.name)) + .collect(); + let mut records = BTreeMap::new(); + + for package in &packages { + records + .entry((package.name.clone(), "~msrv".to_string())) + .or_insert_with(|| PerCrateDep { + crate_name: package.name.clone(), + dep: "~msrv".to_string(), + req: package + .rust_version + .clone() + .unwrap_or_else(|| "-".to_string()), + scope: Scope::Msrv, + pkg: "-".to_string(), + }); + } + for package in packages { + for dependency in package.dependencies { + if !matches!( + dependency.kind.as_deref(), + None | Some("normal") | Some("build") + ) { + continue; + } + let dep = dependency + .rename + .clone() + .unwrap_or_else(|| dependency.name.clone()); + let key = (package.name.clone(), dep.clone()); + records.entry(key).or_insert_with(|| PerCrateDep { + crate_name: package.name.clone(), + dep, + req: strip_caret(dependency.req.as_deref().unwrap_or("-")), + scope: if workspace_names.contains(&dependency.name) { + Scope::Internal + } else { + Scope::External + }, + pkg: dependency.name, + }); + } + } + records.into_values().collect() +} + +fn dependency_kind(kind: Option<&str>) -> DepKind { + match kind { + None | Some("normal") => DepKind::Runtime, + Some("build") => DepKind::Build, + Some("dev") => DepKind::Dev, + Some(_) => DepKind::Runtime, + } +} + +fn strip_caret(req: &str) -> String { + req.strip_prefix('^').unwrap_or(req).to_string() +} + +fn encode_workspace_cache(records: &BTreeMap) -> serde_json::Result { + let cached: Vec<_> = records + .iter() + .map(|(key, record)| CachedDep { + key: key.clone(), + ver: record.ver.clone(), + kind: record.kind.as_str().to_string(), + optional: record.optional, + default_features: record.default_features, + features: record.features.clone(), + }) + .collect(); + serde_json::to_string(&cached) +} + +fn decode_workspace_cache(contents: &str) -> Option> { + let cached: Vec = serde_json::from_str(contents).ok()?; + cached + .into_iter() + .map(|record| { + let kind = match record.kind.as_str() { + "runtime" => DepKind::Runtime, + "build" => DepKind::Build, + "dev" => DepKind::Dev, + "unused" => DepKind::Unused, + _ => return None, + }; + Some(( + record.key, + DepRecord { + ver: record.ver, + kind, + optional: record.optional, + default_features: record.default_features, + features: record.features, + }, + )) + }) + .collect() +} + +fn encode_per_crate_cache(records: &[PerCrateDep]) -> serde_json::Result { + let cached: Vec<_> = records + .iter() + .map(|record| CachedPerCrateDep { + crate_name: record.crate_name.clone(), + dep: record.dep.clone(), + req: record.req.clone(), + scope: match record.scope { + Scope::Internal => "int", + Scope::External => "ext", + Scope::Msrv => "msrv", + } + .to_string(), + pkg: record.pkg.clone(), + }) + .collect(); + serde_json::to_string(&cached) +} + +fn decode_per_crate_cache(contents: &str) -> Option> { + let cached: Vec = serde_json::from_str(contents).ok()?; + cached + .into_iter() + .map(|record| { + let scope = match record.scope.as_str() { + "int" => Scope::Internal, + "ext" => Scope::External, + "msrv" => Scope::Msrv, + _ => return None, + }; + Some(PerCrateDep { + crate_name: record.crate_name, + dep: record.dep, + req: record.req, + scope, + pkg: record.pkg, + }) + }) + .collect() +} diff --git a/src/changelog.rs b/src/changelog.rs new file mode 100644 index 0000000..d161496 --- /dev/null +++ b/src/changelog.rs @@ -0,0 +1,642 @@ +//! Librustzcash-style changelog rendering for public API diff lines. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use regex::Regex; + +use crate::model::Section; +use crate::traitmap::TraitMap; + +const WIDTH: usize = 100; +const BOILERPLATE: &[&str] = &[ + "from", + "into", + "try_from", + "try_into", + "clone", + "clone_from", + "fmt", + "hash", + "eq", + "ne", + "cmp", + "partial_cmp", + "lt", + "le", + "gt", + "ge", + "default", + "deref", + "deref_mut", + "as_ref", + "as_mut", + "borrow", + "borrow_mut", + "drop", + "serialize", + "deserialize", + "into_iter", + "next", +]; + +fn inner_generic_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"<[^<>]*>").expect("valid inner-generic regex")) +} + +fn long_path_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"[A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*::").expect("valid path regex") + }) +} + +fn lifetime_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"'[A-Za-z_][A-Za-z0-9_]* *").expect("valid lifetime regex")) +} + +fn generic_leading_comma_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"< *, *").expect("valid generic-list regex")) +} + +fn generic_trailing_comma_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r" *, *>").expect("valid generic-list regex")) +} + +fn attribute_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^#\[[^]]*\] +").expect("valid attribute regex")) +} +fn pub_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^pub +").expect("valid visibility regex")) +} + +fn pub_module_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^pub +mod +").expect("valid public-module regex")) +} + +fn impl_keyword_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^impl +").expect("valid impl-keyword regex")) +} + +fn qualifiers_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^(?:(?:const|async|unsafe) +)*").expect("valid qualifier regex")) +} + +fn item_keyword_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^(?:fn|static|type|use) +").expect("valid item-keyword regex")) +} +fn qualified_item_keyword_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"^(?:fn|static|type|use|mod|struct|enum|trait|union) +") + .expect("valid qualified-item keyword regex") + }) +} + +fn declaration_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"^(?:mod|struct|enum|trait|union) +").expect("valid declaration regex") + }) +} + +fn impl_generics_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^impl *<[^<>]*>").expect("valid impl-generics regex")) +} + +fn arbitrary_impl_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"^impl[ <].*Arbitrary.* for ").expect("valid Arbitrary-impl regex") + }) +} + +fn proptest_assoc_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"^(?:pub +)?type [^=]*::(?:Parameters|Strategy) =") + .expect("valid proptest-associated-type regex") + }) +} + +fn proptest_method_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"::(?:arbitrary|arbitrary_with)\(").expect("valid proptest-method regex") + }) +} + +fn structural_eq_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"^impl[ <].*Structural(?:Partial)?Eq.* for ") + .expect("valid structural-equality regex") + }) +} + +fn strip_inner_generics(mut value: String) -> String { + while let Some(found) = inner_generic_re().find(&value) { + value.replace_range(found.range(), ""); + } + value +} + +fn last_seg(path: &str) -> String { + let path = strip_inner_generics(path.to_string()); + path.rsplit("::").next().unwrap_or(&path).to_string() +} + +/// Returns the first balanced generic argument list, or the unmatched suffix. +fn outer_gen(path: &str) -> Option<&str> { + let start = path.find('<')?; + let mut depth = 0usize; + for (offset, byte) in path.as_bytes()[start..].iter().enumerate() { + match byte { + b'<' => depth += 1, + b'>' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(&path[start..start + offset + 1]); + } + } + _ => {} + } + } + Some(&path[start..]) +} + +fn keep2(path: &str) -> String { + let mut path = path.to_string(); + while let Some(found) = long_path_re().find(&path) { + let matched = &path[found.range()]; + let remove_len = matched.find("::").map_or(0, |colon| colon + 2); + if remove_len == 0 { + break; + } + path.replace_range(found.start()..found.start() + remove_len, ""); + } + path +} + +fn short_gen(generics: &str) -> String { + let mut generics = lifetime_re().replace_all(generics, "").into_owned(); + generics = generics.replace("<>", ""); + generics = generic_leading_comma_re() + .replace_all(&generics, "<") + .into_owned(); + generics = generic_trailing_comma_re() + .replace_all(&generics, ">") + .into_owned(); + keep2(&generics) +} + +fn shorten_inner_gen(path: &str) -> String { + let Some(generics) = outer_gen(path) else { + return path.to_string(); + }; + let start = path.find('<').unwrap_or(path.len()); + let end = start + generics.len(); + format!("{}{}{}", &path[..start], short_gen(generics), &path[end..]) +} + +fn self_generics(signature: &str, self_short: &str) -> String { + let needle = format!("{self_short}<"); + let Some(start) = signature.find(&needle) else { + return String::new(); + }; + let suffix = &signature[start + self_short.len()..]; + outer_gen(suffix).map(short_gen).unwrap_or_default() +} + +fn trait_disp(trait_path: &str) -> String { + let Some(generics) = outer_gen(trait_path) else { + return last_seg(trait_path); + }; + let start = trait_path.find('<').unwrap_or(trait_path.len()); + format!("{}{}", last_seg(&trait_path[..start]), short_gen(generics)) +} + +fn strip_attribute(value: &str) -> String { + attribute_re().replace(value, "").into_owned() +} + +fn strip_pub(value: &str) -> String { + pub_re().replace(value, "").into_owned() +} + +fn strip_item_prefix(value: &str) -> String { + let value = qualifiers_re().replace(value, ""); + item_keyword_re().replace(&value, "").into_owned() +} + +fn truncate_item(mut path: String) -> String { + if let Some(at) = path.find([' ', '(', '=']) { + path.truncate(at); + } + if path.ends_with(':') { + path.pop(); + } + path +} + +fn group_key(signature: &str) -> String { + let mut signature = strip_inner_generics(strip_attribute(signature)); + let is_declaration; + let path; + if signature.starts_with("impl ") { + signature = impl_keyword_re().replace(&signature, "").into_owned(); + if let Some(at) = signature.rfind(" for ") { + signature = signature[at + 5..].to_string(); + } + path = signature; + is_declaration = true; + } else { + signature = strip_pub(&signature); + if declaration_re().is_match(&signature) { + path = declaration_re().replace(&signature, "").into_owned(); + is_declaration = true; + } else { + path = strip_item_prefix(&signature); + is_declaration = false; + } + } + + let mut path = truncate_item(path); + if !is_declaration { + if let Some(at) = path.rfind("::") { + path.truncate(at); + } + } + path +} + +fn qual_path(signature: &str, crate_prefix: &str) -> String { + let mut signature = strip_attribute(signature); + signature = impl_generics_re().replace(&signature, "impl").into_owned(); + if signature.starts_with("impl ") { + if !crate_prefix.is_empty() { + signature = signature.replace(&format!("{crate_prefix}::"), ""); + } + return signature; + } + + signature = strip_pub(&signature); + signature = qualifiers_re().replace(&signature, "").into_owned(); + signature = qualified_item_keyword_re() + .replace(&signature, "") + .into_owned(); + truncate_item(strip_inner_generics(signature)) +} + +fn disp(path: &str, crate_prefix: &str) -> String { + if crate_prefix.is_empty() { + return path.to_string(); + } + path.strip_prefix(&format!("{crate_prefix}::")) + .unwrap_or(path) + .to_string() +} + +fn member_disp(path: &str, group: &str, crate_prefix: &str) -> String { + if path.starts_with("impl ") { + return path.to_string(); + } + if let Some(member) = path.strip_prefix(&format!("{group}::")) { + return member.to_string(); + } + disp(path, crate_prefix) +} + +fn relsig(signature: &str, crate_prefix: &str) -> String { + let mut signature = strip_attribute(signature); + signature = strip_pub(&signature); + if !crate_prefix.is_empty() { + signature = signature.replace(&format!("{crate_prefix}::"), ""); + } + signature +} + +fn is_proptest(signature: &str) -> bool { + signature.contains("proptest::") + || arbitrary_impl_re().is_match(signature) + || proptest_assoc_re().is_match(signature) + || proptest_method_re().is_match(signature) +} + +struct ImplGroup { + key: String, + is_header: bool, + header_text: Option, +} + +fn impl_group( + signature: &str, + group: &str, + qualified_path: &str, + crate_prefix: &str, + traits: &TraitMap, +) -> Option { + if qualified_path.starts_with("impl ") { + let body = qualified_path.strip_prefix("impl ")?.trim_start(); + let first_for = body.find(" for ")?; + let last_for = body.rfind(" for ")?; + let trait_path = &body[..first_for]; + let self_path = &body[last_for + 5..]; + let self_path = shorten_inner_gen(self_path); + return Some(ImplGroup { + key: format!("impl {} for {self_path}", last_seg(trait_path)), + is_header: true, + header_text: Some(format!("impl {} for {self_path}", trait_disp(trait_path))), + }); + } + + let self_short = last_seg(group); + let member = last_seg(qualified_path); + let trait_path = traits.get(&(self_short.clone(), member))?; + Some(ImplGroup { + key: format!( + "impl {trait_path} for {}{}", + disp(group, crate_prefix), + self_generics(signature, &self_short) + ), + is_header: false, + header_text: None, + }) +} + +#[derive(Default)] +struct Group { + key: String, + is_impl: bool, + header_text: Option, + members: Vec, +} + +fn render_changed(lines: &[String], crate_prefix: &str) -> Vec { + let mut output = Vec::new(); + let mut pending = String::new(); + let mut skip = false; + let mut seen = HashSet::new(); + + for buffered in lines.iter().filter(|line| !line.trim().is_empty()) { + if let Some(old) = buffered.strip_prefix(" - ") { + skip = is_proptest(old); + pending = relsig(old, crate_prefix); + continue; + } + let Some(new) = buffered.strip_prefix(" + ") else { + continue; + }; + if skip { + skip = false; + continue; + } + let new = relsig(new, crate_prefix); + if !seen.insert((pending.clone(), new.clone())) { + continue; + } + output.push(format!("- `{pending}`")); + output.push(format!(" → `{new}`")); + } + output +} + +fn render_plain(lines: &[String], crate_prefix: &str, traits: &TraitMap) -> Vec { + let buffered: Vec<&str> = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(String::as_str) + .collect(); + + let mut lazy_static = HashSet::new(); + let mut added_modules = Vec::new(); + for line in &buffered { + if (line.starts_with("impl<") || line.starts_with("impl ")) + && line.contains("LazyStatic") + && line.contains(" for ") + { + lazy_static.insert(group_key(line)); + } + + let module = strip_attribute(line); + if pub_module_re().is_match(&module) { + let module = pub_module_re().replace(&module, "").into_owned(); + if !added_modules.contains(&module) { + added_modules.push(module); + } + } + } + + let mut groups = Vec::::new(); + let mut group_indices = HashMap::::new(); + let mut seen_items = HashSet::<(String, String)>::new(); + + for signature in buffered { + let impl_line = signature.starts_with("impl<") || signature.starts_with("impl "); + if impl_line && !signature.contains(" for ") { + continue; + } + if is_proptest(signature) || structural_eq_re().is_match(signature) { + continue; + } + + let original_group = group_key(signature); + let qualified_path = qual_path(signature, crate_prefix); + if lazy_static.contains(&original_group) && qualified_path != original_group { + continue; + } + if added_modules.iter().any(|module| { + qualified_path != *module + && (original_group == *module || original_group.starts_with(&format!("{module}::"))) + }) { + continue; + } + if !seen_items.insert((original_group.clone(), qualified_path.clone())) { + continue; + } + + let impl_info = impl_group( + signature, + &original_group, + &qualified_path, + crate_prefix, + traits, + ); + let key = impl_info + .as_ref() + .map_or_else(|| original_group.clone(), |info| info.key.clone()); + let index = if let Some(index) = group_indices.get(&key) { + *index + } else { + let index = groups.len(); + groups.push(Group { + key: key.clone(), + ..Group::default() + }); + group_indices.insert(key, index); + index + }; + let group = &mut groups[index]; + if let Some(info) = impl_info { + group.is_impl = true; + if info.is_header { + group.header_text = info.header_text; + } else { + group.members.push(last_seg(&qualified_path)); + } + } else { + group.members.push(qualified_path); + } + } + + emit_groups(groups, crate_prefix) +} + +fn emit_groups(groups: Vec, crate_prefix: &str) -> Vec { + let mut output = Vec::new(); + let mut memberless = Vec::<(String, String)>::new(); + + for group in groups { + let displayed_members: Vec<&str> = group + .members + .iter() + .filter(|member| group.is_impl || member.as_str() != group.key) + .map(String::as_str) + .collect(); + + if group.is_impl { + let header = group.header_text.as_deref().unwrap_or(&group.key); + let kept: Vec<&str> = displayed_members + .into_iter() + .filter(|member| !BOILERPLATE.contains(member)) + .collect(); + if kept.is_empty() { + if let Some(suffix) = header.strip_prefix("impl ") { + if let Some((trait_path, self_path)) = suffix.split_once(" for ") { + memberless.push((trait_path.to_string(), self_path.to_string())); + } + } + continue; + } + output.push(format!("- `{header}`:")); + output.extend(kept.into_iter().map(|member| format!(" - `{member}`"))); + continue; + } + + if group.key == crate_prefix { + output.extend( + displayed_members + .into_iter() + .map(|member| format!("- `{}`", member_disp(member, &group.key, crate_prefix))), + ); + continue; + } + + let header = disp(&group.key, crate_prefix); + match displayed_members.as_slice() { + [] => output.push(format!("- `{header}`")), + [member] => output.push(format!( + "- `{header}::{}`", + member_disp(member, &group.key, crate_prefix) + )), + members => { + let members = members + .iter() + .map(|member| member_disp(member, &group.key, crate_prefix)) + .collect::>(); + let one_line = format!("- `{header}::{{{}}}`", members.join(", ")); + if one_line.chars().count() <= WIDTH { + output.push(one_line); + } else { + output.push(format!("- `{header}`:")); + output.extend(members.into_iter().map(|member| format!(" - `{member}`"))); + } + } + } + } + + emit_memberless(memberless, &mut output); + output +} + +fn emit_memberless(memberless: Vec<(String, String)>, output: &mut Vec) { + let mut by_self = Vec::<(String, Vec)>::new(); + let mut self_indices = HashMap::::new(); + for (trait_path, self_path) in memberless { + let index = if let Some(index) = self_indices.get(&self_path) { + *index + } else { + let index = by_self.len(); + by_self.push((self_path.clone(), Vec::new())); + self_indices.insert(self_path, index); + index + }; + by_self[index].1.push(trait_path); + } + + let mut by_trait = Vec::<(String, Vec)>::new(); + let mut trait_indices = HashMap::::new(); + for (self_path, trait_paths) in by_self { + if trait_paths.len() >= 2 { + output.push(format!( + "- `impl {{{}}} for {self_path}`", + trait_paths.join(", ") + )); + continue; + } + let Some(trait_path) = trait_paths.into_iter().next() else { + continue; + }; + let index = if let Some(index) = trait_indices.get(&trait_path) { + *index + } else { + let index = by_trait.len(); + by_trait.push((trait_path.clone(), Vec::new())); + trait_indices.insert(trait_path, index); + index + }; + by_trait[index].1.push(self_path); + } + + for (trait_path, self_paths) in by_trait { + match self_paths.as_slice() { + [self_path] => output.push(format!("- `impl {trait_path} for {self_path}`")), + _ => { + output.push(format!("- `impl {trait_path}` for:")); + output.extend( + self_paths + .into_iter() + .map(|self_path| format!(" - `{self_path}`")), + ); + } + } + } +} + +/// Renders one API diff section as markdown bullet lines. +pub fn render( + lines: &[String], + section: Section, + crate_prefix: &str, + traits: &TraitMap, +) -> Vec { + if section == Section::Changed { + render_changed(lines, crate_prefix) + } else { + render_plain(lines, crate_prefix, traits) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/changelog/tests.rs b/src/changelog/tests.rs new file mode 100644 index 0000000..1010aeb --- /dev/null +++ b/src/changelog/tests.rs @@ -0,0 +1,146 @@ +use super::render; +use crate::model::Section; +use crate::traitmap::TraitMap; + +struct Case { + name: &'static str, + lines: &'static [&'static str], + section: Section, + traits: &'static [((&'static str, &'static str), &'static str)], + expected: &'static [&'static str], +} + +#[test] +fn renders_librustzcash_changelog_groups() { + let cases = [ + Case { + name: "brace-grouped members", + lines: &[ + "pub struct fixture::Widget", + "impl fixture::Widget", + "pub fn fixture::Widget::new() -> fixture::Widget", + "pub fn fixture::Widget::run(&self)", + ], + section: Section::Added, + traits: &[], + expected: &["- `Widget::{new, run}`"], + }, + Case { + name: "over-wide members", + lines: &[ + "pub struct fixture::Verifier", + "impl fixture::Verifier", + "pub fn fixture::Verifier::check_cross_address_disabled(&self)", + "pub fn fixture::Verifier::enforce_nullifier_uniqueness(&self)", + "pub fn fixture::Verifier::validate_ironwood_proof_size(&self)", + "pub fn fixture::Verifier::validate_orchard_value_balance(&self)", + ], + section: Section::Added, + traits: &[], + expected: &[ + "- `Verifier`:", + " - `check_cross_address_disabled`", + " - `enforce_nullifier_uniqueness`", + " - `validate_ironwood_proof_size`", + " - `validate_orchard_value_balance`", + ], + }, + Case { + name: "associated item with Self generics", + lines: &["pub type fixture::Foo::Bytes = [u8; 48]"], + section: Section::Added, + traits: &[(("Foo", "Bytes"), "IntoDisk")], + expected: &["- `impl IntoDisk for Foo`:", " - `Bytes`"], + }, + Case { + name: "changed signature pair", + lines: &[ + " - pub fn fixture::f() -> u8", + " + pub fn fixture::f() -> u16", + ], + section: Section::Changed, + traits: &[], + expected: &["- `fn f() -> u8`", " → `fn f() -> u16`"], + }, + Case { + name: "marker over several types", + lines: &[ + "impl fixture::Marker for fixture::A", + "impl fixture::Marker for fixture::B", + ], + section: Section::Added, + traits: &[], + expected: &["- `impl Marker` for:", " - `A`", " - `B`"], + }, + Case { + name: "impl lifetime stripped", + lines: &[ + "impl<'a> core::convert::From<&'a u8> for fixture::Foo", + "pub fn fixture::Foo::from(_: &'a u8) -> fixture::Foo", + ], + section: Section::Added, + traits: &[(("Foo", "from"), "From")], + expected: &["- `impl From<&u8> for Foo`"], + }, + Case { + name: "nested generic paths shortened", + lines: &[ + "impl core::convert::From> for fixture::Foo", + "pub fn fixture::Foo::from(_: core::option::Option) -> fixture::Foo", + ], + section: Section::Added, + traits: &[(("Foo", "from"), "From")], + expected: &["- `impl From> for Foo`"], + }, + Case { + name: "derives collapse by Self type", + lines: &[ + "#[derive(Clone, Debug)] pub struct fixture::Bar", + "impl core::clone::Clone for fixture::Bar", + "impl core::fmt::Debug for fixture::Bar", + ], + section: Section::Added, + traits: &[], + expected: &["- `Bar`", "- `impl {Clone, Debug} for Bar`"], + }, + Case { + name: "whole module subsumes contents", + lines: &[ + "pub mod fixture::m", + "pub struct fixture::m::Foo", + "pub fn fixture::m::g()", + "pub fn fixture::sibling() -> u8", + ], + section: Section::Added, + traits: &[], + expected: &["- `m`", "- `sibling`"], + }, + ]; + + for case in cases { + let lines = case + .lines + .iter() + .map(|line| (*line).to_string()) + .collect::>(); + let mut traits = TraitMap::new(); + for &((self_name, member), trait_path) in case.traits { + traits.insert( + (self_name.to_string(), member.to_string()), + trait_path.to_string(), + ); + } + let expected = case + .expected + .iter() + .map(|line| (*line).to_string()) + .collect::>(); + + assert_eq!( + render(&lines, case.section, "fixture", &traits), + expected, + "{}", + case.name + ); + } +} diff --git a/src/changelog_out.rs b/src/changelog_out.rs new file mode 100644 index 0000000..c012bcf --- /dev/null +++ b/src/changelog_out.rs @@ -0,0 +1,208 @@ +//! Librustzcash-style changelog document rendering. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use crate::changelog; +use crate::ctx::Ctx; +use crate::model::{Bump, PerCrateDep, Report, Scope, Section}; +use crate::traitmap::{self, TraitMap}; +use crate::version_req; + +#[derive(Default, Debug, PartialEq, Eq)] +struct DependencyMarkdown { + changed: BTreeMap>, + removed: BTreeMap>, +} + +fn dependency_markdown(base: &[PerCrateDep], head: &[PerCrateDep]) -> DependencyMarkdown { + let base_by_key: HashMap<(&str, &str), &PerCrateDep> = base + .iter() + .map(|row| ((row.crate_name.as_str(), row.dep.as_str()), row)) + .collect(); + let head_keys: HashSet<(&str, &str)> = head + .iter() + .map(|row| (row.crate_name.as_str(), row.dep.as_str())) + .collect(); + let mut markdown = DependencyMarkdown::default(); + + for row in head { + let Some(old) = base_by_key.get(&(row.crate_name.as_str(), row.dep.as_str())) else { + continue; + }; + if old.req == row.req { + continue; + } + let line = match row.scope { + Scope::Msrv if row.req != "-" => Some(format!("- MSRV is now {}.", row.req)), + Scope::Msrv => None, + Scope::Internal => Some(format!( + "- `{}` dependency bumped to `{}`.", + row.dep, row.req + )), + Scope::External => Some(format!("- Migrated to `{} {}`.", row.dep, row.req)), + }; + if let Some(line) = line { + let lines = markdown.changed.entry(row.crate_name.clone()).or_default(); + if row.scope == Scope::Msrv { + lines.insert(0, line); + } else { + lines.push(line); + } + } + } + + for row in base { + if row.dep != "~msrv" && !head_keys.contains(&(row.crate_name.as_str(), row.dep.as_str())) { + markdown + .removed + .entry(row.crate_name.clone()) + .or_default() + .push(format!("- `{}` dependency.", row.dep)); + } + } + markdown +} + +fn fold_public_dependency_notes( + report: &Report, + dependency: &mut DependencyMarkdown, +) -> BTreeMap> { + let mut extra = BTreeMap::>::new(); + for result in &report.crates { + for finding in &result.pubdep { + let note = if version_req::classify_bump(&finding.old, &finding.new) == Bump::Major { + format!( + "its types appear in this crate's public API, so downstream users must \ + upgrade `{}` in lockstep.", + finding.dep + ) + } else { + "its types appear in this crate's public API, so check whether downstream users \ + are affected." + .to_string() + }; + let migrated = format!("- Migrated to `{} {}`.", finding.dep, finding.new); + let replacement = format!("- Migrated to `{} {}`; {note}", finding.dep, finding.new); + let mut folded = false; + if let Some(lines) = dependency.changed.get_mut(&result.name) { + if let Some(line) = lines.iter_mut().find(|line| line.contains(&migrated)) { + *line = line.replacen(&migrated, &replacement, 1); + folded = true; + } + } + if !folded { + extra.entry(result.name.clone()).or_default().push(format!( + "- Public dependency `{}` changed to `{}`; {note}", + finding.dep, finding.new + )); + } + } + } + extra +} + +fn append_section(out: &mut String, heading: &str, parts: &[&[String]]) { + if parts.iter().all(|lines| lines.is_empty()) { + return; + } + out.push_str("### "); + out.push_str(heading); + out.push('\n'); + for lines in parts { + for line in *lines { + out.push_str(line); + out.push('\n'); + } + } + out.push('\n'); +} + +pub fn emit(ctx: &Ctx, report: &Report, base: &[PerCrateDep], head: &[PerCrateDep]) -> String { + let mut dependency = dependency_markdown(base, head); + let public_dependency = fold_public_dependency_notes(report, &mut dependency); + + let changed_crates: Vec = report + .crates + .iter() + .filter(|result| result.total() > 0) + .map(|result| result.name.clone()) + .collect(); + let removed_crates: Vec = report + .crates + .iter() + .filter(|result| result.removed > 0) + .map(|result| result.name.clone()) + .collect(); + + let (head_traits, base_traits) = if changed_crates.is_empty() { + (HashMap::new(), HashMap::new()) + } else { + ctx.progress.start(); + let head_traits = traitmap::dump(ctx, &ctx.refs.head_sha, &changed_crates); + let base_traits = if removed_crates.is_empty() { + HashMap::new() + } else { + traitmap::dump(ctx, &ctx.refs.baseline_sha, &removed_crates) + }; + ctx.progress.clear(); + (head_traits, base_traits) + }; + + let empty_traits = TraitMap::new(); + let mut out = String::new(); + for result in &report.crates { + let prefix = result.prefix(); + let head_map = head_traits.get(&result.name).unwrap_or(&empty_traits); + let base_map = base_traits.get(&result.name).unwrap_or(&empty_traits); + let added = if result.added > 0 { + changelog::render(&result.added_lines, Section::Added, &prefix, head_map) + } else { + Vec::new() + }; + let changed_api = if result.changed > 0 { + changelog::render(&result.changed_lines, Section::Changed, &prefix, head_map) + } else { + Vec::new() + }; + let removed_api = if result.removed > 0 { + changelog::render(&result.removed_lines, Section::Removed, &prefix, base_map) + } else { + Vec::new() + }; + let dep_changed = dependency + .changed + .get(&result.name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let pubdep = public_dependency + .get(&result.name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let dep_removed = dependency + .removed + .get(&result.name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let has_changed = !dep_changed.is_empty() || !pubdep.is_empty() || !changed_api.is_empty(); + let has_removed = !removed_api.is_empty() || !dep_removed.is_empty(); + if added.is_empty() && !has_changed && !has_removed { + continue; + } + + out.push_str("## "); + out.push_str(&result.name); + out.push_str("\n\n"); + append_section(&mut out, "Added", &[&added]); + if has_changed { + append_section(&mut out, "Changed", &[dep_changed, pubdep, &changed_api]); + } + if has_removed { + append_section(&mut out, "Removed", &[&removed_api, dep_removed]); + } + } + out +} + +#[cfg(test)] +#[path = "changelog_out/tests.rs"] +mod tests; diff --git a/src/changelog_out/tests.rs b/src/changelog_out/tests.rs new file mode 100644 index 0000000..118597e --- /dev/null +++ b/src/changelog_out/tests.rs @@ -0,0 +1,45 @@ +use super::dependency_markdown; +use crate::model::{PerCrateDep, Scope}; + +fn dep(crate_name: &str, name: &str, req: &str, scope: Scope) -> PerCrateDep { + PerCrateDep { + crate_name: crate_name.to_string(), + dep: name.to_string(), + req: req.to_string(), + scope, + pkg: name.to_string(), + } +} + +#[test] +fn dependency_markdown_matches_changelog_wording_and_order() { + let base = vec![ + dep("alpha", "external", "1", Scope::External), + dep("alpha", "internal", "1", Scope::Internal), + dep("alpha", "removed", "3", Scope::External), + dep("alpha", "~msrv", "1.75", Scope::Msrv), + dep("beta", "~msrv", "1.70", Scope::Msrv), + ]; + let head = vec![ + dep("alpha", "external", "2", Scope::External), + dep("alpha", "internal", "2", Scope::Internal), + dep("alpha", "~msrv", "1.81", Scope::Msrv), + dep("beta", "~msrv", "-", Scope::Msrv), + ]; + + let markdown = dependency_markdown(&base, &head); + assert_eq!( + markdown.changed.get("alpha"), + Some(&vec![ + "- MSRV is now 1.81.".to_string(), + "- Migrated to `external 2`.".to_string(), + "- `internal` dependency bumped to `2`.".to_string(), + ]) + ); + assert_eq!( + markdown.removed.get("alpha"), + Some(&vec!["- `removed` dependency.".to_string()]) + ); + assert!(!markdown.removed.contains_key("beta")); + assert!(!markdown.changed.contains_key("beta")); +} diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100644 index 0000000..7c02c9d --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,38 @@ +//! Run context: everything resolved once at startup and read by every analysis module. + +use std::path::PathBuf; + +use crate::cache::{Cache, RunTmp}; +use crate::model::{Options, Refs}; +use crate::progress::Progress; +use crate::style::Style; + +/// Shared, immutable-after-setup run state. +pub struct Ctx { + pub opts: Options, + pub style: Style, + pub cache: Cache, + pub tmp: RunTmp, + /// Nightly toolchain name used for rustdoc JSON. + pub toolchain: String, + /// Feature policy passed to every cargo-public-api and rustdoc invocation. + pub feature_args: Vec, + pub refs: Refs, + pub progress: Progress, + /// Detached worktree at the baseline ref. + pub baseline_worktree: PathBuf, + /// Detached worktree at the head ref. + pub head_worktree: PathBuf, + /// `CARGO_TARGET_DIR` for baseline builds. + pub baseline_target: PathBuf, + /// `CARGO_TARGET_DIR` for head builds. + pub head_target: PathBuf, +} + +impl Ctx { + /// True when a rustdoc JSON result for this ref may be cached: a working-tree snapshot + /// is not content-addressed by its SHA in any stable way, so it never is. + pub fn api_json_cacheable(&self, ref_sha: &str) -> bool { + !self.refs.head_is_worktree_snapshot || ref_sha != self.refs.head_sha + } +} diff --git a/src/deps.rs b/src/deps.rs new file mode 100644 index 0000000..1e55426 --- /dev/null +++ b/src/deps.rs @@ -0,0 +1,112 @@ +//! Workspace dependency classification. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::model::{ + kind_label, label_rank, Bump, DepAdded, DepChanged, DepDiff, DepRecord, DepRemoved, +}; +use crate::version_req::classify_bump; + +/// Render changes to default features and the explicit feature set. +pub fn feature_diff(old_def: bool, new_def: bool, old: &[String], new: &[String]) -> String { + let mut delta = Vec::new(); + if old_def != new_def { + delta.push(if old_def { + "-default!".to_string() + } else { + "+default!".to_string() + }); + } + + let old: BTreeSet<&str> = old + .iter() + .map(String::as_str) + .filter(|f| !f.is_empty()) + .collect(); + let new: BTreeSet<&str> = new + .iter() + .map(String::as_str) + .filter(|f| !f.is_empty()) + .collect(); + let mut features: Vec = old + .difference(&new) + .map(|feature| format!("-{feature}")) + .chain(new.difference(&old).map(|feature| format!("+{feature}"))) + .collect(); + features.sort(); + delta.extend(features); + delta.join(",") +} + +/// Compare workspace dependencies and classify consumer-visible breakage. +pub fn diff(base: &BTreeMap, head: &BTreeMap) -> DepDiff { + let mut result = DepDiff::default(); + + for (name, old) in base { + let Some(new) = head.get(name) else { + let label = kind_label(old.kind, old.optional); + if label == "runtime" { + result.breaking += 1; + } + result.removed.push(DepRemoved { + name: name.clone(), + version: old.ver.clone(), + kind: label, + }); + continue; + }; + + if old == new { + continue; + } + + let bump = classify_bump(&old.ver, &new.ver); + let old_label = kind_label(old.kind, old.optional); + let new_label = kind_label(new.kind, new.optional); + let label = if label_rank(&new_label) >= label_rank(&old_label) { + new_label + } else { + old_label + }; + let features = feature_diff( + old.default_features, + new.default_features, + &old.features, + &new.features, + ); + + if label == "runtime" + && (bump == Bump::Major || features.split(',').any(|token| token.starts_with('-'))) + { + result.breaking += 1; + } + + result.changed.push(DepChanged { + name: name.clone(), + old: old.ver.clone(), + new: new.ver.clone(), + bump, + kind: label, + features, + }); + } + + for (name, new) in head { + if !base.contains_key(name) { + result.added.push(DepAdded { + name: name.clone(), + version: new.ver.clone(), + kind: kind_label(new.kind, new.optional), + }); + } + } + + result.removed.sort_by(|a, b| a.name.cmp(&b.name)); + result.changed.sort_by(|a, b| a.name.cmp(&b.name)); + result.added.sort_by(|a, b| a.name.cmp(&b.name)); + result +} + +#[cfg(test)] +#[path = "deps/tests.rs"] +mod tests; diff --git a/src/deps/tests.rs b/src/deps/tests.rs new file mode 100644 index 0000000..c04297b --- /dev/null +++ b/src/deps/tests.rs @@ -0,0 +1,141 @@ +use std::collections::BTreeMap; + +use super::{diff, feature_diff}; +use crate::model::{DepKind, DepRecord}; + +fn dep( + ver: &str, + kind: DepKind, + optional: bool, + default_features: bool, + features: &[&str], +) -> DepRecord { + DepRecord { + ver: ver.to_string(), + kind, + optional, + default_features, + features: features + .iter() + .map(|feature| (*feature).to_string()) + .collect(), + } +} + +#[test] +fn feature_delta_puts_default_first_and_sorts_set_difference() { + let old = vec!["zeta".to_string(), "foo".to_string(), "foo".to_string()]; + let new = vec!["async-std".to_string(), "alpha".to_string()]; + + assert_eq!( + feature_diff(true, false, &old, &new), + "-default!,+alpha,+async-std,-foo,-zeta" + ); +} + +#[test] +fn added_hyphenated_feature_is_not_a_removed_feature() { + let base = BTreeMap::from([( + "dep".to_string(), + dep("1.0", DepKind::Runtime, false, true, &[]), + )]); + let head = BTreeMap::from([( + "dep".to_string(), + dep("1.1", DepKind::Runtime, false, true, &["async-std"]), + )]); + + let result = diff(&base, &head); + + assert_eq!(result.changed[0].features, "+async-std"); + assert_eq!(result.breaking, 0); +} + +#[test] +fn breaking_count_is_limited_to_non_optional_runtime_breakage() { + let base = BTreeMap::from([ + ( + "removed-runtime".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ( + "removed-optional".to_string(), + dep("1", DepKind::Runtime, true, true, &[]), + ), + ( + "major-runtime".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ( + "major-build".to_string(), + dep("1", DepKind::Build, false, true, &[]), + ), + ( + "lost-feature".to_string(), + dep("1", DepKind::Runtime, false, true, &["std"]), + ), + ( + "lost-default".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ( + "added-feature".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ]); + let head = BTreeMap::from([ + ( + "major-runtime".to_string(), + dep("2", DepKind::Runtime, false, true, &[]), + ), + ( + "major-build".to_string(), + dep("2", DepKind::Build, false, true, &[]), + ), + ( + "lost-feature".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ( + "lost-default".to_string(), + dep("1", DepKind::Runtime, false, false, &[]), + ), + ( + "added-feature".to_string(), + dep("1", DepKind::Runtime, false, true, &["foo-bar"]), + ), + ]); + + let result = diff(&base, &head); + + assert_eq!(result.breaking, 4); +} + +#[test] +fn stronger_kind_wins_and_kind_ties_use_head_optionality() { + let base = BTreeMap::from([ + ( + "stronger-old".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ( + "tie".to_string(), + dep("1", DepKind::Runtime, false, true, &[]), + ), + ]); + let head = BTreeMap::from([ + ( + "stronger-old".to_string(), + dep("1", DepKind::Build, false, false, &[]), + ), + ( + "tie".to_string(), + dep("2", DepKind::Runtime, true, true, &[]), + ), + ]); + + let result = diff(&base, &head); + + assert_eq!(result.changed[0].kind, "runtime"); + assert_eq!(result.changed[1].kind, "runtime-opt"); + assert_eq!(result.breaking, 1); +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..0a616a5 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,289 @@ +//! Git subprocess operations. + +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Runs Git and returns trimmed stdout. +pub fn git(args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .output() + .map_err(|err| format!("failed to run git: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err("git command failed".to_string()) + } else { + Err(stderr) + } + } +} + +/// Runs Git with all output discarded. +pub fn git_quiet(args: &[&str]) -> bool { + Command::new("git") + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// Reports whether a ref resolves to an object. +pub fn rev_parse_ok(r: &str) -> bool { + git_quiet(&["rev-parse", "--verify", "--quiet", r]) +} + +/// Resolves a ref to its full object ID. +pub fn rev_parse_verify(r: &str) -> Result { + git(&["rev-parse", "--verify", r]) +} + +/// Resolves a ref to a short object ID, retaining the input on failure. +pub fn rev_parse_short(r: &str) -> String { + git(&["rev-parse", "--short", r]).unwrap_or_else(|_| r.to_string()) +} + +/// Finds the best common ancestor of two refs. +pub fn merge_base(a: &str, b: &str) -> Option { + git(&["merge-base", a, b]) + .ok() + .filter(|sha| !sha.is_empty()) +} + +/// Chooses the current branch's useful parent, falling back to `main`. +pub fn detect_parent_branch() -> String { + let current = git(&["branch", "--show-current"]).unwrap_or_default(); + if current.is_empty() || current == "main" { + return "main".to_string(); + } + + let mut upstream = git(&[ + "rev-parse", + "--abbrev-ref", + &format!("{current}@{{upstream}}"), + ]) + .unwrap_or_default(); + if upstream + .split_once('/') + .map_or(upstream == current, |(_, name)| name == current) + { + upstream.clear(); + } + + if !upstream.is_empty() && rev_parse_ok(&upstream) { + return upstream; + } + + if !upstream.is_empty() { + let local = upstream + .strip_prefix("origin/") + .unwrap_or(upstream.as_str()); + if rev_parse_ok(local) { + return local.to_string(); + } + } + + "main".to_string() +} + +/// Reports tracked, staged, or untracked non-ignored changes. +pub fn is_worktree_dirty() -> bool { + git(&["status", "--porcelain"]).is_ok_and(|out| !out.is_empty()) +} + +/// Creates an unreachable commit containing the working tree without changing the real index. +pub fn worktree_snapshot_commit(run_tmp: &Path) -> Result { + let tmp_index = unique_file(run_tmp, ".index") + .map_err(|_| "failed to create temporary git index".to_string())?; + let _cleanup = RemoveFile(tmp_index.clone()); + + let head_sha = git(&["rev-parse", "--verify", "HEAD"]) + .map_err(|_| "cannot resolve HEAD (no commits yet?)".to_string())?; + + if !git_with_index(&tmp_index, &["read-tree", &head_sha], true).0 { + return Err("git read-tree HEAD failed".to_string()); + } + if !git_with_index(&tmp_index, &["add", "-A"], true).0 { + return Err("git add -A failed (working tree snapshot)".to_string()); + } + + let (ok, tree) = git_with_index(&tmp_index, &["write-tree"], false); + if !ok { + return Err("git write-tree failed".to_string()); + } + let tree = tree.trim(); + + let output = Command::new("git") + .args([ + "commit-tree", + tree, + "-p", + &head_sha, + "-m", + "[zc worktree snapshot]", + ]) + .stderr(Stdio::null()) + .output() + .map_err(|_| "git commit-tree failed".to_string())?; + if !output.status.success() { + return Err("git commit-tree failed".to_string()); + } + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + Err("git commit-tree failed".to_string()) + } else { + Ok(commit) + } +} + +/// Adds a quiet detached worktree at a resolved commit. +pub fn worktree_add(dir: &Path, sha: &str) -> Result<(), String> { + let status = Command::new("git") + .args(["worktree", "add", "--detach", "--quiet"]) + .arg(dir) + .arg(sha) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|err| format!("failed to run git worktree add: {err}"))?; + if status.success() { + Ok(()) + } else { + Err("git worktree add failed".to_string()) + } +} + +/// Forcibly removes a registered worktree, ignoring failures. +pub fn worktree_remove(dir: &Path) { + let _ = Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Prunes stale worktree registrations. +pub fn worktree_prune() { + let _ = Command::new("git") + .args(["worktree", "prune"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Reads one file from a Git tree. +pub fn show_file(rev: &str, path: &str) -> Option { + let spec = format!("{rev}:{path}"); + let output = Command::new("git").args(["show", &spec]).output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Maps source-level public type names to their declaration keywords. +pub fn type_kinds(head_sha: &str) -> HashMap { + let output = match Command::new("git") + .args([ + "grep", + "-E", + "^[[:space:]]*pub (struct|enum|trait|union) ", + head_sha, + "--", + "*.rs", + ]) + .stderr(Stdio::null()) + .output() + { + Ok(output) if output.status.success() => output, + _ => return HashMap::new(), + }; + + let text = String::from_utf8_lossy(&output.stdout); + let mut pairs: Vec<(String, String)> = text.lines().filter_map(parse_type_kind_line).collect(); + pairs.sort_unstable(); + pairs.dedup(); + + let mut kinds = HashMap::new(); + for (name, kind) in pairs { + kinds.insert(name, kind); + } + kinds +} + +fn parse_type_kind_line(line: &str) -> Option<(String, String)> { + let mut words = line.split_whitespace(); + while let Some(word) = words.next() { + if matches!(word, "struct" | "enum" | "trait" | "union") { + let raw_name = words.next()?; + let end = raw_name + .find(['<', '(', '{', ':', ';']) + .unwrap_or(raw_name.len()); + let name = &raw_name[..end]; + return (!name.is_empty()).then(|| (name.to_string(), word.to_string())); + } + } + None +} + +fn git_with_index(index: &Path, args: &[&str], discard_stderr: bool) -> (bool, String) { + let mut command = Command::new("git"); + command.args(args).env("GIT_INDEX_FILE", index); + if discard_stderr { + command.stderr(Stdio::null()); + } + match command.output() { + Ok(output) => ( + output.status.success(), + String::from_utf8_lossy(&output.stdout).into_owned(), + ), + Err(_) => (false, String::new()), + } +} + +fn unique_file(parent: &Path, prefix: &str) -> std::io::Result { + for _ in 0..1_000 { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!("{prefix}.{}.{sequence}", std::process::id())); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(_) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not allocate a unique file name", + )) +} + +struct RemoveFile(PathBuf); + +impl Drop for RemoveFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[cfg(test)] +#[path = "git/tests.rs"] +mod tests; diff --git a/src/git/tests.rs b/src/git/tests.rs new file mode 100644 index 0000000..7328eaf --- /dev/null +++ b/src/git/tests.rs @@ -0,0 +1,28 @@ +use super::parse_type_kind_line; + +#[test] +fn parses_type_names_after_declaration_keyword() { + assert_eq!( + parse_type_kind_line("deadbeef:path/file.rs:pub struct Widget {"), + Some(("Widget".to_string(), "struct".to_string())) + ); + assert_eq!( + parse_type_kind_line("deadbeef:path/file.rs: pub enum State{Ready}"), + Some(("State".to_string(), "enum".to_string())) + ); + assert_eq!( + parse_type_kind_line("deadbeef:path/file.rs:pub trait Service: Send"), + Some(("Service".to_string(), "trait".to_string())) + ); + assert_eq!( + parse_type_kind_line("deadbeef:path/file.rs:pub union Bits;"), + Some(("Bits".to_string(), "union".to_string())) + ); +} + +#[test] +fn rejects_lines_without_a_name_after_the_keyword() { + assert_eq!(parse_type_kind_line("path.rs:pub struct"), None); + assert_eq!(parse_type_kind_line("path.rs:pub fn structish()"), None); + assert_eq!(parse_type_kind_line("path.rs:pub enum {"), None); +} diff --git a/src/group.rs b/src/group.rs new file mode 100644 index 0000000..b67c939 --- /dev/null +++ b/src/group.rs @@ -0,0 +1,382 @@ +//! Public API diff grouping by owning type or module. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use regex::Regex; + +use crate::model::{GroupMode, GroupRecord, Section}; + +struct Patterns { + attribute: Regex, + generic: Regex, + impl_prefix: Regex, + pub_prefix: Regex, + declaration: Regex, + qualifiers: Regex, + item_keyword: Regex, + truncate: Regex, + member_leaf: Regex, + type_segment: Regex, + declaration_kind: Regex, + member_keyword: Regex, + upper_camel: Regex, +} + +fn patterns() -> &'static Patterns { + static PATTERNS: OnceLock = OnceLock::new(); + PATTERNS.get_or_init(|| Patterns { + attribute: Regex::new(r"^#\[[^]]*\] +").expect("valid attribute regex"), + generic: Regex::new(r"<[^<>]*>").expect("valid generic regex"), + impl_prefix: Regex::new(r"^impl +").expect("valid impl regex"), + pub_prefix: Regex::new(r"^pub +").expect("valid visibility regex"), + declaration: Regex::new(r"^(mod|struct|enum|trait|union) +") + .expect("valid declaration regex"), + qualifiers: Regex::new(r"^(?:(?:const|async|unsafe) +)*").expect("valid qualifier regex"), + item_keyword: Regex::new(r"^(?:fn|static|type|use) +").expect("valid item keyword regex"), + truncate: Regex::new(r"[ (=].*").expect("valid truncation regex"), + member_leaf: Regex::new(r"::[^:]+$").expect("valid member regex"), + type_segment: Regex::new(r"::[A-Z][^:]*$").expect("valid type segment regex"), + declaration_kind: Regex::new(r"^pub (mod|struct|enum|trait|union) ") + .expect("valid declaration kind regex"), + member_keyword: Regex::new( + r"^pub (?:mod|struct|enum|trait|union|fn|const|static|type|use|async) ", + ) + .expect("valid member keyword regex"), + upper_camel: Regex::new(r"^[A-Z]").expect("valid UpperCamelCase regex"), + }) +} + +fn without_attribute(signature: &str) -> String { + patterns().attribute.replace(signature, "").into_owned() +} + +fn group_key(signature: &str) -> String { + let regexes = patterns(); + let mut signature = without_attribute(signature); + + loop { + let stripped = regexes.generic.replace_all(&signature, "").into_owned(); + if stripped == signature { + break; + } + signature = stripped; + } + + let (mut key, declaration) = if regexes.impl_prefix.is_match(&signature) { + let signature = regexes.impl_prefix.replace(&signature, "").into_owned(); + let key = signature + .rfind(" for ") + .map_or(signature.as_str(), |index| { + &signature[index + " for ".len()..] + }); + (key.to_string(), true) + } else { + let mut signature = regexes.pub_prefix.replace(&signature, "").into_owned(); + let declaration = regexes.declaration.is_match(&signature); + if declaration { + signature = regexes.declaration.replace(&signature, "").into_owned(); + } else { + signature = regexes.qualifiers.replace(&signature, "").into_owned(); + signature = regexes.item_keyword.replace(&signature, "").into_owned(); + } + (signature, declaration) + }; + + key = regexes.truncate.replace(&key, "").into_owned(); + if key.ends_with(':') { + key.pop(); + } + if !declaration && key.contains("::") { + key = regexes.member_leaf.replace(&key, "").into_owned(); + } + key +} + +fn mod_of(key: &str) -> String { + let mut module = key.to_string(); + loop { + let stripped = patterns().type_segment.replace(&module, "").into_owned(); + if stripped == module { + return module; + } + module = stripped; + } +} + +fn decl_kind(signature: &str) -> Option { + let signature = without_attribute(signature); + patterns() + .declaration_kind + .captures(&signature) + .and_then(|captures| captures.get(1)) + .map(|kind| kind.as_str().to_string()) +} + +fn member_kind(signature: &str) -> Option { + let signature = without_attribute(signature); + if !signature.starts_with("pub ") || patterns().member_keyword.is_match(&signature) { + return None; + } + Some(if signature.contains(": ") { + "struct".to_string() + } else { + "enum".to_string() + }) +} + +fn fallback_kind(key: &str) -> String { + let leaf = key.rsplit("::").next().unwrap_or(key); + if patterns().upper_camel.is_match(leaf) { + "type".to_string() + } else { + "mod".to_string() + } +} + +/// Kind sources for a type key, in precedence order. +struct Kinds<'a> { + /// Kinds read from a declaration present in the diff. + declared: HashMap, + /// Kinds read from the head source, keyed by short name. + src: &'a HashMap, + /// Kinds weakly inferred from a member. + member: HashMap, +} + +impl Kinds<'_> { + /// Resolved kind: declared in the diff > head source by short name > inferred from a + /// member > naming convention. + fn resolve(&self, key: &str) -> String { + if let Some(kind) = self.declared.get(key) { + return kind.clone(); + } + let short_name = key.rsplit("::").next().unwrap_or(key); + if let Some(kind) = self.src.get(short_name) { + return kind.clone(); + } + self.member + .get(key) + .cloned() + .unwrap_or_else(|| fallback_kind(key)) + } +} + +fn is_ext(key: &str, crate_prefix: &str) -> bool { + !crate_prefix.is_empty() + && key != crate_prefix + && !key + .strip_prefix(crate_prefix) + .is_some_and(|suffix| suffix.starts_with("::")) +} + +struct Line<'a> { + text: &'a str, + key: String, + module: String, +} + +fn emit_type( + records: &mut Vec, + key: &str, + members: &[usize], + lines: &[Line<'_>], + kinds: &Kinds<'_>, +) { + records.push(GroupRecord::TypeHeader { + name: key.to_string(), + kind: kinds.resolve(key), + }); + + let module = key.rfind("::").map_or(key, |index| &key[..index]); + let module_prefix = format!("{module}::"); + for &index in members { + records.push(GroupRecord::Item( + lines[index].text.replace(&module_prefix, ""), + )); + } +} + +fn emit_module( + records: &mut Vec, + module: &str, + types: &[String], + members: &HashMap>, + lines: &[Line<'_>], + kinds: &Kinds<'_>, +) { + records.push(GroupRecord::ModHeader(module.to_string())); + let module_prefix = format!("{module}::"); + + for key in types { + let Some(type_members) = members.get(key) else { + continue; + }; + if key == module { + for &index in type_members { + let mut display = lines[index].text.replace(&module_prefix, ""); + if display.starts_with("pub mod ") { + let name = display + .rsplit_once("::") + .map_or(display.as_str(), |(_, name)| name); + display = format!("pub mod {name}"); + } + records.push(GroupRecord::Item(display)); + } + continue; + } + + let relative_name = key.strip_prefix(&module_prefix).unwrap_or(key).to_string(); + records.push(GroupRecord::TypeSub { + name: relative_name, + kind: kinds.resolve(key), + }); + let owner_pattern = format!(r"{}(?:<[^<>]*>)?::", regex::escape(key)); + let owner_regex = Regex::new(&owner_pattern).ok(); + for &index in type_members { + let display = owner_regex.as_ref().map_or_else( + || lines[index].text.to_string(), + |regex| regex.replace_all(lines[index].text, "").into_owned(), + ); + records.push(GroupRecord::DeepItem(display.replace(&module_prefix, ""))); + } + } +} + +/// Groups public API signatures while preserving each key's first appearance. +pub fn group( + lines: &[String], + section: Section, + mode: GroupMode, + crate_prefix: &str, + src_kinds: &HashMap, +) -> Vec { + if mode == GroupMode::Flat { + return Vec::new(); + } + + let mut grouped_lines = Vec::new(); + let mut declared_kinds = HashMap::new(); + let mut member_kinds = HashMap::new(); + let mut changed_key = String::new(); + + for line in lines { + if line.trim().is_empty() { + continue; + } + + let (key, declared_kind, inferred_kind) = if section == Section::Changed { + if let Some(signature) = line.strip_prefix(" - ") { + changed_key = group_key(signature); + ( + changed_key.clone(), + decl_kind(signature), + member_kind(signature), + ) + } else { + (changed_key.clone(), None, None) + } + } else { + (group_key(line), decl_kind(line), member_kind(line)) + }; + let module = mod_of(&key); + if let Some(kind) = declared_kind { + declared_kinds.insert(key.clone(), kind); + } + if let Some(kind) = inferred_kind { + member_kinds.entry(key.clone()).or_insert(kind); + } + grouped_lines.push(Line { + text: line, + key, + module, + }); + } + + let kinds = Kinds { + declared: declared_kinds, + src: src_kinds, + member: member_kinds, + }; + + let mut records = Vec::new(); + let mut members: HashMap> = HashMap::new(); + + if mode == GroupMode::Type { + let mut type_order = Vec::new(); + for (index, line) in grouped_lines.iter().enumerate() { + if !members.contains_key(&line.key) { + type_order.push(line.key.clone()); + } + members.entry(line.key.clone()).or_default().push(index); + } + + for key in type_order.iter().filter(|key| !is_ext(key, crate_prefix)) { + emit_type(&mut records, key, &members[key], &grouped_lines, &kinds); + } + let external: Vec<&String> = type_order + .iter() + .filter(|key| is_ext(key, crate_prefix)) + .collect(); + if !external.is_empty() { + records.push(GroupRecord::ExtDivider); + } + for key in external { + emit_type(&mut records, key, &members[key], &grouped_lines, &kinds); + } + return records; + } + + let mut module_order = Vec::new(); + let mut module_types: HashMap> = HashMap::new(); + for (index, line) in grouped_lines.iter().enumerate() { + if !module_types.contains_key(&line.module) { + module_order.push(line.module.clone()); + module_types.insert(line.module.clone(), Vec::new()); + } + if !members.contains_key(&line.key) { + module_types + .entry(line.module.clone()) + .or_default() + .push(line.key.clone()); + } + members.entry(line.key.clone()).or_default().push(index); + } + + for module in module_order + .iter() + .filter(|module| !is_ext(module, crate_prefix)) + { + emit_module( + &mut records, + module, + &module_types[module], + &members, + &grouped_lines, + &kinds, + ); + } + let external: Vec<&String> = module_order + .iter() + .filter(|module| is_ext(module, crate_prefix)) + .collect(); + if !external.is_empty() { + records.push(GroupRecord::ExtDivider); + } + for module in external { + emit_module( + &mut records, + module, + &module_types[module], + &members, + &grouped_lines, + &kinds, + ); + } + + records +} + +#[cfg(test)] +#[path = "group/tests.rs"] +mod tests; diff --git a/src/group/tests.rs b/src/group/tests.rs new file mode 100644 index 0000000..69b6a5e --- /dev/null +++ b/src/group/tests.rs @@ -0,0 +1,160 @@ +use std::collections::HashMap; + +use super::group; +use crate::model::{GroupMode, GroupRecord, Section}; + +fn lines(lines: &[&str]) -> Vec { + lines.iter().map(|line| (*line).to_string()).collect() +} + +#[test] +fn groups_public_api_items() { + struct Case { + name: &'static str, + lines: Vec, + section: Section, + mode: GroupMode, + expected: Vec, + } + + let cases = vec![ + Case { + name: "variant under enum", + lines: lines(&["pub enum zc_fixture::Color", "pub zc_fixture::Color::Red"]), + section: Section::Added, + mode: GroupMode::Mod, + expected: vec![ + GroupRecord::ModHeader("zc_fixture".to_string()), + GroupRecord::TypeSub { + name: "Color".to_string(), + kind: "enum".to_string(), + }, + GroupRecord::DeepItem("pub enum Color".to_string()), + GroupRecord::DeepItem("pub Red".to_string()), + ], + }, + Case { + name: "by type keeps type prefix", + lines: lines(&["pub enum zc_fixture::Color", "pub zc_fixture::Color::Red"]), + section: Section::Added, + mode: GroupMode::Type, + expected: vec![ + GroupRecord::TypeHeader { + name: "zc_fixture::Color".to_string(), + kind: "enum".to_string(), + }, + GroupRecord::Item("pub enum Color".to_string()), + GroupRecord::Item("pub Color::Red".to_string()), + ], + }, + Case { + name: "generic arguments on owner", + lines: lines(&[ + "pub struct zc_fixture::Wrap", + "pub fn zc_fixture::Wrap::get(&self)", + ]), + section: Section::Added, + mode: GroupMode::Mod, + expected: vec![ + GroupRecord::ModHeader("zc_fixture".to_string()), + GroupRecord::TypeSub { + name: "Wrap".to_string(), + kind: "struct".to_string(), + }, + GroupRecord::DeepItem("pub struct Wrap".to_string()), + GroupRecord::DeepItem("pub fn get(&self)".to_string()), + ], + }, + Case { + name: "interleaved enum items use one header", + lines: lines(&[ + "pub enum zc_fixture::E", + "pub zc_fixture::E::A", + "pub zc_fixture::E::A::x: u32", + "pub zc_fixture::E::B", + "pub zc_fixture::E::B::y: u32", + "pub zc_fixture::E::Other", + ]), + section: Section::Added, + mode: GroupMode::Mod, + expected: vec![ + GroupRecord::ModHeader("zc_fixture".to_string()), + GroupRecord::TypeSub { + name: "E".to_string(), + kind: "enum".to_string(), + }, + GroupRecord::DeepItem("pub enum E".to_string()), + GroupRecord::DeepItem("pub A".to_string()), + GroupRecord::DeepItem("pub B".to_string()), + GroupRecord::DeepItem("pub Other".to_string()), + GroupRecord::TypeSub { + name: "E::A".to_string(), + kind: "struct".to_string(), + }, + GroupRecord::DeepItem("pub x: u32".to_string()), + GroupRecord::TypeSub { + name: "E::B".to_string(), + kind: "struct".to_string(), + }, + GroupRecord::DeepItem("pub y: u32".to_string()), + ], + }, + Case { + name: "foreign type follows divider", + lines: lines(&[ + "pub fn dep::Foo::name(&self)", + "pub fn zc_fixture::Own::go(&self)", + ]), + section: Section::Added, + mode: GroupMode::Mod, + expected: vec![ + GroupRecord::ModHeader("zc_fixture".to_string()), + GroupRecord::TypeSub { + name: "Own".to_string(), + kind: "type".to_string(), + }, + GroupRecord::DeepItem("pub fn go(&self)".to_string()), + GroupRecord::ExtDivider, + GroupRecord::ModHeader("dep".to_string()), + GroupRecord::TypeSub { + name: "Foo".to_string(), + kind: "type".to_string(), + }, + GroupRecord::DeepItem("pub fn name(&self)".to_string()), + ], + }, + Case { + name: "changed key comes from old line", + lines: lines(&[ + " - pub fn zc_fixture::Thing::old(&self)", + " + pub fn zc_fixture::Renamed::new(&self)", + ]), + section: Section::Changed, + mode: GroupMode::Mod, + expected: vec![ + GroupRecord::ModHeader("zc_fixture".to_string()), + GroupRecord::TypeSub { + name: "Thing".to_string(), + kind: "type".to_string(), + }, + GroupRecord::DeepItem(" - pub fn old(&self)".to_string()), + GroupRecord::DeepItem(" + pub fn Renamed::new(&self)".to_string()), + ], + }, + ]; + + for case in cases { + assert_eq!( + group( + &case.lines, + case.section, + case.mode, + "zc_fixture", + &HashMap::new(), + ), + case.expected, + "{}", + case.name, + ); + } +} diff --git a/src/help.txt b/src/help.txt new file mode 100644 index 0000000..5b1db94 --- /dev/null +++ b/src/help.txt @@ -0,0 +1,187 @@ +zc — Detect public API and dependency changes across all +workspace crates. + +Compares every workspace crate between two git refs (baseline and head, +where head defaults to the working tree if dirty, else HEAD). Up to four +sections are emitted (sections 2 and 4 are opt-in): + + 1. Workspace dependency diff. Each dep is classified by the strongest + kind it is used with across the workspace (runtime > build > dev). + Optional runtime deps are labelled `runtime-opt`. Crates whose name + ends in `-test` (or is exactly `zebra-test`) are excluded from + classification so their transitive runtime deps don't masquerade as + production deps. Workspace-internal crates are excluded entirely (the + per-crate section already covers them). + + 2. Optional Cargo.lock diff (--with-lock). Transitive changes only; + direct workspace deps are suppressed because they already appear in + section 1. + + 3. Per-crate public API diff via `cargo public-api`. + + 4. Optional const/static value + doc-comment diff (--with-values). + cargo-public-api compares signatures only, so a `pub const` whose value + changes (e.g. 99 -> 1000) or an item whose doc text changes shows as + "no change". This section catches those via rustdoc JSON. + +PREREQUISITES + cargo install cargo-public-api --version 0.52.0 --locked + a nightly toolchain (builds rustdoc JSON) + +USAGE + zc [options] [ []] + +ARGUMENTS + Git ref to compare against: branch name, tag, or commit SHA. + Git ref to compare to. + +DEFAULT BEHAVIOR (when is not given) + The script picks `head` based on whether the working tree is dirty, and — + when comparing against a parent branch — diffs from the *branch point* (the + merge-base of that branch and the head) rather than the branch's current + tip, so commits merged onto the parent after you branched don't pollute the + diff (or any changelog built from it): + - 0 args, dirty: head = working tree, baseline = HEAD + - 0 args, clean: head = HEAD, baseline = merge-base(parent, HEAD) + (parent = upstream tracking ref, or 'main') + - 1 arg, dirty: head = working tree, baseline = merge-base(, HEAD) + - 1 arg, clean: head = HEAD, baseline = merge-base(, HEAD) + - 2 args: head = , baseline = (exact, no merge-base) + + merge-base(X, HEAD) == X whenever X is already an ancestor of HEAD, so the + branch point only differs from the ref when the branch has advanced past it. + It is computed from local history — no network access needed. + + "Working tree" includes both staged and unstaged changes plus any + untracked files (so a brand-new .rs file shows as added API). + +OPTIONS + -h, --help Print this help message and exit. + -V, --version Print the installed version and exit. + --with-lock Also diff Cargo.lock for transitive dep changes. + --by-type By default, per-crate API items are grouped into a + module > type > member hierarchy: a module header, then a + type sub-header tagged with its kind — read from its + declaration in the diff, else the head source, else inferred + from a member, else a generic `(type)`/`(mod)` — with the + module and type + prefixes factored out of each item; items living directly in + a module (free fns, consts, mod decls) sit under the module + header. --by-type uses a flat type grouping instead: one + tagged header per type, members keeping their `Type::` prefix. + In either mode, items whose path is in another crate (a + trait impl this crate adds to a foreign type) are collected + under a separate "trait impls on external types" section. + --flat Don't group at all; print the original flat, + fully-qualified, one-item-per-line list. + --with-values Also diff public const/static values and doc text via + rustdoc JSON. Catches changes cargo-public-api can't see + because it is signature-only. + --changelog Emit a librustzcash-style changelog (markdown) on stdout + instead of the diff: one `## ` section per changed + crate, with `### Added`/`### Changed`/`### Removed` lists + grouped under their owning type (own-crate paths made + crate-relative, foreign-type paths kept in full). Per-crate + dependency changes are folded in too: internal workspace + bumps and external `Migrated to ...` lines under Changed, + dropped deps under Removed. Other output is suppressed. + The draft still needs curation: see librustzcash's + CONTRIBUTING.md "Changelog Entries" for what requires an + entry and how it is worded, and skills/zc/ for the + curation workflow. + --json Emit machine-readable JSON on stdout. + Progress and diagnostics go to stderr. Schema: + { + baseline: ", )'>", + baseline_sha: "", + head: "", + head_sha: "", + verdict: "ok" | "breaking" | "error", + totals: { + removed, changed, added, # API items + api_breaking, # removed + changed + dep_breaking, # breaking runtime deps + error_crates, # cargo-public-api fails + value_changed, # const/static value changes + doc_changed, # doc-comment changes + public_dep_breaking # public-dep incompatible + }, + deps: { + removed: [ {name, version, kind} ], + changed: [ {name, old, new, bump, kind, features} ], + added: [ {name, version, kind} ] + }, + values: [ {crate, path, type, old, new} ], + docs: [ {crate, path} ], + public_dep_breaks: [ {crate, dep, old, new, + class} ], # breaking|review + crates: [ + { name, removed, changed, added, status, + error: null | { + stage, ref, ref_sha, command, stderr, hint + } + } + ] + } + Error `stage` is baseline_build, head_build, or diff. + zc keeps --all-features and does not automatically fall + back to default features because that can hide public API. + +EXAMPLES + zc # if dirty: HEAD -> working tree + # if clean: branch point with parent -> HEAD + zc main # diff against the branch point with main + # (HEAD, or working tree if dirty) + zc v4.2.0 # diff against v4.2.0 (a tag/ancestor: exact) + zc v4.1.0 v4.2.0 # compare two arbitrary refs (exact, no merge-base) + zc --with-lock # include transitive Cargo.lock diff + zc --with-values main # also flag const/static value + doc changes + zc --json main # machine-readable output for CI + +OUTPUT + Sections are printed in order: + 1. Workspace dep diff — removed / changed / added. + Colors: red = consumer-visible breaking (runtime major bump, + runtime removal), yellow = changed but not breaking, dim = + internal (build / dev / runtime-opt), green = added. + 2. Transitive Cargo.lock diff (only with --with-lock). Direct + workspace deps are suppressed (already in section 1); each + transitive crate is annotated with the direct deps that pull + it in (`via foo, bar` — truncated to 3 with `...(+N)`). + 3. Per-crate public-API counts, one row per workspace crate: + crate-name -R ~C +A [(additive)] + Crates with only additions are tagged `(additive)` so reviewers + can focus on breaking ones. Crates with no changes show as + `no changes`; crates where cargo-public-api failed show as + `error: `. + 4. Summary table — aligned counts per crate plus a Total row, + printed only if at least one crate changed. Zero counts are + rendered as `-` for readability. + 5. Detailed diffs — for each changed crate, the removed / changed + / added API items. + 6. Value/doc changes (only with --with-values) — const/static values + shown as `old -> new` (counted as breaking), and public items whose + doc-comment text changed (informational). + 7. Final verdict line: BREAKING / ERROR / OK with a one-line + summary of the contributing factors. + With --json a single JSON document is printed instead (schema above). + +ENVIRONMENT + CARGO_TARGET_DIR Used as the root for zc's cache. When unset, + `target/zc-cache/` is used. Dependency and derived + TSV caches are keyed on the resolved ref SHA plus a hash + of the zc binary. Rustdoc JSON is keyed on ref SHA, crate, + cargo-public-api version, nightly rustc version, and + feature policy. + ZC_TOOLCHAIN Nightly toolchain name used to build rustdoc JSON. + Defaults to the first installed `nightly*` toolchain. + NO_COLOR When set to any non-empty value, disables ANSI + color output (https://no-color.org). + +EXIT CODES + 0 Clean. No breaking API, dependency, or value changes were found. + 1 Breaking changes were detected. + 2 Analysis error. zc could not produce a trustworthy verdict because + cargo-public-api, rustdoc, metadata, or another analysis step failed. + 64 Usage or setup error, such as an unknown option, bad ref, missing + required tool, or unsupported shell. diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..864e76e --- /dev/null +++ b/src/json.rs @@ -0,0 +1,241 @@ +//! Machine-readable report serialization. + +use serde::Serialize; + +use crate::model::{ApiError, Bump, CrateResult, CrateStatus, Report}; +use crate::version_req; + +#[derive(Serialize)] +struct JsonReport<'a> { + baseline: &'a str, + baseline_sha: &'a str, + head: &'a str, + head_sha: &'a str, + verdict: &'static str, + totals: Totals, + deps: Deps<'a>, + values: Vec>, + docs: Vec>, + public_dep_breaks: Vec>, + crates: Vec>, +} + +#[derive(Serialize)] +struct Totals { + removed: usize, + changed: usize, + added: usize, + api_breaking: usize, + dep_breaking: usize, + error_crates: usize, + value_changed: usize, + doc_changed: usize, + public_dep_breaking: usize, +} + +#[derive(Serialize)] +struct Deps<'a> { + removed: Vec>, + changed: Vec>, + added: Vec>, +} + +#[derive(Serialize)] +struct DepRemoved<'a> { + name: &'a str, + version: &'a str, + kind: &'a str, +} + +#[derive(Serialize)] +struct DepChanged<'a> { + name: &'a str, + old: &'a str, + new: &'a str, + bump: &'static str, + kind: &'a str, + features: &'a str, +} + +#[derive(Serialize)] +struct DepAdded<'a> { + name: &'a str, + version: &'a str, + kind: &'a str, +} + +#[derive(Serialize)] +struct Value<'a> { + #[serde(rename = "crate")] + crate_name: &'a str, + path: &'a str, + #[serde(rename = "type")] + ty: &'a str, + old: &'a str, + new: &'a str, +} + +#[derive(Serialize)] +struct Doc<'a> { + #[serde(rename = "crate")] + crate_name: &'a str, + path: &'a str, +} + +#[derive(Serialize)] +struct PublicDepBreak<'a> { + #[serde(rename = "crate")] + crate_name: &'a str, + dep: &'a str, + old: &'a str, + new: &'a str, + class: &'static str, +} + +#[derive(Serialize)] +struct Crate<'a> { + name: &'a str, + removed: usize, + changed: usize, + added: usize, + status: &'static str, + error: Option>, +} + +#[derive(Serialize)] +struct Error<'a> { + stage: &'static str, + #[serde(rename = "ref")] + ref_label: &'a str, + ref_sha: &'a str, + command: &'a str, + stderr: &'a str, + hint: &'a str, +} + +impl<'a> From<&'a ApiError> for Error<'a> { + fn from(error: &'a ApiError) -> Self { + Self { + stage: error.stage.as_str(), + ref_label: &error.ref_label, + ref_sha: &error.ref_sha, + command: &error.command, + stderr: &error.stderr, + hint: &error.hint, + } + } +} + +impl<'a> From<&'a CrateResult> for Crate<'a> { + fn from(result: &'a CrateResult) -> Self { + Self { + name: &result.name, + removed: result.removed, + changed: result.changed, + added: result.added, + status: match result.status { + CrateStatus::Ok => "ok", + CrateStatus::Error => "error", + }, + error: result.error.as_ref().map(Error::from), + } + } +} + +pub fn emit(report: &Report) -> String { + let deps = Deps { + removed: report + .deps + .removed + .iter() + .map(|dep| DepRemoved { + name: &dep.name, + version: &dep.version, + kind: &dep.kind, + }) + .collect(), + changed: report + .deps + .changed + .iter() + .map(|dep| DepChanged { + name: &dep.name, + old: &dep.old, + new: &dep.new, + bump: dep.bump.as_str(), + kind: &dep.kind, + features: &dep.features, + }) + .collect(), + added: report + .deps + .added + .iter() + .map(|dep| DepAdded { + name: &dep.name, + version: &dep.version, + kind: &dep.kind, + }) + .collect(), + }; + let values = report + .values + .iter() + .map(|change| Value { + crate_name: &change.crate_name, + path: &change.path, + ty: &change.ty, + old: &change.old, + new: &change.new, + }) + .collect(); + let docs = report + .docs + .iter() + .map(|change| Doc { + crate_name: &change.crate_name, + path: &change.path, + }) + .collect(); + let public_dep_breaks = report + .crates + .iter() + .flat_map(|result| { + result.pubdep.iter().map(move |finding| PublicDepBreak { + crate_name: &result.name, + dep: &finding.dep, + old: &finding.old, + new: &finding.new, + class: if version_req::classify_bump(&finding.old, &finding.new) == Bump::Major { + "breaking" + } else { + "review" + }, + }) + }) + .collect(); + let document = JsonReport { + baseline: &report.refs.baseline_label, + baseline_sha: &report.refs.baseline_short, + head: &report.refs.head_label, + head_sha: &report.refs.head_short, + verdict: report.verdict().as_str(), + totals: Totals { + removed: report.removed_total, + changed: report.changed_total, + added: report.added_total, + api_breaking: report.api_breaking(), + dep_breaking: report.deps.breaking, + error_crates: report.error_crate_count, + value_changed: report.values.len(), + doc_changed: report.docs.len(), + public_dep_breaking: report.pubdep_break_total, + }, + deps, + values, + docs, + public_dep_breaks, + crates: report.crates.iter().map(Crate::from).collect(), + }; + serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string()) +} diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..78e133d --- /dev/null +++ b/src/lock.rs @@ -0,0 +1,206 @@ +//! Resolved dependency changes from Cargo.lock. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::process::Command; + +use crate::ctx::Ctx; +use crate::git; +use crate::model::LockDiff; + +/// Compare the lock files at both refs, suppressing direct dependencies. +pub fn diff(ctx: &Ctx, direct_names: &HashSet) -> Option { + let base = git::show_file(&ctx.refs.baseline, "Cargo.lock"); + let head = git::show_file(&ctx.refs.head_ref, "Cargo.lock"); + let (Some(base), Some(head)) = (base, head) else { + warn_missing(ctx); + return None; + }; + + let base = extract_lock(&base); + let head = extract_lock(&head); + if base.is_empty() || head.is_empty() { + warn_missing(ctx); + return None; + } + + let tree = Command::new("cargo") + .args(["tree", "--prefix=depth", "--edges=normal", "--workspace"]) + .output() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default(); + + Some(build_diff(&base, &head, direct_names, &tree)) +} + +fn warn_missing(ctx: &Ctx) { + eprintln!( + "{}warning:{} Cargo.lock missing at one or both refs; skipping lock diff", + ctx.style.yellow, ctx.style.reset + ); +} + +fn extract_lock(contents: &str) -> Vec<(String, String)> { + let mut packages = BTreeSet::new(); + let mut in_package = false; + let mut name = String::new(); + let mut version = String::new(); + + for line in contents.split('\n') { + if line.starts_with("[[package]]") { + name.clear(); + version.clear(); + in_package = true; + continue; + } + if in_package { + if let Some(value) = quoted_assignment(line, "name") { + name = value.to_string(); + } + if let Some(value) = quoted_assignment(line, "version") { + version = value.to_string(); + } + if line.is_empty() { + emit_package(&mut packages, &name, &version); + name.clear(); + version.clear(); + in_package = false; + } + } + } + + if in_package { + emit_package(&mut packages, &name, &version); + } + + packages.into_iter().collect() +} + +fn quoted_assignment<'a>(line: &'a str, key: &str) -> Option<&'a str> { + let mut rest = line.strip_prefix(key)?; + rest = rest.trim_start_matches(' '); + rest = rest.strip_prefix('=')?; + rest = rest.trim_start_matches(' '); + rest = rest.strip_prefix('"')?; + Some(rest.strip_suffix('"').unwrap_or(rest)) +} + +fn emit_package(packages: &mut BTreeSet<(String, String)>, name: &str, version: &str) { + if !name.is_empty() && !version.is_empty() { + packages.insert((name.to_string(), version.to_string())); + } +} + +fn build_diff( + base: &[(String, String)], + head: &[(String, String)], + direct_names: &HashSet, + tree: &str, +) -> LockDiff { + let base = versions_by_name(base); + let head = versions_by_name(head); + let mut result = LockDiff::default(); + + for (name, old) in &base { + if direct_names.contains(name) { + continue; + } + match head.get(name) { + None => result.removed.push((name.clone(), old.clone())), + Some(new) if old != new => { + result + .changed + .push((name.clone(), old.clone(), new.clone())); + } + Some(_) => {} + } + } + + for (name, versions) in &head { + if !direct_names.contains(name) && !base.contains_key(name) { + result.added.push((name.clone(), versions.clone())); + } + } + + result.removed.sort_by(|a, b| a.0.cmp(&b.0)); + result.changed.sort_by(|a, b| a.0.cmp(&b.0)); + result.added.sort_by(|a, b| a.0.cmp(&b.0)); + result.via = attribution(tree, direct_names); + result +} + +fn versions_by_name(packages: &[(String, String)]) -> BTreeMap { + let mut versions: BTreeMap> = BTreeMap::new(); + for (name, version) in packages { + let entry = versions.entry(name.clone()).or_default(); + if entry.last() != Some(version) { + entry.push(version.clone()); + } + } + versions + .into_iter() + .map(|(name, versions)| (name, versions.join(","))) + .collect() +} + +fn attribution(tree: &str, direct_names: &HashSet) -> HashMap { + let mut sources: HashMap> = HashMap::new(); + let mut anchor: Option<&str> = None; + + for line in tree.lines() { + let Some((depth, name)) = tree_entry(line) else { + continue; + }; + if depth == 0 { + anchor = None; + continue; + } + if direct_names.contains(name) { + anchor = Some(name); + continue; + } + let Some(direct) = anchor else { + continue; + }; + let entry = sources.entry(name.to_string()).or_default(); + if !entry.iter().any(|seen| seen == direct) { + entry.push(direct.to_string()); + } + } + + sources + .into_iter() + .map(|(name, sources)| (name, truncate_sources(&sources))) + .collect() +} + +fn tree_entry(line: &str) -> Option<(usize, &str)> { + let digit_count = line.bytes().take_while(u8::is_ascii_digit).count(); + if digit_count == 0 || digit_count == line.len() { + return None; + } + let depth = line[..digit_count].parse().ok()?; + let rest = &line[digit_count..]; + let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + if name_end == 0 { + return None; + } + Some((depth, &rest[..name_end])) +} + +fn truncate_sources(sources: &[String]) -> String { + if sources.len() <= 3 { + sources.join(",") + } else { + format!( + "{},{},{},...(+{})", + sources[0], + sources[1], + sources[2], + sources.len() - 3 + ) + } +} + +#[cfg(test)] +#[path = "lock/tests.rs"] +mod tests; diff --git a/src/lock/tests.rs b/src/lock/tests.rs new file mode 100644 index 0000000..fecbb2d --- /dev/null +++ b/src/lock/tests.rs @@ -0,0 +1,90 @@ +use std::collections::HashSet; + +use super::{attribution, build_diff, extract_lock}; + +#[test] +fn parser_emits_a_trailing_package_without_a_blank_line() { + let lock = concat!( + "version = 3\n\n", + "[[package]]\n", + "name = \"alpha\"\n", + "version = \"1.0.0\"\n\n", + "[[package]]\n", + "name = \"omega\"\n", + "version = \"9.0.0\"", + ); + + assert_eq!( + extract_lock(lock), + vec![ + ("alpha".to_string(), "1.0.0".to_string()), + ("omega".to_string(), "9.0.0".to_string()), + ] + ); +} + +#[test] +fn attribution_deduplicates_in_first_seen_order_and_truncates() { + let direct = HashSet::from([ + "first".to_string(), + "second".to_string(), + "third".to_string(), + "fourth".to_string(), + ]); + let tree = concat!( + "0root-a v1\n", + "1first v1\n", + "2target v1\n", + "0root-b v1\n", + "1second v1\n", + "2target v1\n", + "0root-c v1\n", + "1first v1\n", + "2target v1\n", + "1third v1\n", + "2target v1\n", + "0root-d v1\n", + "1fourth v1\n", + "2target v1\n", + ); + + let via = attribution(tree, &direct); + + assert_eq!(via["target"], "first,second,third,...(+1)"); +} + +#[test] +fn lock_diff_groups_versions_and_suppresses_direct_dependencies() { + let base = vec![ + ("direct".to_string(), "1.0.0".to_string()), + ("multi".to_string(), "1.0.0".to_string()), + ("multi".to_string(), "2.0.0".to_string()), + ("removed".to_string(), "1.0.0".to_string()), + ]; + let head = vec![ + ("added".to_string(), "1.0.0".to_string()), + ("direct".to_string(), "2.0.0".to_string()), + ("multi".to_string(), "2.0.0".to_string()), + ("multi".to_string(), "3.0.0".to_string()), + ]; + let direct = HashSet::from(["direct".to_string()]); + + let result = build_diff(&base, &head, &direct, ""); + + assert_eq!( + result.changed, + vec![( + "multi".to_string(), + "1.0.0,2.0.0".to_string(), + "2.0.0,3.0.0".to_string(), + )] + ); + assert_eq!( + result.removed, + vec![("removed".to_string(), "1.0.0".to_string())] + ); + assert_eq!( + result.added, + vec![("added".to_string(), "1.0.0".to_string())] + ); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..661f3f5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,511 @@ +//! zc — detect public API and dependency changes across all workspace crates between two +//! git refs. +//! +//! Git, Cargo, rustdoc and cargo-public-api stay subprocesses: they are the source of truth +//! for refs, feature resolution and API surfaces. Everything above them — parsing, +//! classification, grouping and rendering — is Rust. + +mod api; +mod cache; +mod cargo_meta; +mod changelog; +mod changelog_out; +mod ctx; +mod deps; +mod git; +mod group; +mod json; +mod lock; +mod model; +mod progress; +mod pubdep; +mod render; +mod style; +mod traitmap; +mod values; +mod version_req; + +use std::collections::HashSet; +use std::process::ExitCode; + +use ctx::Ctx; +use model::{GroupMode, Options, Refs, EXIT_ANALYSIS, EXIT_BREAKING, EXIT_OK, EXIT_USAGE}; +use progress::Progress; +use style::Style; + +const HELP: &str = include_str!("help.txt"); +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn main() -> ExitCode { + match run() { + Ok(code) => ExitCode::from(code as u8), + Err(err) => { + eprintln!("{err}"); + ExitCode::from(EXIT_ANALYSIS as u8) + } + } +} + +/// A parsed command line. +struct Args { + opts: Options, + positional: Vec, +} + +fn parse_args(argv: Vec, style: &Style) -> Result { + let mut opts = Options { + with_lock: false, + with_values: false, + json_mode: false, + changelog_mode: false, + group_mode: GroupMode::Mod, + }; + let mut positional = Vec::new(); + let mut it = argv.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "-h" | "--help" => return Err((HELP.to_string(), EXIT_OK)), + "-V" | "--version" => return Err((format!("zc {VERSION}"), EXIT_OK)), + "--with-lock" => opts.with_lock = true, + "--flat" => opts.group_mode = GroupMode::Flat, + "--by-type" => opts.group_mode = GroupMode::Type, + "--with-values" => opts.with_values = true, + "--json" => opts.json_mode = true, + "--changelog" => opts.changelog_mode = true, + "--" => { + positional.extend(it); + break; + } + other if other.starts_with('-') => { + return Err(( + format!( + "{}error:{} unknown option '{other}' (run with --help for usage)", + style.red, style.reset + ), + EXIT_USAGE, + )) + } + other => { + positional.push(other.to_string()); + positional.extend(it); + break; + } + } + } + if positional.len() > 2 { + return Err(( + format!( + "{}error:{} too many positional arguments, expected at most 2 \ + (run with --help for usage)", + style.red, style.reset + ), + EXIT_USAGE, + )); + } + Ok(Args { opts, positional }) +} + +fn run() -> Result { + let argv: Vec = std::env::args().skip(1).collect(); + // Style for pre-parse diagnostics: assume a document mode only once we know. + let probe = Style::detect(false); + let args = match parse_args(argv, &probe) { + Ok(args) => args, + Err((msg, EXIT_OK)) => { + println!("{msg}"); + return Ok(EXIT_OK); + } + Err((msg, code)) => { + eprintln!("{msg}"); + return Ok(code); + } + }; + let opts = args.opts; + let document_mode = opts.json_mode || opts.changelog_mode; + let style = Style::detect(document_mode); + + // ── ref resolution ──────────────────────────────────────────────── + let positional = args.positional; + let dirty = positional.len() < 2 && git::is_worktree_dirty(); + + let (mut baseline, mut head_ref, mut head_label, use_merge_base) = match positional.len() { + 2 => ( + positional[0].clone(), + positional[1].clone(), + positional[1].clone(), + false, + ), + 1 if dirty => ( + positional[0].clone(), + String::new(), + "working tree".to_string(), + true, + ), + 1 => ( + positional[0].clone(), + "HEAD".to_string(), + "HEAD".to_string(), + true, + ), + _ if dirty => ( + "HEAD".to_string(), + String::new(), + "working tree".to_string(), + false, + ), + _ => ( + git::detect_parent_branch(), + "HEAD".to_string(), + "HEAD".to_string(), + true, + ), + }; + + let mut baseline_label = baseline.clone(); + if use_merge_base { + let head_for_mb = if head_ref.is_empty() { + "HEAD" + } else { + &head_ref + }; + if let Some(mb) = git::merge_base(&baseline, head_for_mb) { + baseline_label = format!("merge-base({baseline_label}, {head_label})"); + baseline = mb; + } + } + + if !git::rev_parse_ok(&baseline) { + eprintln!( + "{}error:{} unknown git ref '{baseline}' (run with --help for usage)", + style.red, style.reset + ); + return Ok(EXIT_USAGE); + } + if !head_ref.is_empty() && !git::rev_parse_ok(&head_ref) { + eprintln!( + "{}error:{} unknown git ref '{head_ref}' (run with --help for usage)", + style.red, style.reset + ); + return Ok(EXIT_USAGE); + } + + // ── prerequisites ───────────────────────────────────────────────── + let cargo_public_api_version = match api::cargo_public_api_version() { + Some(v) => v, + None => { + eprintln!( + "{}error:{} cargo-public-api is not installed", + style.red, style.reset + ); + eprintln!(" install it with: cargo install cargo-public-api"); + return Ok(EXIT_USAGE); + } + }; + let toolchain = match api::nightly_toolchain() { + Some(t) => t, + None => { + eprintln!( + "{}error:{} zc needs a nightly toolchain to build rustdoc JSON", + style.red, style.reset + ); + eprintln!(" install one with: rustup toolchain install nightly"); + return Ok(EXIT_USAGE); + } + }; + let rustc_version = match api::rustc_version(&toolchain) { + Some(v) => v, + None => { + eprintln!( + "{}error:{} selected nightly toolchain '{toolchain}' cannot run rustc", + style.red, style.reset + ); + return Ok(EXIT_USAGE); + } + }; + + // cargo-public-api and the rustdoc JSON builds always run with all features, so + // feature-gated public items are never silently missing from the diff, trait map, or + // value/doc index. Kept identical across all of them so they stay in sync. + let feature_args = vec!["--all-features".to_string()]; + + let cache = cache::Cache::new( + VERSION, + &cargo_public_api_version, + &rustc_version, + &feature_args, + ) + .map_err(|e| format!("{}error:{} {e}", style.red, style.reset))?; + let tmp = + cache::RunTmp::new().map_err(|e| format!("{}error:{} {e}", style.red, style.reset))?; + git::worktree_prune(); + cache.prune_old_api_json(); + + // A dirty working tree is snapshotted into an unreachable commit, so the API diff can + // address it as a ref without touching the tree, index, or stash. + let mut head_is_worktree_snapshot = false; + if head_ref.is_empty() { + head_ref = git::worktree_snapshot_commit(&tmp.dir) + .map_err(|e| format!("{}error:{} {e}", style.red, style.reset))?; + head_is_worktree_snapshot = true; + } + + let baseline_short = git::rev_parse_short(&baseline); + let head_short = git::rev_parse_short(&head_ref); + let baseline_sha = git::rev_parse_verify(&baseline).map_err(|_| { + format!( + "{}error:{} cannot resolve baseline ref '{baseline}'", + style.red, style.reset + ) + })?; + let head_sha = git::rev_parse_verify(&head_ref) + .map_err(|_| format!("{}error:{} cannot resolve head ref", style.red, style.reset))?; + + let refs = Refs { + baseline, + baseline_label, + baseline_sha, + baseline_short, + head_ref, + head_label: std::mem::take(&mut head_label), + head_sha, + head_short, + head_is_worktree_snapshot, + }; + + let baseline_worktree = tmp.sub("api-baseline")?; + git::worktree_add(&baseline_worktree, &refs.baseline_sha).map_err(|_| { + format!( + "{}error:{} failed to create public-api worktree for '{}'", + style.red, style.reset, refs.baseline_label + ) + })?; + let head_worktree = tmp.sub("api-head")?; + git::worktree_add(&head_worktree, &refs.head_sha).map_err(|_| { + format!( + "{}error:{} failed to create public-api worktree for '{}'", + style.red, style.reset, refs.head_label + ) + })?; + + let baseline_target = tmp.dir.join("api-baseline-target"); + let head_target = tmp.dir.join("api-head-target"); + for dir in [&baseline_target, &head_target] { + std::fs::create_dir_all(dir).map_err(|_| { + format!( + "{}error:{} failed to create public-api target dirs", + style.red, style.reset + ) + })?; + } + + let ctx = Ctx { + opts, + style, + cache, + tmp, + toolchain, + feature_args, + refs, + progress: Progress::new(), + baseline_worktree, + head_worktree, + baseline_target, + head_target, + }; + + if !document_mode { + render::header(&ctx); + } + + // ── workspace dependency diff ───────────────────────────────────── + let base_deps = cargo_meta::dump_workspace_deps(&ctx, &ctx.refs.baseline) + .map_err(|e| format!("{}error:{} {e}", ctx.style.red, ctx.style.reset))?; + let head_deps = cargo_meta::dump_workspace_deps(&ctx, &ctx.refs.head_ref) + .map_err(|e| format!("{}error:{} {e}", ctx.style.red, ctx.style.reset))?; + let dep_diff = deps::diff(&base_deps, &head_deps); + if !document_mode { + render::dep_section(&ctx, &dep_diff); + } + + // ── transitive (Cargo.lock) diff ────────────────────────────────── + let all_crates = cargo_meta::workspace_crate_names(&ctx.head_worktree); + if all_crates.is_empty() { + eprintln!( + "{}warning:{} no workspace crates discovered via cargo metadata", + ctx.style.yellow, ctx.style.reset + ); + } + let crate_count = all_crates.len(); + + if ctx.opts.with_lock { + // Direct workspace deps (and workspace members) are suppressed: the section above + // already covers them. + let mut direct: HashSet = HashSet::new(); + for name in base_deps.keys().chain(head_deps.keys()) { + direct.insert(name.clone()); + if let Some((_, real)) = name.split_once(" (pkg: ") { + direct.insert(real.trim_end_matches(')').to_string()); + } + } + direct.extend(all_crates.iter().cloned()); + if let (Some(diff), false) = (lock::diff(&ctx, &direct).as_ref(), document_mode) { + render::lock_section(&ctx, diff); + } + } + + // ── per-crate API diff ──────────────────────────────────────────── + // Per-crate direct deps at both refs feed the public-dependency semver join. Cached, so + // --changelog's later read is a hit; failure degrades to an empty join. + let pubdep_tables = pubdep::PubdepTables::build( + &cargo_meta::dump_per_crate_deps(&ctx, &ctx.refs.baseline_sha).unwrap_or_default(), + &cargo_meta::dump_per_crate_deps(&ctx, &ctx.refs.head_sha).unwrap_or_default(), + ); + + ctx.progress.start(); + let crates = api::analyze(&ctx, &all_crates, &pubdep_tables); + ctx.progress.clear(); + + let removed_total: usize = crates.iter().map(|c| c.removed).sum(); + let changed_total: usize = crates.iter().map(|c| c.changed).sum(); + let added_total: usize = crates.iter().map(|c| c.added).sum(); + let changed_crate_count = crates.iter().filter(|c| c.total() > 0).count(); + let error_crate_count = crates + .iter() + .filter(|c| c.status == model::CrateStatus::Error) + .count(); + + if !document_mode { + render::api_rows(&ctx, &crates); + } + + // ── const/static value + doc-comment diff (--with-values) ───────── + let (values, docs) = if ctx.opts.with_values { + values::diff(&ctx, crate_count) + } else { + (Vec::new(), Vec::new()) + }; + + // A reachable dependency change is only a break when its requirement change is provably + // incompatible; an "unknown" one is a review item and never flips the verdict. + let mut pubdep_break_total = 0; + let mut pubdep_review_total = 0; + for c in &crates { + for f in &c.pubdep { + match version_req::classify_bump(&f.old, &f.new) { + model::Bump::Major => pubdep_break_total += 1, + _ => pubdep_review_total += 1, + } + } + } + + let report = model::Report { + refs: ctx.refs.clone(), + deps: dep_diff, + crates, + crate_count, + values, + docs, + removed_total, + changed_total, + added_total, + changed_crate_count, + error_crate_count, + pubdep_break_total, + pubdep_review_total, + }; + + // ── summary ─────────────────────────────────────────────────────── + if !document_mode { + render::summary(&ctx, &report); + } + let nothing_changed = report.removed_total + + report.changed_total + + report.added_total + + report.values.len() + + report.docs.len() + == 0 + && report.error_crate_count == 0 + && report.pubdep_break_total == 0 + && report.pubdep_review_total == 0; + // In a document mode this falls through: a crate may still have dependency-only + // changes to document. + if nothing_changed && !document_mode { + println!(); + println!( + " {}No public API changes.{}", + ctx.style.green, ctx.style.reset + ); + // Dep-only changes still deserve a verdict. + if report.deps.breaking > 0 { + println!(); + println!( + "{}{}BREAKING{}{}: runtime-deps: {} breaking.{}", + ctx.style.red, + ctx.style.bold, + ctx.style.reset, + ctx.style.red, + report.deps.breaking, + ctx.style.reset + ); + return Ok(EXIT_BREAKING); + } + return Ok(EXIT_OK); + } + + // ── changelog document ──────────────────────────────────────────── + if ctx.opts.changelog_mode { + if report.error_crate_count > 0 { + render::api_errors(&ctx, &report); + return Ok(EXIT_ANALYSIS); + } + let base = cargo_meta::dump_per_crate_deps(&ctx, &ctx.refs.baseline_sha).map_err(|_| { + format!( + "{}error:{} could not read per-crate dependencies at baseline ({})", + ctx.style.red, ctx.style.reset, ctx.refs.baseline_sha + ) + })?; + let head = cargo_meta::dump_per_crate_deps(&ctx, &ctx.refs.head_sha).map_err(|_| { + format!( + "{}error:{} could not read per-crate dependencies at head ({})", + ctx.style.red, ctx.style.reset, ctx.refs.head_sha + ) + })?; + if base.is_empty() || head.is_empty() { + return Err(format!( + "{}error:{} per-crate dependency dump was empty for one side (baseline={}, \ + head={}); refusing to emit a degenerate diff", + ctx.style.red, ctx.style.reset, ctx.refs.baseline_sha, ctx.refs.head_sha + )); + } + print!("{}", changelog_out::emit(&ctx, &report, &base, &head)); + return Ok(EXIT_OK); + } + + // ── JSON document ───────────────────────────────────────────────── + if ctx.opts.json_mode { + println!("{}", json::emit(&report)); + return Ok(report.verdict().exit_code()); + } + + // ── detailed diffs ──────────────────────────────────────────────── + let src_kinds = if ctx.opts.group_mode == GroupMode::Flat { + Default::default() + } else { + git::type_kinds(&ctx.refs.head_sha) + }; + render::details(&ctx, &report, &src_kinds); + render::values_section(&ctx, &report); + render::pubdep_section(&ctx, &report); + + println!(); + if report.error_crate_count > 0 { + render::api_errors(&ctx, &report); + return Ok(EXIT_ANALYSIS); + } + render::verdict(&ctx, &report); + Ok(if report.any_breaking() { + EXIT_BREAKING + } else { + EXIT_OK + }) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..53fe91b --- /dev/null +++ b/src/model.rs @@ -0,0 +1,406 @@ +//! Shared data model. Every module in this crate speaks these types; nothing here shells out. + +use std::fmt; + +/// Exit codes, mirroring the documented contract. +pub const EXIT_OK: i32 = 0; +pub const EXIT_BREAKING: i32 = 1; +pub const EXIT_ANALYSIS: i32 = 2; +pub const EXIT_USAGE: i32 = 64; + +/// Crate names whose dependencies are excluded from workspace dep classification: test-only +/// crates whose "runtime" deps are really downstream test deps. +pub fn is_test_crate(name: &str) -> bool { + name == "zebra-test" || name.ends_with("-test") +} + +/// Per-crate item grouping for the detailed diff sections. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum GroupMode { + /// Cluster by owning module, with type sub-headers (default). + Mod, + /// One tagged header per type; members keep their `Type::` prefix. + Type, + /// Ungrouped, fully-qualified, one item per line. + Flat, +} + +/// Command-line options. +#[derive(Clone, Debug)] +pub struct Options { + pub with_lock: bool, + pub with_values: bool, + pub json_mode: bool, + pub changelog_mode: bool, + pub group_mode: GroupMode, +} + +/// The two ends of the comparison, fully resolved. +#[derive(Clone, Debug)] +pub struct Refs { + /// Baseline ref as resolved (may be a merge-base SHA). + pub baseline: String, + /// Display label, e.g. `merge-base(main, HEAD)`. + pub baseline_label: String, + pub baseline_sha: String, + pub baseline_short: String, + /// Head ref; a synthesized snapshot commit when the working tree is dirty. + pub head_ref: String, + /// Display label, e.g. `working tree`. + pub head_label: String, + pub head_sha: String, + pub head_short: String, + pub head_is_worktree_snapshot: bool, +} + +/// Strongest dependency kind a crate is used with across the workspace. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DepKind { + Runtime, + Build, + Dev, + Unused, +} + +impl DepKind { + /// Rank for "strongest kind wins" comparisons. + pub fn rank(self) -> u8 { + match self { + DepKind::Runtime => 3, + DepKind::Build => 2, + DepKind::Dev => 1, + DepKind::Unused => 0, + } + } + + pub fn as_str(self) -> &'static str { + match self { + DepKind::Runtime => "runtime", + DepKind::Build => "build", + DepKind::Dev => "dev", + DepKind::Unused => "unused", + } + } +} + +impl fmt::Display for DepKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A kind label including optionality: `runtime-opt` for an optional runtime dep. +pub fn kind_label(kind: DepKind, optional: bool) -> String { + if optional && kind == DepKind::Runtime { + "runtime-opt".to_string() + } else { + kind.as_str().to_string() + } +} + +/// Rank of a rendered kind label, so old/new labels can be compared directly. +pub fn label_rank(label: &str) -> u8 { + match label { + "runtime" | "runtime-opt" => 3, + "build" => 2, + "dev" => 1, + _ => 0, + } +} + +/// One workspace dependency at one ref, keyed elsewhere by its display name +/// (the Cargo rename if any, formatted `foo (pkg: bar)`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DepRecord { + /// Version requirement from Cargo.toml, leading `^` stripped. + pub ver: String, + pub kind: DepKind, + /// True when every max-kind usage is optional. + pub optional: bool, + /// True when any usage enables default features. + pub default_features: bool, + /// Sorted union of explicitly-enabled features. + pub features: Vec, +} + +/// Semver-compatibility class of a requirement change, under Cargo's caret rules. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Bump { + Major, + Minor, + Patch, + /// The requirement changed without moving the version it starts at, so the text alone + /// cannot say whether a consumer is affected. + Unknown, +} + +impl Bump { + pub fn as_str(self) -> &'static str { + match self { + Bump::Major => "major", + Bump::Minor => "minor", + Bump::Patch => "patch", + Bump::Unknown => "unknown", + } + } +} + +impl fmt::Display for Bump { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Debug)] +pub struct DepRemoved { + pub name: String, + pub version: String, + pub kind: String, +} + +#[derive(Clone, Debug)] +pub struct DepChanged { + pub name: String, + pub old: String, + pub new: String, + pub bump: Bump, + pub kind: String, + /// Rendered feature delta, e.g. `-default!,+std,-foo` (empty when unchanged). + pub features: String, +} + +#[derive(Clone, Debug)] +pub struct DepAdded { + pub name: String, + pub version: String, + pub kind: String, +} + +/// Workspace dependency diff plus its breaking count. +#[derive(Clone, Debug, Default)] +pub struct DepDiff { + pub removed: Vec, + pub changed: Vec, + pub added: Vec, + /// Only runtime (non-optional) deps that are removed, major-bumped, or lose features. + pub breaking: usize, +} + +/// Scope of a per-crate dependency row. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Scope { + /// The dep is itself a workspace member. + Internal, + External, + /// Pseudo-row carrying the crate's `rust-version`. + Msrv, +} + +/// One (workspace crate, direct runtime/build dependency) pair at a ref. +#[derive(Clone, Debug)] +pub struct PerCrateDep { + pub crate_name: String, + /// Display key: the Cargo rename if any, else the real crate name. + pub dep: String, + pub req: String, + pub scope: Scope, + /// Real package name (rustdoc renders foreign paths by this). + pub pkg: String, +} + +/// Transitive (Cargo.lock) diff. +#[derive(Clone, Debug, Default)] +pub struct LockDiff { + /// `(name, versions)` — versions comma-joined when a crate is locked at several majors. + pub removed: Vec<(String, String)>, + /// `(name, old versions, new versions)`. + pub changed: Vec<(String, String, String)>, + pub added: Vec<(String, String)>, + /// Reverse attribution: transitive crate -> direct deps pulling it in, already + /// truncated to three with `...(+N)`. + pub via: std::collections::HashMap, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CrateStatus { + Ok, + Error, +} + +/// Stage at which per-crate analysis failed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ErrorStage { + BaselineBuild, + HeadBuild, + Diff, +} + +impl ErrorStage { + pub fn as_str(self) -> &'static str { + match self { + ErrorStage::BaselineBuild => "baseline_build", + ErrorStage::HeadBuild => "head_build", + ErrorStage::Diff => "diff", + } + } +} + +/// A per-crate analysis failure, as surfaced in the human report and in `--json`. +#[derive(Clone, Debug)] +pub struct ApiError { + pub stage: ErrorStage, + /// Ref label, or `base..head` for the diff stage. + pub ref_label: String, + pub ref_sha: String, + pub command: String, + /// Last 80 lines of stderr. + pub stderr: String, + pub hint: String, +} + +/// A dependency that both changed incompatibly (or unclearly) and is reachable in the +/// crate's public API. +#[derive(Clone, Debug)] +pub struct PubdepFinding { + pub dep: String, + pub old: String, + pub new: String, +} + +/// Everything known about one workspace crate after the API diff. +#[derive(Clone, Debug)] +pub struct CrateResult { + pub name: String, + pub removed: usize, + pub changed: usize, + pub added: usize, + /// Bare signature lines. + pub removed_lines: Vec, + /// Alternating ` - ` / ` + ` lines. + pub changed_lines: Vec, + pub added_lines: Vec, + pub status: CrateStatus, + pub error: Option, + pub pubdep: Vec, +} + +impl CrateResult { + pub fn total(&self) -> usize { + self.removed + self.changed + self.added + } + + /// The crate's lib path prefix, e.g. `zebra-state` -> `zebra_state`. + pub fn prefix(&self) -> String { + self.name.replace('-', "_") + } +} + +/// A `pub const`/`pub static` whose evaluated value changed. +#[derive(Clone, Debug)] +pub struct ValueChange { + pub crate_name: String, + pub path: String, + pub ty: String, + pub old: String, + pub new: String, +} + +/// A public item whose doc text changed. +#[derive(Clone, Debug)] +pub struct DocChange { + pub crate_name: String, + pub path: String, +} + +/// One record in the grouped item stream consumed by the human renderer. +/// Mirrors the `H`/`M`/`T`/`I`/`J`/`X` records the previous awk emitted. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GroupRecord { + /// Flat `--by-type` header: type path + resolved kind. + TypeHeader { name: String, kind: String }, + /// Nested-mode module header. + ModHeader(String), + /// Nested-mode type sub-header (name relative to its module) + resolved kind. + TypeSub { name: String, kind: String }, + /// Item directly under a `TypeHeader`/`ModHeader`. + Item(String), + /// Item under a `TypeSub` (deeper indent). + DeepItem(String), + /// Divider introducing trait impls on external types. + ExtDivider, +} + +/// Which diff bucket a section renders. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Section { + Removed, + Changed, + Added, +} + +/// Overall verdict. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Verdict { + Ok, + Breaking, + Error, +} + +impl Verdict { + pub fn as_str(self) -> &'static str { + match self { + Verdict::Ok => "ok", + Verdict::Breaking => "breaking", + Verdict::Error => "error", + } + } + + pub fn exit_code(self) -> i32 { + match self { + Verdict::Ok => EXIT_OK, + Verdict::Breaking => EXIT_BREAKING, + Verdict::Error => EXIT_ANALYSIS, + } + } +} + +/// Aggregated analysis state, assembled by `main` and consumed by the renderers. +#[derive(Clone, Debug)] +pub struct Report { + pub refs: Refs, + pub deps: DepDiff, + pub crates: Vec, + pub crate_count: usize, + pub values: Vec, + pub docs: Vec, + pub removed_total: usize, + pub changed_total: usize, + pub added_total: usize, + pub changed_crate_count: usize, + pub error_crate_count: usize, + pub pubdep_break_total: usize, + pub pubdep_review_total: usize, +} + +impl Report { + pub fn api_breaking(&self) -> usize { + self.removed_total + self.changed_total + } + + pub fn any_breaking(&self) -> bool { + self.api_breaking() > 0 + || self.deps.breaking > 0 + || !self.values.is_empty() + || self.pubdep_break_total > 0 + } + + pub fn verdict(&self) -> Verdict { + if self.error_crate_count > 0 { + Verdict::Error + } else if self.any_breaking() { + Verdict::Breaking + } else { + Verdict::Ok + } + } +} diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..cfc6b0d --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,125 @@ +//! Interactive progress reporting. + +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::style::stderr_is_tty; + +const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct Progress { + message: Arc>, + stop: Arc, + thread: Mutex>>, + stderr_is_tty: bool, +} + +impl Progress { + pub fn new() -> Progress { + Progress { + message: Arc::new(Mutex::new(String::new())), + stop: Arc::new(AtomicBool::new(false)), + thread: Mutex::new(None), + stderr_is_tty: stderr_is_tty(), + } + } + + /// Starts the interactive spinner once. + pub fn start(&self) { + if !self.stderr_is_tty { + return; + } + + let mut slot = lock(&self.thread); + if slot.is_some() { + return; + } + + self.stop.store(false, Ordering::Release); + let message = Arc::clone(&self.message); + let stop = Arc::clone(&self.stop); + if let Ok(handle) = thread::Builder::new().spawn(move || { + let mut frame = 0; + let mut current = String::new(); + while !stop.load(Ordering::Acquire) { + let shared = lock(&message); + current.clear(); + current.push_str(&shared); + drop(shared); + if !current.is_empty() { + let mut stderr = io::stderr().lock(); + let _ = write!( + stderr, + "\r{} {}\x1b[K", + FRAMES[frame % FRAMES.len()], + current + ); + let _ = stderr.flush(); + } + frame += 1; + thread::park_timeout(Duration::from_millis(100)); + } + }) { + *slot = Some(handle); + } + } + + /// Replaces the current status or emits one line on non-interactive stderr. + pub fn set(&self, msg: &str) { + if self.stderr_is_tty { + if lock(&self.thread).is_some() { + let mut message = lock(&self.message); + message.clear(); + message.push_str(msg); + } + } else { + eprintln!("zc: {msg}"); + } + } + + /// Stops the spinner and erases its terminal line. + pub fn clear(&self) { + self.stop_thread(); + if self.stderr_is_tty { + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[K"); + let _ = stderr.flush(); + } + } + + fn stop_thread(&self) { + self.stop.store(true, Ordering::Release); + let handle = lock(&self.thread).take(); + if let Some(handle) = handle { + handle.thread().unpark(); + let _ = handle.join(); + } + lock(&self.message).clear(); + } +} + +impl Default for Progress { + fn default() -> Self { + Self::new() + } +} + +impl Drop for Progress { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + let handle = lock(&self.thread).take(); + if let Some(handle) = handle { + handle.thread().unpark(); + let _ = handle.join(); + } + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/src/pubdep.rs b/src/pubdep.rs new file mode 100644 index 0000000..aafd12c --- /dev/null +++ b/src/pubdep.rs @@ -0,0 +1,143 @@ +//! Reachable public dependencies whose version requirements changed incompatibly. + +use std::collections::HashMap; +use std::process::{Command, Stdio}; + +use regex::Regex; + +use crate::ctx::Ctx; +use crate::model::{Bump, PerCrateDep, PubdepFinding, Scope}; +use crate::version_req::classify_bump; + +/// Per-crate dependency requirements and head-side external dependency order. +pub struct PubdepTables { + base: HashMap>, + head: HashMap>, + packages: HashMap>, + external: HashMap>, +} + +impl PubdepTables { + pub fn build(base: &[PerCrateDep], head: &[PerCrateDep]) -> PubdepTables { + let mut tables = PubdepTables { + base: HashMap::new(), + head: HashMap::new(), + packages: HashMap::new(), + external: HashMap::new(), + }; + for row in base.iter().filter(|row| row.scope != Scope::Msrv) { + tables + .base + .entry(row.crate_name.clone()) + .or_default() + .insert(row.dep.clone(), row.req.clone()); + } + for row in head.iter().filter(|row| row.scope != Scope::Msrv) { + tables + .head + .entry(row.crate_name.clone()) + .or_default() + .insert(row.dep.clone(), row.req.clone()); + tables + .packages + .entry(row.crate_name.clone()) + .or_default() + .insert(row.dep.clone(), row.pkg.clone()); + if row.scope == Scope::External { + tables + .external + .entry(row.crate_name.clone()) + .or_default() + .push(row.dep.clone()); + } + } + tables + } + + pub fn req(&self, head_side: bool, crate_name: &str, dep: &str) -> Option<&str> { + let table = if head_side { &self.head } else { &self.base }; + table + .get(crate_name) + .and_then(|deps| deps.get(dep)) + .map(String::as_str) + } +} + +/// Finds incompatible external dependencies that occur in a crate's full head API. +pub fn compute(ctx: &Ctx, tables: &PubdepTables, crate_name: &str) -> Vec { + let Some(external) = tables.external.get(crate_name) else { + return Vec::new(); + }; + let candidates: Vec<_> = external + .iter() + .filter_map(|dep| { + let old = tables.req(false, crate_name, dep)?; + let new = tables.req(true, crate_name, dep)?; + if old.is_empty() + || new.is_empty() + || old == new + || !matches!(classify_bump(old, new), Bump::Major | Bump::Unknown) + { + return None; + } + Some((dep.as_str(), old, new)) + }) + .collect(); + if candidates.is_empty() { + return Vec::new(); + } + + let mut command = Command::new("cargo"); + command + .env("CARGO_TARGET_DIR", &ctx.head_target) + .arg(format!("+{}", ctx.toolchain)) + .arg("public-api") + .args(&ctx.feature_args) + .arg("--manifest-path") + .arg(ctx.head_worktree.join("Cargo.toml")) + .arg("-p") + .arg(crate_name) + .arg("-ss") + .stderr(Stdio::null()); + let Ok(output) = command.output() else { + return Vec::new(); + }; + if output.stdout.is_empty() { + return Vec::new(); + } + let surface = String::from_utf8_lossy(&output.stdout); + + candidates + .into_iter() + .filter(|(dep, _, _)| { + let pkg = tables + .packages + .get(crate_name) + .and_then(|packages| packages.get(*dep)) + .filter(|pkg| !pkg.is_empty()) + .map(String::as_str) + .unwrap_or(dep); + reachable(&surface, dep, pkg) + }) + .map(|(dep, old, new)| PubdepFinding { + dep: dep.to_string(), + old: old.to_string(), + new: new.to_string(), + }) + .collect() +} + +fn reachable(surface: &str, dep: &str, pkg: &str) -> bool { + let dep = dep.replace('-', "_"); + let pkg = pkg.replace('-', "_"); + let pattern = format!( + r"(^|[^A-Za-z0-9_])({}|{})::", + regex::escape(&dep), + regex::escape(&pkg) + ); + Regex::new(&pattern).is_ok_and(|regex| regex.is_match(surface)) +} + +#[cfg(test)] +#[path = "pubdep/tests.rs"] +mod tests; diff --git a/src/pubdep/tests.rs b/src/pubdep/tests.rs new file mode 100644 index 0000000..6bf0e4b --- /dev/null +++ b/src/pubdep/tests.rs @@ -0,0 +1,47 @@ +use super::*; + +fn row(crate_name: &str, dep: &str, req: &str, scope: Scope, pkg: &str) -> PerCrateDep { + PerCrateDep { + crate_name: crate_name.into(), + dep: dep.into(), + req: req.into(), + scope, + pkg: pkg.into(), + } +} + +#[test] +fn tables_skip_msrv_and_preserve_head_external_order() { + let base = vec![ + row("demo", "renamed", "1", Scope::External, "real-package"), + row("demo", "rust-version", "1.82", Scope::Msrv, ""), + ]; + let head = vec![ + row("demo", "second", "2", Scope::External, "second"), + row("demo", "renamed", "2", Scope::External, "real-package"), + row("demo", "inside", "0.1", Scope::Internal, "inside"), + row("demo", "rust-version", "1.83", Scope::Msrv, ""), + ]; + + let tables = PubdepTables::build(&base, &head); + assert_eq!(tables.req(false, "demo", "renamed"), Some("1")); + assert_eq!(tables.req(true, "demo", "inside"), Some("0.1")); + assert_eq!(tables.req(true, "demo", "rust-version"), None); + assert_eq!(tables.external["demo"], ["second", "renamed"]); + assert_eq!(tables.packages["demo"]["renamed"], "real-package"); +} + +#[test] +fn reachability_matches_identifier_roots_and_cargo_renames() { + let surface = concat!( + "pub fn demo::one() -> renamed_dep::Type\n", + "pub fn demo::two() -> real_package::Other\n", + "pub fn demo::three() -> prefixrenamed_dep::Nope\n", + ); + + assert!(reachable(surface, "renamed-dep", "unrelated")); + assert!(reachable(surface, "alias", "real-package")); + assert!(!reachable(surface, "prefix", "missing")); + assert!(!reachable("xrenamed_dep::Type", "renamed-dep", "missing")); + assert!(reachable("renamed_dep::Type", "renamed-dep", "missing")); +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..38e10d2 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,628 @@ +//! Human-readable report rendering. + +use std::collections::HashMap; + +use crate::ctx::Ctx; +use crate::group; +use crate::model::{ + Bump, CrateResult, CrateStatus, DepDiff, GroupMode, GroupRecord, LockDiff, Report, Section, +}; +use crate::style::Style; +use crate::version_req; + +pub fn header(ctx: &Ctx) { + println!( + "{}Comparing public API: {}{}{} ({}){} {}-> {}{}{} ({}){}", + ctx.style.bold, + ctx.refs.baseline_label, + ctx.style.reset, + ctx.style.dim, + ctx.refs.baseline_short, + ctx.style.reset, + ctx.style.bold, + ctx.refs.head_label, + ctx.style.reset, + ctx.style.dim, + ctx.refs.head_short, + ctx.style.reset + ); + println!(); +} + +pub fn dep_section(ctx: &Ctx, deps: &DepDiff) { + if deps.removed.is_empty() && deps.changed.is_empty() && deps.added.is_empty() { + return; + } + let s = &ctx.style; + println!( + "{}Dependency changes{}{} (kind: runtime = consumer-visible, build/dev = internal){}", + s.bold, s.reset, s.dim, s.reset + ); + println!(); + + if !deps.removed.is_empty() { + println!(" {}Removed ({}):{}", s.red, deps.removed.len(), s.reset); + for dep in &deps.removed { + let color = match dep.kind.as_str() { + "runtime" => s.red, + "runtime-opt" | "build" => s.yellow, + _ => s.dim, + }; + println!( + " {}- {} {}{} {}[{}]{}", + color, dep.name, dep.version, s.reset, s.dim, dep.kind, s.reset + ); + } + println!(); + } + + if !deps.changed.is_empty() { + println!(" {}Changed ({}):{}", s.yellow, deps.changed.len(), s.reset); + let width = deps + .changed + .iter() + .map(|dep| dep.name.chars().count()) + .max() + .unwrap_or(0); + for dep in &deps.changed { + let color = if dep.bump == Bump::Major && dep.kind == "runtime" { + s.red + } else if matches!(dep.kind.as_str(), "dev" | "build" | "runtime-opt") { + s.dim + } else { + s.yellow + }; + if dep.old == dep.new { + print!( + " {}{: {} ({}){} {}[{}]{}", + color, + dep.name, + dep.old, + dep.new, + dep.bump, + s.reset, + s.dim, + dep.kind, + s.reset, + width = width + ); + } + if !dep.features.is_empty() { + print!(" {}features:{} {}", s.dim, s.reset, dep.features); + } + println!(); + } + println!(); + } + + if !deps.added.is_empty() { + println!(" {}Added ({}):{}", s.green, deps.added.len(), s.reset); + for dep in &deps.added { + println!( + " {}+ {} {}{} {}[{}]{}", + s.green, dep.name, dep.version, s.reset, s.dim, dep.kind, s.reset + ); + } + println!(); + } +} + +fn via_suffix(style: &Style, via: Option<&str>) -> String { + match via.filter(|value| !value.is_empty()) { + Some(value) => format!(" {}via {}{}", style.dim, value, style.reset), + None => String::new(), + } +} + +pub fn lock_section(ctx: &Ctx, lock: &LockDiff) { + if lock.removed.is_empty() && lock.changed.is_empty() && lock.added.is_empty() { + return; + } + let s = &ctx.style; + println!( + "{}Transitive (Cargo.lock) changes{}{} (direct deps already reported above){}", + s.bold, s.reset, s.dim, s.reset + ); + println!(); + + if !lock.changed.is_empty() { + println!(" {}Changed ({}):{}", s.yellow, lock.changed.len(), s.reset); + let width = lock + .changed + .iter() + .map(|(name, _, _)| name.chars().count()) + .max() + .unwrap_or(0); + for (name, old, new) in &lock.changed { + println!( + " {}{: {}{}{}", + s.yellow, + name, + old, + new, + s.reset, + via_suffix(s, lock.via.get(name).map(String::as_str)), + width = width + ); + } + println!(); + } + if !lock.added.is_empty() { + println!(" {}Added ({}):{}", s.green, lock.added.len(), s.reset); + for (name, version) in &lock.added { + println!( + " {}+ {} {}{}{}", + s.green, + name, + version, + s.reset, + via_suffix(s, lock.via.get(name).map(String::as_str)) + ); + } + println!(); + } + if !lock.removed.is_empty() { + println!(" {}Removed ({}):{}", s.red, lock.removed.len(), s.reset); + for (name, version) in &lock.removed { + println!(" {}- {} {}{}", s.red, name, version, s.reset); + } + println!(); + } +} + +pub fn api_rows(ctx: &Ctx, crates: &[CrateResult]) { + let s = &ctx.style; + let width = crates + .iter() + .map(|result| result.name.chars().count()) + .max() + .unwrap_or(0) + .max(16); + let mut rows = String::new(); + for result in crates { + if result.status == CrateStatus::Error { + let error = match &result.error { + Some(error) => error, + None => continue, + }; + let first = error + .stderr + .lines() + .next() + .filter(|line| !line.is_empty()) + .unwrap_or("cargo public-api failed"); + rows.push_str(&format!( + " {: 0 { + let tag = if result.removed == 0 && result.changed == 0 { + format!(" {}(additive){}", s.dim, s.reset) + } else { + String::new() + }; + rows.push_str(&format!( + " {:, +) { + if mode == GroupMode::Flat { + for line in lines.iter().filter(|line| !line.is_empty()) { + match section { + Section::Removed => println!(" {}- {}{}", style.red, line, style.reset), + Section::Added => println!(" {}+ {}{}", style.green, line, style.reset), + Section::Changed => { + if let Some(item) = line.strip_prefix(" - ") { + println!(" {}- {}{}", style.red, item, style.reset); + } else if let Some(item) = line.strip_prefix(" + ") { + println!(" {}+ {}{}", style.green, item, style.reset); + } else { + println!(" {line}"); + } + } + } + } + return; + } + + let records = group::group(lines, section, mode, crate_prefix, src_kinds); + let mut previous: Option<&GroupRecord> = None; + for record in &records { + match record { + GroupRecord::TypeHeader { name, kind } => { + if previous.is_some() { + println!(); + } + let name = if name.is_empty() { "(other)" } else { name }; + if kind.is_empty() { + println!(" {}{}{}", style.dim, name, style.reset); + } else { + println!(" {}{} ({}){}", style.dim, name, kind, style.reset); + } + } + GroupRecord::ModHeader(name) => { + if previous.is_some() { + println!(); + } + let name = if name.is_empty() { "(other)" } else { name }; + println!(" {}{}{}", style.dim, name, style.reset); + } + GroupRecord::ExtDivider => { + println!(); + println!( + " {}[trait impls on external types]{}", + style.dim, style.reset + ); + } + GroupRecord::TypeSub { name, kind } => { + if previous.is_some() && !matches!(previous, Some(GroupRecord::ModHeader(_))) { + println!(); + } + println!(" {}{} ({}){}", style.dim, name, kind, style.reset); + } + GroupRecord::Item(item) | GroupRecord::DeepItem(item) => { + let deep = matches!(record, GroupRecord::DeepItem(_)); + if !deep + && matches!( + previous, + Some(GroupRecord::TypeSub { .. } | GroupRecord::DeepItem(_)) + ) + { + println!(); + } + let indent = if deep { " " } else { " " }; + match section { + Section::Removed => { + println!("{}{}- {}{}", indent, style.red, item, style.reset) + } + Section::Added => { + println!("{}{}+ {}{}", indent, style.green, item, style.reset) + } + Section::Changed => { + if let Some(value) = item.strip_prefix(" - ") { + println!("{}{}- {}{}", indent, style.red, value, style.reset); + } else if let Some(value) = item.strip_prefix(" + ") { + println!("{}{}+ {}{}", indent, style.green, value, style.reset); + } else { + println!("{indent}{item}"); + } + } + } + } + } + previous = Some(record); + } +} + +pub fn details(ctx: &Ctx, report: &Report, src_kinds: &HashMap) { + for result in report.crates.iter().filter(|result| result.total() > 0) { + println!(); + println!("{}{}{}", ctx.style.bold, result.name, ctx.style.reset); + let prefix = result.prefix(); + if result.removed > 0 { + println!(); + println!( + " {}Removed ({}):{}", + ctx.style.red, result.removed, ctx.style.reset + ); + render_items( + &ctx.style, + &result.removed_lines, + Section::Removed, + ctx.opts.group_mode, + &prefix, + src_kinds, + ); + } + if result.changed > 0 { + println!(); + println!( + " {}Changed ({}):{}", + ctx.style.yellow, result.changed, ctx.style.reset + ); + render_items( + &ctx.style, + &result.changed_lines, + Section::Changed, + ctx.opts.group_mode, + &prefix, + src_kinds, + ); + } + if result.added > 0 { + println!(); + println!( + " {}Added ({}):{}", + ctx.style.green, result.added, ctx.style.reset + ); + render_items( + &ctx.style, + &result.added_lines, + Section::Added, + ctx.opts.group_mode, + &prefix, + src_kinds, + ); + } + } +} + +pub fn values_section(ctx: &Ctx, report: &Report) { + let s = &ctx.style; + if !report.values.is_empty() { + println!(); + println!( + "{}Value changes ({}){}{} — const/static values; cargo-public-api can't see these{}", + s.bold, + report.values.len(), + s.reset, + s.dim, + s.reset + ); + let mut last_crate = ""; + for change in &report.values { + if change.crate_name != last_crate { + println!(); + println!(" {}{}{}", s.bold, change.crate_name, s.reset); + last_crate = &change.crate_name; + } + println!( + " {}~ {}: {}{}", + s.yellow, change.path, change.ty, s.reset + ); + println!( + " {}{}{} {}->{} {}{}{}", + s.red, change.old, s.reset, s.dim, s.reset, s.green, change.new, s.reset + ); + } + } + if !report.docs.is_empty() { + println!(); + println!( + "{}Doc changes ({}){}{} — public doc-comment text changed{}", + s.bold, + report.docs.len(), + s.reset, + s.dim, + s.reset + ); + let mut last_crate = ""; + for change in &report.docs { + if change.crate_name != last_crate { + println!(); + println!(" {}{}{}", s.bold, change.crate_name, s.reset); + last_crate = &change.crate_name; + } + println!( + " {}~ {}{}{} (doc text changed){}", + s.yellow, change.path, s.reset, s.dim, s.reset + ); + } + } +} + +pub fn pubdep_section(ctx: &Ctx, report: &Report) { + if report.pubdep_break_total + report.pubdep_review_total == 0 { + return; + } + let s = &ctx.style; + println!(); + println!( + "{}Public-dependency changes ({} breaking, {} to review){}{} — public API exposes a changed dependency; cargo-public-api can't see these{}", + s.bold, + report.pubdep_break_total, + report.pubdep_review_total, + s.reset, + s.dim, + s.reset + ); + for result in report + .crates + .iter() + .filter(|result| !result.pubdep.is_empty()) + { + println!(); + println!(" {}{}{}", s.bold, result.name, s.reset); + for finding in &result.pubdep { + if version_req::classify_bump(&finding.old, &finding.new) == Bump::Major { + println!( + " {}{}{}{}: {}{}{}{} {}->{} {}{}{}{} (incompatible; reachable in public API){}", + s.red, + finding.dep, + s.reset, + s.dim, + s.reset, + s.red, + finding.old, + s.reset, + s.dim, + s.reset, + s.green, + finding.new, + s.reset, + s.dim, + s.reset + ); + } else { + println!( + " {}{}{}{}: {}{}{}{} {}->{} {}{}{}{} (compatibility unclear; reachable in public API){}", + s.yellow, + finding.dep, + s.reset, + s.dim, + s.reset, + s.yellow, + finding.old, + s.reset, + s.dim, + s.reset, + s.green, + finding.new, + s.reset, + s.dim, + s.reset + ); + } + } + } +} + +pub fn verdict(ctx: &Ctx, report: &Report) { + let s = &ctx.style; + if report.any_breaking() { + let mut parts = Vec::new(); + if report.api_breaking() > 0 { + parts.push(format!( + "api: {} removed / {} changed", + report.removed_total, report.changed_total + )); + } + if report.deps.breaking > 0 { + parts.push(format!("runtime-deps: {} breaking", report.deps.breaking)); + } + if !report.values.is_empty() { + parts.push(format!("values: {} changed", report.values.len())); + } + if report.pubdep_break_total > 0 { + parts.push(format!( + "public-dep: {} breaking", + report.pubdep_break_total + )); + } + println!( + "{}{}BREAKING{}{}: {}.{}", + s.red, + s.bold, + s.reset, + s.red, + parts.join("; "), + s.reset + ); + if report.added_total > 0 { + println!( + "{}(also {} new API items, additive only){}", + s.dim, report.added_total, s.reset + ); + } + if !report.docs.is_empty() { + println!( + "{}(also {} public doc-comment change(s)){}", + s.dim, + report.docs.len(), + s.reset + ); + } + } else { + if report.added_total > 0 { + println!( + "{}OK: {} new API items, no breaking changes.{}", + s.green, report.added_total, s.reset + ); + } else { + println!("{}OK: no breaking changes.{}", s.green, s.reset); + } + if !report.docs.is_empty() { + println!( + "{}(also {} public doc-comment change(s)){}", + s.dim, + report.docs.len(), + s.reset + ); + } + } +} + +pub fn api_errors(ctx: &Ctx, report: &Report) { + let s = &ctx.style; + eprintln!( + "{}{}ERROR{}{}: cargo-public-api failed for {} crate(s).{}", + s.red, s.bold, s.reset, s.red, report.error_crate_count, s.reset + ); + for result in report + .crates + .iter() + .filter(|result| result.status == CrateStatus::Error) + { + let error = match &result.error { + Some(error) => error, + None => continue, + }; + eprintln!(); + eprintln!(" {}{}{}", s.bold, result.name, s.reset); + eprintln!(" stage: {}", error.stage.as_str()); + eprintln!(" ref: {} ({})", error.ref_label, error.ref_sha); + eprintln!(" command: {}", error.command); + eprintln!(" hint: {}", error.hint); + eprintln!(" stderr:"); + if error.stderr.is_empty() { + eprintln!(" "); + } else { + for line in error.stderr.lines() { + eprintln!(" {line}"); + } + } + } +} + +#[cfg(test)] +#[path = "render/tests.rs"] +mod tests; diff --git a/src/render/tests.rs b/src/render/tests.rs new file mode 100644 index 0000000..ca25e77 --- /dev/null +++ b/src/render/tests.rs @@ -0,0 +1,98 @@ +use super::via_suffix; +use crate::json; +use crate::model::{CrateResult, CrateStatus, DepDiff, Refs, Report}; +use crate::style::Style; + +#[test] +fn via_suffix_matches_lock_output() { + let style = Style { + dim: "", + reset: "", + ..Style::default() + }; + assert_eq!( + via_suffix(&style, Some("direct-a,direct-b")), + " via direct-a,direct-b" + ); + assert_eq!(via_suffix(&style, Some("")), ""); + assert_eq!(via_suffix(&style, None), ""); +} + +#[test] +fn json_shape_and_field_order_match_the_documented_schema() { + let report = Report { + refs: Refs { + baseline: "base-ref".to_string(), + baseline_label: "merge-base(main, HEAD)".to_string(), + baseline_sha: "1111111111111111111111111111111111111111".to_string(), + baseline_short: "1111111".to_string(), + head_ref: "HEAD".to_string(), + head_label: "working tree".to_string(), + head_sha: "2222222222222222222222222222222222222222".to_string(), + head_short: "2222222".to_string(), + head_is_worktree_snapshot: true, + }, + deps: DepDiff::default(), + crates: vec![CrateResult { + name: "sample".to_string(), + removed: 0, + changed: 0, + added: 0, + removed_lines: Vec::new(), + changed_lines: Vec::new(), + added_lines: Vec::new(), + status: CrateStatus::Ok, + error: None, + pubdep: Vec::new(), + }], + crate_count: 1, + values: Vec::new(), + docs: Vec::new(), + removed_total: 0, + changed_total: 0, + added_total: 0, + changed_crate_count: 0, + error_crate_count: 0, + pubdep_break_total: 0, + pubdep_review_total: 0, + }; + assert_eq!( + json::emit(&report), + r#"{ + "baseline": "merge-base(main, HEAD)", + "baseline_sha": "1111111", + "head": "working tree", + "head_sha": "2222222", + "verdict": "ok", + "totals": { + "removed": 0, + "changed": 0, + "added": 0, + "api_breaking": 0, + "dep_breaking": 0, + "error_crates": 0, + "value_changed": 0, + "doc_changed": 0, + "public_dep_breaking": 0 + }, + "deps": { + "removed": [], + "changed": [], + "added": [] + }, + "values": [], + "docs": [], + "public_dep_breaks": [], + "crates": [ + { + "name": "sample", + "removed": 0, + "changed": 0, + "added": 0, + "status": "ok", + "error": null + } + ] +}"# + ); +} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..469cdbc --- /dev/null +++ b/src/style.rs @@ -0,0 +1,49 @@ +//! ANSI styling. Disabled when stdout is not a terminal, when `NO_COLOR` is set to a +//! non-empty value (), or in `--json`/`--changelog` mode. + +#[derive(Clone, Copy, Debug, Default)] +pub struct Style { + pub red: &'static str, + pub green: &'static str, + pub yellow: &'static str, + pub bold: &'static str, + pub dim: &'static str, + pub reset: &'static str, +} + +impl Style { + /// Colors on only for an interactive stdout in a document-free mode. + pub fn detect(document_mode: bool) -> Style { + let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); + if document_mode || no_color || !stdout_is_tty() { + Style::default() + } else { + Style { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + bold: "\x1b[1m", + dim: "\x1b[2m", + reset: "\x1b[0m", + } + } + } +} + +pub fn stdout_is_tty() -> bool { + is_tty(1) +} + +pub fn stderr_is_tty() -> bool { + is_tty(2) +} + +fn is_tty(fd: i32) -> bool { + // SAFETY: isatty is a pure query on a file descriptor. + unsafe { libc_isatty(fd) == 1 } +} + +extern "C" { + #[link_name = "isatty"] + fn libc_isatty(fd: i32) -> i32; +} diff --git a/src/traitmap.rs b/src/traitmap.rs new file mode 100644 index 0000000..7a6dd19 --- /dev/null +++ b/src/traitmap.rs @@ -0,0 +1,168 @@ +//! Trait attribution for associated public API items. + +use std::collections::{HashMap, HashSet}; +use std::fs; + +use serde_json::Value; + +use crate::ctx::Ctx; +use crate::git; + +pub type TraitMap = HashMap<(String, String), String>; + +/// Builds per-crate trait maps at one ref, using compatible three-column TSV caches. +pub fn dump(ctx: &Ctx, ref_sha: &str, crates: &[String]) -> HashMap { + let Ok(sha) = git::rev_parse_verify(ref_sha) else { + return HashMap::new(); + }; + let target = ctx.tmp.dir.join("trait-target"); + let _ = fs::create_dir_all(&target); + let worktree = match ctx.tmp.sub("trait") { + Ok(path) => path, + Err(_) => return HashMap::new(), + }; + if git::worktree_add(&worktree, ref_sha).is_err() { + return HashMap::new(); + } + + let mut maps = HashMap::new(); + for (index, crate_name) in crates.iter().enumerate() { + let cache_name = format!( + "{}.{}.{}.traitmap.tsv", + sha, ctx.cache.script_hash, crate_name + ); + let cache_path = ctx.cache.path(&cache_name); + if let Ok(cached) = fs::read_to_string(&cache_path) { + maps.insert(crate_name.clone(), parse_tsv(&cached)); + continue; + } + + ctx.progress.set(&format!( + "--changelog: trait map [{}/{}] {crate_name}", + index + 1, + crates.len() + )); + if let Ok(path) = crate::api::rustdoc_json(ctx, crate_name, &worktree, &target, &sha) { + let rows = fs::read_to_string(path) + .ok() + .map(|json| extract(&json)) + .unwrap_or_default(); + let tsv = render_tsv(&rows); + ctx.cache.write_atomic(&cache_name, &tsv); + maps.insert(crate_name.clone(), rows_to_map(&rows)); + } else { + maps.insert(crate_name.clone(), TraitMap::new()); + } + } + git::worktree_remove(&worktree); + maps +} + +fn extract(json: &str) -> Vec<(String, String, String)> { + let Ok(root) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let Some(index) = root.get("index").and_then(Value::as_object) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for item in index.values() { + let Some(implementation) = item + .get("inner") + .and_then(Value::as_object) + .and_then(|inner| inner.get("impl")) + .and_then(Value::as_object) + else { + continue; + }; + let Some(trait_item) = implementation.get("trait").filter(|item| !item.is_null()) else { + continue; + }; + let Some(trait_path) = trait_item.get("path").and_then(Value::as_str) else { + continue; + }; + let Some(self_path) = implementation + .get("for") + .and_then(Value::as_object) + .and_then(|item| item.get("resolved_path")) + .and_then(Value::as_object) + .and_then(|path| path.get("path")) + .and_then(Value::as_str) + else { + continue; + }; + let self_short = self_path.rsplit("::").next().unwrap_or(self_path); + let Some(items) = implementation.get("items").and_then(Value::as_array) else { + continue; + }; + for id in items { + let id = match id { + Value::String(id) => id.clone(), + _ => id.to_string(), + }; + let Some(member) = index + .get(&id) + .and_then(|item| item.get("name")) + .and_then(Value::as_str) + else { + continue; + }; + rows.push(( + self_short.to_string(), + member.to_string(), + trait_path.to_string(), + )); + } + } + rows.sort_unstable(); + rows.dedup(); + rows +} + +fn rows_to_map(rows: &[(String, String, String)]) -> TraitMap { + let mut map = TraitMap::new(); + for (self_name, member, trait_path) in rows { + map.entry((self_name.clone(), member.clone())) + .or_insert_with(|| trait_path.clone()); + } + map +} + +fn render_tsv(rows: &[(String, String, String)]) -> String { + let mut output = String::new(); + for (self_name, member, trait_path) in rows { + output.push_str(self_name); + output.push('\t'); + output.push_str(member); + output.push('\t'); + output.push_str(trait_path); + output.push('\n'); + } + output +} + +fn parse_tsv(contents: &str) -> TraitMap { + let mut rows = Vec::new(); + let mut seen = HashSet::new(); + for line in contents.lines() { + let mut fields = line.split('\t'); + let (Some(self_name), Some(member), Some(trait_path)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let row = ( + self_name.to_string(), + member.to_string(), + trait_path.to_string(), + ); + if seen.insert(row.clone()) { + rows.push(row); + } + } + rows_to_map(&rows) +} + +#[cfg(test)] +#[path = "traitmap/tests.rs"] +mod tests; diff --git a/src/traitmap/tests.rs b/src/traitmap/tests.rs new file mode 100644 index 0000000..5edc304 --- /dev/null +++ b/src/traitmap/tests.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn extracts_only_concrete_trait_impl_members_and_sorts_rows() { + let json = r#"{ + "index": { + "impl-z": {"inner": {"impl": { + "trait": {"path": "core::fmt::Display"}, + "for": {"resolved_path": {"path": "demo::Widget"}}, + "items": [12, "11", "missing"] + }}}, + "11": {"name": "fmt"}, + "12": {"name": "Output"}, + "impl-inherent": {"inner": {"impl": { + "trait": null, + "for": {"resolved_path": {"path": "demo::Widget"}}, + "items": ["13"] + }}}, + "13": {"name": "new"}, + "impl-blanket": {"inner": {"impl": { + "trait": {"path": "core::convert::Into"}, + "for": {"borrowed_ref": {"type": {"generic": "T"}}}, + "items": ["14"] + }}}, + "14": {"name": "into"} + } + }"#; + + assert_eq!( + extract(json), + vec![ + ( + "Widget".into(), + "Output".into(), + "core::fmt::Display".into() + ), + ("Widget".into(), "fmt".into(), "core::fmt::Display".into()), + ] + ); +} + +#[test] +fn deduplicates_rows_and_keeps_first_trait_on_key_collision() { + let rows = vec![ + ("Widget".into(), "item".into(), "a::First".into()), + ("Widget".into(), "item".into(), "a::First".into()), + ("Widget".into(), "item".into(), "z::Second".into()), + ]; + + let map = rows_to_map(&rows); + assert_eq!(map.len(), 1); + assert_eq!( + map.get(&("Widget".into(), "item".into())) + .map(String::as_str), + Some("a::First") + ); +} + +#[test] +fn parses_bash_compatible_three_column_tsv() { + let map = parse_tsv("Widget\titem\ta::First\nWidget\titem\tz::Second\n"); + assert_eq!( + map.get(&("Widget".into(), "item".into())) + .map(String::as_str), + Some("a::First") + ); +} diff --git a/src/values.rs b/src/values.rs new file mode 100644 index 0000000..0b58521 --- /dev/null +++ b/src/values.rs @@ -0,0 +1,375 @@ +//! Public constant, static, and documentation diffs from rustdoc JSON. + +use std::collections::HashMap; +use std::fs; + +use serde::de::{MapAccess, Visitor}; +use serde::Deserialize; +use serde_json::Value; + +use crate::cargo_meta::workspace_crate_names; +use crate::ctx::Ctx; +use crate::git; +use crate::model::{DocChange, ValueChange}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ValueRow { + crate_name: String, + path: String, + ty: String, + value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DocRow { + crate_name: String, + path: String, + docs: String, +} + +#[derive(Default)] +struct OrderedEntries(Vec<(String, Value)>); + +impl<'de> Deserialize<'de> for OrderedEntries { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct EntriesVisitor; + + impl<'de> Visitor<'de> for EntriesVisitor { + type Value = OrderedEntries; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON object") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some(entry) = map.next_entry()? { + entries.push(entry); + } + Ok(OrderedEntries(entries)) + } + } + + deserializer.deserialize_map(EntriesVisitor) + } +} + +#[derive(Deserialize, Default)] +struct RustdocIndex { + #[serde(default)] + index: HashMap, + #[serde(default)] + paths: OrderedEntries, +} + +enum IndexRow { + Value(ValueRow), + Doc(DocRow), +} + +/// Computes value and documentation changes, preserving the head rustdoc path order. +pub fn diff(ctx: &Ctx, crate_count: usize) -> (Vec, Vec) { + let target = ctx.tmp.dir.join("values-target"); + let _ = fs::create_dir_all(&target); + + ctx.progress.start(); + let base = dump_index(ctx, &ctx.refs.baseline_sha, "base", crate_count, &target); + let head = dump_index(ctx, &ctx.refs.head_sha, "head", crate_count, &target); + ctx.progress.clear(); + compare(&base, &head) +} + +fn dump_index( + ctx: &Ctx, + ref_sha: &str, + ref_label: &str, + crate_count: usize, + target: &std::path::Path, +) -> Vec { + let cache_name = format!("{}.{}.values.tsv", ref_sha, ctx.cache.script_hash); + if let Some(cached) = ctx.cache.read_if_present(&cache_name) { + return parse_tsv(&cached); + } + + let worktree = match ctx.tmp.sub("values") { + Ok(path) => path, + Err(_) => return Vec::new(), + }; + if git::worktree_add(&worktree, ref_sha).is_err() { + eprintln!( + "{}warning:{} could not create worktree for '{}' (value diff)", + ctx.style.yellow, ctx.style.reset, ref_sha + ); + return Vec::new(); + } + + let crates = workspace_crate_names(&worktree); + let mut rows = Vec::new(); + let mut failed = false; + for (index, crate_name) in crates.iter().enumerate() { + ctx.progress.set(&format!( + "--with-values: rustdoc JSON [{ref_label} {}/{}] {crate_name}", + index + 1, + crate_count + )); + match crate::api::rustdoc_json(ctx, crate_name, &worktree, target, ref_sha) { + Ok(path) => { + if let Ok(json) = fs::read_to_string(path) { + rows.extend(extract(&json, crate_name)); + } + } + Err(_) => failed = true, + } + } + git::worktree_remove(&worktree); + + let tsv = render_tsv(&rows); + if !failed { + ctx.cache.write_atomic(&cache_name, &tsv); + } + parse_tsv(&tsv) +} + +fn extract(json: &str, crate_name: &str) -> Vec { + let Ok(root) = serde_json::from_str::(json) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for (id, path_entry) in root.paths.0 { + let Some(item) = root.index.get(&id) else { + continue; + }; + if item.get("visibility").and_then(Value::as_str) != Some("public") { + continue; + } + let Some(path) = path_entry.get("path").and_then(Value::as_array) else { + continue; + }; + let Some(path) = path + .iter() + .map(Value::as_str) + .collect::>>() + .map(|segments| segments.join("::")) + else { + continue; + }; + let inner = item.get("inner").and_then(Value::as_object); + + if let Some(constant) = inner + .and_then(|inner| inner.get("constant")) + .and_then(Value::as_object) + { + let ty = constant + .get("type") + .map(type_text) + .unwrap_or_else(|| "null".to_string()); + let value = constant + .get("const") + .and_then(Value::as_object) + .and_then(|value| { + value + .get("value") + .filter(|value| !value.is_null()) + .or_else(|| value.get("expr").filter(|value| !value.is_null())) + }) + .map(jq_text) + .unwrap_or_else(|| "?".to_string()); + rows.push(IndexRow::Value(ValueRow { + crate_name: crate_name.to_string(), + path: path.clone(), + ty, + value, + })); + } else if let Some(static_item) = inner + .and_then(|inner| inner.get("static")) + .and_then(Value::as_object) + { + let ty = static_item + .get("type") + .map(type_text) + .unwrap_or_else(|| "null".to_string()); + let value = static_item + .get("expr") + .filter(|value| !value.is_null()) + .map(jq_text) + .unwrap_or_else(|| "?".to_string()); + rows.push(IndexRow::Value(ValueRow { + crate_name: crate_name.to_string(), + path: path.clone(), + ty, + value, + })); + } + + if let Some(docs) = item + .get("docs") + .and_then(Value::as_str) + .filter(|docs| !docs.is_empty()) + { + rows.push(IndexRow::Doc(DocRow { + crate_name: crate_name.to_string(), + path, + docs: base64(docs.as_bytes()), + })); + } + } + rows +} + +fn type_text(value: &Value) -> String { + value + .get("primitive") + .filter(|value| !value.is_null()) + .map(jq_text) + .or_else(|| { + value + .get("resolved_path") + .and_then(|path| path.get("name")) + .filter(|value| !value.is_null()) + .map(jq_text) + }) + .unwrap_or_else(|| value.to_string()) +} + +fn jq_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + _ => value.to_string(), + } +} + +fn compare(base: &[IndexRow], head: &[IndexRow]) -> (Vec, Vec) { + let mut base_values = HashMap::new(); + let mut base_docs = HashMap::new(); + for row in base { + match row { + IndexRow::Value(row) => { + base_values.insert( + (row.crate_name.as_str(), row.path.as_str()), + (row.ty.as_str(), row.value.as_str()), + ); + } + IndexRow::Doc(row) => { + base_docs.insert( + (row.crate_name.as_str(), row.path.as_str()), + row.docs.as_str(), + ); + } + } + } + + let mut values = Vec::new(); + let mut docs = Vec::new(); + for row in head { + match row { + IndexRow::Value(row) => { + if let Some((ty, old)) = + base_values.get(&(row.crate_name.as_str(), row.path.as_str())) + { + if *old != row.value { + values.push(ValueChange { + crate_name: row.crate_name.clone(), + path: row.path.clone(), + ty: (*ty).to_string(), + old: (*old).to_string(), + new: row.value.clone(), + }); + } + } + } + IndexRow::Doc(row) => { + if let Some(old) = base_docs.get(&(row.crate_name.as_str(), row.path.as_str())) { + if *old != row.docs { + docs.push(DocChange { + crate_name: row.crate_name.clone(), + path: row.path.clone(), + }); + } + } + } + } + } + (values, docs) +} + +fn render_tsv(rows: &[IndexRow]) -> String { + let mut output = String::new(); + for row in rows { + let fields: Vec<&str> = match row { + IndexRow::Value(row) => vec!["V", &row.crate_name, &row.path, &row.ty, &row.value], + IndexRow::Doc(row) => vec!["D", &row.crate_name, &row.path, &row.docs], + }; + output.push_str( + &fields + .into_iter() + .map(tsv_escape) + .collect::>() + .join("\t"), + ); + output.push('\n'); + } + output +} + +fn parse_tsv(contents: &str) -> Vec { + let mut rows = Vec::new(); + for line in contents.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + match fields.as_slice() { + ["V", crate_name, path, ty, value] => rows.push(IndexRow::Value(ValueRow { + crate_name: (*crate_name).to_string(), + path: (*path).to_string(), + ty: (*ty).to_string(), + value: (*value).to_string(), + })), + ["D", crate_name, path, docs] => rows.push(IndexRow::Doc(DocRow { + crate_name: (*crate_name).to_string(), + path: (*path).to_string(), + docs: (*docs).to_string(), + })), + _ => {} + } + } + rows +} + +fn tsv_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\t', "\\t") + .replace('\r', "\\r") + .replace('\n', "\\n") +} + +fn base64(bytes: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let bits = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + output.push(TABLE[((bits >> 18) & 63) as usize] as char); + output.push(TABLE[((bits >> 12) & 63) as usize] as char); + output.push(if chunk.len() > 1 { + TABLE[((bits >> 6) & 63) as usize] as char + } else { + '=' + }); + output.push(if chunk.len() > 2 { + TABLE[(bits & 63) as usize] as char + } else { + '=' + }); + } + output +} + +#[cfg(test)] +#[path = "values/tests.rs"] +mod tests; diff --git a/src/values/tests.rs b/src/values/tests.rs new file mode 100644 index 0000000..1af1614 --- /dev/null +++ b/src/values/tests.rs @@ -0,0 +1,126 @@ +use super::*; + +#[test] +fn extracts_public_values_and_docs_in_path_order() { + let json = r#"{ + "paths": { + "9": {"path": ["demo", "SECOND"]}, + "2": {"path": ["demo", "FIRST"]}, + "7": {"path": ["demo", "PRIVATE"]} + }, + "index": { + "2": { + "visibility": "public", + "docs": "first docs", + "inner": {"constant": { + "type": {"primitive": "u64"}, + "const": {"value": "2", "expr": "1 + 1"} + }} + }, + "7": { + "visibility": "crate", + "docs": "hidden", + "inner": {"constant": { + "type": {"primitive": "u8"}, "const": {"value": "1"} + }} + }, + "9": { + "visibility": "public", + "docs": "", + "inner": {"static": { + "type": {"resolved_path": {"name": "Widget"}}, "expr": "make()" + }} + } + } + }"#; + + let rows = extract(json, "demo-crate"); + assert_eq!(rows.len(), 3); + match &rows[0] { + IndexRow::Value(row) => { + assert_eq!(row.path, "demo::SECOND"); + assert_eq!(row.ty, "Widget"); + assert_eq!(row.value, "make()"); + } + IndexRow::Doc(_) => panic!("the first path should produce a value row"), + } + match &rows[1] { + IndexRow::Value(row) => { + assert_eq!(row.path, "demo::FIRST"); + assert_eq!(row.ty, "u64"); + assert_eq!(row.value, "2"); + } + IndexRow::Doc(_) => panic!("the second path should produce a value row first"), + } + match &rows[2] { + IndexRow::Doc(row) => { + assert_eq!(row.path, "demo::FIRST"); + assert_eq!(row.docs, "Zmlyc3QgZG9jcw=="); + } + IndexRow::Value(_) => panic!("the item should produce its documentation row second"), + } +} + +#[test] +fn compares_only_shared_items_in_head_order_and_uses_base_type() { + let base = vec![ + IndexRow::Value(ValueRow { + crate_name: "demo".into(), + path: "demo::A".into(), + ty: "OldA".into(), + value: "1".into(), + }), + IndexRow::Value(ValueRow { + crate_name: "demo".into(), + path: "demo::B".into(), + ty: "OldB".into(), + value: "2".into(), + }), + IndexRow::Doc(DocRow { + crate_name: "demo".into(), + path: "demo::A".into(), + docs: "old".into(), + }), + ]; + let head = vec![ + IndexRow::Value(ValueRow { + crate_name: "demo".into(), + path: "demo::B".into(), + ty: "NewB".into(), + value: "20".into(), + }), + IndexRow::Value(ValueRow { + crate_name: "demo".into(), + path: "demo::ADDED".into(), + ty: "Added".into(), + value: "3".into(), + }), + IndexRow::Value(ValueRow { + crate_name: "demo".into(), + path: "demo::A".into(), + ty: "NewA".into(), + value: "10".into(), + }), + IndexRow::Doc(DocRow { + crate_name: "demo".into(), + path: "demo::A".into(), + docs: "new".into(), + }), + IndexRow::Doc(DocRow { + crate_name: "demo".into(), + path: "demo::ADDED".into(), + docs: "new item".into(), + }), + ]; + + let (values, docs) = compare(&base, &head); + assert_eq!(values.len(), 2); + assert_eq!(values[0].path, "demo::B"); + assert_eq!(values[0].ty, "OldB"); + assert_eq!(values[0].old, "2"); + assert_eq!(values[0].new, "20"); + assert_eq!(values[1].path, "demo::A"); + assert_eq!(values[1].ty, "OldA"); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0].path, "demo::A"); +} diff --git a/src/version_req.rs b/src/version_req.rs new file mode 100644 index 0000000..2c80b68 --- /dev/null +++ b/src/version_req.rs @@ -0,0 +1,69 @@ +use crate::model::Bump; + +/// Normalizes a Cargo version requirement for comparison. +pub fn req_norm(req: &str) -> String { + let mut normalized: String = req.chars().filter(|c| !c.is_whitespace()).collect(); + if let Some(rest) = normalized.strip_prefix('^') { + normalized = rest.to_string(); + } + + let bytes = normalized.as_bytes(); + let metadata = bytes.windows(2).position(|pair| { + matches!(pair[0], b'-' | b'+') && (pair[1].is_ascii_alphanumeric() || pair[1] == b'.') + }); + if let Some(index) = metadata { + normalized.truncate(index); + } + normalized +} + +/// Returns the first numeric version literal in a Cargo requirement. +pub fn req_version(req: &str) -> String { + let normalized = req_norm(req); + let Some(start) = normalized.find(|c: char| c.is_ascii_digit()) else { + return String::new(); + }; + let version: String = normalized[start..] + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + version.strip_suffix('.').unwrap_or(&version).to_string() +} + +/// Classifies a requirement change under Cargo's caret compatibility rules. +pub fn classify_bump(old: &str, new: &str) -> Bump { + let old_version = req_version(old); + let new_version = req_version(new); + if old_version == new_version && req_norm(old) != req_norm(new) { + return Bump::Unknown; + } + + let old_major = component(&old_version, 0); + let new_major = component(&new_version, 0); + if old_major != new_major { + return Bump::Major; + } + + let old_minor = component(&old_version, 1); + let new_minor = component(&new_version, 1); + if old_minor != new_minor { + return if old_major == "0" { + Bump::Major + } else { + Bump::Minor + }; + } + + if old_major == "0" && old_minor == "0" && old_version != new_version { + return Bump::Major; + } + Bump::Patch +} + +fn component(version: &str, index: usize) -> &str { + version.split('.').nth(index).unwrap_or(version) +} + +#[cfg(test)] +#[path = "version_req/tests.rs"] +mod tests; diff --git a/src/version_req/tests.rs b/src/version_req/tests.rs new file mode 100644 index 0000000..2a79c56 --- /dev/null +++ b/src/version_req/tests.rs @@ -0,0 +1,69 @@ +use super::{classify_bump, req_norm, req_version}; +use crate::model::Bump; + +#[test] +fn normalizes_caret_and_metadata() { + assert_eq!(req_norm(" ^1.0.0 "), "1.0.0"); + assert_eq!(req_norm("1.0.0-rc1"), "1.0.0"); + assert_eq!(req_norm("1.0.0+build.7"), "1.0.0"); + assert_eq!(req_norm("1.0.0-alpha-beta"), "1.0.0"); +} + +#[test] +fn extracts_first_version_literal() { + assert_eq!(req_version("^1.2.3"), "1.2.3"); + assert_eq!(req_version("~0.29"), "0.29"); + assert_eq!(req_version("=0.0.1"), "0.0.1"); + assert_eq!(req_version(">=0.29, <0.31"), "0.29"); + assert_eq!(req_version("0.29.*"), "0.29"); + assert_eq!(req_version("*"), ""); +} + +#[test] +fn prerelease_graduation_is_not_a_requirement_change() { + assert_eq!(req_norm("1.0.0-rc1"), req_norm("1.0.0")); + assert_eq!(classify_bump("1.0.0-rc1", "1.0.0"), Bump::Patch); +} + +#[test] +fn classifies_caret_compatibility_boundaries() { + // Cargo's caret rules: the leftmost nonzero component decides compatibility. + assert_eq!(classify_bump("1.2.3", "2.0.0"), Bump::Major); + assert_eq!(classify_bump("1.2.3", "1.3.0"), Bump::Minor); + assert_eq!(classify_bump("1.2.3", "1.2.4"), Bump::Patch); + assert_eq!(classify_bump("0.1", "0.2"), Bump::Major); + assert_eq!(classify_bump("0.1.1", "0.1.2"), Bump::Patch); + assert_eq!(classify_bump("0.29", "0.30"), Bump::Major); + assert_eq!(classify_bump("0.0.1", "0.0.2"), Bump::Major); + assert_eq!(classify_bump("0.0.1", "0.0.1"), Bump::Patch); +} + +#[test] +fn operators_and_wildcards_reduce_to_the_version_they_start_at() { + assert_eq!(classify_bump("~0.29", "~0.30"), Bump::Major); + assert_eq!(classify_bump("=0.0.1", "=0.0.2"), Bump::Major); + assert_eq!(classify_bump("0.30.0-pre.0", "0.30.0"), Bump::Patch); + // A compound range is represented by its floor, so a moved floor still classifies. + assert_eq!(classify_bump(">=0.29, <0.31", ">=0.30, <0.32"), Bump::Major); + // A bare requirement and a caret requirement are the same set: no change. + assert_eq!(classify_bump("0.29", "^0.29"), Bump::Patch); + // A wildcard reduces to its numeric prefix; a bare `*` names no version at all. + assert_eq!(classify_bump("0.29.*", "0.30.*"), Bump::Major); + assert_eq!(classify_bump("*", "*"), Bump::Patch); +} + +#[test] +fn unchanged_floor_with_changed_text_is_unknown() { + // With the floor held the change is confined to the ceiling or an operator, and the + // requirement text alone does not say whether a consumer is affected. + assert_eq!( + classify_bump(">=0.29, <0.31", ">=0.29, <0.30"), + Bump::Unknown + ); + assert_eq!( + classify_bump(">=0.29, <0.31", ">=0.29, <0.32"), + Bump::Unknown + ); + assert_eq!(classify_bump(">=0.29", ">0.29"), Bump::Unknown); + assert_eq!(classify_bump("^0.29", "~0.29"), Bump::Unknown); +} diff --git a/tests/run.sh b/tests/run.sh index 0fd85a0..5789f8d 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -6,20 +6,31 @@ # exit code. We assert *behavior* (verdict / exit code / a key item name), not # exact `cargo public-api` output — that text shifts with the rustc version. # -# Requires the same tools zc does: cargo-public-api, jq, cargo, a Rust -# toolchain (with a nightly available, which cargo-public-api uses for rustdoc -# JSON). Run from anywhere: tests/run.sh +# Requires the same tools zc does: cargo-public-api, cargo, a Rust toolchain (with a +# nightly available, which cargo-public-api uses for rustdoc JSON), plus jq for the +# harness's own JSON assertions. Run from anywhere: +# `cargo build --release && tests/run.sh`, or point ZC at another build. set -uo pipefail -ZC=${ZC:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/zc"} +ZC=${ZC:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/target/release/zc"} +if [ ! -x "$ZC" ]; then + echo "error: no zc binary at $ZC (run: cargo build --release)" >&2 + exit 64 +fi pass=0 fail=0 -ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass + 1)); } -bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail + 1)); } +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +bad() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" + fail=$((fail + 1)) +} -assert_eq() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (got '$1', want '$2')"; fi; } -assert_contains() { case "$1" in *"$2"*) ok "$3" ;; *) bad "$3 (output missing: $2)" ;; esac; } +assert_eq() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (got '$1', want '$2')"; fi; } +assert_contains() { case "$1" in *"$2"*) ok "$3" ;; *) bad "$3 (output missing: $2)" ;; esac } api_cache_file() { # $1=repo $2=sha $3=crate local repo=$1 sha=$2 crate=$3 @@ -37,7 +48,10 @@ api_cache_file() { # $1=repo $2=sha $3=crate api_cache_count_for_sha() { # $1=repo $2=sha local repo=$1 sha=$2 local cache_dir=$repo/target/zc-cache - [ -d "$cache_dir" ] || { printf '0'; return; } + [ -d "$cache_dir" ] || { + printf '0' + return + } find "$cache_dir" -maxdepth 1 -type f -name "${sha}.*.api.json" | wc -l | tr -d ' ' } @@ -77,7 +91,7 @@ edition = "2021" EOF mkdir -p "$d/src" printf '%s\n' "$1" >"$d/src/lib.rs" - ( cd "$d" && cargo generate-lockfile -q ) >/dev/null 2>&1 + (cd "$d" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$d" add -A git -C "$d" commit -qm base printf '%s' "$d" @@ -101,7 +115,8 @@ before_head=$(git -C "$repo" rev-parse HEAD) before_status=$(git -C "$repo" status --porcelain) before_branch=$(git -C "$repo" branch --show-current) before_worktrees=$(worktree_count "$repo") -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) +rc=$? assert_eq "$rc" 1 "removed pub fn: exit 1" assert_contains "$out" "BREAKING" "removed pub fn: BREAKING verdict" assert_contains "$out" "foo" "removed pub fn: names the removed item" @@ -112,13 +127,15 @@ rm -rf "$repo" repo=$(new_repo 'pub fn foo() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn foo() {}\npub fn added() {}' 'add fn') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) +rc=$? assert_eq "$rc" 0 "additive only: exit 0" assert_contains "$out" "OK" "additive only: OK verdict" # 3) Changing only a private item is no public-API change. head2=$(commit_lib "$repo" $'pub fn foo() {}\npub fn added() {}\nfn helper() {}' 'add private fn') -out=$( cd "$repo" && "$ZC" "$head" "$head2" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$head" "$head2" 2>&1) +rc=$? assert_eq "$rc" 0 "private-only change: exit 0" assert_contains "$out" "No public API changes" "private-only change: reported as no change" rm -rf "$repo" @@ -127,7 +144,8 @@ rm -rf "$repo" repo=$(new_repo 'pub fn foo() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" 'pub fn bar() {}' 'swap') -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ); rc=$? +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) +rc=$? assert_eq "$rc" 1 "--json: breaking changes exit 1" if printf '%s' "$json" | jq -e 'has("totals") and has("crates") and .verdict == "breaking"' >/dev/null 2>&1; then ok "--json: valid shape, verdict=breaking" @@ -144,7 +162,8 @@ before_head=$(git -C "$repo" rev-parse HEAD) before_status=$(git -C "$repo" status --porcelain) before_branch=$(git -C "$repo" branch --show-current) before_worktrees=$(worktree_count "$repo") -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ); rc=$? +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) +rc=$? assert_eq "$rc" 2 "--json: analysis error exits 2" if printf '%s' "$json" | jq -e ' .verdict == "error" and @@ -163,7 +182,8 @@ before_head=$(git -C "$repo" rev-parse HEAD) before_status=$(git -C "$repo" status --porcelain) before_branch=$(git -C "$repo" branch --show-current) before_worktrees=$(worktree_count "$repo") -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) +rc=$? assert_eq "$rc" 2 "human error: analysis error exits 2" assert_contains "$out" "stage: head_build" "human error: shows failing stage" assert_contains "$out" "zc fixture build failure" "human error: shows stderr tail" @@ -174,7 +194,8 @@ before_branch=$(git -C "$repo" branch --show-current) before_worktrees=$(worktree_count "$repo") changelog_stdout_file=$(mktemp) changelog_stderr_file=$(mktemp) -( cd "$repo" && "$ZC" --changelog "$base" "$head" >"$changelog_stdout_file" 2>"$changelog_stderr_file" ); rc=$? +(cd "$repo" && "$ZC" --changelog "$base" "$head" >"$changelog_stdout_file" 2>"$changelog_stderr_file") +rc=$? changelog_stdout=$(cat "$changelog_stdout_file") changelog_stderr=$(cat "$changelog_stderr_file") rm -f "$changelog_stdout_file" "$changelog_stderr_file" @@ -186,7 +207,8 @@ assert_repo_unchanged "$repo" "$before_head" "$before_status" "$before_branch" " rm -rf "$repo" # 4c) Usage errors use a distinct code. -out=$( "$ZC" --definitely-not-a-zc-option 2>&1 ); rc=$? +out=$("$ZC" --definitely-not-a-zc-option 2>&1) +rc=$? assert_eq "$rc" 64 "usage error: exit 64" assert_contains "$out" "unknown option" "usage error: explains the option failure" @@ -202,11 +224,12 @@ commit_lib "$repo" $'pub fn foo() {}\npub fn feature_fn() {}' 'feature work' >/d git -C "$repo" checkout -q main commit_lib "$repo" $'pub fn foo() {}\npub fn upstream_fn() {}' 'upstream work after branch' >/dev/null git -C "$repo" checkout -q feature -out=$( cd "$repo" && "$ZC" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" 2>&1) +rc=$? assert_contains "$out" "feature_fn" "merge-base default: shows the branch's own addition" case "$out" in - *upstream_fn*) bad "merge-base default: leaked the parent's post-branch change (upstream_fn)" ;; - *) ok "merge-base default: excludes the parent's post-branch change" ;; +*upstream_fn*) bad "merge-base default: leaked the parent's post-branch change (upstream_fn)" ;; +*) ok "merge-base default: excludes the parent's post-branch change" ;; esac # And the header should announce the merge-base baseline, not a branch tip. assert_contains "$out" "merge-base(main, HEAD)" "merge-base default: labels the baseline" @@ -228,7 +251,8 @@ git -C "$repo" remote add origin "$repo" git -C "$repo" update-ref refs/remotes/origin/feature "$(git -C "$repo" rev-parse feature)" git -C "$repo" config branch.feature.remote origin git -C "$repo" config branch.feature.merge refs/heads/feature -out=$( cd "$repo" && "$ZC" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" 2>&1) +rc=$? assert_contains "$out" "merge-base(main, HEAD)" "self-upstream: baseline falls through to main" assert_contains "$out" "feature_fn" "self-upstream: still shows the branch's own addition" rm -rf "$repo" @@ -239,7 +263,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub enum Color { Red, Green, Blue }' 'add Color enum') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ) +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) if printf '%s\n' "$out" | grep -qE '^ +zc_fixture$'; then ok "default: bare module header (no tag)" else @@ -258,7 +282,7 @@ else fi # 6b) --by-type uses a flat type header (tagged); members keep the type prefix. -byt=$( cd "$repo" && "$ZC" --by-type "$base" "$head" 2>&1 ) +byt=$(cd "$repo" && "$ZC" --by-type "$base" "$head" 2>&1) if printf '%s\n' "$byt" | grep -qE '^ +zc_fixture::Color +\(enum\)$'; then ok "--by-type: flat type header with (enum) tag" else @@ -267,7 +291,7 @@ fi assert_contains "$byt" "+ pub Color::Red" "--by-type: members keep the type prefix" # 6c) --flat keeps fully-qualified paths and emits no indented group header. -flat=$( cd "$repo" && "$ZC" --flat "$base" "$head" 2>&1 ) +flat=$(cd "$repo" && "$ZC" --flat "$base" "$head" 2>&1) assert_contains "$flat" "pub zc_fixture::Color::Red" "--flat: keeps fully-qualified paths" if printf '%s\n' "$flat" | grep -qE '^ +(zc_fixture|Color)(::[A-Za-z0-9_]+)*( +\([a-z]+\))?$'; then bad "--flat: should not emit a group header" @@ -282,7 +306,7 @@ rm -rf "$repo" repo=$(new_repo $'pub trait Speak { fn hello(&self); }') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub trait Speak { fn hello(&self); fn bye(&self); }' 'add trait method bye') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ) +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) if printf '%s\n' "$out" | grep -qE '^ +Speak +\(trait\)$'; then ok "default: pre-existing type kind read from head source (trait)" else @@ -296,7 +320,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub enum E { A { x: u32 }, B { y: u32 } }' 'add enum with struct-variants') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ) +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) nhdr=$(printf '%s\n' "$out" | grep -cE '^ +E +\(enum\)$') assert_eq "$nhdr" "1" "clustering: enum header appears exactly once (no duplicate)" rm -rf "$repo" @@ -306,7 +330,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub struct Wrap(pub T);\nimpl Wrap { pub fn get(&self) -> u32 { self.0 } }' 'add generic Wrap') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ) +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) assert_contains "$out" "+ pub fn get(" "generics: type prefix with generic args is factored" if printf '%s\n' "$out" | grep -qF 'Wrap::get'; then bad "generics: member should not keep the Wrap:: prefix" @@ -346,7 +370,7 @@ git -C "$ws" init -q git -C "$ws" config user.email t@t git -C "$ws" config user.name t git -C "$ws" config commit.gpgsign false -( cd "$ws" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$ws" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$ws" add -A git -C "$ws" commit -qm base base=$(git -C "$ws" rev-parse HEAD) @@ -354,7 +378,7 @@ printf 'pub trait Ext { fn tag(&self) -> u8; fn name(&self) -> u8; }\nimpl Ext f git -C "$ws" add -A git -C "$ws" commit -qm head head=$(git -C "$ws" rev-parse HEAD) -out=$( cd "$ws" && "$ZC" "$base" "$head" 2>&1 ) +out=$(cd "$ws" && "$ZC" "$base" "$head" 2>&1) assert_contains "$out" "[trait impls on external types]" "external: foreign-type items get a dedicated section" # The foreign item is bucketed under its real crate (`dep`), not as a module of # the analyzed crate. @@ -370,7 +394,7 @@ rm -rf "$ws" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub struct Widget;\nimpl Widget { pub fn new() -> Self { Widget } pub fn run(&self) {} }' 'add Widget') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "## zc_fixture" "--changelog: per-crate heading" assert_contains "$out" "### Added" "--changelog: Added section" assert_contains "$out" "- \`Widget::{new, run}\`" "--changelog: type members brace-grouped on one line" @@ -382,12 +406,12 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub struct Verifier;\nimpl Verifier { pub fn check_cross_address_disabled(&self) {} pub fn enforce_nullifier_uniqueness(&self) {} pub fn validate_ironwood_proof_size(&self) {} pub fn validate_orchard_value_balance(&self) {} }' 'add Verifier') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`Verifier\`:" "--changelog: over-wide group uses a type header" assert_contains "$out" " - \`validate_ironwood_proof_size\`" "--changelog: over-wide group members indented as sub-bullets" case "$out" in - *'Verifier::{'*) bad "--changelog: over-wide group should not stay on one brace line" ;; - *) ok "--changelog: over-wide group is not kept inline" ;; +*'Verifier::{'*) bad "--changelog: over-wide group should not stay on one brace line" ;; +*) ok "--changelog: over-wide group is not kept inline" ;; esac rm -rf "$repo" @@ -423,7 +447,7 @@ git -C "$ws" init -q git -C "$ws" config user.email t@t git -C "$ws" config user.name t git -C "$ws" config commit.gpgsign false -( cd "$ws" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$ws" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$ws" add -A git -C "$ws" commit -qm base base=$(git -C "$ws" rev-parse HEAD) @@ -438,11 +462,11 @@ edition = "2021" [dependencies] dep = { path = "../dep", version = "0.2.0" } EOF -( cd "$ws" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$ws" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$ws" add -A git -C "$ws" commit -qm head head=$(git -C "$ws" rev-parse HEAD) -out=$( cd "$ws" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$ws" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`dep\` dependency bumped to \`0.2.0\`." "--changelog: internal dep bump under Changed" assert_contains "$out" "- \`dep2\` dependency." "--changelog: dropped dep under Removed" rm -rf "$ws" @@ -479,7 +503,7 @@ git -C "$ws" init -q git -C "$ws" config user.email t@t git -C "$ws" config user.name t git -C "$ws" config commit.gpgsign false -( cd "$ws" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$ws" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$ws" add -A git -C "$ws" commit -qm base base=$(git -C "$ws" rev-parse HEAD) @@ -493,11 +517,11 @@ edition = "2021" [dependencies] ext_dep = { path = "../ext_dep", version = "0.30.0" } EOF -( cd "$ws" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$ws" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$ws" add -A git -C "$ws" commit -qm head head=$(git -C "$ws" rev-parse HEAD) -out=$( cd "$ws" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$ws" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- Migrated to \`ext_dep 0.30.0\`." "--changelog: external dep bump under Changed" rm -rf "$ws" @@ -507,7 +531,7 @@ rm -rf "$ws" repo=$(new_repo $'pub trait IntoDisk { type Bytes; }\npub struct Foo(pub T);') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub trait IntoDisk { type Bytes; }\npub struct Foo(pub T);\nimpl IntoDisk for Foo { type Bytes = [u8; 48]; }' 'add IntoDisk impl') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`impl IntoDisk for Foo\`:" "--changelog: added assoc item grouped under impl header with Self generics" assert_contains "$out" "- \`Bytes\`" "--changelog: assoc type shown as a bare member under the impl" rm -rf "$repo" @@ -517,25 +541,31 @@ rm -rf "$repo" repo=$(new_repo 'pub fn f() -> u8 { 0 }') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" 'pub fn f() -> u16 { 0 }' 'widen return type') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`fn f() -> u8\`" "--changelog: Changed shows the old signature" assert_contains "$out" "→ \`fn f() -> u16\`" "--changelog: Changed shows the new signature after an arrow" rm -rf "$repo" # 6n) An MSRV (rust-version) bump is documented under ### Changed. repo=$(mktemp -d) -git -C "$repo" init -q; git -C "$repo" config user.email t@t; git -C "$repo" config user.name t; git -C "$repo" config commit.gpgsign false +git -C "$repo" init -q +git -C "$repo" config user.email t@t +git -C "$repo" config user.name t +git -C "$repo" config commit.gpgsign false printf '/target\n' >"$repo/.gitignore" printf '[package]\nname = "zc_fixture"\nversion = "0.1.0"\nedition = "2021"\nrust-version = "1.70"\n' >"$repo/Cargo.toml" -mkdir -p "$repo/src"; echo 'pub fn f() {}' >"$repo/src/lib.rs" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 -git -C "$repo" add -A; git -C "$repo" commit -qm base +mkdir -p "$repo/src" +echo 'pub fn f() {}' >"$repo/src/lib.rs" +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 +git -C "$repo" add -A +git -C "$repo" commit -qm base base=$(git -C "$repo" rev-parse HEAD) printf '[package]\nname = "zc_fixture"\nversion = "0.1.0"\nedition = "2021"\nrust-version = "1.75"\n' >"$repo/Cargo.toml" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 -git -C "$repo" add -A; git -C "$repo" commit -qm head +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 +git -C "$repo" add -A +git -C "$repo" commit -qm head head=$(git -C "$repo" rev-parse HEAD) -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- MSRV is now 1.75." "--changelog: MSRV bump documented under Changed" rm -rf "$repo" @@ -544,7 +574,7 @@ rm -rf "$repo" repo=$(new_repo $'pub trait Marker {}\npub struct A;\npub struct B;') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub trait Marker {}\npub struct A;\npub struct B;\nimpl Marker for A {}\nimpl Marker for B {}' 'impl Marker on A and B') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`impl Marker\` for:" "--changelog: a trait on multiple types is grouped by trait" rm -rf "$repo" @@ -554,7 +584,7 @@ rm -rf "$repo" repo=$(new_repo $'pub trait T { fn m(&self); }\npub struct S;\nimpl T for S { fn m(&self) {} }') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub trait T { fn m(&self); }\npub struct S;' 'remove impl T for S') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`impl T for S\`:" "--changelog: removed impl method grouped under its impl (base map)" rm -rf "$repo" @@ -564,7 +594,7 @@ rm -rf "$repo" repo=$(new_repo 'pub struct Foo;') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub struct Foo;\nimpl<\'a> From<&\'a u8> for Foo { fn from(_: &\'a u8) -> Self { Foo } }' 'add lifetime-param impl') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl From<&u8> for Foo" "--changelog: impl<'a> recognized; lifetime stripped" case "$out" in *'::impl`'* | *'- `impl`'*) bad "--changelog: impl<'a> must not collapse to a stray 'impl' member" ;; @@ -579,7 +609,7 @@ rm -rf "$repo" repo=$(new_repo 'pub struct Foo;') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub struct Foo;\npub mod sub { pub struct Bar; }\nimpl From> for Foo { fn from(_: core::option::Option) -> Self { Foo } }' 'add nested From') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl From> for Foo" "--changelog: keep one module segment + nested generics" rm -rf "$repo" @@ -588,7 +618,7 @@ rm -rf "$repo" repo=$(new_repo $'pub struct Foo;\npub struct Bar;') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub struct Foo;\npub struct Bar;\nimpl From for Foo { fn from(_: Bar) -> Self { Foo } }' 'add From') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl From for Foo" "--changelog: From impl is documented" case "$out" in *'`from`'*) bad "--changelog: a From impl must not list its boilerplate 'from' method" ;; @@ -601,7 +631,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\n#[derive(PartialEq)]\npub struct X(pub u8);' 'derive PartialEq') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl PartialEq for X" "--changelog: real derived impl kept" case "$out" in *StructuralPartialEq*) bad "--changelog: StructuralPartialEq compiler marker must be dropped" ;; @@ -615,7 +645,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub const fn answer() -> u8 { 42 }' 'add const fn') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`answer\`" "--changelog: const fn keeps its name" case "$out" in *'- `fn`'*) bad "--changelog: const fn must not collapse to a stray 'fn' group" ;; @@ -628,7 +658,7 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\n#[derive(Hash)]\npub struct K(pub u8);' 'derive Hash') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl Hash for K" "--changelog: -ss surfaces auto-derived impls" rm -rf "$repo" @@ -637,7 +667,7 @@ rm -rf "$repo" repo=$(new_repo 'pub struct Foo;') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub struct Foo;\n#[derive(Clone, Debug)]\npub struct Bar(pub u8);' 'derive Clone, Debug on Bar') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "impl {Clone, Debug} for Bar" "--changelog: derives on one type collapse to impl {..} for T" rm -rf "$repo" @@ -646,11 +676,11 @@ rm -rf "$repo" repo=$(new_repo 'pub fn placeholder() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" $'pub fn placeholder() {}\npub fn sibling() -> u8 { 0 }\npub mod m { pub struct Foo; pub fn g() -> u8 { 0 } }' 'add module m and a sibling fn') -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- \`m\`" "--changelog: an added module is listed on its own" assert_contains "$out" "- \`sibling\`" "--changelog: items outside the module are unaffected" case "$out" in -*Foo*|*'m::g'*) bad "--changelog: contents of an added module should be subsumed" ;; +*Foo* | *'m::g'*) bad "--changelog: contents of an added module should be subsumed" ;; *) ok "--changelog: added module subsumes its contents" ;; esac rm -rf "$repo" @@ -659,14 +689,16 @@ rm -rf "$repo" repo=$(new_repo 'pub fn foo() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" 'pub fn bar() {}' 'swap foo -> bar') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) +rc=$? assert_eq "$rc" 1 "api cache hit: initial diff exit 1" base_cache=$(api_cache_file "$repo" "$base" zc_fixture) || base_cache="" head_cache=$(api_cache_file "$repo" "$head" zc_fixture) || head_cache="" if [ -n "$base_cache" ] && [ -n "$head_cache" ]; then ok "api cache hit: populated both ref cache files" if cp "$base_cache" "$head_cache"; then - out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? + out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) + rc=$? assert_eq "$rc" 0 "api cache hit: tampered cache consumed" assert_contains "$out" "No public API changes" "api cache hit: tampered cache hides diff" else @@ -687,7 +719,8 @@ fresh_cache="$cache_dir/fresh.fp.zc_fixture.api.json" printf '%s\n' '{}' >"$old_cache" printf '%s\n' '{}' >"$fresh_cache" touch -t 200001010000 "$old_cache" -( cd "$repo" && "$ZC" "$base" "$base" >/dev/null 2>&1 ); rc=$? +(cd "$repo" && "$ZC" "$base" "$base" >/dev/null 2>&1) +rc=$? assert_eq "$rc" 0 "api cache gc: zc run succeeds" if [ ! -e "$old_cache" ]; then ok "api cache gc: old api json removed" @@ -704,7 +737,8 @@ rm -rf "$repo" # 9) Dirty working-tree snapshots are not written to the rustdoc JSON cache. repo=$(new_repo 'pub fn foo() {}') printf '%s\n' 'pub fn bar() {}' >"$repo/src/lib.rs" -json=$( cd "$repo" && "$ZC" --json 2>/dev/null ); rc=$? +json=$(cd "$repo" && "$ZC" --json 2>/dev/null) +rc=$? assert_eq "$rc" 1 "snapshot api cache: dirty diff exit 1" snapshot_short=$(printf '%s' "$json" | jq -r '.head_sha // empty') snapshot_sha=$(git -C "$repo" rev-parse --verify "${snapshot_short}^{commit}" 2>/dev/null || true) @@ -720,12 +754,14 @@ rm -rf "$repo" repo=$(new_repo 'pub fn foo() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(commit_lib "$repo" 'pub fn bar() {}' 'swap foo -> bar') -out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? +out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) +rc=$? assert_eq "$rc" 1 "corrupt api cache: initial diff exit 1" head_cache=$(api_cache_file "$repo" "$head" zc_fixture) || head_cache="" if [ -n "$head_cache" ]; then printf '%s\n' 'not json' >"$head_cache" - out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? + out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) + rc=$? assert_eq "$rc" 1 "corrupt api cache: rebuild preserves verdict" assert_contains "$out" "BREAKING" "corrupt api cache: breaking verdict retained" if jq -e . "$head_cache" >/dev/null 2>&1; then @@ -734,7 +770,8 @@ if [ -n "$head_cache" ]; then bad "corrupt api cache: cache was not overwritten with JSON" fi printf '%s\n' '{}' >"$head_cache" - out=$( cd "$repo" && "$ZC" "$base" "$head" 2>&1 ); rc=$? + out=$(cd "$repo" && "$ZC" "$base" "$head" 2>&1) + rc=$? assert_eq "$rc" 1 "empty-object api cache: rebuild preserves verdict" assert_contains "$out" "BREAKING" "empty-object api cache: breaking verdict retained" if jq -e 'has("format_version") and has("root") and has("index")' "$head_cache" >/dev/null 2>&1; then @@ -748,11 +785,13 @@ fi rm -rf "$repo" # 11) --version prints the release version without requiring analysis tools. -ver=$(grep -m1 '^ZC_VERSION=' "$ZC" | cut -d= -f2) -out=$("$ZC" --version 2>&1); rc=$? +ver=$(grep -m1 '^version = ' "$(dirname "${BASH_SOURCE[0]}")/../Cargo.toml" | cut -d'"' -f2) +out=$("$ZC" --version 2>&1) +rc=$? assert_eq "$rc" 0 "--version: exit 0" assert_eq "$out" "zc $ver" "--version: prints 'zc '" -out=$("$ZC" -V 2>&1); rc=$? +out=$("$ZC" -V 2>&1) +rc=$? assert_eq "$rc" 0 "-V alias: exit 0" assert_eq "$out" "zc $ver" "-V alias: prints the version line" @@ -777,7 +816,7 @@ new_pubdep_repo() { # $1=foo dependency line $2=foo/src/lib.rs -> repo dir printf '%s\n' "$lib" >"$d/foo/src/lib.rs" printf '[package]\nname = "bar"\nversion = "0.1.0"\nedition = "2021"\n' >"$d/bar/Cargo.toml" printf 'pub struct Error;\n' >"$d/bar/src/lib.rs" - ( cd "$d" && cargo generate-lockfile -q ) >/dev/null 2>&1 + (cd "$d" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$d" add -A git -C "$d" commit -qm base printf '%s' "$d" @@ -787,7 +826,7 @@ bump_pubdep_head() { # $1=repo $2=new foo dependency line -> head sha sed -i.bak 's/^version = "0.1.0"/version = "0.2.0"/' "$d/bar/Cargo.toml" rm "$d/bar/Cargo.toml.bak" printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n%s\n' "$dep_line" >"$d/foo/Cargo.toml" - ( cd "$d" && cargo generate-lockfile -q ) >/dev/null 2>&1 + (cd "$d" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$d" add -A git -C "$d" commit -qm head git -C "$d" rev-parse HEAD @@ -797,14 +836,15 @@ bump_pubdep_head() { # $1=repo $2=new foo dependency line -> head sha repo=$(new_pubdep_repo 'bar = { path = "../bar", version = "0.1" }' 'pub fn f() -> Result<(), bar::Error> { Ok(()) }') base=$(git -C "$repo" rev-parse HEAD) head=$(bump_pubdep_head "$repo" 'bar = { path = "../bar", version = "0.2" }') -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ); rc=$? +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) +rc=$? assert_eq "$rc" 1 "public-dep: exposed major bump exits 1" if printf '%s' "$json" | jq -e '.totals.public_dep_breaking >= 1 and (.public_dep_breaks | any(.crate == "foo" and .dep == "bar" and .new == "0.2"))' >/dev/null 2>&1; then ok "public-dep: exposed major bump attributed to foo/bar" else bad "public-dep: expected foo/bar public_dep_break, got: $(printf '%s' "$json" | jq -c '.public_dep_breaks' 2>/dev/null)" fi -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "## foo" "public-dep --changelog: foo section present" assert_contains "$out" "- Migrated to \`bar 0.2\`; its types appear in this crate's public API, so downstream users must upgrade \`bar\` in lockstep." "public-dep --changelog: break folded into the single Migrated entry" case "$out" in @@ -817,7 +857,7 @@ rm -rf "$repo" repo=$(new_pubdep_repo 'bar = { path = "../bar", version = "0.1" }' $'fn helper() -> Result<(), bar::Error> { Ok(()) }\npub fn f() {}') base=$(git -C "$repo" rev-parse HEAD) head=$(bump_pubdep_head "$repo" 'bar = { path = "../bar", version = "0.2" }') -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ) +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) if printf '%s' "$json" | jq -e '.totals.public_dep_breaking == 0 and (.public_dep_breaks | length == 0)' >/dev/null 2>&1; then ok "public-dep: private-only use is not flagged" else @@ -830,7 +870,7 @@ rm -rf "$repo" repo=$(new_pubdep_repo 'baz = { package = "bar", path = "../bar", version = "0.1" }' 'pub fn f() -> Result<(), baz::Error> { Ok(()) }') base=$(git -C "$repo" rev-parse HEAD) head=$(bump_pubdep_head "$repo" 'baz = { package = "bar", path = "../bar", version = "0.2" }') -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ) +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) if printf '%s' "$json" | jq -e '.public_dep_breaks | any(.crate == "foo" and .dep == "baz")' >/dev/null 2>&1; then ok "public-dep: renamed dep (package = bar, used as baz) still joins" else @@ -852,17 +892,17 @@ printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[depende printf 'pub fn f() -> Result<(), bar::Error> { Ok(()) }\n' >"$repo/foo/src/lib.rs" printf '[package]\nname = "bar"\nversion = "0.0.1"\nedition = "2021"\n' >"$repo/bar/Cargo.toml" printf 'pub struct Error;\n' >"$repo/bar/src/lib.rs" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$repo" add -A git -C "$repo" commit -qm base base=$(git -C "$repo" rev-parse HEAD) printf '[package]\nname = "bar"\nversion = "0.0.2"\nedition = "2021"\n' >"$repo/bar/Cargo.toml" printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nbar = { path = "../bar", version = "0.0.2" }\n' >"$repo/foo/Cargo.toml" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$repo" add -A git -C "$repo" commit -qm head head=$(git -C "$repo" rev-parse HEAD) -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ) +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) if printf '%s' "$json" | jq -e '.public_dep_breaks | any(.crate == "foo" and .dep == "bar" and .new == "0.0.2")' >/dev/null 2>&1; then ok "public-dep: 0.0.x patch bump is semver-incompatible and joins" else @@ -876,7 +916,7 @@ rm -rf "$repo" repo=$(new_pubdep_repo 'bar = { path = "../bar", version = "~0.1" }' 'pub fn f() -> Result<(), bar::Error> { Ok(()) }') base=$(git -C "$repo" rev-parse HEAD) head=$(bump_pubdep_head "$repo" 'bar = { path = "../bar", version = "~0.2" }') -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ) +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) if printf '%s' "$json" | jq -e '.public_dep_breaks | any(.crate == "foo" and .dep == "bar")' >/dev/null 2>&1; then ok "public-dep: operator-prefixed requirement (~0.1 -> ~0.2) still joins" else @@ -884,48 +924,8 @@ else fi rm -rf "$repo" -# 13) Requirement classification, pinned directly. `cargo metadata` reports `req` -# strings that are not always a single version — operators, ranges, wildcards, -# pre-release metadata — and every dependency verdict rests on reading them -# correctly. Both helpers are pure, so extract and source them. -cb_defs=$(mktemp) -sed -n '/^req_norm() {/,/^}/p;/^req_version() {/,/^}/p;/^classify_bump() {/,/^}/p' "$ZC" >"$cb_defs" -# shellcheck source=/dev/null -. "$cb_defs" -rm -f "$cb_defs" -check_bump() { # $1=old $2=new $3=expected - assert_eq "$(classify_bump "$1" "$2")" "$3" "classify_bump: $1 -> $2 is $3" -} -# Cargo's caret rules: the leftmost nonzero component decides compatibility. -check_bump 1.2.3 2.0.0 major -check_bump 1.2.3 1.3.0 minor -check_bump 1.2.3 1.2.4 patch -check_bump 0.1 0.2 major -check_bump 0.29 0.30 major -check_bump 0.0.1 0.0.2 major -check_bump 0.0.1 0.0.1 patch -# Operators are not part of the version, and metadata does not move the triple — -# the shape a crate graduating a pre-release produces. -check_bump '~0.29' '~0.30' major -check_bump '=0.0.1' '=0.0.2' major -check_bump '1.0.0-rc1' '1.0.0' patch -check_bump '0.30.0-pre.0' '0.30.0' patch -# A compound range is represented by its floor, so a moved floor still classifies. -check_bump '>=0.29, <0.31' '>=0.30, <0.32' major -# But with the floor held, the change is confined to the ceiling, and the -# requirement text alone does not say whether a consumer is affected: narrowing -# drops a version from the resolvable set, widening admits one. Neither is -# decidable here, so neither is guessed. -check_bump '>=0.29, <0.31' '>=0.29, <0.30' unknown -check_bump '>=0.29, <0.31' '>=0.29, <0.32' unknown -# Same reasoning for an operator swap that keeps the version but changes the set. -check_bump '>=0.29' '>0.29' unknown -check_bump '^0.29' '~0.29' unknown -# A bare requirement and a caret requirement are the same set, so that is no change. -check_bump '0.29' '^0.29' patch -# A wildcard reduces to its numeric prefix; a bare `*` names no version at all. -check_bump '0.29.*' '0.30.*' major -check_bump '*' '*' patch +# 13) Requirement classification (`classify_bump` and friends) is pure logic and is pinned +# by the crate's own unit tests in src/version_req/tests.rs. # 12f) A reachable dependency whose requirement changed without moving the version # it starts at is a review item, not a break: widening a ceiling cannot be # shown incompatible from the requirement text. It must be reported, must say @@ -943,23 +943,24 @@ printf '[package]\nname = "bar"\nversion = "0.1.0"\nedition = "2021"\n' >"$repo/ printf 'pub struct Error;\n' >"$repo/bar/src/lib.rs" printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nbar = { path = "../bar", version = ">=0.1, <0.3" }\n' >"$repo/foo/Cargo.toml" printf 'pub fn f() -> Result<(), bar::Error> { Ok(()) }\n' >"$repo/foo/src/lib.rs" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$repo" add -A git -C "$repo" commit -qm base base=$(git -C "$repo" rev-parse HEAD) printf '[package]\nname = "foo"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nbar = { path = "../bar", version = ">=0.1, <0.4" }\n' >"$repo/foo/Cargo.toml" -( cd "$repo" && cargo generate-lockfile -q ) >/dev/null 2>&1 +(cd "$repo" && cargo generate-lockfile -q) >/dev/null 2>&1 git -C "$repo" add -A git -C "$repo" commit -qm head head=$(git -C "$repo" rev-parse HEAD) -json=$( cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null ); rc=$? +json=$(cd "$repo" && "$ZC" --json "$base" "$head" 2>/dev/null) +rc=$? assert_eq "$rc" 0 "public-dep review: an undecidable requirement change does not fail the run" if printf '%s' "$json" | jq -e '.verdict == "ok" and .totals.public_dep_breaking == 0 and (.public_dep_breaks | any(.crate == "foo" and .dep == "bar" and .class == "review"))' >/dev/null 2>&1; then ok "public-dep review: reported as class review, excluded from the breaking count" else bad "public-dep review: expected an ok verdict with a review entry, got: $(printf '%s' "$json" | jq -c '{verdict, t: .totals.public_dep_breaking, b: .public_dep_breaks}' 2>/dev/null)" fi -out=$( cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null ) +out=$(cd "$repo" && "$ZC" --changelog "$base" "$head" 2>/dev/null) assert_contains "$out" "- Migrated to \`bar >=0.1, <0.4\`; its types appear in this crate's public API, so check whether downstream users are affected." "public-dep review --changelog: asks for review in the single Migrated entry" case "$out" in *"must upgrade"* | *"breaking change"*) bad "public-dep review --changelog: an undecidable change must not assert a break" ;; diff --git a/zc b/zc deleted file mode 100755 index bc1e8c0..0000000 --- a/zc +++ /dev/null @@ -1,3129 +0,0 @@ -#!/usr/bin/env bash -# zc — Detect public API and dependency changes across all -# workspace crates. -# -# Compares every workspace crate between two git refs (baseline and head, -# where head defaults to the working tree if dirty, else HEAD). Up to four -# sections are emitted (sections 2 and 4 are opt-in): -# -# 1. Workspace dependency diff. Each dep is classified by the strongest -# kind it is used with across the workspace (runtime > build > dev). -# Optional runtime deps are labelled `runtime-opt`. Crates whose name -# ends in `-test` (or is exactly `zebra-test`) are excluded from -# classification so their transitive runtime deps don't masquerade as -# production deps. Workspace-internal crates are excluded entirely (the -# per-crate section already covers them). -# -# 2. Optional Cargo.lock diff (--with-lock). Transitive changes only; -# direct workspace deps are suppressed because they already appear in -# section 1. -# -# 3. Per-crate public API diff via `cargo public-api`. -# -# 4. Optional const/static value + doc-comment diff (--with-values). -# cargo-public-api compares signatures only, so a `pub const` whose value -# changes (e.g. 99 -> 1000) or an item whose doc text changes shows as -# "no change". This section catches those via rustdoc JSON. -# -# PREREQUISITES -# cargo install cargo-public-api --version 0.52.0 --locked -# jq -# a nightly toolchain (builds rustdoc JSON) -# -# USAGE -# zc [options] [ []] -# -# ARGUMENTS -# Git ref to compare against: branch name, tag, or commit SHA. -# Git ref to compare to. -# -# DEFAULT BEHAVIOR (when is not given) -# The script picks `head` based on whether the working tree is dirty, and — -# when comparing against a parent branch — diffs from the *branch point* (the -# merge-base of that branch and the head) rather than the branch's current -# tip, so commits merged onto the parent after you branched don't pollute the -# diff (or any changelog built from it): -# - 0 args, dirty: head = working tree, baseline = HEAD -# - 0 args, clean: head = HEAD, baseline = merge-base(parent, HEAD) -# (parent = upstream tracking ref, or 'main') -# - 1 arg, dirty: head = working tree, baseline = merge-base(, HEAD) -# - 1 arg, clean: head = HEAD, baseline = merge-base(, HEAD) -# - 2 args: head = , baseline = (exact, no merge-base) -# -# merge-base(X, HEAD) == X whenever X is already an ancestor of HEAD, so the -# branch point only differs from the ref when the branch has advanced past it. -# It is computed from local history — no network access needed. -# -# "Working tree" includes both staged and unstaged changes plus any -# untracked files (so a brand-new .rs file shows as added API). -# -# OPTIONS -# -h, --help Print this help message and exit. -# -V, --version Print the installed version and exit. -# --with-lock Also diff Cargo.lock for transitive dep changes. -# --by-type By default, per-crate API items are grouped into a -# module > type > member hierarchy: a module header, then a -# type sub-header tagged with its kind — read from its -# declaration in the diff, else the head source, else inferred -# from a member, else a generic `(type)`/`(mod)` — with the -# module and type -# prefixes factored out of each item; items living directly in -# a module (free fns, consts, mod decls) sit under the module -# header. --by-type uses a flat type grouping instead: one -# tagged header per type, members keeping their `Type::` prefix. -# In either mode, items whose path is in another crate (a -# trait impl this crate adds to a foreign type) are collected -# under a separate "trait impls on external types" section. -# --flat Don't group at all; print the original flat, -# fully-qualified, one-item-per-line list. -# --with-values Also diff public const/static values and doc text via -# rustdoc JSON. Catches changes cargo-public-api can't see -# because it is signature-only. -# --changelog Emit a librustzcash-style changelog (markdown) on stdout -# instead of the diff: one `## ` section per changed -# crate, with `### Added`/`### Changed`/`### Removed` lists -# grouped under their owning type (own-crate paths made -# crate-relative, foreign-type paths kept in full). Per-crate -# dependency changes are folded in too: internal workspace -# bumps and external `Migrated to ...` lines under Changed, -# dropped deps under Removed. Other output is suppressed. -# The draft still needs curation: see librustzcash's -# CONTRIBUTING.md "Changelog Entries" for what requires an -# entry and how it is worded, and skills/zc/ for the -# curation workflow. -# --json Emit machine-readable JSON on stdout. -# Progress and diagnostics go to stderr. Schema: -# { -# baseline: ", )'>", -# baseline_sha: "", -# head: "", -# head_sha: "", -# verdict: "ok" | "breaking" | "error", -# totals: { -# removed, changed, added, # API items -# api_breaking, # removed + changed -# dep_breaking, # breaking runtime deps -# error_crates, # cargo-public-api fails -# value_changed, # const/static value changes -# doc_changed, # doc-comment changes -# public_dep_breaking # public-dep incompatible -# }, -# deps: { -# removed: [ {name, version, kind} ], -# changed: [ {name, old, new, bump, kind, features} ], -# added: [ {name, version, kind} ] -# }, -# values: [ {crate, path, type, old, new} ], -# docs: [ {crate, path} ], -# public_dep_breaks: [ {crate, dep, old, new, -# class} ], # breaking|review -# crates: [ -# { name, removed, changed, added, status, -# error: null | { -# stage, ref, ref_sha, command, stderr, hint -# } -# } -# ] -# } -# Error `stage` is baseline_build, head_build, or diff. -# zc keeps --all-features and does not automatically fall -# back to default features because that can hide public API. -# -# EXAMPLES -# zc # if dirty: HEAD -> working tree -# # if clean: branch point with parent -> HEAD -# zc main # diff against the branch point with main -# # (HEAD, or working tree if dirty) -# zc v4.2.0 # diff against v4.2.0 (a tag/ancestor: exact) -# zc v4.1.0 v4.2.0 # compare two arbitrary refs (exact, no merge-base) -# zc --with-lock # include transitive Cargo.lock diff -# zc --with-values main # also flag const/static value + doc changes -# zc --json main # machine-readable output for CI -# -# OUTPUT -# Sections are printed in order: -# 1. Workspace dep diff — removed / changed / added. -# Colors: red = consumer-visible breaking (runtime major bump, -# runtime removal), yellow = changed but not breaking, dim = -# internal (build / dev / runtime-opt), green = added. -# 2. Transitive Cargo.lock diff (only with --with-lock). Direct -# workspace deps are suppressed (already in section 1); each -# transitive crate is annotated with the direct deps that pull -# it in (`via foo, bar` — truncated to 3 with `...(+N)`). -# 3. Per-crate public-API counts, one row per workspace crate: -# crate-name -R ~C +A [(additive)] -# Crates with only additions are tagged `(additive)` so reviewers -# can focus on breaking ones. Crates with no changes show as -# `no changes`; crates where cargo-public-api failed show as -# `error: `. -# 4. Summary table — aligned counts per crate plus a Total row, -# printed only if at least one crate changed. Zero counts are -# rendered as `-` for readability. -# 5. Detailed diffs — for each changed crate, the removed / changed -# / added API items. -# 6. Value/doc changes (only with --with-values) — const/static values -# shown as `old -> new` (counted as breaking), and public items whose -# doc-comment text changed (informational). -# 7. Final verdict line: BREAKING / ERROR / OK with a one-line -# summary of the contributing factors. -# With --json a single JSON document is printed instead (schema above). -# -# ENVIRONMENT -# CARGO_TARGET_DIR Used as the root for zc's cache. When unset, -# `target/zc-cache/` is used. Dependency and derived -# TSV caches are keyed on the resolved ref SHA plus a hash -# of this script. Rustdoc JSON is keyed on ref SHA, crate, -# cargo-public-api version, nightly rustc version, and -# feature policy. -# ZC_TOOLCHAIN Nightly toolchain name used to build rustdoc JSON. -# Defaults to the first installed `nightly*` toolchain. -# NO_COLOR When set to any non-empty value, disables ANSI -# color output (https://no-color.org). -# -# EXIT CODES -# 0 Clean. No breaking API, dependency, or value changes were found. -# 1 Breaking changes were detected. -# 2 Analysis error. zc could not produce a trustworthy verdict because -# cargo-public-api, rustdoc, metadata, or another analysis step failed. -# 64 Usage or setup error, such as an unknown option, bad ref, missing -# required tool, or unsupported shell. - -EXIT_OK=0 -EXIT_BREAKING=1 -EXIT_ANALYSIS=2 -EXIT_USAGE=64 - -if [ -z "${BASH_VERSION:-}" ]; then - echo "error: zc must be run with bash 4+" >&2 - echo " macOS users can install it with: brew install bash" >&2 - exit "$EXIT_USAGE" -fi - -zc_reexec_with_newer_bash() { - [ -z "${ZC_BASH_REEXEC:-}" ] || return 1 - [ -f "$0" ] && [ -r "$0" ] || return 1 - local candidate path major - for candidate in "${ZC_BASH:-}" bash /opt/homebrew/bin/bash /usr/local/bin/bash; do - [ -n "$candidate" ] || continue - path=$(command -v "$candidate" 2>/dev/null || true) - [ -n "$path" ] || continue - major=$("$path" -c 'printf "%s\n" "${BASH_VERSINFO[0]}"' 2>/dev/null || printf '0\n') - case "$major" in - [4-9] | [1-9][0-9]*) exec env ZC_BASH_REEXEC=1 "$path" "$0" "$@" ;; - esac - done - return 1 -} - -if (( BASH_VERSINFO[0] < 4 )); then - zc_reexec_with_newer_bash "$@" || { - echo "error: bash 4+ required (associative arrays)" >&2 - echo " macOS ships bash 3.2. Install a newer bash with: brew install bash" >&2 - echo " Then run: /opt/homebrew/bin/bash $0 $*" >&2 - exit "$EXIT_USAGE" - } -fi - -set -euo pipefail - -# Version string. Used as the dep-cache key when the script isn't a readable -# file on disk (e.g. run via `curl … | bash`, where $0 can't be hashed). Bump -# it whenever the dep-dump / metadata logic changes so piped runs don't reuse a -# stale cache. When run from a real file, the file's content hash is used -# instead (see SCRIPT_HASH below), so this only matters for piped execution. -ZC_VERSION=0.3.0 - -# ── tunables ────────────────────────────────────────────────────────── -# Regex (jq-syntax) matching crate names whose deps should be excluded -# from the workspace dep classification. These are test-only crates -# whose "runtime" deps are actually downstream test deps. -TEST_CRATE_PATTERN='^(zebra-test|.*-test)$' - -# ── colors ──────────────────────────────────────────────────────────── -# Disabled when output is not a terminal, or when NO_COLOR is set to any -# non-empty value (https://no-color.org). -if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then - RED=$'\033[31m' - GREEN=$'\033[32m' - YELLOW=$'\033[33m' - BOLD=$'\033[1m' - DIM=$'\033[2m' - RESET=$'\033[0m' -else - RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" -fi - -# ── progress ────────────────────────────────────────────────────────── -# A spinner-prefixed status line for the slow per-crate loops -# (cargo-public-api, --with-values rustdoc). A background tick re-renders -# every 100ms so the spinner keeps moving even while a single cargo -# invocation blocks the foreground. Non-TTY stderr gets one line per progress update. -SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') -progress_state_file= -progress_pid= - -# Start the background tick. Idempotent. -progress_start() { - [ -n "$progress_pid" ] && return 0 - [ -t 2 ] || return 0 - progress_state_file=$(mktemp -p "$RUN_TMP" progress.XXXXXX) - : >"$progress_state_file" - ( - i=0 - while :; do - msg=$(cat "$progress_state_file" 2>/dev/null || true) - [ -n "$msg" ] && printf '\r%s %s\033[K' "${SPINNER_FRAMES[i % 10]}" "$msg" >&2 - i=$((i + 1)) - sleep 0.1 - done - ) & - progress_pid=$! -} - -progress() { - if [ -n "$progress_state_file" ]; then - printf '%s' "$1" >"$progress_state_file" - elif [ ! -t 2 ]; then - printf 'zc: %s\n' "$1" >&2 - fi -} - -# Stop the tick and clear the line. -progress_clear() { - if [ -n "$progress_pid" ]; then - kill "$progress_pid" 2>/dev/null || true - wait "$progress_pid" 2>/dev/null || true - progress_pid= - progress_state_file= - fi - [ -t 2 ] || return 0 - printf '\r\033[K' >&2 -} - -# ── help ────────────────────────────────────────────────────────────── -# The full help is the leading comment block of this file, extracted with sed. -# That only works when $0 is a readable file; when piped (`curl … | bash`) the -# source isn't re-readable, so fall back to a short usage that points at a -# downloadable copy for the full reference. -show_help() { - if [ -f "$0" ] && [ -r "$0" ]; then - sed -n '/^#/!q; s/^# \{0,1\}//; 1d; p' "$0" - return - fi - cat <<'EOF' -zc — diff the public API and dependencies of a Rust workspace between two git refs. - -Usage: zc [options] [ []] - -Options: --with-lock --with-values --by-type --flat --changelog --json -V/--version -h/--help - -Piped execution shows only this short help. For the full reference, grab a copy: - curl -fsSL https://raw.githubusercontent.com/ZcashFoundation/zc/v0.3.0/zc -o zc - chmod +x zc && ./zc --help -EOF -} - -# `--version` runs before any tool prerequisite check. -show_version() { - printf 'zc %s\n' "$ZC_VERSION" -} - -with_lock=false -with_values=false -json_mode=false -changelog_mode=false -# Per-crate item grouping: mod (default) clusters by owning module; type -# clusters by owning type (with kind tags); flat is the ungrouped list. -group_mode="mod" -while [ $# -gt 0 ]; do - case "$1" in - -h | --help) - show_help - exit "$EXIT_OK" - ;; - -V | --version) - show_version - exit "$EXIT_OK" - ;; - --with-lock) - with_lock=true - shift - ;; - --flat) - group_mode="flat" - shift - ;; - --by-type) - group_mode="type" - shift - ;; - --with-values) - with_values=true - shift - ;; - --json) - json_mode=true - shift - ;; - --changelog) - changelog_mode=true - shift - ;; - --) - shift - break - ;; - -*) - echo "${RED}error:${RESET} unknown option '$1' (run with --help for usage)" >&2 - exit "$EXIT_USAGE" - ;; - *) - break - ;; - esac -done - -# cargo-public-api and the rustdoc JSON builds always run with all features, so -# feature-gated public items are never silently missing from the diff, trait map, -# or value/doc index. Kept identical across all of them so they stay in sync. -feature_args=(--all-features) - -# In JSON or changelog mode, silence colours and redirect human-readable output -# to /dev/null so the generated document is the only thing on stdout (fd 3). -if $json_mode || $changelog_mode; then - RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" - exec 3>&1 >/dev/null -fi - -# ── baseline detection ──────────────────────────────────────────────── - -# Detect the parent branch to use when the user doesn't pass a baseline -# AND the working tree is clean. The returned ref is then reduced to its -# merge-base with HEAD (see `reduce_to_merge_base`). Strategy, in order: -# 1. If we're on 'main' (or HEAD is detached and there is no branch), -# use 'main' — comparing main to HEAD is the only useful default. -# 2. Otherwise, if the current branch has an upstream tracking ref -# configured, use that (stripping a leading `origin/`). -# 3. Fall back to 'main'. -# Returning the upstream ref (not just "main") means feature branches -# tracking release branches compare against the right target. -detect_parent_branch() { - local current upstream - current=$(git branch --show-current 2>/dev/null) - if [ -z "$current" ] || [ "$current" = "main" ]; then - echo "main" - return - fi - upstream=$(git rev-parse --abbrev-ref "${current}@{upstream}" 2>/dev/null || true) - # A feature branch usually tracks its OWN remote (e.g. `origin/`, - # set by `git push -u`). That's no use as a diff baseline — comparing a branch - # to itself yields nothing — so ignore a same-named upstream and fall through - # to `main`. Only a DIFFERENTLY-named upstream (e.g. a release branch a feature - # tracks) is a real parent. `${upstream#*/}` strips the leading `/`. - if [ -n "$upstream" ] && [ "${upstream#*/}" = "$current" ]; then - upstream="" - fi - # Prefer the upstream ref verbatim (e.g. `origin/release-X`); fall back to - # the `origin/`-stripped name only if it resolves locally, else `main`. - # Returning a bare name that doesn't exist locally would abort the run when - # the baseline ref is verified. - if [ -n "$upstream" ] && git rev-parse --verify --quiet "$upstream" >/dev/null; then - echo "$upstream" - elif [ -n "$upstream" ] && git rev-parse --verify --quiet "${upstream#origin/}" >/dev/null; then - echo "${upstream#origin/}" - else - echo "main" - fi -} - -# ── merge-base reduction ─────────────────────────────────────────────── -# -# When comparing against a parent branch (the default, or an explicit single -# baseline), diff from where HEAD *diverged* from that branch — the merge-base — -# rather than the branch's current tip. Otherwise every commit that landed on -# the parent after we branched off (or last merged from) it shows up as spurious -# added/removed API, polluting the diff and any changelog built from it. The -# branch point is always local history — any parent commit we merged is by -# definition a local ancestor — so this needs no network access. -# -# `merge-base(X, HEAD) == X` whenever X is already an ancestor of HEAD, so this -# is a no-op for tags and release commits and only moves the baseline exactly -# when the parent branch has advanced past the branch point — the case worth -# fixing. Reduces the global `baseline` (a ref) to that merge-base SHA and tags -# `baseline_label` accordingly. A no-op (leaves `baseline` untouched) if the two -# refs share no common ancestor (unrelated histories). -reduce_to_merge_base() { - local mb head_for_mb="${head_ref:-HEAD}" - if mb=$(git merge-base "$baseline" "$head_for_mb" 2>/dev/null) && [ -n "$mb" ]; then - baseline_label="merge-base($baseline_label, $head_label)" - baseline="$mb" - fi -} - -# Is the working tree dirty? "Dirty" means any tracked file is modified -# or staged, OR any untracked-and-non-ignored file exists. We use -# `--porcelain` because it's the documented stable format and trivial -# to test with `-n`. -is_worktree_dirty() { - local out - out=$(git status --porcelain 2>/dev/null) || return 1 - [ -n "$out" ] -} - -# Synthesize a commit that captures the current working tree (tracked + -# untracked + staged), parented at HEAD. Returns the commit SHA. -# -# The point: cargo-public-api's diff syntax requires two refs (it has -# no "ref vs working tree" mode for git refs — only published versions). -# We work around this by snapshotting the working tree into a real commit -# object, *without* touching the working tree, the index, or the stash -# list. Done with a temp index so `git add -A` doesn't disturb anything. -# -# The resulting commit isn't reachable from any branch, so it'll be -# garbage-collected eventually. To keep it alive for the lifetime of -# this script, the caller passes the SHA into cargo-public-api via the -# normal ref-resolution path (it gets used immediately, before gc). -worktree_snapshot_commit() { - local tmp_index head_sha tree commit - tmp_index=$(mktemp -p "$RUN_TMP" .index.XXXXXX) - # Seed the temp index with HEAD's tree, then add the entire working - # tree (including untracked files) on top. - if ! head_sha=$(git rev-parse --verify HEAD 2>/dev/null); then - echo "${RED}error:${RESET} cannot resolve HEAD (no commits yet?)" >&2 - return 1 - fi - GIT_INDEX_FILE="$tmp_index" git read-tree "$head_sha" 2>/dev/null || { - echo "${RED}error:${RESET} git read-tree HEAD failed" >&2 - return 1 - } - GIT_INDEX_FILE="$tmp_index" git add -A 2>/dev/null || { - echo "${RED}error:${RESET} git add -A failed (working tree snapshot)" >&2 - return 1 - } - tree=$(GIT_INDEX_FILE="$tmp_index" git write-tree) || { - echo "${RED}error:${RESET} git write-tree failed" >&2 - return 1 - } - commit=$(git commit-tree "$tree" -p "$head_sha" \ - -m "[zc worktree snapshot]" 2>/dev/null) || { - echo "${RED}error:${RESET} git commit-tree failed" >&2 - return 1 - } - rm -f "$tmp_index" - echo "$commit" -} - -# Argument parsing — see "DEFAULT BEHAVIOR" in the doc header. -# -# - 2 args: explicit baseline + head, no working-tree magic. -# - 0 or 1 args: head depends on whether the working tree is dirty: -# dirty → head = synthesized worktree-snapshot commit -# clean → head = HEAD (committed only) -if [ $# -gt 2 ]; then - echo "${RED}error:${RESET} too many positional arguments, expected at most 2 (run with --help for usage)" >&2 - exit "$EXIT_USAGE" -fi - -# `head_label` is what we display in headings and error messages. For -# the synthesized worktree commit, we don't want to expose the SHA -# (it's an implementation detail) — show "working tree" instead. -head_label="" - -# RUN_TMP is created later (it's needed for worktree_snapshot_commit), -# so we defer dirty-detection until after that. Stash the desired mode -# here as a flag and resolve below. -mode_dirty=false -if [ $# -lt 2 ] && is_worktree_dirty; then - mode_dirty=true -fi - -# `use_merge_base` controls whether a branch baseline is reduced to its branch -# point with HEAD (see `reduce_to_merge_base`). On for the "compare my work -# against a parent branch" cases (default, or a single explicit baseline); off -# for an explicit two-ref comparison, which is taken literally, and for the -# dirty no-arg case, whose baseline is already HEAD. -if [ $# -ge 2 ]; then - baseline="$1" - head_ref="$2" - head_label="$2" - use_merge_base=false -elif [ $# -eq 1 ]; then - baseline="$1" - use_merge_base=true - if $mode_dirty; then - head_ref="" # filled in below, after RUN_TMP exists - head_label="working tree" - else - head_ref="HEAD" - head_label="HEAD" - fi -else - if $mode_dirty; then - baseline="HEAD" - head_ref="" # filled in below - head_label="working tree" - use_merge_base=false - else - baseline=$(detect_parent_branch) - head_ref="HEAD" - head_label="HEAD" - use_merge_base=true - fi -fi - -# Display label for the baseline ref; defaults to the ref itself. -baseline_label="$baseline" - -# Reduce a branch baseline to its branch point (merge-base with the head), so -# commits that landed on the parent after we branched don't pollute the diff. -if $use_merge_base; then - reduce_to_merge_base -fi - -# Verify the baseline ref exists. Head verification is deferred until -# after RUN_TMP is set up (worktree-snapshot mode resolves head_ref then). -if ! git rev-parse --verify "$baseline" >/dev/null 2>&1; then - echo "${RED}error:${RESET} unknown git ref '$baseline' (run with --help for usage)" >&2 - exit "$EXIT_USAGE" -fi -if [ -n "$head_ref" ] && ! git rev-parse --verify "$head_ref" >/dev/null 2>&1; then - echo "${RED}error:${RESET} unknown git ref '$head_ref' (run with --help for usage)" >&2 - exit "$EXIT_USAGE" -fi - -# Verify cargo-public-api is installed. -if ! cargo public-api --version >/dev/null 2>&1; then - echo "${RED}error:${RESET} cargo-public-api is not installed" >&2 - echo " install it with: cargo install cargo-public-api" >&2 - exit "$EXIT_USAGE" -fi - -nightly_toolchain="${ZC_TOOLCHAIN:-}" -if [ -z "$nightly_toolchain" ]; then - nightly_toolchain=$(rustup toolchain list 2>/dev/null | grep -oE 'nightly[^ ]*' | head -n1 || true) -fi -if [ -z "$nightly_toolchain" ]; then - echo "${RED}error:${RESET} zc needs a nightly toolchain to build rustdoc JSON" >&2 - echo " install one with: rustup toolchain install nightly" >&2 - exit "$EXIT_USAGE" -fi - -# Verify jq is installed. -if ! command -v jq >/dev/null 2>&1; then - echo "${RED}error:${RESET} jq is not installed" >&2 - exit "$EXIT_USAGE" -fi - -baseline_short=$(git rev-parse --short "$baseline" 2>/dev/null || echo "$baseline") - -# ── workspace dependency diff ───────────────────────────────────────── -# -# For each ref we snapshot the workspace dependency list via `cargo metadata` -# in a detached worktree, then classify each workspace dep by the strongest -# kind it is used with across the workspace: -# -# runtime > build > dev > unused -# -# This lets us separate consumer-visible dep changes from internal/test-only -# ones. `cargo metadata` also resolves feature flags and renames accurately, -# which a `sed` parse of `Cargo.toml` cannot. - -# Normalize a requirement for comparison: drop whitespace, a leading caret (bare -# and caret requirements are the same set in Cargo), and pre-release / build -# metadata. Metadata does not move the major/minor/patch triple, so `1.0.0-rc1` -# and `1.0.0` normalize alike — the shape a crate graduating a pre-release -# produces, which must not read as a compatibility change. -req_norm() { # $1=requirement -> stdout normalized requirement - local r=${1// /} - r=${r#^} - printf '%s' "${r//[-+][0-9A-Za-z.]*/}" -} - -# Reduce a requirement to the version it starts at. These strings come from -# `cargo metadata`'s `req`, which is not always a single version: it carries -# comparison operators (`~0.29`, `=0.0.1`), ranges (`>=0.29, <0.31`), and -# wildcards (`0.29.*`). Keep the first version literal, so an operator is dropped, -# a range is represented by its floor — the version a consumer has to unify with — -# and a wildcard by its numeric prefix. -req_version() { # $1=requirement -> stdout bare version ("" when none) - local r - r=$(req_norm "$1") - r=${r#"${r%%[0-9]*}"} - r=${r%%[!0-9.]*} - printf '%s' "${r%.}" -} - -# Classify a version bump: returns "major", "minor", "patch", or "unknown". -# "major" means semver-incompatible under Cargo's caret rules, i.e. a change to -# the leftmost nonzero component. So for a 0.x crate a minor bump (0.1 -> 0.2) is -# major, and for a 0.0.x crate (where `^0.0.1` admits only 0.0.1) even a patch -# bump (0.0.1 -> 0.0.2) is major. -# -# "unknown" is for a requirement that changed without moving the version it -# starts at, so the change is in an operator, a ceiling, or an added clause and -# the requirement text alone does not say whether a consumer is affected. Both -# `>=0.29,<0.31` -> `>=0.29,<0.30` (0.30 leaves the resolvable set) and `>=0.29` -# -> `>0.29` (0.29 itself leaves it) land here. Deciding them needs the resolved -# versions, which this comparison does not have. -# -# Callers that must not raise a false alarm treat "unknown" as not-breaking; the -# public-dependency join treats it as a candidate, since there it is filtered by -# whether the dependency is reachable in the crate's public API. -classify_bump() { - local old new - old=$(req_version "$1") new=$(req_version "$2") - if [ "$old" = "$new" ] && [ "$(req_norm "$1")" != "$(req_norm "$2")" ]; then - echo "unknown" - return - fi - local old_major=${old%%.*} new_major=${new%%.*} - if [ "$old_major" != "$new_major" ]; then - echo "major" - return - fi - local old_rest=${old#*.} new_rest=${new#*.} - local old_minor=${old_rest%%.*} new_minor=${new_rest%%.*} - if [ "$old_minor" != "$new_minor" ]; then - if [ "$old_major" = "0" ]; then echo "major"; else echo "minor"; fi - return - fi - # 0.0.x: the patch component is the leftmost nonzero one, so a patch bump is - # breaking as well — `^0.0.1` does not admit 0.0.2. - if [ "$old_major" = "0" ] && [ "$old_minor" = "0" ] && [ "$old" != "$new" ]; then - echo "major" - return - fi - echo "patch" -} - -# Portable SHA-1 of a file, printing the first 12 hex chars. Tries -# sha1sum (GNU), shasum (macOS/BSD/Perl), then openssl. Fails loudly if -# none are available — silently falling back to non-hash mtime data -# would break the "edits to this script invalidate the cache" contract. -sha1_short() { - if command -v sha1sum >/dev/null 2>&1; then - sha1sum "$1" | cut -c1-12 - elif command -v shasum >/dev/null 2>&1; then - shasum -a 1 "$1" | cut -c1-12 - elif command -v openssl >/dev/null 2>&1; then - openssl dgst -sha1 "$1" | awk '{print $NF}' | cut -c1-12 - else - echo "${RED}error:${RESET} no SHA-1 tool found (install sha1sum, shasum, or openssl)" >&2 - exit "$EXIT_USAGE" - fi -} - -# List workspace crate names (one per line), sorted. Used by both the -# per-crate API-diff loop and the lock-diff direct-dep filter. -workspace_crate_names() { # $1=workspace dir, defaults to PWD - local workspace_dir=${1:-$PWD} - cargo metadata --manifest-path "$workspace_dir/Cargo.toml" --no-deps --format-version 1 2>/dev/null | - jq -r ' - (.workspace_members) as $ws | - [.packages[] | select(.id as $id | $ws | index($id)) | .name] - | sort[] - ' -} - -# Cache directory for resolved dep dumps. Keyed on the ref's resolved SHA -# plus a hash of this script, so edits to the jq query invalidate the cache. -# When $0 isn't a readable file (piped via `curl … | bash`), it can't be -# hashed, so fall back to the version string as the cache key. -CACHE_DIR="${CARGO_TARGET_DIR:-target}/zc-cache" -if [ -f "$0" ] && [ -r "$0" ]; then - SCRIPT_HASH=$(sha1_short "$0") -else - SCRIPT_HASH="v${ZC_VERSION}" -fi -mkdir -p "$CACHE_DIR" 2>/dev/null || true -CACHE_DIR=$(cd "$CACHE_DIR" 2>/dev/null && pwd -P) || { - echo "${RED}error:${RESET} failed to resolve zc cache directory" >&2 - exit "$EXIT_USAGE" -} - -cargo_public_api_version=$(cargo public-api --version 2>/dev/null) || { - echo "${RED}error:${RESET} cargo-public-api failed during version detection" >&2 - exit "$EXIT_USAGE" -} -rustc_version=$(rustc +"$nightly_toolchain" --version 2>/dev/null) || { - echo "${RED}error:${RESET} selected nightly toolchain '$nightly_toolchain' cannot run rustc" >&2 - exit "$EXIT_USAGE" -} -api_fp_input=$(mktemp -p "$CACHE_DIR" api-fingerprint.XXXXXX) || { - echo "${RED}error:${RESET} failed to create zc cache fingerprint input" >&2 - exit "$EXIT_USAGE" -} -{ - printf '%s\n' "$cargo_public_api_version" - printf '%s\n' "$rustc_version" - printf '%s\n' "${feature_args[@]}" -} >"$api_fp_input" -API_FP=$(sha1_short "$api_fp_input") -rm -f "$api_fp_input" - -# Per-run temp directory for worktrees; cleaned on exit. Each worktree gets -# a unique subdir under here, so a single EXIT trap handles all of them -# (including the case where the script aborts mid-run). -RUN_TMP=$(mktemp -d) -cleanup_run_tmp() { - # Stop the background progress tick if still running (Ctrl-C path). - if [ -n "${progress_pid:-}" ]; then - kill "$progress_pid" 2>/dev/null || true - fi - # Best-effort: remove any worktrees still registered, then nuke the dir. - if [ -d "$RUN_TMP" ]; then - for wt in "$RUN_TMP"/*; do - [ -d "$wt" ] || continue - git worktree remove --force "$wt" >/dev/null 2>&1 || true - done - rm -rf "$RUN_TMP" - fi -} -trap cleanup_run_tmp EXIT -git worktree prune >/dev/null 2>&1 || true -find "$CACHE_DIR" -maxdepth 1 -type f -name '*.api.json' -mtime +14 -delete >/dev/null 2>&1 || true - -# If we deferred head_ref resolution (worktree-snapshot mode), do it now that -# RUN_TMP exists. The synthesized commit object is parented at HEAD and contains -# the working tree's tracked + untracked + staged contents. -head_is_worktree_snapshot=false -if [ -z "$head_ref" ]; then - head_ref=$(worktree_snapshot_commit) || exit "$EXIT_ANALYSIS" - head_is_worktree_snapshot=true -fi - -head_short=$(git rev-parse --short "$head_ref" 2>/dev/null || echo "$head_ref") - -# Pre-resolve full-length SHAs for baseline + head before creating isolated -# worktrees. This also makes HEAD unambiguous when head_ref is a snapshot commit. -baseline_sha=$(git rev-parse --verify "$baseline" 2>/dev/null) || { - echo "${RED}error:${RESET} cannot resolve baseline ref '$baseline'" >&2 - exit "$EXIT_ANALYSIS" -} -head_sha=$(git rev-parse --verify "$head_ref" 2>/dev/null) || { - echo "${RED}error:${RESET} cannot resolve head ref" >&2 - exit "$EXIT_ANALYSIS" -} - -api_baseline_worktree=$(mktemp -d -p "$RUN_TMP" api-baseline.XXXXXX) -if ! git worktree add --detach --quiet "$api_baseline_worktree" "$baseline_sha" 2>/dev/null; then - echo "${RED}error:${RESET} failed to create public-api worktree for '$baseline_label'" >&2 - exit "$EXIT_ANALYSIS" -fi - -api_head_worktree=$(mktemp -d -p "$RUN_TMP" api-head.XXXXXX) -if ! git worktree add --detach --quiet "$api_head_worktree" "$head_sha" 2>/dev/null; then - echo "${RED}error:${RESET} failed to create public-api worktree for '$head_label'" >&2 - exit "$EXIT_ANALYSIS" -fi - -api_baseline_target="$RUN_TMP/api-baseline-target" -api_head_target="$RUN_TMP/api-head-target" -mkdir -p "$api_baseline_target" "$api_head_target" 2>/dev/null || { - echo "${RED}error:${RESET} failed to create public-api target dirs" >&2 - exit "$EXIT_ANALYSIS" -} - -echo "${BOLD}Comparing public API: ${baseline_label}${RESET}${DIM} (${baseline_short})${RESET} ${BOLD}-> ${head_label}${RESET}${DIM} (${head_short})${RESET}" -echo "" - -# Dump one TSV row per workspace dependency at a ref. Columns are: -# -# name Display key — the dep's rename if any, otherwise its real -# crate name. If renamed, formatted as "foo (pkg: bar)". -# ver Version requirement from Cargo.toml (leading "^" stripped). -# kind Strongest kind across all usages: runtime | build | dev | -# unused. -# opt "opt" if every max-kind usage is optional, else "req". -# def "def" if any usage enables default features, else "nodef". -# feat Comma-joined sorted union of explicitly-enabled features -# across usages (empty if none). -# -# Uses `git worktree add --detach` so the current tree is untouched, and -# `cargo metadata --no-deps` so only workspace crates are inspected (fast, -# no network, no registry pull unless lockfile is missing). -# -# Results are cached under $CACHE_DIR keyed on ref-SHA + script hash, so -# edits to this script's jq query automatically invalidate prior caches. -dump_workspace_deps() { - local ref=$1 - local sha - sha=$(git rev-parse --verify "$ref" 2>/dev/null) || { - echo "${RED}error:${RESET} cannot resolve ref '$ref'" >&2 - return 1 - } - local cache_file="$CACHE_DIR/${sha}.${SCRIPT_HASH}.tsv" - if [ -s "$cache_file" ]; then - cat "$cache_file" - return 0 - fi - - # Worktree lives under RUN_TMP so the script-level EXIT trap cleans it - # up even if we abort partway through. - local tmp cache_tmp - tmp=$(mktemp -d -p "$RUN_TMP") - - if ! git worktree add --detach --quiet "$tmp" "$ref" 2>/dev/null; then - echo "${RED}error:${RESET} failed to create worktree for '$ref'" >&2 - return 1 - fi - - # A stale Cargo.lock at `$ref` would cause cargo to resolve against - # the current registry instead of the locked versions, producing - # results that don't match what CI would see. Rather than silently - # "retry without --locked" and report a drifted answer, surface the - # failure loudly so the user knows the dep diff may be misleading. - local meta - if ! meta=$(cargo metadata --manifest-path "$tmp/Cargo.toml" \ - --no-deps --format-version 1 --locked 2>/dev/null); then - echo "${RED}error:${RESET} lockfile out of sync at '$ref' (cargo metadata --locked failed)" >&2 - echo "${DIM} either update Cargo.lock at that ref, or re-run without --locked by editing this script${RESET}" >&2 - return 1 - fi - - # Collect (name, kind, features) triples from every non-test workspace - # crate's dependencies. Then for each name, pick the strongest kind - # (runtime > build > dev > unused) and the union of all explicitly-enabled - # features across usages. - # - # Crates whose sole purpose is test infrastructure (matching `*-test` or - # named `zebra-test`) are excluded from classification, because their - # "runtime" deps are downstream crates' test deps and would otherwise - # upgrade every dev-dep to runtime in the merged view. - # - # Workspace-internal crates (those listed in `workspace_members`) are - # excluded from the output entirely, because their versions are covered - # by the per-crate public-API diff section. - cache_tmp=$(mktemp -p "$CACHE_DIR" "${sha}.${SCRIPT_HASH}.deps.XXXXXX") || return 1 - jq -r --arg test_pat "$TEST_CRATE_PATTERN" ' - def kind_rank: - {"runtime": 3, "build": 2, "dev": 1, "unused": 0}[.]; - - def is_test_crate($n): - $n | test($test_pat); - - # Kind of a single dep entry. null kind == runtime in cargo metadata. - def entry_kind: - if .kind == null or .kind == "normal" then "runtime" - elif .kind == "build" then "build" - elif .kind == "dev" then "dev" - else "runtime" - end; - - # Display key for a dep: the rename if one is set (matching what - # appears in Cargo.toml), otherwise the real crate name. - def disp_key: (.rename // .name); - - # Names of workspace-internal crates, to filter them out below. - # Resolved by joining .workspace_members (list of package IDs) against - # .packages[].id + .name. - (.workspace_members) as $ws_ids | - ([ .packages[] - | select(.id as $id | $ws_ids | index($id)) - | .name - ]) as $ws_names | - - # Per-dep usage across non-test workspace crates: kind + features - # + optionality. A dep is considered "optional" only if every usage - # that resolves to its strongest kind is marked optional. - # - # Keying on `disp_key` means `foo = { package = "bar" }` is tracked - # as `foo (pkg: bar)`, matching what appears in Cargo.toml. - ( - [ .packages[] - | select(is_test_crate(.name) | not) - | .dependencies[] - | { - key: disp_key, - real_name: .name, - kind: entry_kind, - optional: (.optional // false), - uses_default: (.uses_default_features // true), - features: (.features // []) - } - ] - | group_by(.key) - | map( - . as $entries | - ($entries | map(.kind) | max_by(kind_rank)) as $max_kind | - { - key: .[0].key, - real_name: .[0].real_name, - kind: $max_kind, - optional: ( - [ $entries[] | select(.kind == $max_kind) | .optional ] - | all - ), - # Defaults are "on" if any usage enables them. - uses_default: (map(.uses_default) | any), - features: (map(.features) | add | unique) - } - ) - ) as $used | - - # Workspace-level dep table (source of versions). Strip the leading - # "^" cargo inserts for caret requirements so the output matches - # what a human typed in Cargo.toml. - ( - [ .packages[] - | select(is_test_crate(.name) | not) - | .dependencies[] - | { - key: disp_key, - real_name: .name, - req: ((.req // "-") | sub("^\\^"; "")) - } - ] - | unique_by(.key) - ) as $declared | - - # Merge usage kinds/features/optionality into the declared list, - # filter out workspace-internal crates, and emit TSV. The display - # key (column 1) uses the renamed-as name; if the crate is renamed, - # the real crate name is appended as "name (pkg: real)". - ( - ($used | map({(.key): .}) | add // {}) as $by_key | - $declared - | map(. + ($by_key[.key] // {kind: "unused", optional: false, uses_default: true, features: []})) - | map(select(.key as $k | ($ws_names | index($k)) | not)) - | unique_by(.key) - | sort_by(.key) - | .[] - | [ - (if .key == .real_name then .key - else "\(.key) (pkg: \(.real_name))" end), - .req, - .kind, - (if .optional then "opt" else "req" end), - (if .uses_default then "def" else "nodef" end), - (.features | sort | join(",")) - ] | @tsv - ) - ' <<<"$meta" >"$cache_tmp" || { - rm -f "$cache_tmp" - echo "${RED}error:${RESET} jq failed processing metadata for '$ref'" >&2 - return 1 - } - mv -f "$cache_tmp" "$cache_file" - cat "$cache_file" -} - -# Dump one TSV row per (workspace crate, direct runtime/build dependency) at a -# ref. Unlike `dump_workspace_deps` this is used only by --changelog and differs -# in two ways: it keeps workspace-internal crates (their version bumps are the -# single most common dependency line in these changelogs) and preserves the -# per-crate granularity the changelog needs (each crate's section lists its own -# dependency changes). Columns: -# -# crate the workspace crate that declares the dependency -# dep the dependency's display key (rename if any, else real crate name) -# req version requirement from Cargo.toml (leading "^" stripped) -# scope "int" if the dep is itself a workspace member, else "ext" -# -# Dev-only dependencies are skipped — they are not part of the published API or -# runtime surface and Zebra/lrz changelogs never document them. Cached on -# ref-SHA + script hash like the other dumps. -dump_per_crate_deps() { # $1=ref -> stdout TSV - local ref=$1 sha cache_file tmp cache_tmp meta - sha=$(git rev-parse --verify "$ref" 2>/dev/null) || return 1 - cache_file="$CACHE_DIR/${sha}.${SCRIPT_HASH}.percrate-deps.tsv" - if [ -s "$cache_file" ]; then cat "$cache_file"; return 0; fi - tmp=$(mktemp -d -p "$RUN_TMP") - if ! git worktree add --detach --quiet "$tmp" "$ref" 2>/dev/null; then - echo "${YELLOW}warning:${RESET} could not create worktree for '$ref' (per-crate deps)" >&2 - return 1 - fi - if ! meta=$(cargo metadata --manifest-path "$tmp/Cargo.toml" \ - --no-deps --format-version 1 --locked 2>/dev/null); then - git worktree remove --force "$tmp" >/dev/null 2>&1 || true - echo "${YELLOW}warning:${RESET} cargo metadata failed at '$ref' (per-crate deps)" >&2 - return 1 - fi - git worktree remove --force "$tmp" >/dev/null 2>&1 || true - cache_tmp=$(mktemp -p "$CACHE_DIR" "${sha}.${SCRIPT_HASH}.percrate-deps.XXXXXX") || return 1 - jq -r --arg test_pat "$TEST_CRATE_PATTERN" ' - (.workspace_members) as $ws_ids | - ([ .packages[] | select(.id as $id | $ws_ids | index($id)) | .name ]) as $ws_names | - ( - # One MSRV pseudo-row per crate (rust-version), plus the dep rows. - [ .packages[] - | select(.id as $id | $ws_ids | index($id)) - | select(.name | test($test_pat) | not) - | { crate: .name, dep: "~msrv", req: (.rust_version // "-"), scope: "msrv", pkg: "-" } ] - + - [ .packages[] - | select(.id as $id | $ws_ids | index($id)) - | select(.name | test($test_pat) | not) - | .name as $crate - | .dependencies[] - | .name as $depname - | select(.kind == null or .kind == "normal" or .kind == "build") - | { crate: $crate, - dep: (.rename // .name), - req: ((.req // "-") | sub("^\\^"; "")), - scope: (if ($ws_names | index($depname)) then "int" else "ext" end), - pkg: $depname } ] - ) - | unique_by(.crate + "/" + .dep) - | sort_by(.crate, .dep) - | .[] - | [ .crate, .dep, .req, .scope, .pkg ] | @tsv - ' <<<"$meta" >"$cache_tmp" || { - rm -f "$cache_tmp" - echo "${YELLOW}warning:${RESET} jq failed processing per-crate deps for '$ref'" >&2 - return 1 - } - mv -f "$cache_tmp" "$cache_file" - cat "$cache_file" -} - -# Build the trait-impl map (see TRAIT_MAP_JQ) for the given crates at $ref, by -# compiling each crate's rustdoc JSON once. Used only by --changelog to attribute -# associated items to their `impl Trait for Self`. Needs a nightly toolchain; -# emits nothing (silently) if absent or if rustdoc JSON fails for a crate, so the -# changelog degrades to plain type grouping rather than erroring. Per-crate -# results are cached on ref-SHA + script hash. -dump_trait_map() { # $1=ref $2..=crate names -> stdout TSV: self member trait - local ref=$1; shift - local sha wt target json crate cache_file cache_tmp err_file idx=0 total=$# - [ -z "${nightly_toolchain:-}" ] && return 0 - sha=$(git rev-parse --verify "$ref" 2>/dev/null) || return 0 - target="$RUN_TMP/trait-target"; mkdir -p "$target" 2>/dev/null || true - wt=$(mktemp -d -p "$RUN_TMP") - git worktree add --detach --quiet "$wt" "$ref" 2>/dev/null || return 0 - for crate in "$@"; do - idx=$((idx + 1)) - cache_file="$CACHE_DIR/${sha}.${SCRIPT_HASH}.${crate}.traitmap.tsv" - if [ -f "$cache_file" ]; then cat "$cache_file"; continue; fi - progress "--changelog: trait map [$idx/$total] $crate" - err_file="$RUN_TMP/${crate}.traitmap.err" - if run_public_api_rustdoc "$crate" "$wt" "$target" "$err_file" "$sha"; then - json=$PUBLIC_API_JSON_RESULT - if [ -f "$json" ]; then - cache_tmp=$(mktemp -p "$CACHE_DIR" "${sha}.${SCRIPT_HASH}.${crate}.traitmap.XXXXXX") || continue - jq -r "$TRAIT_MAP_JQ" "$json" 2>/dev/null | sort -u >"$cache_tmp" || : >"$cache_tmp" - mv -f "$cache_tmp" "$cache_file" || { rm -f "$cache_tmp"; continue; } - cat "$cache_file" - fi - fi - done - git worktree remove --force "$wt" >/dev/null 2>&1 || true -} - -base_dump=$(dump_workspace_deps "$baseline") || exit "$EXIT_ANALYSIS" -head_dump=$(dump_workspace_deps "$head_ref") || exit "$EXIT_ANALYSIS" - -# Build two maps keyed on dep name. Each value is the dep's record -# columns 2-6 (ver, kind, opt, def, feat) tab-joined into a single -# string, so we only need two associative arrays instead of one per -# column. `split_dep` below unpacks a record back into positional -# globals DEP_VER / DEP_KIND / DEP_OPT / DEP_DEF / DEP_FEAT. -declare -A base_dep head_dep -while IFS=$'\t' read -r name ver kind opt def feat; do - [ -z "$name" ] && continue - base_dep[$name]=$(printf '%s\t%s\t%s\t%s\t%s' "$ver" "$kind" "$opt" "$def" "$feat") -done <<<"$base_dump" -while IFS=$'\t' read -r name ver kind opt def feat; do - [ -z "$name" ] && continue - head_dep[$name]=$(printf '%s\t%s\t%s\t%s\t%s' "$ver" "$kind" "$opt" "$def" "$feat") -done <<<"$head_dump" - -# Split a "ver\tkind\topt\tdef\tfeat" record into the named global vars. -split_dep() { - IFS=$'\t' read -r DEP_VER DEP_KIND DEP_OPT DEP_DEF DEP_FEAT <<<"$1" -} - -# Rank a kind for "strongest kind wins" comparisons. -kind_rank() { - case "$1" in - runtime | runtime-opt) echo 3 ;; - build) echo 2 ;; - dev) echo 1 ;; - *) echo 0 ;; - esac -} - -# Format a kind label including optionality: "runtime-opt", "runtime", etc. -kind_label() { - local kind=$1 opt=$2 - if [ "$opt" = "opt" ] && [ "$kind" = "runtime" ]; then - echo "runtime-opt" - else - echo "$kind" - fi -} - -# Diff two comma-separated feature lists plus a default-features toggle. -# Output "+a,-b,+c" style. Default-feature toggles are surfaced first -# and suffixed with `!` so they stand out, and are tracked separately -# from the feature list so a hypothetical feature literally named -# "default" can never be conflated with the default-features flag: -# -# -default!,+std,-foo # lost defaults, gained std, lost foo -feature_diff() { - local old_def=$1 new_def=$2 old_feat=$3 new_feat=$4 - local default_entry="" - if [ "$old_def" != "$new_def" ]; then - if [ "$old_def" = "def" ]; then - default_entry="-default!" - else - default_entry="+default!" - fi - fi - local rest=() f - if [ "$old_feat" != "$new_feat" ]; then - local -A in_old in_new - IFS=',' read -ra old_arr <<<"$old_feat" - IFS=',' read -ra new_arr <<<"$new_feat" - for f in "${old_arr[@]}"; do [ -n "$f" ] && in_old[$f]=1; done - for f in "${new_arr[@]}"; do [ -n "$f" ] && in_new[$f]=1; done - for f in "${!in_old[@]}"; do - [ -z "${in_new[$f]+x}" ] && rest+=("-$f") - done - for f in "${!in_new[@]}"; do - [ -z "${in_old[$f]+x}" ] && rest+=("+$f") - done - fi - local sorted=() - if [ ${#rest[@]} -gt 0 ]; then - readarray -t sorted < <(printf '%s\n' "${rest[@]}" | sort) - fi - local out=() - [ -n "$default_entry" ] && out+=("$default_entry") - out+=("${sorted[@]}") - ( - IFS=',' - echo "${out[*]}" - ) -} - -dep_removed=() -dep_changed=() -dep_added=() -dep_breaking_count=0 - -# Walk base_dep for removed + changed deps, then walk head_dep for added -# deps (names not in base_dep). Each iteration splits the record, compares -# fields, and pushes into the matching bucket. Breaking classification is -# intentionally narrow — `dep_breaking_count` only ticks up for runtime -# (non-optional) deps that are removed, major-bumped, or lose features / -# default-features. Changes to build-only, dev-only, or runtime-opt deps -# don't affect the verdict. -for name in "${!base_dep[@]}"; do - split_dep "${base_dep[$name]}" - old_ver=$DEP_VER old_kind=$DEP_KIND old_opt=$DEP_OPT old_def=$DEP_DEF old_feat=$DEP_FEAT - if [ -z "${head_dep[$name]+x}" ]; then - label=$(kind_label "$old_kind" "$old_opt") - dep_removed+=("$name|$old_ver|$label") - [ "$label" = "runtime" ] && dep_breaking_count=$((dep_breaking_count + 1)) - continue - fi - split_dep "${head_dep[$name]}" - new_ver=$DEP_VER new_kind=$DEP_KIND new_opt=$DEP_OPT new_def=$DEP_DEF new_feat=$DEP_FEAT - if [ "$old_ver" = "$new_ver" ] && [ "$old_kind" = "$new_kind" ] && - [ "$old_opt" = "$new_opt" ] && [ "$old_def" = "$new_def" ] && - [ "$old_feat" = "$new_feat" ]; then - continue - fi - bump=$(classify_bump "$old_ver" "$new_ver") - # Strongest kind across old/new determines display color. - if [ "$(kind_rank "$new_kind")" -ge "$(kind_rank "$old_kind")" ]; then - kind=$new_kind opt=$new_opt - else - kind=$old_kind opt=$old_opt - fi - label=$(kind_label "$kind" "$opt") - feat_delta=$(feature_diff "$old_def" "$new_def" "$old_feat" "$new_feat") - dep_changed+=("$name|$old_ver|$new_ver|$bump|$label|$feat_delta") - if [ "$label" = "runtime" ]; then - # A removed feature or lost default (`-name` / `-default!`) is breaking; - # a hyphen *inside* an added feature name (`+async-std`) is not. - feat_breaking=false - if [ -n "$feat_delta" ]; then - IFS=',' read -ra feat_toks <<<"$feat_delta" - for ft in "${feat_toks[@]}"; do - [[ "$ft" == -* ]] && { feat_breaking=true; break; } - done - fi - if [ "$bump" = "major" ] || $feat_breaking; then - dep_breaking_count=$((dep_breaking_count + 1)) - fi - fi -done -for name in "${!head_dep[@]}"; do - if [ -z "${base_dep[$name]+x}" ]; then - split_dep "${head_dep[$name]}" - label=$(kind_label "$DEP_KIND" "$DEP_OPT") - dep_added+=("$name|$DEP_VER|$label") - fi -done - -# Sort each bucket by name for stable output. Guard empty inputs so -# `readarray` doesn't insert a single empty element. -[ ${#dep_removed[@]} -gt 0 ] && readarray -t dep_removed < <(printf '%s\n' "${dep_removed[@]}" | sort) -[ ${#dep_changed[@]} -gt 0 ] && readarray -t dep_changed < <(printf '%s\n' "${dep_changed[@]}" | sort) -[ ${#dep_added[@]} -gt 0 ] && readarray -t dep_added < <(printf '%s\n' "${dep_added[@]}" | sort) - -if [ $((${#dep_removed[@]} + ${#dep_changed[@]} + ${#dep_added[@]})) -gt 0 ]; then - echo "${BOLD}Dependency changes${RESET}${DIM} (kind: runtime = consumer-visible, build/dev = internal)${RESET}" - echo "" - - if [ ${#dep_removed[@]} -gt 0 ]; then - echo " ${RED}Removed (${#dep_removed[@]}):${RESET}" - for entry in "${dep_removed[@]}"; do - IFS='|' read -r name ver kind <<<"$entry" - # runtime = red (breaking); runtime-opt/build = yellow; dev = dim. - case "$kind" in - runtime) color=$RED ;; - runtime-opt | build) color=$YELLOW ;; - *) color=$DIM ;; - esac - printf " ${color}- %s %s${RESET} ${DIM}[%s]${RESET}\n" "$name" "$ver" "$kind" - done - echo "" - fi - - if [ ${#dep_changed[@]} -gt 0 ]; then - echo " ${YELLOW}Changed (${#dep_changed[@]}):${RESET}" - max_len=0 - for entry in "${dep_changed[@]}"; do - IFS='|' read -r name _ _ _ _ _ <<<"$entry" - [ ${#name} -gt "$max_len" ] && max_len=${#name} - done - for entry in "${dep_changed[@]}"; do - IFS='|' read -r name old new bump kind feat_delta <<<"$entry" - # Major bumps on runtime (non-optional) deps are consumer-visible - # breaking. Optional runtime deps, build deps, and dev deps are - # downgraded. - color="$YELLOW" - if [ "$bump" = "major" ] && [ "$kind" = "runtime" ]; then - color="$RED" - elif [ "$kind" = "dev" ] || [ "$kind" = "build" ] || [ "$kind" = "runtime-opt" ]; then - color="$DIM" - fi - if [ "$old" = "$new" ]; then - # Version unchanged; only kind or features differ. - printf " ${color}%-${max_len}s %s${RESET} ${DIM}[%s]${RESET}" \ - "$name" "$old" "$kind" - else - printf " ${color}%-${max_len}s %s -> %s (%s)${RESET} ${DIM}[%s]${RESET}" \ - "$name" "$old" "$new" "$bump" "$kind" - fi - if [ -n "$feat_delta" ]; then - printf " ${DIM}features:${RESET} %s" "$feat_delta" - fi - printf "\n" - done - echo "" - fi - - if [ ${#dep_added[@]} -gt 0 ]; then - echo " ${GREEN}Added (${#dep_added[@]}):${RESET}" - for entry in "${dep_added[@]}"; do - IFS='|' read -r name ver kind <<<"$entry" - printf " ${GREEN}+ %s %s${RESET} ${DIM}[%s]${RESET}\n" "$name" "$ver" "$kind" - done - echo "" - fi -fi - -# ── Cargo.lock diff (opt-in) ────────────────────────────────────────── -# -# Parses the resolved `[[package]]` table from Cargo.lock at each ref and -# reports transitive version changes. Direct-dep changes already appear in -# the workspace dep diff above and are suppressed here. - -if $with_lock; then - # Extract "name@version" lines from Cargo.lock at a ref. - # - # Walks the TOML `[[package]]` blocks line-by-line (fast and dep-free - # vs pulling in a TOML parser). A blank line terminates the block and - # triggers the emit. The END action catches the edge case of a lock - # file that ends without a trailing blank line — rare, but cargo - # doesn't guarantee one, so the last package would otherwise be lost. - extract_lock() { - git show "$1:Cargo.lock" 2>/dev/null | awk ' - /^\[\[package\]\]/ { name=""; version=""; in_pkg=1; next } - in_pkg && /^name *= *"/ { sub(/^name *= *"/, ""); sub(/"$/, ""); name=$0 } - in_pkg && /^version *= *"/ { sub(/^version *= *"/, ""); sub(/"$/, ""); version=$0 } - in_pkg && /^$/ { - if (name != "" && version != "") print name "@" version - name=""; version=""; in_pkg=0 - } - END { - if (in_pkg && name != "" && version != "") print name "@" version - } - ' | sort -u - } - - base_lock=$(extract_lock "$baseline") || base_lock="" - head_lock=$(extract_lock "$head_ref") || head_lock="" - - if [ -z "$base_lock" ] || [ -z "$head_lock" ]; then - echo "${YELLOW}warning:${RESET} Cargo.lock missing at one or both refs; skipping lock diff" >&2 - else - # Declared workspace-dep names (to suppress direct deps) plus the - # workspace-internal crate names themselves — the latter always - # appear in Cargo.lock for the workspace, but their versions are - # covered by the per-crate public-API section and shouldn't show - # up again here. - declare -A direct_names=() - for n in "${!base_dep[@]}" "${!head_dep[@]}"; do - direct_names[$n]=1 - # Renamed deps display as `foo (pkg: real)`; Cargo.lock and - # cargo-tree use the real name, so register that too. - if [[ "$n" == *" (pkg: "* ]]; then - real=${n##* (pkg: } - direct_names[${real%)}]=1 - fi - done - while IFS= read -r n; do - [ -n "$n" ] && direct_names[$n]=1 - done < <(workspace_crate_names "$api_head_worktree") - - # Lock files can list the same crate at multiple major versions - # (e.g. `foo 1.2.0` and `foo 2.0.0` coexisting). Comma-join them - # into a single slot per crate so the removed/changed/added logic - # below compares the *set* of versions — a multi-version crate - # only appears in `changed` if the set actually differs. - declare -A base_lock_ver head_lock_ver - while IFS='@' read -r name ver; do - [ -z "$name" ] && continue - if [ -n "${base_lock_ver[$name]+x}" ]; then - base_lock_ver[$name]="${base_lock_ver[$name]},$ver" - else - base_lock_ver[$name]=$ver - fi - done <<<"$base_lock" - while IFS='@' read -r name ver; do - [ -z "$name" ] && continue - if [ -n "${head_lock_ver[$name]+x}" ]; then - head_lock_ver[$name]="${head_lock_ver[$name]},$ver" - else - head_lock_ver[$name]=$ver - fi - done <<<"$head_lock" - - lock_changed=() - lock_added=() - lock_removed=() - for name in "${!base_lock_ver[@]}"; do - # Skip if this is a direct workspace dep (already reported above). - [ -n "${direct_names[$name]+x}" ] && continue - old=${base_lock_ver[$name]} - new=${head_lock_ver[$name]:-} - if [ -z "$new" ]; then - lock_removed+=("$name|$old") - elif [ "$old" != "$new" ]; then - lock_changed+=("$name|$old|$new") - fi - done - for name in "${!head_lock_ver[@]}"; do - [ -n "${direct_names[$name]+x}" ] && continue - [ -z "${base_lock_ver[$name]+x}" ] && - lock_added+=("$name|${head_lock_ver[$name]}") - done - - # Sort by name for stable output. Guard empty inputs so - # `readarray` doesn't insert a single empty element. - [ ${#lock_removed[@]} -gt 0 ] && readarray -t lock_removed < <(printf '%s\n' "${lock_removed[@]}" | sort) - [ ${#lock_changed[@]} -gt 0 ] && readarray -t lock_changed < <(printf '%s\n' "${lock_changed[@]}" | sort) - [ ${#lock_added[@]} -gt 0 ] && readarray -t lock_added < <(printf '%s\n' "${lock_added[@]}" | sort) - - # Build a reverse attribution map: for each transitive crate, - # record the *direct* workspace-level deps that pull it in. - # - # Done in a single forward pass: run `cargo tree --prefix=depth` - # from the workspace root and, while walking each dependency - # chain, remember the last workspace-declared dep seen. Every - # subsequent crate under that chain is attributed to it. - # - # This replaces the prior per-crate `cargo tree -i` invocation - # (one subprocess per transitive change) with a single subprocess. - declare -A attrib - current_direct="" - while IFS= read -r line; do - [[ "$line" =~ ^([0-9]+)([^[:space:]]+) ]] || continue - depth=${BASH_REMATCH[1]} - name=${BASH_REMATCH[2]} - if [ "$depth" = "0" ]; then - # Reset anchor for this workspace root's subtree. - current_direct="" - continue - fi - # If this crate is itself a direct workspace-declared dep, - # it becomes the anchor for everything below it. - if [ -n "${direct_names[$name]+x}" ]; then - current_direct=$name - continue - fi - # Otherwise, if we're under an anchor, attribute this crate. - [ -z "$current_direct" ] && continue - # Dedup: append only if not already recorded for this crate. - existing=${attrib[$name]:-} - case ",$existing," in - *",$current_direct,"*) ;; - *) - if [ -z "$existing" ]; then - attrib[$name]=$current_direct - else - attrib[$name]="$existing,$current_direct" - fi - ;; - esac - done < <(cargo tree --prefix=depth --edges=normal --workspace 2>/dev/null || true) - - # Return the attribution string for one crate (empty if none), - # truncated to 3 sources with "...(+N)". - attribute_transitive() { - local raw=${attrib[$1]:-} - [ -z "$raw" ] && { - echo "" - return - } - IFS=',' read -ra parts <<<"$raw" - if [ ${#parts[@]} -le 3 ]; then - echo "$raw" - else - printf '%s,%s,%s,...(+%d)\n' \ - "${parts[0]}" "${parts[1]}" "${parts[2]}" "$((${#parts[@]} - 3))" - fi - } - - if [ $((${#lock_removed[@]} + ${#lock_changed[@]} + ${#lock_added[@]})) -gt 0 ]; then - echo "${BOLD}Transitive (Cargo.lock) changes${RESET}${DIM} (direct deps already reported above)${RESET}" - echo "" - if [ ${#lock_changed[@]} -gt 0 ]; then - echo " ${YELLOW}Changed (${#lock_changed[@]}):${RESET}" - max_len=0 - for entry in "${lock_changed[@]}"; do - IFS='|' read -r name _ _ <<<"$entry" - [ ${#name} -gt "$max_len" ] && max_len=${#name} - done - for entry in "${lock_changed[@]}"; do - IFS='|' read -r name old new <<<"$entry" - via=$(attribute_transitive "$name") - if [ -n "$via" ]; then - printf " ${YELLOW}%-${max_len}s %s -> %s${RESET} ${DIM}via %s${RESET}\n" \ - "$name" "$old" "$new" "$via" - else - printf " ${YELLOW}%-${max_len}s %s -> %s${RESET}\n" "$name" "$old" "$new" - fi - done - echo "" - fi - if [ ${#lock_added[@]} -gt 0 ]; then - echo " ${GREEN}Added (${#lock_added[@]}):${RESET}" - for entry in "${lock_added[@]}"; do - IFS='|' read -r name ver <<<"$entry" - via=$(attribute_transitive "$name") - if [ -n "$via" ]; then - printf " ${GREEN}+ %s %s${RESET} ${DIM}via %s${RESET}\n" "$name" "$ver" "$via" - else - printf " ${GREEN}+ %s %s${RESET}\n" "$name" "$ver" - fi - done - echo "" - fi - if [ ${#lock_removed[@]} -gt 0 ]; then - echo " ${RED}Removed (${#lock_removed[@]}):${RESET}" - for entry in "${lock_removed[@]}"; do - IFS='|' read -r name ver <<<"$entry" - printf " ${RED}- %s %s${RESET}\n" "$name" "$ver" - done - echo "" - fi - fi - fi -fi - -# ── per-crate API diff ───────────────────────────────────────────────── -# -# Crate discovery uses `cargo metadata --no-deps` so nested workspace -# crates are found regardless of directory layout. For each crate we build -# rustdoc JSON in the isolated baseline and head worktrees, diff those JSON -# files with cargo-public-api, and parse the text output into three buckets. -# (e.g. rustdoc crashed, baseline missing) are captured per-crate as -# `status=error` and surfaced in the summary and verdict. - -readarray -t all_crates < <(workspace_crate_names "$api_head_worktree") -crate_count=${#all_crates[@]} -if [ "$crate_count" -eq 0 ]; then - echo "${YELLOW}warning:${RESET} no workspace crates discovered via cargo metadata" >&2 -fi - -removed_total=0 -changed_total=0 -added_total=0 - -declare -a crate_names=() -declare -a crate_removed=() -declare -a crate_changed=() -declare -a crate_added=() -declare -a crate_removed_lines=() -declare -a crate_changed_lines=() -declare -a crate_added_lines=() -declare -a crate_status=() -declare -a crate_error_stage=() -declare -a crate_error_ref=() -declare -a crate_error_ref_sha=() -declare -a crate_error_command=() -declare -a crate_error_stderr=() -declare -a crate_error_hint=() -declare -a crate_pubdep_lines=() - -changed_crate_count=0 -error_crate_count=0 - -# ── public-dependency semver join ────────────────────────────────────── -# cargo-public-api compares signature *text*, so a foreign type re-exposed in a -# crate's public API (e.g. `-> Result<(), rocksdb::Error>`) looks unchanged even -# when the foreign crate takes a semver-incompatible bump. We join two signals -# zc already has: a crate's external direct deps that major-bumped, and the -# foreign crate roots reachable in that crate's public API. Direct deps only for -# now; internal workspace bumps are already covered by the per-crate API diff. -declare -A pubdep_req_base=() pubdep_req_head=() pubdep_crate_ext=() pubdep_pkg=() - -# Populate the lookup tables from `dump_per_crate_deps` TSV at both refs. `dep` -# is the Cargo rename (the name used in code), `pkg` the real package name — -# rustdoc renders foreign paths by the package name, so the join needs both. -build_pubdep_tables() { # $1=base TSV $2=head TSV - local cr dp rq sc pkg - while IFS=$'\t' read -r cr dp rq sc pkg; do - { [ -z "$cr" ] || [ "$sc" = msrv ]; } && continue - pubdep_req_base["$cr|$dp"]=$rq - done <<<"$1" - while IFS=$'\t' read -r cr dp rq sc pkg; do - { [ -z "$cr" ] || [ "$sc" = msrv ]; } && continue - pubdep_req_head["$cr|$dp"]=$rq - pubdep_pkg["$cr|$dp"]=$pkg - [ "$sc" = ext ] && pubdep_crate_ext["$cr"]+="$dp"$'\n' - done <<<"$2" -} - -# Emit findings for crate $1 (head rustdoc JSON $2): one "depoldnew" -# line per external direct dep that both major-bumped and is reachable in the -# crate's public API. Result in PUBDEP_RESULT (empty when none). -compute_pubdep_breaks() { # $1=crate - local crate=$1 dep bq hq root api_text buf="" - PUBDEP_RESULT="" - local ext=${pubdep_crate_ext[$crate]:-} - [ -n "$ext" ] || return 0 - local -a majors=() - while IFS= read -r dep; do - [ -z "$dep" ] && continue - bq=${pubdep_req_base["$crate|$dep"]:-} - hq=${pubdep_req_head["$crate|$dep"]:-} - { [ -z "$bq" ] || [ -z "$hq" ] || [ "$bq" = "$hq" ]; } && continue - # `unknown` (a compound/wildcard requirement whose floor held) is kept as - # a candidate: it may or may not be compatible, and the public-API - # reachability check below is what decides whether it is worth reporting. - case $(classify_bump "$bq" "$hq") in - major | unknown) majors+=("$dep") ;; - esac - done <<<"$ext" - [ ${#majors[@]} -gt 0 ] || return 0 - # Full public API surface at head (the diff is empty in exactly the case we - # care about, so we need the whole surface, not the diff). List mode builds - # from the head worktree, reusing the already-populated target dir so this is - # a rustdoc-only pass, and only runs for crates with a major external bump. - api_text=$(CARGO_TARGET_DIR="$api_head_target" cargo +"$nightly_toolchain" public-api \ - "${feature_args[@]}" --manifest-path "$api_head_worktree/Cargo.toml" \ - -p "$crate" -ss 2>/dev/null || true) - [ -n "$api_text" ] || return 0 - for dep in "${majors[@]}"; do - local pkg=${pubdep_pkg["$crate|$dep"]:-$dep} - root=${dep//-/_} - local root_pkg=${pkg//-/_} - if printf '%s\n' "$api_text" | grep -qE "(^|[^A-Za-z0-9_])(${root}|${root_pkg})::"; then - buf+="${dep}"$'\t'"${pubdep_req_base["$crate|$dep"]}"$'\t'"${pubdep_req_head["$crate|$dep"]}"$'\n' - fi - done - PUBDEP_RESULT=$buf -} - -public_api_error_tail() { - local file=$1 text - text=$(tail -n 80 "$file" 2>/dev/null | tr -d '\r' || true) - if [ -z "$text" ]; then - text="public API analysis failed without writing stderr" - fi - printf '%s' "$text" -} - -public_api_hint() { - local text=${1,,} - case "$text" in - *protoc* | *protobuf-compiler*) - echo "Install protoc, for example brew install protobuf or apt-get install protobuf-compiler, then rerun zc." - ;; - *"custom build command"*) - echo "A build script failed. Run the command shown above to inspect the crate's build requirements." - ;; - *"cargo.lock"* | *"lock file"* | *"lockfile"*) - echo "The lockfile or dependency resolution failed at this ref. Check Cargo.lock and rerun zc." - ;; - *"requires rustc"* | *"rustc "*"is not supported"*) - echo "The selected Rust toolchain cannot build this ref. Install the required toolchain and rerun zc." - ;; - *"no library targets"* | *"does not have a library target"*) - echo "cargo-public-api can only analyze library targets. Exclude this crate or add a library target." - ;; - *"could not compile"*) - echo "The crate did not compile under the selected feature set. Fix the build or choose a supported feature policy." - ;; - *) - echo "Run the command shown above and fix the failing crate build before trusting the API diff." - ;; - esac -} - -public_api_command() { - local crate=$1 stage=$2 - case "$stage" in - baseline_build | head_build) printf 'cargo public-api %s -p %s -ss' "${feature_args[*]}" "$crate" ;; - *) - local first_ref second_ref first_sha second_sha - first_ref=${PUBLIC_API_ERROR_REF%%..*} - second_ref=${PUBLIC_API_ERROR_REF#*..} - first_sha=${PUBLIC_API_ERROR_REF_SHA%%..*} - second_sha=${PUBLIC_API_ERROR_REF_SHA#*..} - printf 'run at %s (%s): cargo public-api %s -p %s -ss; run at %s (%s): cargo public-api %s -p %s -ss' \ - "$first_ref" "$first_sha" "${feature_args[*]}" "$crate" \ - "$second_ref" "$second_sha" "${feature_args[*]}" "$crate" - ;; - esac -} - -print_public_api_errors() { - { - echo "${RED}${BOLD}ERROR${RESET}${RED}: cargo-public-api failed for ${error_crate_count} crate(s).${RESET}" - for i in "${!crate_names[@]}"; do - [ "${crate_status[$i]}" = error ] || continue - echo "" - echo " ${BOLD}${crate_names[$i]}${RESET}" - echo " stage: ${crate_error_stage[$i]}" - echo " ref: ${crate_error_ref[$i]} (${crate_error_ref_sha[$i]})" - echo " command: ${crate_error_command[$i]}" - echo " hint: ${crate_error_hint[$i]}" - echo " stderr:" - while IFS= read -r line; do - printf ' %s\n' "$line" - done <<<"${crate_error_stderr[$i]}" - done - } >&2 -} - -public_api_json_path() { # $1=crate $2=target-dir - printf '%s/doc/%s.json' "$2" "${1//-/_}" -} - -api_json_cache_file() { # $1=ref-sha $2=crate - printf '%s/%s.%s.%s.api.json' "$CACHE_DIR" "$1" "$API_FP" "$2" -} - -api_json_cacheable() { # $1=ref-sha - [ "$head_is_worktree_snapshot" != true ] || [ "$1" != "$head_sha" ] -} - -cached_api_json() { # $1=ref-sha $2=crate -> stdout path - local cache_file - cache_file=$(api_json_cache_file "$1" "$2") - if [ -s "$cache_file" ] && jq -e 'has("format_version") and has("root") and has("index")' "$cache_file" >/dev/null 2>&1; then - touch "$cache_file" 2>/dev/null || true - printf '%s' "$cache_file" - return 0 - fi - return 1 -} - -store_api_json_cache() { # $1=ref-sha $2=crate $3=json - local sha=$1 crate=$2 json=$3 cache_file cache_tmp - cache_file=$(api_json_cache_file "$sha" "$crate") - cache_tmp=$(mktemp -p "$CACHE_DIR" "${sha}.${API_FP}.${crate}.api.XXXXXX") || return 0 - if cp "$json" "$cache_tmp" 2>/dev/null; then - mv -f "$cache_tmp" "$cache_file" 2>/dev/null || rm -f "$cache_tmp" - else - rm -f "$cache_tmp" - fi -} - -run_public_api_rustdoc() { # $1=crate $2=worktree $3=target-dir $4=err-file $5=ref-sha - local crate=$1 wt=$2 target=$3 err_file=$4 sha=$5 json - PUBLIC_API_JSON_RESULT="" - if api_json_cacheable "$sha" && json=$(cached_api_json "$sha" "$crate"); then - PUBLIC_API_JSON_RESULT=$json - return 0 - fi - if CARGO_TARGET_DIR="$target" cargo +"$nightly_toolchain" rustdoc -q \ - --manifest-path "$wt/Cargo.toml" -p "$crate" --lib "${feature_args[@]}" \ - -- -Z unstable-options --output-format json >/dev/null 2>"$err_file"; then - json=$(public_api_json_path "$crate" "$target") - if [ -f "$json" ]; then - PUBLIC_API_JSON_RESULT=$json - if api_json_cacheable "$sha"; then - store_api_json_cache "$sha" "$crate" "$json" - fi - return 0 - fi - printf 'rustdoc did not produce %s\n' "$json" >"$err_file" - fi - return 1 -} - -record_public_api_failure() { # $1=crate $2=stage $3=ref-label $4=ref-sha $5=err-file - local crate=$1 err_file=$5 - PUBLIC_API_ERROR_STAGE=$2 - PUBLIC_API_ERROR_REF=$3 - PUBLIC_API_ERROR_REF_SHA=$4 - PUBLIC_API_ERROR_STDERR=$(public_api_error_tail "$err_file") - PUBLIC_API_ERROR_COMMAND=$(public_api_command "$crate" "$2") - PUBLIC_API_ERROR_HINT=$(public_api_hint "$PUBLIC_API_ERROR_STDERR") -} - -# Auto-size the crate-name column so longer names (e.g. if a new -# `-test-helpers` crate lands) don't misalign the table. Floor at 16 so -# a workspace with only short names still has visible padding. -crate_col_width=16 -for crate_name in "${all_crates[@]}"; do - [ ${#crate_name} -gt "$crate_col_width" ] && crate_col_width=${#crate_name} -done - -# Buffer per-crate header rows. We only print the "Public API changes" -# section header if at least one crate has a non-"no changes" row (a real -# diff, or a cargo-public-api error). Rows are appended here as the loop -# runs; the section is emitted in one go after the loop completes. -api_rows="" - -# Per-crate direct deps at both refs feed the public-dependency semver join. -# Cached, so --changelog's later read is a hit; failure degrades to an empty -# join rather than aborting a non-changelog run. -build_pubdep_tables \ - "$(dump_per_crate_deps "$baseline_sha" 2>/dev/null || true)" \ - "$(dump_per_crate_deps "$head_sha" 2>/dev/null || true)" - -progress_start -for i in "${!all_crates[@]}"; do - crate_name="${all_crates[$i]}" - progress "public-api: [$((i + 1))/$crate_count] $crate_name" - - # `-ss` (not `-sss`) omits blanket and auto-trait impls but *keeps* - # auto-derived ones, so a changelog-worthy `impl Hash for T` is not hidden. - # In a diff this only surfaces derives that actually changed, so the extra - # noise is bounded to added/removed types. - base_err_file="$RUN_TMP/${crate_name}.baseline.err" - head_err_file="$RUN_TMP/${crate_name}.head.err" - err_file="$RUN_TMP/${crate_name}.diff.err" - public_api_failed=false - - if ! run_public_api_rustdoc "$crate_name" "$api_baseline_worktree" "$api_baseline_target" "$base_err_file" "$baseline_sha"; then - public_api_failed=true - record_public_api_failure "$crate_name" baseline_build "$baseline_label" "$baseline_sha" "$base_err_file" - else - base_json=$PUBLIC_API_JSON_RESULT - fi - if ! $public_api_failed; then - if ! run_public_api_rustdoc "$crate_name" "$api_head_worktree" "$api_head_target" "$head_err_file" "$head_sha"; then - public_api_failed=true - record_public_api_failure "$crate_name" head_build "$head_label" "$head_sha" "$head_err_file" - else - head_json=$PUBLIC_API_JSON_RESULT - fi - fi - if ! $public_api_failed; then - if ! output=$(cd "$RUN_TMP" && cargo public-api "${feature_args[@]}" -p "$crate_name" -ss diff "$base_json" "$head_json" 2>"$err_file"); then - public_api_failed=true - record_public_api_failure "$crate_name" diff "$baseline_label..$head_label" "$baseline_sha..$head_sha" "$err_file" - fi - fi - - if $public_api_failed; then - error_crate_count=$((error_crate_count + 1)) - crate_names+=("$crate_name") - crate_removed+=(0) - crate_changed+=(0) - crate_added+=(0) - crate_removed_lines+=("") - crate_changed_lines+=("") - crate_added_lines+=("") - crate_status+=("error") - crate_error_stage+=("$PUBLIC_API_ERROR_STAGE") - crate_error_ref+=("$PUBLIC_API_ERROR_REF") - crate_error_ref_sha+=("$PUBLIC_API_ERROR_REF_SHA") - crate_error_command+=("$PUBLIC_API_ERROR_COMMAND") - crate_error_stderr+=("$PUBLIC_API_ERROR_STDERR") - crate_error_hint+=("$PUBLIC_API_ERROR_HINT") - crate_pubdep_lines+=("") - first_line=$(printf '%s\n' "$PUBLIC_API_ERROR_STDERR" | sed -n '1p') - api_rows+=$(printf " %-${crate_col_width}s ${RED}error${RESET}: %s${DIM} (%s)${RESET}" \ - "$crate_name" "$PUBLIC_API_ERROR_STAGE" "${first_line:-cargo public-api failed}")$'\n' - continue - fi - - removed_buf="" changed_old_buf="" changed_new_buf="" added_buf="" - section="" - while IFS= read -r line; do - case "$line" in - "Removed items from the public API") section=removed ;; - "Changed items in the public API") section=changed ;; - "Added items to the public API") section=added ;; - =*) ;; - "(none)") ;; - "") ;; - -*) - case "$section" in - removed) removed_buf+="${line#-}"$'\n' ;; - changed) changed_old_buf+="${line#-}"$'\n' ;; - esac - ;; - +*) - case "$section" in - changed) changed_new_buf+="${line#+}"$'\n' ;; - added) added_buf+="${line#+}"$'\n' ;; - esac - ;; - esac - done <<<"$output" - - # Interleave old/new lines for the changed section. - changed_buf="" - if [ -n "$changed_old_buf" ]; then - mapfile -t old_arr <<<"$changed_old_buf" - mapfile -t new_arr <<<"$changed_new_buf" - for j in "${!old_arr[@]}"; do - [ -z "${old_arr[$j]}" ] && continue - changed_buf+=" - ${old_arr[$j]}"$'\n' - changed_buf+=" + ${new_arr[$j]:-}"$'\n' - done - fi - - r=$(printf '%s' "$removed_buf" | grep -c . || true) - c=$(printf '%s' "$changed_old_buf" | grep -c . || true) - a=$(printf '%s' "$added_buf" | grep -c . || true) - - crate_names+=("$crate_name") - crate_removed+=("$r") - crate_changed+=("$c") - crate_added+=("$a") - crate_removed_lines+=("$removed_buf") - crate_changed_lines+=("$changed_buf") - crate_added_lines+=("$added_buf") - crate_status+=("ok") - crate_error_stage+=("") - crate_error_ref+=("") - crate_error_ref_sha+=("") - crate_error_command+=("") - crate_error_stderr+=("") - crate_error_hint+=("") - - compute_pubdep_breaks "$crate_name" - crate_pubdep_lines+=("$PUBDEP_RESULT") - - removed_total=$((removed_total + r)) - changed_total=$((changed_total + c)) - added_total=$((added_total + a)) - - if [ $((r + c + a)) -gt 0 ]; then - changed_crate_count=$((changed_crate_count + 1)) - # Additive-only crates get a dim "(additive)" tag so reviewers can - # focus on the breaking ones. - tag="" - if [ "$r" -eq 0 ] && [ "$c" -eq 0 ]; then - tag=" ${DIM}(additive)${RESET}" - fi - api_rows+=$(printf " %-${crate_col_width}s ${RED}-%d${RESET} ${YELLOW}~%d${RESET} ${GREEN}+%d${RESET}%s" \ - "$crate_name" "$r" "$c" "$a" "$tag")$'\n' - fi - # Unchanged crates produce no row — the Summary's "(N/M crates with - # changes)" count is the sole signal for them. -done - -progress_clear - -if [ -n "$api_rows" ]; then - echo "${BOLD}Public API changes${RESET}" - echo "" - printf '%s' "$api_rows" -fi - -# ── const/static value + doc-comment diff (opt-in: --with-values) ────── -# cargo-public-api compares *signatures*, so a `pub const` whose value changes -# (e.g. 99 -> 1000) or a public item whose doc-comment text changes shows as -# "no change". With --with-values we additionally build rustdoc JSON for every -# crate at both refs and diff the evaluated const/static values and the doc -# text. The rustdoc JSON is shared with the public API diff when possible. -value_changed_total=0 -doc_changed_total=0 -declare -a value_changes=() -declare -a doc_changes=() - -if $with_values; then - # Split the lookup off the `:-` default so a no-match `grep` (exit 1 under - # pipefail) can't abort the script via `set -e` before the skip-guard below. - nightly_toolchain="${ZC_TOOLCHAIN:-}" - if [ -z "$nightly_toolchain" ]; then - nightly_toolchain=$(rustup toolchain list 2>/dev/null | grep -oE 'nightly[^ ]*' | head -n1 || true) - fi - if [ -z "$nightly_toolchain" ]; then - echo "${YELLOW}warning:${RESET} --with-values needs a nightly toolchain for rustdoc JSON; skipping value/doc diff" >&2 - else - values_target="$RUN_TMP/values-target" - mkdir -p "$values_target" 2>/dev/null || true - - # Emit one TSV row per public const/static value and doc-bearing item in - # a crate's rustdoc JSON, keyed by the item's stable semantic path - # (rustdoc item ids are not stable across refs, but `.paths` is): - # V - # D - # - # Reads the (unstable) rustdoc JSON shape directly. If a future nightly - # changes these field paths, the jq below silently yields zero rows - # (reported as "no change"), not an error — revisit on toolchain bumps. - extract_value_doc() { # $1=json $2=crate - jq -r --arg crate "$2" ' - .index as $idx | .paths as $paths | - $paths | to_entries[] | .key as $id | .value as $p | - ($idx[$id]) as $it | - select($it != null and $it.visibility == "public") | - ($p.path | join("::")) as $path | - ( - ( if $it.inner.constant != null then - ["V", $crate, $path, - ($it.inner.constant.type | (.primitive // .resolved_path.name // tostring)), - ($it.inner.constant.const.value // $it.inner.constant.const.expr // "?")] - elif $it.inner.static != null then - ["V", $crate, $path, - ($it.inner.static.type | (.primitive // .resolved_path.name // tostring)), - ($it.inner.static.expr // "?")] - else empty end ), - ( if ($it.docs != null and ($it.docs | length) > 0) then - ["D", $crate, $path, ($it.docs | @base64)] - else empty end ) - ) | @tsv - ' "$1" 2>/dev/null - } - - # Build rustdoc JSON for every workspace crate at $ref and emit the - # combined V/D rows. Cached on ref-SHA + script hash, like the dep dump. - dump_value_doc_index() { # $1=ref $2=ref-label -> stdout TSV - local ref=$1 ref_label=$2 sha cache_file wt tmp_out crate json err_file failed=0 idx=0 - sha=$(git rev-parse --verify "$ref" 2>/dev/null) || return 1 - cache_file="$CACHE_DIR/${sha}.${SCRIPT_HASH}.values.tsv" - if [ -s "$cache_file" ]; then cat "$cache_file"; return 0; fi - wt=$(mktemp -d -p "$RUN_TMP" values.XXXXXX) - if ! git worktree add --detach --quiet "$wt" "$ref" 2>/dev/null; then - echo "${YELLOW}warning:${RESET} could not create worktree for '$ref' (value diff)" >&2 - return 1 - fi - tmp_out=$(mktemp -p "$RUN_TMP") - while IFS= read -r crate; do - [ -z "$crate" ] && continue - idx=$((idx + 1)) - progress "--with-values: rustdoc JSON [$ref_label $idx/$crate_count] $crate" - err_file="$RUN_TMP/${crate}.${ref_label}.values.err" - if run_public_api_rustdoc "$crate" "$wt" "$values_target" "$err_file" "$sha"; then - json=$PUBLIC_API_JSON_RESULT - { [ -f "$json" ] && extract_value_doc "$json" "$crate" >>"$tmp_out"; } || true - else - # A crate that fails to build rustdoc JSON must not poison - # the cache, or its value/doc changes are hidden until the - # script hash changes. Skip caching this (partial) run. - failed=1 - fi - done < <(workspace_crate_names "$wt") - git worktree remove --force "$wt" >/dev/null 2>&1 || true - if [ "$failed" -eq 0 ]; then - mv "$tmp_out" "$cache_file" 2>/dev/null || true - cat "$cache_file" 2>/dev/null || true - else - cat "$tmp_out" - fi - } - - values_base_tsv="$RUN_TMP/values.base.tsv" - values_head_tsv="$RUN_TMP/values.head.tsv" - progress_start - dump_value_doc_index "$baseline_sha" base >"$values_base_tsv" || : >"$values_base_tsv" - dump_value_doc_index "$head_sha" head >"$values_head_tsv" || : >"$values_head_tsv" - progress_clear - - # Value changes: items present in both refs whose evaluated value differs. - # (Added/removed items are already covered by the signature diff above.) - while IFS= read -r row; do - [ -z "$row" ] && continue - value_changes+=("$row") - done < <(awk -F'\t' ' - FNR==NR { if ($1=="V") { k=$2 SUBSEP $3; t[k]=$4; v[k]=$5 } next } - $1=="V" { k=$2 SUBSEP $3; if (k in v && v[k] != $5) printf "%s\t%s\t%s\t%s\t%s\n", $2, $3, t[k], v[k], $5 } - ' "$values_base_tsv" "$values_head_tsv") - - # Doc changes: items present in both refs whose doc text differs. - while IFS= read -r row; do - [ -z "$row" ] && continue - doc_changes+=("$row") - done < <(awk -F'\t' ' - FNR==NR { if ($1=="D") { k=$2 SUBSEP $3; d[k]=$4 } next } - $1=="D" { k=$2 SUBSEP $3; if (k in d && d[k] != $4) printf "%s\t%s\n", $2, $3 } - ' "$values_base_tsv" "$values_head_tsv") - - value_changed_total=${#value_changes[@]} - doc_changed_total=${#doc_changes[@]} - fi -fi - -# Total public-dependency semver breaks across all crates. -# A reachable dependency change is only a *break* when its requirement change is -# provably incompatible. An "unknown" one is a review item, so it is reported and -# counted separately and never flips the verdict — otherwise widening a version -# ceiling would fail a consumer's CI. -pubdep_break_total=0 -pubdep_review_total=0 -for pd in "${crate_pubdep_lines[@]:-}"; do - [ -n "$pd" ] || continue - while IFS=$'\t' read -r pd_dep pd_old pd_new; do - [ -z "$pd_dep" ] && continue - case $(classify_bump "$pd_old" "$pd_new") in - major) pubdep_break_total=$((pubdep_break_total + 1)) ;; - *) pubdep_review_total=$((pubdep_review_total + 1)) ;; - esac - done <<<"$pd" -done - -# ── summary ─────────────────────────────────────────────────────────── - -echo "" -echo "${BOLD}Summary${RESET} ${DIM}(${changed_crate_count}/${crate_count} crates with changes)${RESET}" - -# Only show the table if there are changes; otherwise a one-liner suffices. -# In JSON mode we always fall through so the JSON block below emits. -if [ $((removed_total + changed_total + added_total + value_changed_total + doc_changed_total)) -eq 0 ] && [ "$error_crate_count" -eq 0 ] && [ "$pubdep_break_total" -eq 0 ] && - [ "$pubdep_review_total" -eq 0 ]; then - echo "" - echo " ${GREEN}No public API changes.${RESET}" - # In changelog mode, fall through even with no API changes: a crate may still - # have dependency-only changes to document (Zebra's "No API changes; internal - # dependencies updated" case). - if ! $json_mode && ! $changelog_mode; then - # Still emit the final verdict so dep-only changes surface. - if [ "$dep_breaking_count" -gt 0 ]; then - echo "" - echo "${RED}${BOLD}BREAKING${RESET}${RED}: runtime-deps: $dep_breaking_count breaking.${RESET}" - exit "$EXIT_BREAKING" - fi - exit "$EXIT_OK" - fi -fi - -# ── detailed diffs ──────────────────────────────────────────────────── - -# Group key for a public-API signature: the type or module that owns the item, -# so a type's declaration and its methods/fields/variants/impls cluster under -# one header. Generic params are stripped; for `impl … for T` the key is `T`; -# for a member (fn/const/field/variant/…) the trailing `::leaf` is dropped; a -# type/mod declaration keeps its full path (so it heads its own group). -GROUP_AWK=' -function group_key(s, p, isdecl) { - isdecl = 0 - gsub(/^#\[[^]]*\] +/, "", s) - while (s ~ /<[^<>]*>/) gsub(/<[^<>]*>/, "", s) - if (s ~ /^impl /) { - sub(/^impl +/, "", s) - if (s ~ / for /) sub(/^.* for /, "", s) - p = s; isdecl = 1 - } else { - sub(/^pub +/, "", s) - if (s ~ /^(mod|struct|enum|trait|union) /) { - sub(/^(mod|struct|enum|trait|union) +/, "", s); isdecl = 1 - } else { - sub(/^(const +|async +|unsafe +)*/, "", s); sub(/^(fn|static|type|use) +/, "", s) - } - p = s - } - sub(/[ (=].*/, "", p) - sub(/:$/, "", p) - if (!isdecl && p ~ /::/) sub(/::[^:]+$/, "", p) - return p -} -# Module owning a type key: strip trailing UpperCamelCase (type) segments, so -# `a::b::Foo` and `a::b::block_request::Bar` collapse to their module paths. -function mod_of(k) { - while (k ~ /::[A-Z][^:]*$/) sub(/::[A-Z][^:]*$/, "", k) - return k -} -# Declaration kind for a header tag: the keyword if the line declares its group -# (a mod/struct/enum/trait/union), else empty. -function decl_kind(s, t) { - gsub(/^#\[[^]]*\] +/, "", s) - if (s ~ /^pub (mod|struct|enum|trait|union) /) { - sub(/^pub +/, "", s); t = s; sub(/ .*/, "", t); return t - } - return "" -} -# Weak kind inferred from a member when the declaration is not in the diff: a -# field implies a struct, a (tuple/unit) variant implies an enum. -function member_kind(s) { - gsub(/^#\[[^]]*\] +/, "", s) - if (s !~ /^pub /) return "" - if (s ~ /^pub (mod|struct|enum|trait|union|fn|const|static|type|use|async) /) return "" - if (s ~ /: /) return "struct" - return "enum" -} -# Last-resort kind from the key by Rust naming convention: an UpperCamelCase -# leaf is a type, anything else (snake_case path / crate root) is a module. -function fallback_kind(k, seg) { - seg = k; sub(/.*::/, "", seg) - if (seg ~ /^[A-Z]/) return "type" - return "mod" -} -# Resolved kind for a type key: declared in the diff > read from the source at -# the head ref (by short name) > inferred from a member > naming-convention -# fallback. -function kind_of(t, name) { - if (t in tkind) return tkind[t] - name = t; sub(/.*::/, "", name) - if (name in srckind) return srckind[name] - if (t in tkindw) return tkindw[t] - return fallback_kind(t) -} -# A key is "external" when its path is in a different crate than the one being -# analyzed — a trait impl this crate adds to a foreign type. Such items are -# bucketed separately so they are not mistaken for modules of this crate -# (cargo-public-api names them by the Self type path, not the impl location). -function is_ext(k) { - return (crate_prefix != "" && k !~ ("^" crate_prefix "(::|$)")) -} -# Emit one type group (flat --by-type mode): header + members elided of the -# type module prefix. -function emit_type(t, k, mp, disp) { - print "H\t" t "\t" kind_of(t) - mp = t; sub(/::[^:]*$/, "", mp) - for (k = 1; k <= mcnt[t]; k++) { - disp = buf[members[t, k]] - if (mp != "") gsub(mp "::", "", disp) - print "I\t" disp - } -} -# Emit one module group (nested mod mode): module header, then each owning type -# as a sub-header with members, or module-level items directly. -function emit_module(m, ti, t, k, disp, nm) { - print "M\t" m - for (ti = 1; ti <= tcnt[m]; ti++) { - t = tlist[m, ti] - if (t == m) { - for (k = 1; k <= mcnt[t]; k++) { - disp = buf[members[t, k]] - gsub(m "::", "", disp) - if (disp ~ /^pub mod /) { nm = disp; sub(/.*::/, "", nm); disp = "pub mod " nm } - print "I\t" disp - } - } else { - print "T\t" substr(t, length(m) + 3) "\t" kind_of(t) - for (k = 1; k <= mcnt[t]; k++) { - disp = buf[members[t, k]] - # Strip the owning type prefix, tolerating a generic arg list on - # it (e.g. `ValueBalance::Bytes`). - gsub(t "(<[^<>]*>)?::", "", disp) - gsub(m "::", "", disp) - print "J\t" disp - } - } - } -} -# Load the type -> kind map produced from `git grep` of the head source. -BEGIN { - if (kindsfile != "") { - while ((getline ln < kindsfile) > 0) { - ix = index(ln, "\t") - if (ix > 0) srckind[substr(ln, 1, ix - 1)] = substr(ln, ix + 1) - } - close(kindsfile) - } -} -/^[[:space:]]*$/ { next } -{ buf[++n] = $0 } -END { - # Pass 1: per line, the owning type key tk[i] and its module mkey[i], plus a - # kind recorded per type key (declaration > member-inferred). In changed mode - # the key comes from the `- old` line and carries to the following `+ new`. - for (i = 1; i <= n; i++) { - s = buf[i] - if (mode == "changed") { - if (s ~ /^ - /) { sig = s; sub(/^ - /, "", sig); ctk = group_key(sig); dk = decl_kind(sig); mk = member_kind(sig) } - else { dk = ""; mk = "" } - tk[i] = ctk - } else { - tk[i] = group_key(s) - dk = decl_kind(s) - mk = member_kind(s) - } - mkey[i] = mod_of(tk[i]) - if (dk != "") tkind[tk[i]] = dk - if (mk != "" && !(tk[i] in tkindw)) tkindw[tk[i]] = mk - } - - # Pass 2: CLUSTER items by key so a type whose items are interleaved with a - # nested type (e.g. an enum and a struct-variant of it) still gets a single - # header rather than a duplicate. Clusters and their members keep - # first-appearance order. - # Own-crate groups are emitted first; external-type groups (trait impls on - # foreign types) are collected after an `X` divider so they read as a - # distinct section rather than as modules of this crate. - if (level == "type") { - # Flat: one header per type. - for (i = 1; i <= n; i++) { - t = tk[i] - if (!(t in tseen)) { tseen[t] = 1; torder[++norder] = t } - members[t, ++mcnt[t]] = i - } - for (o = 1; o <= norder; o++) if (!is_ext(torder[o])) emit_type(torder[o]) - ex = 0 - for (o = 1; o <= norder; o++) { - t = torder[o] - if (is_ext(t)) { if (!ex) { print "X"; ex = 1 } emit_type(t) } - } - } else { - # Nested: cluster by module, then by type within each module. - for (i = 1; i <= n; i++) { - m = mkey[i]; t = tk[i] - if (!(m in mseen)) { mseen[m] = 1; modorder[++nmod] = m } - if (!(t in tseen)) { tseen[t] = 1; tlist[m, ++tcnt[m]] = t } - members[t, ++mcnt[t]] = i - } - for (mo = 1; mo <= nmod; mo++) if (!is_ext(modorder[mo])) emit_module(modorder[mo]) - ex = 0 - for (mo = 1; mo <= nmod; mo++) { - m = modorder[mo] - if (is_ext(m)) { if (!ex) { print "X"; ex = 1 } emit_module(m) } - } - } -}' - -# jq filter over a crate's rustdoc JSON emitting one row per trait-impl -# associated item, used to attribute changelog items to their `impl Trait for -# Self`. Columns: . Only concrete -# trait impls are kept (inherent impls have no trait; blanket impls over `&T` -# etc. have no `for.resolved_path`). Reads the unstable rustdoc JSON shape -# directly; a future nightly that changes it just yields zero rows. -TRAIT_MAP_JQ=' -.index as $idx -| [ $idx[] | select((.inner | type == "object") and (.inner | has("impl"))) ][] -| .inner.impl as $im -| select($im.trait != null) -| select($im.for | type == "object" and has("resolved_path")) -| ($im.trait.path) as $tr -| ($im.for.resolved_path.path | split("::") | last) as $self_short -| ($im.items[]? | $idx[tostring] | select(. != null) | .name // empty) as $m -| [$self_short, $m, $tr] | @tsv -' - -# ── changelog generation (--changelog) ───────────────────────────────── -# Emits one diff section as librustzcash-style markdown bullets: items grouped -# under their owning type (`- `module::Type`:` + indented members), bare types -# inline, own-crate paths made crate-relative, foreign-type paths kept in full. -# Trait-impl associated items (methods, assoc types/consts) are instead grouped -# under an `impl for ` header. The trait is recovered from the -# crate rustdoc JSON (`traitfile`, built by dump_trait_map) because the unchanged -# `impl` line is absent from a *changed* assoc item like `ValueBalance::Bytes`; -# the Self generics are recovered from the signature line. Added/removed impls -# carry the `impl … for …` line in the diff and become the group header directly. -# `group_key` mirrors GROUP_AWK; `qual_path` keeps the member name (no leaf-drop). -CHANGELOG_AWK=' -function last_seg(p, a, k) { while (p ~ /<[^<>]*>/) gsub(/<[^<>]*>/, "", p); k = split(p, a, "::"); return a[k] } -# The first balanced `<...>` in p (handles nested generics like `Option`), -# or "" if there is none. match(/<[^<>]*>/) only finds non-nested spans. -function outer_gen(p, st, i, depth, c) { - st = index(p, "<"); if (st == 0) return "" - depth = 0 - for (i = st; i <= length(p); i++) { - c = substr(p, i, 1) - if (c == "<") depth++ - else if (c == ">") { depth--; if (depth == 0) return substr(p, st, i - st + 1) } - } - return substr(p, st) -} -# Reduce every `a::b::c::D` path to its last two segments (one module + the name), -# so a generic arg keeps just enough to disambiguate (`orchard::Nullifier`). -function keep2(p, s1, full, rest, fl) { - while (match(p, /[A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*::/)) { - s1 = RSTART; full = substr(p, RSTART, RLENGTH); rest = full - sub(/^[A-Za-z_][A-Za-z0-9_]*::/, "", rest) - fl = length(full) - length(rest) - p = substr(p, 1, s1 - 1) substr(p, s1 + fl) - } - return p -} -# Tidy a generic-argument string for display: drop lifetimes and shorten paths. -function short_gen(g) { - gsub(/'\''[A-Za-z_][A-Za-z0-9_]* */, "", g) # drop lifetimes (e.g. <"de"> on Deserialize) - gsub(/<>/, "", g); gsub(/< *, */, "<", g); gsub(/ *, *>/, ">", g) - return keep2(g) -} -function shorten_inner_gen(p, g, st) { - g = outer_gen(p); if (g == "") return p - st = index(p, "<") - return substr(p, 1, st - 1) short_gen(g) substr(p, st + length(g)) -} -function self_generics(s, ts, st, p) { - st = index(s, ts "<"); if (st == 0) return "" - p = substr(s, st + length(ts)) - return short_gen(outer_gen(p)) -} -# Trait short name keeping its own generics (paths shortened to one module segment, -# lifetimes dropped), e.g. core::convert::From -# -> From. -function trait_disp(tp, g, st) { - g = outer_gen(tp); if (g == "") return last_seg(tp) - st = index(tp, "<") - return last_seg(substr(tp, 1, st - 1)) short_gen(g) -} -function group_key(s, p, isdecl) { - isdecl = 0 - gsub(/^#\[[^]]*\] +/, "", s) - while (s ~ /<[^<>]*>/) gsub(/<[^<>]*>/, "", s) - if (s ~ /^impl /) { - sub(/^impl +/, "", s) - if (s ~ / for /) sub(/^.* for /, "", s) - p = s; isdecl = 1 - } else { - sub(/^pub +/, "", s) - if (s ~ /^(mod|struct|enum|trait|union) /) { - sub(/^(mod|struct|enum|trait|union) +/, "", s); isdecl = 1 - } else { - # Strip leading fn qualifiers (const/async/unsafe) then the item - # keyword, so `const fn Foo::new` keeps `Foo::new`, not just `fn`. - sub(/^(const +|async +|unsafe +)*/, "", s); sub(/^(fn|static|type|use) +/, "", s) - } - p = s - } - sub(/[ (=].*/, "", p) - sub(/:$/, "", p) - if (!isdecl && p ~ /::/) sub(/::[^:]+$/, "", p) - return p -} -function qual_path(s, p) { - gsub(/^#\[[^]]*\] +/, "", s) - # Drop an impl block leading generic params (a lifetime/type parameter list - # right after `impl`) so a parameterised impl is still recognised here as an - # impl; group_key strips all generics first so it already is, and without this - # qual_path would collapse such a line to a stray `impl` member. - sub(/^impl *<[^<>]*>/, "impl", s) - if (s ~ /^impl /) { if (crate_prefix != "") gsub(crate_prefix "::", "", s); return s } - sub(/^pub +/, "", s) - sub(/^(const +|async +|unsafe +)*/, "", s); sub(/^(fn|static|type|use|mod|struct|enum|trait|union) +/, "", s) - p = s - while (p ~ /<[^<>]*>/) gsub(/<[^<>]*>/, "", p) - sub(/[ (=].*/, "", p) - sub(/:$/, "", p) - return p -} -function disp(path) { - if (crate_prefix != "" && index(path, crate_prefix "::") == 1) return substr(path, length(crate_prefix) + 3) - return path -} -function member_disp(qp, gk) { - if (qp ~ /^impl /) return qp - if (index(qp, gk "::") == 1) return substr(qp, length(gk) + 3) - return disp(qp) -} -# A full signature, cleaned for display in the Changed section: drop the leading -# attribute and `pub`, and make own-crate paths relative. Keeps params/return/ -# value so the old -> new pair shows exactly what changed. -function relsig(s) { - gsub(/^#\[[^]]*\] +/, "", s) - sub(/^pub +/, "", s) - if (crate_prefix != "") gsub(crate_prefix "::", "", s) - return s -} -# proptest `Arbitrary` machinery, dragged in by --all-features (the proptest-impl -# feature) and never changelog-worthy. Matches the impl line, the `Parameters`/ -# `Strategy` associated types, the `arbitrary`/`arbitrary_with` methods, and any -# line that names a `proptest::` path (the larger generated Strategy/Parameters). -function is_proptest(s) { - if (s ~ /proptest::/) return 1 - if (s ~ /^impl[ <].*Arbitrary.* for /) return 1 - if (s ~ /^(pub +)?type [^=]*::(Parameters|Strategy) =/) return 1 - if (s ~ /::(arbitrary|arbitrary_with)\(/) return 1 - return 0 -} -# Impl-group key "impl for " for item s, or "" if s is not a -# trait-impl member/header. Sets G_ISHEADER=1 when s is the impl line itself. -function impl_group(s, gk0, qp, body, tp, sp, ts, mem, tr) { - G_ISHEADER = 0 - if (qp ~ /^impl /) { - body = qp; sub(/^impl +/, "", body) - if (body !~ / for /) return "" - tp = body; sub(/ for .*/, "", tp) - sp = body; sub(/^.* for /, "", sp) - G_ISHEADER = 1 - # Cluster on the bare trait name (so a separate `from`/`as_bytes` member - # joins this group) but remember the full `impl Trait for Self` line - # for display, so e.g. `impl From for X` keeps its source type. - G_HDRTEXT = "impl " trait_disp(tp) " for " shorten_inner_gen(sp) - return "impl " last_seg(tp) " for " shorten_inner_gen(sp) - } - ts = last_seg(gk0); mem = last_seg(qp) - tr = traitmap[ts SUBSEP mem] - if (tr == "") return "" - return "impl " tr " for " disp(gk0) self_generics(s, ts) -} -BEGIN { - # Max width for a one-line brace group `- ` + "`type::{a, b, ...}`"; past - # this, librustzcash breaks the group onto a `type:` header with one - # 2-space-indented ` - ` member per line, so we do the same. - WIDTH = 100 - # Trait methods implied by the impl itself — listing them under an - # `impl Trait for Self` header is noise (an `impl From` only has `from`). - bn = split("from into try_from try_into clone clone_from fmt hash eq ne cmp partial_cmp lt le gt ge default deref deref_mut as_ref as_mut borrow borrow_mut drop serialize deserialize into_iter next", bsplit, " ") - for (bi = 1; bi <= bn; bi++) boilerplate[bsplit[bi]] = 1 - if (traitfile != "") while ((getline line < traitfile) > 0) { - nf = split(line, f, "\t"); if (nf >= 3) traitmap[f[1] SUBSEP f[2]] = f[3] - } -} -/^[[:space:]]*$/ { next } -{ buf[++n] = $0 } -END { - # Changed: the buffer is alternating ` - ` / ` + ` lines. - # Render each pair as old -> new so the entry actually shows what changed, - # rather than just naming the item (which a "Changed" entry must do). - if (mode == "changed") { - for (i = 1; i <= n; i++) { - line = buf[i] - if (line ~ /^ - /) { skip = is_proptest(line); sub(/^ - /, "", line); pend = relsig(line); continue } - if (line ~ /^ \+ /) { - if (skip) { skip = 0; continue } # proptest Arbitrary machinery (test-only) - sub(/^ \+ /, "", line); nw = relsig(line) - if ((pend SUBSEP nw) in seenpair) continue - seenpair[pend SUBSEP nw] = 1 - print "- `" pend "`" - print " → `" nw "`" - } - } - } else { - # lazy_static wrappers (anything with a LazyStatic impl): their whole impl - # cluster (Deref/Pointable/Receiver/LazyStatic + assoc items) is macro - # machinery, keyed by the wrapper Self type. - for (i = 1; i <= n; i++) if (buf[i] ~ /^impl[ <].*LazyStatic.* for /) lazystatic[group_key(buf[i])] = 1 - # A whole module added/removed: its contents are implied, so list just the - # module and drop everything underneath it. - for (i = 1; i <= n; i++) { ml = buf[i]; gsub(/^#\[[^]]*\] +/, "", ml); if (ml ~ /^pub mod /) { sub(/^pub mod +/, "", ml); addmod[ml] = 1 } } - for (i = 1; i <= n; i++) { - s = buf[i] - if (s ~ /^impl[ <]/ && s !~ / for /) continue # inherent impl block header, not an item - if (is_proptest(s)) continue # proptest Arbitrary machinery (test-only) - if (s ~ /^impl[ <].*Structural(Partial)?Eq.* for /) continue # compiler-internal derive marker - gk0 = group_key(s); qp = qual_path(s) - if ((gk0 in lazystatic) && qp != gk0) continue # lazy_static machinery (keep the type decl) - inmod = 0 # subsumed by an added/removed module (but keep the module decl itself) - for (mp in addmod) if (qp != mp && (gk0 == mp || index(gk0, mp "::") == 1)) { inmod = 1; break } - if (inmod) continue - if ((gk0 SUBSEP qp) in seenitem) continue # cargo-public-api dupes some impls - seenitem[gk0 SUBSEP qp] = 1 - ig = impl_group(s, gk0, qp) - if (ig != "") { gk = ig; isimpl[gk] = 1 } else { gk = gk0 } - if (!(gk in seen)) { seen[gk] = 1; order[++norder] = gk } - if (ig != "" && G_ISHEADER) { hdrtext[gk] = G_HDRTEXT; continue } # impl line: header only - if (ig != "") members[gk, ++mcnt[gk]] = last_seg(qp) # impl member: leaf name - else members[gk, ++mcnt[gk]] = qp # type member: full path - } - for (o = 1; o <= norder; o++) { - gk = order[o] - nd = 0 - for (k = 1; k <= mcnt[gk]; k++) if (isimpl[gk] || members[gk, k] != gk) ndm[++nd] = members[gk, k] - if (isimpl[gk]) { - hdr = (gk in hdrtext) ? hdrtext[gk] : gk - nkeep = 0 # drop boilerplate trait methods (from, clone, fmt, ...) - for (k = 1; k <= nd; k++) if (!(ndm[k] in boilerplate)) keep[++nkeep] = ndm[k] - if (nkeep == 0) { # nothing meaningful left: member-less impl - sfx = hdr; sub(/^impl /, "", sfx); fp = index(sfx, " for ") - mtrait[++nmli] = substr(sfx, 1, fp - 1); mself[nmli] = substr(sfx, fp + 5) - delete ndm; delete keep; continue - } - print "- `" hdr "`:" - for (k = 1; k <= nkeep; k++) print " - `" keep[k] "`" - delete keep - } else if (gk == crate_prefix) { # crate-root free items: one bare bullet each - for (k = 1; k <= nd; k++) print "- `" member_disp(ndm[k], gk) "`" - } else { - hdr = disp(gk) - if (nd == 0) print "- `" hdr "`" - else if (nd == 1) print "- `" hdr "::" member_disp(ndm[1], gk) "`" - else { # brace-group the type members - line = "" - for (k = 1; k <= nd; k++) line = line (k > 1 ? ", " : "") member_disp(ndm[k], gk) - oneline = "- `" hdr "::{" line "}`" - if (length(oneline) <= WIDTH) print oneline - else { # too wide: header + indented members - print "- `" hdr "`:" - for (k = 1; k <= nd; k++) print " - `" member_disp(ndm[k], gk) "`" - } - } - } - delete ndm - } - # Member-less impls: collapse many-traits-on-one-type into - # `impl {A, B, ...} for T` (e.g. a new type and its derives); the leftover - # singletons group by trait across the types they cover. - for (k = 1; k <= nmli; k++) { - sf = mself[k] - if (!(sf in mss)) { mss[sf] = 1; msorder[++nms] = sf } - mst[sf, ++mstc[sf]] = mtrait[k] - } - for (mi = 1; mi <= nms; mi++) { - sf = msorder[mi] - if (mstc[sf] >= 2) { - bl = "" - for (k = 1; k <= mstc[sf]; k++) bl = bl (k > 1 ? ", " : "") mst[sf, k] - print "- `impl {" bl "} for " sf "`" - } else { - tr = mst[sf, 1] - if (!(tr in tseen)) { tseen[tr] = 1; torder[++nt] = tr } - tself[tr, ++tcnt[tr]] = sf - } - } - for (t = 1; t <= nt; t++) { - tn = torder[t] - if (tcnt[tn] == 1) print "- `impl " tn " for " tself[tn, 1] "`" - else { - print "- `impl " tn "` for:" - for (k = 1; k <= tcnt[tn]; k++) print " - `" tself[tn, k] "`" - } - } - } -}' - -# Emit per-section changelog markdown for buffer $1 (kind $2, crate prefix $3). -# Prints nothing when the buffer is empty. -render_changelog() { - local buffer=$1 kind=$2 crate_prefix=$3 traitfile=${4:-} mode=plain - [ "$kind" = changed ] && mode=changed - [ -z "$buffer" ] && return - printf '%s' "$buffer" | awk -v mode="$mode" -v crate_prefix="$crate_prefix" -v traitfile="$traitfile" "$CHANGELOG_AWK" -} - -# Render one diff section (kind = removed|added|changed). Per $group_mode: -# `mod` (default) clusters items under their owning module, `type` clusters -# under their owning type with a kind tag, and `flat` prints the ungrouped, -# fully-qualified list. Grouped modes elide the shared module prefix. -render_items() { - local buffer=$1 kind=$2 crate_prefix=$3 mode=plain - [ "$kind" = changed ] && mode=changed - - if [ "$group_mode" = flat ]; then - while IFS= read -r line; do - [ -z "$line" ] && continue - case "$kind" in - removed) echo " ${RED}- ${line}${RESET}" ;; - added) echo " ${GREEN}+ ${line}${RESET}" ;; - changed) - case "$line" in - " - "*) echo " ${RED}${line# }${RESET}" ;; - " + "*) echo " ${GREEN}${line# }${RESET}" ;; - *) echo " $line" ;; - esac ;; - esac - done <<<"$buffer" - return - fi - - # Record stream from the awk: H = top type header (--by-type), M = module - # header, T = type sub-header (nested), I = item under H/M, J = item under T. - # Headers sit at 4 spaces, sub-headers and shallow items at 6, deep items at 8. - local prev_rec="" rec a b ind - while IFS=$'\t' read -r rec a b; do - case "$rec" in - H) - [ -n "$prev_rec" ] && echo "" - if [ -n "$b" ]; then echo " ${DIM}${a:-(other)} (${b})${RESET}"; else echo " ${DIM}${a:-(other)}${RESET}"; fi - ;; - M) - [ -n "$prev_rec" ] && echo "" - echo " ${DIM}${a:-(other)}${RESET}" - ;; - X) - echo "" - echo " ${DIM}[trait impls on external types]${RESET}" - ;; - T) - { [ -n "$prev_rec" ] && [ "$prev_rec" != M ]; } && echo "" - echo " ${DIM}${a} (${b})${RESET}" - ;; - I | J) - if [ "$rec" = J ]; then - ind=" " - else - ind=" " - # A module-level item after a type sub-group reads as loose at - # this shallower indent; separate it with a blank line. - case "$prev_rec" in T | J) echo "" ;; esac - fi - case "$kind" in - removed) echo "${ind}${RED}- ${a}${RESET}" ;; - added) echo "${ind}${GREEN}+ ${a}${RESET}" ;; - changed) - case "$a" in - " - "*) echo "${ind}${RED}${a# }${RESET}" ;; - " + "*) echo "${ind}${GREEN}${a# }${RESET}" ;; - *) echo "${ind}${a}" ;; - esac ;; - esac - ;; - esac - prev_rec="$rec" - done < <(printf '%s' "$buffer" | awk -v mode="$mode" -v level="$group_mode" -v kindsfile="$TYPE_KINDS_FILE" -v crate_prefix="$crate_prefix" "$GROUP_AWK") -} - -# Type -> kind map read from the head source, so a group header can show the -# real kind of a pre-existing type (one whose declaration isn't in the diff). -# One `git grep` over the head tree; keyed by type short-name. Macro-generated -# types that don't appear literally in source (e.g. lazy_static structs) simply -# aren't found here and fall back to the diff/inference/naming-convention kind. -# Skipped for --flat (no headers) and when grouping found no crates to render. -TYPE_KINDS_FILE="" -if [ "$group_mode" != flat ]; then - TYPE_KINDS_FILE="$RUN_TMP/type-kinds.tsv" - git grep -E '^[[:space:]]*pub (struct|enum|trait|union) ' "$head_sha" -- '*.rs' 2>/dev/null | - awk '{ - for (i = 1; i < NF; i++) - if ($i == "struct" || $i == "enum" || $i == "trait" || $i == "union") { - n = $(i + 1); sub(/[<({:;].*/, "", n) - if (n != "") print n "\t" $i - break - } - }' | sort -u >"$TYPE_KINDS_FILE" 2>/dev/null || : >"$TYPE_KINDS_FILE" -fi - -has_breaking=false -[ $((removed_total + changed_total)) -gt 0 ] && has_breaking=true - -# ── changelog output (--changelog) ───────────────────────────────────── -# Reuse the per-crate diff buffers to emit librustzcash-style markdown to the -# saved stdout (fd 3), one section per changed crate, then stop. (Trait -# attribution from rustdoc JSON is layered onto this in a later pass.) -if $changelog_mode; then - if [ "$error_crate_count" -gt 0 ]; then - print_public_api_errors - exit "$EXIT_ANALYSIS" - fi - - # Per-crate dependency changes, keyed by crate name. Internal workspace - # crate bumps and external migrations go under "### Changed"; removals under - # "### Removed". Added deps are intentionally not emitted — Zebra/lrz - # changelogs do not document them. - declare -A dep_changed_md=() dep_removed_md=() - # A failed dep dump (broken worktree, out-of-sync lockfile, etc.) must not be - # silently treated as "no dependencies" — that turns every baseline dep into a - # bogus "Removed" line, and an empty head into a fully degenerate changelog. - # Abort loudly instead of emitting garbage. - pc_base=$(dump_per_crate_deps "$baseline_sha") || { - echo "${RED}error:${RESET} could not read per-crate dependencies at baseline ($baseline_sha)" >&2 - exit "$EXIT_ANALYSIS" - } - pc_head=$(dump_per_crate_deps "$head_sha") || { - echo "${RED}error:${RESET} could not read per-crate dependencies at head ($head_sha)" >&2 - exit "$EXIT_ANALYSIS" - } - if [ -z "$pc_base" ] || [ -z "$pc_head" ]; then - echo "${RED}error:${RESET} per-crate dependency dump was empty for one side" \ - "(baseline=$baseline_sha, head=$head_sha); refusing to emit a degenerate diff" >&2 - exit "$EXIT_ANALYSIS" - fi - if [ -n "$pc_base$pc_head" ]; then - while IFS=$'\t' read -r tag cr md; do - [ -z "$tag" ] && continue - case "$tag" in - M) dep_changed_md["$cr"]="${md}"$'\n'"${dep_changed_md[$cr]:-}" ;; # MSRV first - C) dep_changed_md["$cr"]+="${md}"$'\n' ;; - R) dep_removed_md["$cr"]+="${md}"$'\n' ;; - esac - done < <(awk -F'\t' ' - $1 == "" { next } - FNR == NR { b[$1 SUBSEP $2] = $3; bseen[$1 SUBSEP $2] = 1; next } - { - k = $1 SUBSEP $2; hseen[k] = 1 - if ((k in bseen) && b[k] != $3) { - if ($4 == "msrv") { - if ($3 != "-") printf "M\t%s\t- MSRV is now %s.\n", $1, $3 - } - else if ($4 == "int") - printf "C\t%s\t- `%s` dependency bumped to `%s`.\n", $1, $2, $3 - else - printf "C\t%s\t- Migrated to `%s %s`.\n", $1, $2, $3 - } - } - END { - for (k in bseen) if (!(k in hseen)) { - split(k, a, SUBSEP) - if (a[2] != "~msrv") printf "R\t%s\t- `%s` dependency.\n", a[1], a[2] - } - } - ' <(printf '%s\n' "$pc_base") <(printf '%s\n' "$pc_head")) - fi - - # Public-dependency requirement changes per crate: a `### Changed` prose note - # for each dependency that changed and whose types are reachable in the crate's - # public API. Without it the draft shows nothing for a crate whose only change - # is a re-exposed dependency, since the signature text is identical. A `major` - # change is a proven break and says the curator must bump the crate's major - # version; an `unknown` one only asks for review, so it must not. - # - # The dependency change has already produced a `Migrated to ...` line above, - # and one change must not yield two bullets: a changelog entry is per change - # and written for the user adapting to it, so a second bullet naming the same - # dependency is noise the curator has to merge by hand. Fold the note into - # that line where it exists, and fall back to a bullet only when it does not. - declare -A pubdep_md=() - for i in "${!crate_names[@]}"; do - [ -n "${crate_pubdep_lines[$i]}" ] || continue - cr=${crate_names[$i]} - while IFS=$'\t' read -r dep old new; do - [ -z "$dep" ] && continue - # Only a proven-incompatible bump may assert a break. An "unknown" - # requirement change is a review item, not a major-version trigger. - case $(classify_bump "$old" "$new") in - major) note="its types appear in this crate's public API, so downstream users must upgrade \`${dep}\` in lockstep." ;; - *) note="its types appear in this crate's public API, so check whether downstream users are affected." ;; - esac - migrated="- Migrated to \`${dep} ${new}\`." - if [ -n "${dep_changed_md[$cr]:-}" ] && [ "${dep_changed_md[$cr]#*"$migrated"}" != "${dep_changed_md[$cr]}" ]; then - dep_changed_md[$cr]=${dep_changed_md[$cr]/"$migrated"/"- Migrated to \`${dep} ${new}\`; ${note}"} - else - pubdep_md[$cr]+="- Public dependency \`${dep}\` changed to \`${new}\`; ${note}"$'\n' - fi - done <<<"${crate_pubdep_lines[$i]}" - done - - # Per-crate rustdoc trait map (at head), so trait-impl associated items group - # under `impl Trait for Self` instead of their bare Self type. Built only for - # crates that have API changes; degrades silently to plain grouping without a - # nightly toolchain or if rustdoc JSON fails. - # `trait_file` is the head map (for Added). Removed items need the *base* map - # (their impls are gone at head), so `trait_file_base` is built for crates with - # removals, letting a removed `impl Error for T`'s `source`/`fmt` group under - # the impl rather than under the bare type. - declare -A trait_file=() trait_file_base=() - [ -z "${nightly_toolchain:-}" ] && nightly_toolchain=$(rustup toolchain list 2>/dev/null | grep -oE 'nightly[^ ]*' | head -n1 || true) - if [ -n "${nightly_toolchain:-}" ]; then - changed_crates=() removed_crates=() - for i in "${!crate_names[@]}"; do - [ $(( ${crate_removed[$i]} + ${crate_changed[$i]} + ${crate_added[$i]} )) -gt 0 ] && - changed_crates+=("${crate_names[$i]}") - [ "${crate_removed[$i]}" -gt 0 ] && removed_crates+=("${crate_names[$i]}") - done - if [ ${#changed_crates[@]} -gt 0 ]; then - progress_start - dump_trait_map "$head_sha" "${changed_crates[@]}" >/dev/null 2>/dev/null || true - [ ${#removed_crates[@]} -gt 0 ] && - { dump_trait_map "$baseline_sha" "${removed_crates[@]}" >/dev/null 2>/dev/null || true; } - progress_clear - for crate in "${changed_crates[@]}"; do - cf="$CACHE_DIR/${head_sha}.${SCRIPT_HASH}.${crate}.traitmap.tsv" - [ -s "$cf" ] && trait_file["$crate"]="$cf" - done - for crate in "${removed_crates[@]}"; do - cfb="$CACHE_DIR/${baseline_sha}.${SCRIPT_HASH}.${crate}.traitmap.tsv" - [ -s "$cfb" ] && trait_file_base["$crate"]="$cfb" - done - fi - fi - - { - for i in "${!crate_names[@]}"; do - name="${crate_names[$i]}" - r="${crate_removed[$i]}" c="${crate_changed[$i]}" a="${crate_added[$i]}" - dc="${dep_changed_md[$name]:-}" dr="${dep_removed_md[$name]:-}" - pm="${pubdep_md[$name]:-}" - tf="${trait_file[$name]:-}" tfb="${trait_file_base[$name]:-}" - pfx="${name//-/_}" - # Render first, then emit only the sections that have content — items - # can be filtered out (e.g. proptest machinery), leaving a section empty. - added_md=""; changed_md=""; removed_md="" - [ "$a" -gt 0 ] && added_md=$(render_changelog "${crate_added_lines[$i]}" added "$pfx" "$tf") - [ "$c" -gt 0 ] && changed_md=$(render_changelog "${crate_changed_lines[$i]}" changed "$pfx" "$tf") - [ "$r" -gt 0 ] && removed_md=$(render_changelog "${crate_removed_lines[$i]}" removed "$pfx" "$tfb") - [ -z "$added_md$changed_md$removed_md$dc$dr$pm" ] && continue - echo "## ${name}" - echo "" - if [ -n "$added_md" ]; then - echo "### Added" - printf '%s\n' "$added_md" - echo "" - fi - if [ -n "$changed_md" ] || [ -n "$dc" ] || [ -n "$pm" ]; then - echo "### Changed" - [ -n "$dc" ] && printf '%s' "$dc" - [ -n "$pm" ] && printf '%s' "$pm" - [ -n "$changed_md" ] && printf '%s\n' "$changed_md" - echo "" - fi - if [ -n "$removed_md" ] || [ -n "$dr" ]; then - echo "### Removed" - [ -n "$removed_md" ] && printf '%s\n' "$removed_md" - [ -n "$dr" ] && printf '%s' "$dr" - echo "" - fi - done - } >&3 - exit "$EXIT_OK" -fi - -if ! $json_mode; then -for i in "${!crate_names[@]}"; do - r="${crate_removed[$i]}" - c="${crate_changed[$i]}" - a="${crate_added[$i]}" - [ $((r + c + a)) -eq 0 ] && continue - - [ $((r + c)) -gt 0 ] && has_breaking=true - - echo "" - echo "${BOLD}${crate_names[$i]}${RESET}" - - # The crate's lib path prefix (e.g. zebra-state -> zebra_state) lets the - # grouping flag items whose path is in another crate as external. - crate_pfx="${crate_names[$i]//-/_}" - - if [ "$r" -gt 0 ]; then - echo "" - echo " ${RED}Removed ($r):${RESET}" - render_items "${crate_removed_lines[$i]}" removed "$crate_pfx" - fi - - if [ "$c" -gt 0 ]; then - echo "" - echo " ${YELLOW}Changed ($c):${RESET}" - render_items "${crate_changed_lines[$i]}" changed "$crate_pfx" - fi - - if [ "$a" -gt 0 ]; then - echo "" - echo " ${GREEN}Added ($a):${RESET}" - render_items "${crate_added_lines[$i]}" added "$crate_pfx" - fi -done - -# ── value / doc changes (--with-values) ─────────────────────────────── -if [ "$value_changed_total" -gt 0 ]; then - echo "" - echo "${BOLD}Value changes (${value_changed_total})${RESET}${DIM} — const/static values; cargo-public-api can't see these${RESET}" - last_crate="" - for row in "${value_changes[@]}"; do - IFS=$'\t' read -r crate path type_ old new <<<"$row" - if [ "$crate" != "$last_crate" ]; then - echo "" - echo " ${BOLD}${crate}${RESET}" - last_crate="$crate" - fi - echo " ${YELLOW}~ ${path}: ${type_}${RESET}" - echo " ${RED}${old}${RESET} ${DIM}->${RESET} ${GREEN}${new}${RESET}" - done -fi -if [ "$doc_changed_total" -gt 0 ]; then - echo "" - echo "${BOLD}Doc changes (${doc_changed_total})${RESET}${DIM} — public doc-comment text changed${RESET}" - last_crate="" - for row in "${doc_changes[@]}"; do - IFS=$'\t' read -r crate path <<<"$row" - if [ "$crate" != "$last_crate" ]; then - echo "" - echo " ${BOLD}${crate}${RESET}" - last_crate="$crate" - fi - echo " ${YELLOW}~ ${path}${RESET}${DIM} (doc text changed)${RESET}" - done -fi -if [ $((pubdep_break_total + pubdep_review_total)) -gt 0 ]; then - echo "" - echo "${BOLD}Public-dependency changes (${pubdep_break_total} breaking, ${pubdep_review_total} to review)${RESET}${DIM} — public API exposes a changed dependency; cargo-public-api can't see these${RESET}" - for i in "${!crate_names[@]}"; do - [ -n "${crate_pubdep_lines[$i]}" ] || continue - echo "" - echo " ${BOLD}${crate_names[$i]}${RESET}" - while IFS=$'\t' read -r dep old new; do - [ -z "$dep" ] && continue - if [ "$(classify_bump "$old" "$new")" = major ]; then - echo " ${RED}${dep}${RESET}${DIM}: ${RESET}${RED}${old}${RESET} ${DIM}->${RESET} ${GREEN}${new}${RESET}${DIM} (incompatible; reachable in public API)${RESET}" - else - echo " ${YELLOW}${dep}${RESET}${DIM}: ${RESET}${YELLOW}${old}${RESET} ${DIM}->${RESET} ${GREEN}${new}${RESET}${DIM} (compatibility unclear; reachable in public API)${RESET}" - fi - done <<<"${crate_pubdep_lines[$i]}" - done -fi -fi - -# ── verdict ─────────────────────────────────────────────────────────── - -echo "" -api_breaking=$((removed_total + changed_total)) -any_breaking=false -$has_breaking && any_breaking=true -[ "$dep_breaking_count" -gt 0 ] && any_breaking=true -[ "$value_changed_total" -gt 0 ] && any_breaking=true -[ "$pubdep_break_total" -gt 0 ] && any_breaking=true - -# ── JSON output (opt-in) ────────────────────────────────────────────── -# When --json is set we assemble a structured document from the same -# tables used by the text verdict and write it to fd 3 (saved stdout), -# then exit with the appropriate status code. -if $json_mode; then - verdict="ok" - if [ "$error_crate_count" -gt 0 ]; then - verdict="error" - elif $any_breaking; then - verdict="breaking" - fi - - crate_jsons=() - for i in "${!crate_names[@]}"; do - crate_jsons+=(--arg "name$i" "${crate_names[$i]}") - crate_jsons+=(--argjson "r$i" "${crate_removed[$i]}") - crate_jsons+=(--argjson "c$i" "${crate_changed[$i]}") - crate_jsons+=(--argjson "a$i" "${crate_added[$i]}") - crate_jsons+=(--arg "status$i" "${crate_status[$i]}") - crate_jsons+=(--arg "stage$i" "${crate_error_stage[$i]}") - crate_jsons+=(--arg "eref$i" "${crate_error_ref[$i]}") - crate_jsons+=(--arg "erefsha$i" "${crate_error_ref_sha[$i]}") - crate_jsons+=(--arg "cmd$i" "${crate_error_command[$i]}") - crate_jsons+=(--arg "stderr$i" "${crate_error_stderr[$i]}") - crate_jsons+=(--arg "hint$i" "${crate_error_hint[$i]}") - done - crate_expr='[' - for i in "${!crate_names[@]}"; do - [ "$i" -gt 0 ] && crate_expr+=',' - crate_expr+="{name:\$name$i,removed:\$r$i,changed:\$c$i,added:\$a$i,status:\$status$i" - crate_expr+=",error:(if \$status$i == \"error\" then {stage:\$stage$i,ref:\$eref$i,ref_sha:\$erefsha$i,command:\$cmd$i,stderr:\$stderr$i,hint:\$hint$i} else null end)}" - done - crate_expr+=']' - - dep_rem_json=$(printf '%s\n' "${dep_removed[@]}" | jq -R -s ' - split("\n") | map(select(length > 0) | split("|") - | {name: .[0], version: .[1], kind: .[2]})') - dep_chg_json=$(printf '%s\n' "${dep_changed[@]}" | jq -R -s ' - split("\n") | map(select(length > 0) | split("|") - | {name: .[0], old: .[1], new: .[2], bump: .[3], kind: .[4], features: (.[5] // "")})') - dep_add_json=$(printf '%s\n' "${dep_added[@]}" | jq -R -s ' - split("\n") | map(select(length > 0) | split("|") - | {name: .[0], version: .[1], kind: .[2]})') - val_json=$(printf '%s\n' "${value_changes[@]}" | jq -R -s ' - split("\n") | map(select(length > 0) | split("\t") - | {crate: .[0], path: .[1], type: .[2], old: .[3], new: .[4]})') - doc_json=$(printf '%s\n' "${doc_changes[@]}" | jq -R -s ' - split("\n") | map(select(length > 0) | split("\t") - | {crate: .[0], path: .[1]})') - - pubdep_rows="" - for i in "${!crate_names[@]}"; do - [ -n "${crate_pubdep_lines[$i]}" ] || continue - while IFS=$'\t' read -r dep old new; do - [ -z "$dep" ] && continue - case $(classify_bump "$old" "$new") in - major) pd_class=breaking ;; - *) pd_class=review ;; - esac - pubdep_rows+="${crate_names[$i]}"$'\t'"${dep}"$'\t'"${old}"$'\t'"${new}"$'\t'"${pd_class}"$'\n' - done <<<"${crate_pubdep_lines[$i]}" - done - pubdep_json=$(printf '%s' "$pubdep_rows" | jq -R -s ' - split("\n") | map(select(length > 0) | split("\t") - | {crate: .[0], dep: .[1], old: .[2], new: .[3], class: .[4]})') - - jq -n \ - --arg baseline "$baseline_label" \ - --arg baseline_sha "$baseline_short" \ - --arg head "$head_label" \ - --arg head_sha "$head_short" \ - --arg verdict "$verdict" \ - --argjson api_breaking "$api_breaking" \ - --argjson dep_breaking "$dep_breaking_count" \ - --argjson error_crates "$error_crate_count" \ - --argjson value_changed "$value_changed_total" \ - --argjson doc_changed "$doc_changed_total" \ - --argjson removed_total "$removed_total" \ - --argjson changed_total "$changed_total" \ - --argjson added_total "$added_total" \ - --argjson deps_removed "$dep_rem_json" \ - --argjson deps_changed "$dep_chg_json" \ - --argjson deps_added "$dep_add_json" \ - --argjson values "$val_json" \ - --argjson docs "$doc_json" \ - --argjson public_dep_breaks "$pubdep_json" \ - --argjson pubdep_break "$pubdep_break_total" \ - "${crate_jsons[@]}" \ - "($crate_expr) as \$crates | - { - baseline: \$baseline, - baseline_sha: \$baseline_sha, - head: \$head, - head_sha: \$head_sha, - verdict: \$verdict, - totals: { - removed: \$removed_total, - changed: \$changed_total, - added: \$added_total, - api_breaking: \$api_breaking, - dep_breaking: \$dep_breaking, - error_crates: \$error_crates, - value_changed: \$value_changed, - doc_changed: \$doc_changed, - public_dep_breaking: \$pubdep_break - }, - deps: { - removed: \$deps_removed, - changed: \$deps_changed, - added: \$deps_added - }, - values: \$values, - docs: \$docs, - public_dep_breaks: \$public_dep_breaks, - crates: \$crates - }" >&3 - - case "$verdict" in - ok) exit "$EXIT_OK" ;; - breaking) exit "$EXIT_BREAKING" ;; - error) exit "$EXIT_ANALYSIS" ;; - esac -fi - -if [ "$error_crate_count" -gt 0 ]; then - print_public_api_errors - exit "$EXIT_ANALYSIS" -fi - -if $any_breaking; then - parts=() - if [ "$api_breaking" -gt 0 ]; then - parts+=("api: $removed_total removed / $changed_total changed") - fi - if [ "$dep_breaking_count" -gt 0 ]; then - parts+=("runtime-deps: $dep_breaking_count breaking") - fi - if [ "$value_changed_total" -gt 0 ]; then - parts+=("values: $value_changed_total changed") - fi - if [ "$pubdep_break_total" -gt 0 ]; then - parts+=("public-dep: $pubdep_break_total breaking") - fi - joined="" - sep="" - for p in "${parts[@]}"; do - joined+="$sep$p" - sep="; " - done - echo "${RED}${BOLD}BREAKING${RESET}${RED}: ${joined}.${RESET}" - if [ "$added_total" -gt 0 ]; then - echo "${DIM}(also $added_total new API items, additive only)${RESET}" - fi - [ "$doc_changed_total" -gt 0 ] && echo "${DIM}(also $doc_changed_total public doc-comment change(s))${RESET}" - exit "$EXIT_BREAKING" -else - if [ "$added_total" -gt 0 ]; then - echo "${GREEN}OK: $added_total new API items, no breaking changes.${RESET}" - else - echo "${GREEN}OK: no breaking changes.${RESET}" - fi - [ "$doc_changed_total" -gt 0 ] && echo "${DIM}(also $doc_changed_total public doc-comment change(s))${RESET}" - # Explicit success: the trailing `[ … ] && echo` above evaluates to exit 1 - # when there are no doc changes, which would otherwise make this non-breaking - # path wrongly report failure. - exit "$EXIT_OK" -fi