mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
287 lines
12 KiB
Rust
287 lines
12 KiB
Rust
//! 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()
|
|
}
|
|
|
|
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<serde_json::Value> {
|
|
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<String> = 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<String> = 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<dyn Fn() -> anyhow::Result<()>>);
|
|
let steps: Vec<Step> = 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(&[]))),
|
|
("assets", Box::new(|| crate::assets::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(_: &[String]) -> anyhow::Result<()> {
|
|
let dir = root().join("corpus");
|
|
std::fs::create_dir_all(&dir)?;
|
|
let pdfjs = dir.join("pdfjs");
|
|
if pdfjs.join("test/pdfs").is_dir() {
|
|
println!("corpus/pdfjs already present");
|
|
} else {
|
|
let mut c = Command::new("git");
|
|
c.current_dir(&dir).args(["clone", "-q", "--depth", "1", "--filter=blob:none", "--sparse", "https://github.com/mozilla/pdf.js.git", "pdfjs"]);
|
|
run(c, "git clone --sparse mozilla/pdf.js")?;
|
|
let mut c = Command::new("git");
|
|
c.current_dir(&pdfjs).args(["sparse-checkout", "set", "test/pdfs"]);
|
|
run(c, "git sparse-checkout set test/pdfs")?;
|
|
}
|
|
println!(
|
|
"Corpora (git-ignored, never committed; tests skip when absent):\n corpus/pdfjs/test/pdfs pdf.js test files committed in-repo (Apache-2.0 repository; individual files keep their own terms)"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// `cargo xtask check [--update-baseline]`: run `printcraft-cli check` over the corpus and compare
|
|
/// with `xtask/baselines/<corpus>.json` (the list of files known not to open/render cleanly).
|
|
/// Fails on any crash, and on any file that regressed from `ok`.
|
|
pub fn check(args: &[String]) -> anyhow::Result<()> {
|
|
let update = args.iter().any(|a| a == "--update-baseline");
|
|
let corpus = root().join("corpus/pdfjs/test/pdfs");
|
|
if !corpus.is_dir() {
|
|
bail!("corpus missing: run `cargo xtask corpus` first");
|
|
}
|
|
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
|
|
let report = root().join("target/check-pdfjs.json");
|
|
let mut c = Command::new(root().join("target/release/printcraft-cli"));
|
|
c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report);
|
|
run(c, "printcraft-cli check corpus/pdfjs")?;
|
|
let results: Vec<serde_json::Value> = 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<String, String> = 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<String, String> =
|
|
std::fs::read_to_string(&baseline_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
|
let regressions: Vec<String> = 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 `printcraft-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", "printcraft-cli"])?;
|
|
let corpus = root().join("corpus/pdfjs/test/pdfs");
|
|
let mut files: Vec<PathBuf> =
|
|
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 = root().join("target/release/printcraft-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::<f64>() / 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<String> {
|
|
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<String, usize> {
|
|
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<String, usize>, b: &BTreeMap<String, usize>) -> 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) }
|
|
}
|