//! Quality gates: layering, wasm, the CI bundle, corpora and the robustness sweep. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, anyhow, bail}; use crate::layers; pub fn root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } /// Cargo's target directory: `CARGO_TARGET_DIR` when it is set (parallel agents use their own), /// else `target/`. pub fn target_dir() -> PathBuf { std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root().join("target"), |d| root().join(d)) } /// The release `pdfcraft-cli` that `cargo build --release -p pdfcraft-cli` produced. pub fn release_cli() -> PathBuf { target_dir().join("release").join(format!("pdfcraft-cli{}", std::env::consts::EXE_SUFFIX)) } fn cargo() -> Command { let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())); c.current_dir(root()); c } fn run(mut cmd: Command, what: &str) -> anyhow::Result<()> { eprintln!("$ {what}"); let status = cmd.status().with_context(|| format!("{what}: failed to spawn"))?; if !status.success() { bail!("{what}: exited with {status}"); } Ok(()) } fn metadata() -> anyhow::Result { let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().context("cargo metadata")?; if !out.status.success() { bail!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)); } Ok(serde_json::from_slice(&out.stdout)?) } pub fn layers(_: &[String]) -> anyhow::Result<()> { let crates = layers::from_metadata(&metadata()?).map_err(|e| anyhow!(e))?; println!("Dependency layering (plan/architecture.md §3)\n"); println!("{:<24} {:<14} workspace deps", "crate", "layer"); for c in &crates { let ws: Vec = c .deps .iter() .filter(|d| d.workspace) .map(|d| format!("{}{}", layers::short_name(&d.name), if d.kind == layers::DepKind::Dev { " (dev)" } else { "" })) .collect(); println!("{:<24} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", ")); } let violations = layers::check(&crates); println!(); if violations.is_empty() { println!("OK: {} crates, no layering violations.", crates.len()); return Ok(()); } for v in &violations { println!(" - {v}"); } bail!("{} layering violation(s)", violations.len()) } pub fn wasm(_: &[String]) -> anyhow::Result<()> { let crates = layers::from_metadata(&metadata()?).map_err(|e| anyhow!(e))?; let set: Vec = crates .into_iter() .filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Standalone(_)))) .map(|c| c.name) .collect(); let mut failed = Vec::new(); for pkg in &set { let mut c = cargo(); c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); if run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_err() { failed.push(pkg.clone()); } } if failed.is_empty() { println!("wasm32: {} crates ok", set.len()); Ok(()) } else { bail!("wasm32 check failed for: {}", failed.join(", ")) } } pub fn ci(_: &[String]) -> anyhow::Result<()> { type Step = (&'static str, Box anyhow::Result<()>>); let steps: Vec = vec![ ("fmt", Box::new(|| run_args(&["fmt", "--all", "--", "--check"]))), ("clippy", Box::new(|| run_args(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("test", Box::new(|| run_args(&["test", "--workspace"]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), ("license-check", Box::new(|| crate::license_check::run(&[]))), ("deny", Box::new(|| deny(&[]))), ("parity", Box::new(|| crate::parity::run(&[]))), ]; for (i, (name, f)) in steps.iter().enumerate() { eprintln!("\n=== ci: {name} ==="); if let Err(e) = f() { println!("\nCI: {} passed, FAILED at `{name}`: {e:#}", i); bail!("ci failed at `{name}`"); } } println!("\nCI: all {} steps passed", steps.len()); Ok(()) } /// Dependency licences, bans, sources and advisories (`deny.toml`). Skipped with a hint when /// cargo-deny is not installed, except under CI (`CI` set), where it is required. pub fn deny(_: &[String]) -> anyhow::Result<()> { let installed = Command::new("cargo-deny").arg("--version").output().is_ok_and(|o| o.status.success()); if !installed { if std::env::var_os("CI").is_some() { bail!("cargo-deny is required in CI: cargo install cargo-deny --locked"); } eprintln!("deny: SKIPPED, cargo-deny is not installed (cargo install cargo-deny --locked)"); return Ok(()); } run_args(&["deny", "--log-level", "error", "check"]) } fn run_args(args: &[&str]) -> anyhow::Result<()> { let mut c = cargo(); c.args(args); run(c, &format!("cargo {}", args.join(" "))) } pub fn corpus(args: &[String]) -> anyhow::Result<()> { crate::corpus::run_cmd(args) } pub fn check(args: &[String]) -> anyhow::Result<()> { let update = args.iter().any(|a| a == "--update-baseline"); let corpus = crate::corpus::pdfs(); // The baseline in xtask/baselines/pdfjs.json describes one exact tree. Sweeping a different // one silently moves the numbers it is compared against, so verify the pin first. match crate::corpus::state()? { crate::corpus::State::Absent => bail!("corpus missing: run `cargo xtask corpus` first"), crate::corpus::State::Mismatch { detail } => { bail!("corpus does not match the pin ({detail}); run `cargo xtask corpus` to repair it") } crate::corpus::State::Verified { files } => println!("corpus verified: {files} files at {}", crate::corpus::PDFJS_COMMIT), } run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; let report = target_dir().join("check-pdfjs.json"); let mut c = Command::new(release_cli()); c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report); run(c, "pdfcraft-cli check corpus/pdfjs")?; let results: Vec = serde_json::from_str(&std::fs::read_to_string(&report)?)?; let name = |v: &serde_json::Value| Path::new(v["file"].as_str().unwrap_or("")).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let status = |v: &serde_json::Value| v["status"].as_str().unwrap_or("?").split(':').next().unwrap_or("?").to_string(); let current: BTreeMap = results.iter().filter(|v| status(v) != "ok").map(|v| (name(v), status(v))).collect(); let crashes: Vec<&String> = current.iter().filter(|(_, s)| s.as_str() == "crash").map(|(f, _)| f).collect(); let baseline_path = root().join("xtask/baselines/pdfjs.json"); if update { std::fs::create_dir_all(baseline_path.parent().expect("has parent"))?; std::fs::write(&baseline_path, serde_json::to_string_pretty(¤t)? + "\n")?; println!("baseline updated: {} known non-ok files", current.len()); } let baseline: BTreeMap = std::fs::read_to_string(&baseline_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); let regressions: Vec = current.iter().filter(|(f, _)| !baseline.contains_key(*f)).map(|(f, s)| format!("{f} ({s})")).collect(); let fixed: Vec<&String> = baseline.keys().filter(|f| !current.contains_key(*f)).collect(); println!( "\n{} files: {} ok, {} known issues, {} regressions, {} newly fixed, {} crashes", results.len(), results.len() - current.len(), current.len() - regressions.len(), regressions.len(), fixed.len(), crashes.len() ); if !fixed.is_empty() { println!("newly fixed (run with --update-baseline): {fixed:?}"); } if !crashes.is_empty() { bail!("crashes: {crashes:?}"); } if !regressions.is_empty() { bail!("regressions: {regressions:?}"); } Ok(()) } /// `cargo xtask text-oracle [--limit N]`: compare `pdfcraft-cli text` with poppler's `pdftotext` /// (external oracle process, never linked) over the corpus. Reports word-level F1 per file and the /// median. Plan target (M2): median ≥ 0.97. pub fn text_oracle(args: &[String]) -> anyhow::Result<()> { let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); if Command::new("pdftotext").arg("-v").output().is_err() { bail!("pdftotext (poppler) not found; install it to run the text oracle (brew install poppler / apt install poppler-utils)"); } run_args(&["build", "--release", "-p", "pdfcraft-cli"])?; let corpus = root().join("corpus/pdfjs/test/pdfs"); let mut files: Vec = std::fs::read_dir(&corpus)?.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect(); files.sort(); let cli = release_cli(); let mut scores = Vec::new(); let mut worst: Vec<(f64, String)> = Vec::new(); for f in files.iter().take(limit) { let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20); let theirs = run_capture(Command::new("pdftotext").arg("-q").arg(f).arg("-"), 20); let (Some(ours), Some(theirs)) = (ours, theirs) else { continue }; let (a, b) = (words(&ours), words(&theirs)); if b.len() < 20 { continue; // image-only or near-empty per the oracle } let score = f1(&a, &b); scores.push(score); worst.push((score, f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); } if scores.is_empty() { bail!("no comparable files"); } scores.sort_by(f64::total_cmp); worst.sort_by(|a, b| a.0.total_cmp(&b.0)); let median = scores[scores.len() / 2]; let mean = scores.iter().sum::() / scores.len() as f64; println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]); println!("lowest:"); for (s, n) in worst.iter().take(12) { println!(" {s:.3} {n}"); } Ok(()) } fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option { let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn().ok()?; let start = std::time::Instant::now(); let mut out = String::new(); let mut stdout = child.stdout.take()?; let reader = std::thread::spawn(move || { use std::io::Read; let mut s = String::new(); let _ = stdout.read_to_string(&mut s); s }); loop { match child.try_wait() { Ok(Some(_)) => break, Ok(None) if start.elapsed().as_secs() > timeout_s => { let _ = child.kill(); let _ = child.wait(); return None; } Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)), Err(_) => return None, } } out.push_str(&reader.join().ok()?); Some(out) } fn words(s: &str) -> BTreeMap { let mut m = BTreeMap::new(); for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) { *m.entry(w.to_lowercase()).or_insert(0) += 1; } m } fn f1(a: &BTreeMap, b: &BTreeMap) -> f64 { let common: usize = a.iter().map(|(w, n)| (*n).min(*b.get(w).unwrap_or(&0))).sum(); let (na, nb): (usize, usize) = (a.values().sum(), b.values().sum()); if na == 0 || nb == 0 { return 0.0; } let (p, r) = (common as f64 / na as f64, common as f64 / nb as f64); if p + r == 0.0 { 0.0 } else { 2.0 * p * r / (p + r) } } #[cfg(test)] mod tests { use super::root; // Issue #211: Finder lists the app under Open With > Recommended only when the // bundle claims PDFs by UTI (#176). #[test] fn macos_bundle_claims_pdf_by_uti() { let pdf = pdf_document_type(); assert!(pdf.contains("com.adobe.pdf"), "claim PDFs by UTI, not just extension/MIME"); assert!(pdf.contains("Editor"), "PDF role stays Editor"); assert!(pdf.contains("Alternate"), "rank stays Alternate: offered without taking over Preview"); assert!(pdf.contains("CFBundleTypeIconFile"), "declares document icon file for Finder"); } // The bundle id must match the app id the binary uses; renames change both (#174). #[test] fn macos_bundle_id_matches_app_id() { assert!(read("packaging/macos/Info.plist.in").contains("ai.storyteller.pdfcraft"), "bundle id"); let main = read("apps/pdfcraft/src/main.rs"); assert!(main.contains(r#"const APP_ID: &str = "ai.storyteller.pdfcraft""#), "APP_ID drifted from the bundle id"); } // Committed file with `` comments stripped, so a commented-out claim cannot pass. fn read(rel: &str) -> String { let text = std::fs::read_to_string(root().join(rel)).expect("committed file"); let mut out = String::with_capacity(text.len()); let mut rest = text.as_str(); while let Some(i) = rest.find("").map_or("", |j| &rest[i + j + 3..]); } out.push_str(rest); out } // The `` inside `CFBundleDocumentTypes` that mentions the PDF UTI: scoped so a // UTI string anywhere else cannot satisfy the asserts above. fn pdf_document_type() -> String { let plist = read("packaging/macos/Info.plist.in"); let docs = plist.split("CFBundleDocumentTypes").nth(1).expect("CFBundleDocumentTypes"); docs.split("") .map(|chunk| chunk.split("").next().unwrap_or("")) .find(|chunk| chunk.contains("com.adobe.pdf")) .expect("a document type for com.adobe.pdf") .to_string() } }