tests/cli_ingest.rs
//! End-to-end CLI test for the `modgunn ingest` verb — the flag-parsing +
//! filesystem-walk + component-print / CycloneDX-emit path in `src/main.rs` that
//! the library-level `manifests` unit tests never touch.
//!
//! Writes real-shaped manifest fixtures to a scratch dir, then DRIVES the compiled
//! binary (`CARGO_BIN_EXE_modgunn`) and asserts on its stdout / exit status. Fully
//! offline, zero-shell — no warehouse, no network.
//!
//! Gated on BOTH `manifests` (the verb's real body) AND `warehouse` (the binary's
//! `required-features`, without which `CARGO_BIN_EXE_modgunn` is not built). Run:
//! `cargo test --features "warehouse manifests" --test cli_ingest`.
#![cfg(all(feature = "manifests", feature = "warehouse"))]
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// A unique scratch directory under the system temp root.
fn temp_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("modgunn-ingest-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Run the compiled `modgunn` binary with `args`; return `(stdout, stderr, code)`.
fn run(args: &[&str]) -> (String, String, i32) {
let out = Command::new(env!("CARGO_BIN_EXE_modgunn"))
.args(args)
.output()
.expect("spawn modgunn binary");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
const CARGO_LOCK: &str = "[[package]]\nname = \"serde\"\nversion = \"1.0.203\"\n\n\
[[package]]\nname = \"anyhow\"\nversion = \"1.0.86\"\n";
/// `ingest --path <Cargo.lock>` parses the lockfile and prints the resolved
/// components + a per-ecosystem summary footer, exit 0. RED-when-broken: if the
/// verb didn't actually parse the manifest, neither component line appears.
#[test]
fn ingest_single_cargo_lock_prints_components() {
let dir = temp_dir("single");
let lock = dir.join("Cargo.lock");
std::fs::write(&lock, CARGO_LOCK).unwrap();
let (stdout, stderr, code) = run(&["ingest", "--path", lock.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0; stderr={stderr}");
assert!(
stdout.contains("cargo serde 1.0.203"),
"serde component row missing:\n{stdout}"
);
assert!(
stdout.contains("cargo anyhow 1.0.86"),
"anyhow component row missing:\n{stdout}"
);
assert!(
stdout.contains("2 component(s) from 1 manifest(s)"),
"summary footer missing:\n{stdout}"
);
assert!(
stdout.contains("[cargo 2]"),
"per-ecosystem breakdown missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// `ingest --path <file> --sbom` emits a valid CycloneDX 1.5 document carrying the
/// components as ecosystem-qualified purls.
#[test]
fn ingest_sbom_emits_cyclonedx() {
let dir = temp_dir("sbom");
let lock = dir.join("Cargo.lock");
std::fs::write(&lock, CARGO_LOCK).unwrap();
let (stdout, stderr, code) = run(&[
"ingest",
"--path",
lock.to_str().unwrap(),
"--sbom",
"--subject",
"myrepo",
]);
assert_eq!(code, 0, "exit 0; stderr={stderr}");
let doc: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is valid JSON");
assert_eq!(doc["bomFormat"], "CycloneDX");
assert_eq!(doc["specVersion"], "1.5");
assert_eq!(doc["metadata"]["component"]["name"], "myrepo");
let purls: Vec<&str> = doc["components"]
.as_array()
.unwrap()
.iter()
.map(|c| c["purl"].as_str().unwrap())
.collect();
assert!(
purls.contains(&"pkg:cargo/serde@1.0.203"),
"purl missing: {purls:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A DIRECTORY walk aggregates every recognised manifest (multi-ecosystem) and the
/// `--ecosystem` filter narrows the printed set to one ecosystem.
#[test]
fn ingest_directory_aggregates_and_filters() {
let dir = temp_dir("dir");
std::fs::write(dir.join("Cargo.lock"), CARGO_LOCK).unwrap();
std::fs::write(
dir.join("requirements.txt"),
"requests==2.31.0\nurllib3>=1.26\n",
)
.unwrap();
// A build tree that must be skipped — its lockfile is NOT authoritative.
std::fs::create_dir_all(dir.join("target")).unwrap();
std::fs::write(
dir.join("target").join("Cargo.lock"),
"[[package]]\nname = \"ghost\"\nversion = \"9.9.9\"\n",
)
.unwrap();
// Full walk: cargo (2) + pip (1, the range is skipped), target/ ignored.
let (stdout, _stderr, code) = run(&["ingest", "--path", dir.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(
stdout.contains("pip requests 2.31.0"),
"pip component missing:\n{stdout}"
);
assert!(
stdout.contains("3 component(s)"),
"expected 3 components (2 cargo + 1 pip):\n{stdout}"
);
assert!(
!stdout.contains("ghost"),
"target/ build tree must be skipped:\n{stdout}"
);
// --ecosystem pip narrows to the single pip component.
let (stdout, _stderr, code) = run(&[
"ingest",
"--path",
dir.to_str().unwrap(),
"--ecosystem",
"pip",
]);
assert_eq!(code, 0);
assert!(
stdout.contains("1 component(s)"),
"ecosystem filter should leave 1:\n{stdout}"
);
assert!(
!stdout.contains("serde"),
"cargo components must be filtered out:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// An explicit file no parser owns is a hard usage error (non-zero exit + a hint
/// listing the supported manifests) — the negative, RED-when-broken assertion.
#[test]
fn ingest_unknown_file_errors() {
let dir = temp_dir("unknown");
let f = dir.join("mystery.txt");
std::fs::write(&f, "nothing here\n").unwrap();
let (_stdout, stderr, code) = run(&["ingest", "--path", f.to_str().unwrap()]);
assert_ne!(code, 0, "an unknown manifest must fail");
assert!(
stderr.contains("no manifest parser owns"),
"expected a helpful error:\n{stderr}"
);
std::fs::remove_dir_all(&dir).ok();
}