use std::path::{Path, PathBuf};
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_roteiro");
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
])
.args(args)
.current_dir(dir)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn roteiro(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("run roteiro")
}
fn write(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
std::fs::write(path, content).expect("write");
}
fn fresh_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("roteiro-review-cli-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
const ADR: &str = "---\n\
adr-id: \"0001\"\n\
status: Accepted\n\
---\n\
\n\
# ADR-0001\n\
\n\
## Decision\n\
\n\
The design centres on [[src/main.rs#greet]].\n";
#[test]
fn review_shows_context_for_a_clean_change_and_fails_on_drift() {
let dir = fresh_dir("context");
write(
&dir,
"src/main.rs",
"fn main() { greet(); }\nfn greet() {}\n",
);
write(&dir, "docs/adr/0001.md", ADR);
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&dir, &["sync"]).status.success(), "initial sync");
let empty = roteiro(&dir, &["review"]);
assert!(empty.status.success());
assert!(
String::from_utf8_lossy(&empty.stdout).contains("no working-tree changes"),
"clean tree reports nothing to review"
);
write(
&dir,
"src/main.rs",
"fn main() { greet(); }\nfn greet() { helper(); }\nfn helper() {}\n",
);
let clean = roteiro(&dir, &["review"]);
let out = String::from_utf8_lossy(&clean.stdout);
assert!(
clean.status.success(),
"non-drift review should exit 0: {out}"
);
assert!(
out.contains("governed by: adr:0001#decision"),
"shows the ADR: {out}"
);
assert!(
out.contains("calls: sym:rust:src/main.rs#helper"),
"shows callee: {out}"
);
assert!(
out.contains("no authored-layer drift"),
"no drift reported: {out}"
);
write(
&dir,
"src/main.rs",
"fn main() { hello(); }\nfn hello() {}\n",
);
let drift = roteiro(&dir, &["review"]);
let out = String::from_utf8_lossy(&drift.stdout);
assert!(
!drift.status.success(),
"drift review must exit non-zero: {out}"
);
assert!(
out.contains("drift introduced by this change"),
"reports drift: {out}"
);
assert!(
out.contains("src/main.rs#greet"),
"names the dangling link target: {out}"
);
let json = roteiro(&dir, &["review", "--json"]);
let text = String::from_utf8_lossy(&json.stdout);
assert!(
text.contains("\"schema\": \"roteiro.review/v1\""),
"schema tag: {text}"
);
assert!(text.contains("\"drift\""), "drift field present: {text}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn review_detects_drift_from_editing_an_adr() {
let dir = fresh_dir("adr-edit");
write(
&dir,
"src/main.rs",
"fn main() { greet(); }\nfn greet() {}\n",
);
write(&dir, "docs/adr/0001.md", ADR);
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&dir, &["sync"]).status.success(), "initial sync");
let edited = format!("{ADR}\nAnd also [[src/main.rs#missing]].\n");
write(&dir, "docs/adr/0001.md", &edited);
let out = roteiro(&dir, &["review"]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
!out.status.success(),
"editing an ADR to add a dangling link must be caught as drift: {text}"
);
assert!(
text.contains("drift introduced by this change"),
"reports the ADR-edit drift: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn range_review_covers_a_branch_vs_base() {
let dir = fresh_dir("range");
write(&dir, "src/main.rs", "fn main() { a(); }\nfn a() {}\n");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "base"]);
git(&dir, &["checkout", "-q", "-b", "feature"]);
write(
&dir,
"src/main.rs",
"fn main() { a(); b(); }\nfn a() {}\nfn b() {}\n",
);
git(&dir, &["commit", "-q", "-am", "add b"]);
assert!(roteiro(&dir, &["sync"]).status.success(), "sync");
let out = roteiro(&dir, &["review", "--base", "main"]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"range review should exit 0 (no drift): {text}"
);
assert!(
text.contains("src/main.rs [modified]"),
"reports the changed file: {text}"
);
assert!(
text.contains("fn b") && text.contains("called by: sym:rust:src/main.rs#main"),
"shows the new symbol's context from the committed graph: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn review_labels_untracked_files_as_added() {
let dir = fresh_dir("added");
write(&dir, "src/main.rs", "fn main() {}\n");
git(&dir, &["init", "-q"]);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&dir, &["sync"]).status.success(), "initial sync");
write(&dir, "src/extra.rs", "pub fn brand_new() {}\n");
let out = roteiro(&dir, &["review"]);
let text = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "no drift → exit 0: {text}");
assert!(
text.contains("src/extra.rs [added]"),
"an untracked new file must be labelled added, not modified: {text}"
);
assert!(
text.contains("fn brand_new"),
"the overlaid file's symbol is reviewed: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
const SHA_308: &str = "2b761ce79c44df5759ef69ef9e5f8476302d10cb";
fn score_run(name: &str, doc: &str, extra: &[&str]) -> (String, String, bool) {
let dir = fresh_dir(name);
let path = dir.join("run.json");
std::fs::write(&path, doc).expect("write run");
let mut args = vec!["review", "--score", path.to_str().expect("utf-8 path")];
args.extend_from_slice(extra);
let out = roteiro(&dir, &args);
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn run_doc(shas: &[&str], findings: &[(&str, &str, u32)]) -> String {
let shas: Vec<String> = shas.iter().map(|s| format!("{s:?}")).collect();
let findings: Vec<String> = findings
.iter()
.map(|(sha, path, line)| {
format!(
"{{\"reviewed_sha\": {sha:?}, \"path\": {path:?}, \"line\": {line}, \
\"description\": \"a finding\"}}"
)
})
.collect();
format!(
"{{\"schema\": \"roteiro.review-run/v1\", \"attempted_shas\": [{}], \
\"findings\": [{}]}}",
shas.join(", "),
findings.join(", ")
)
}
#[test]
fn score_reports_recall_per_defect_class() {
let doc = run_doc(
&[SHA_308],
&[(SHA_308, "docs/adr/0005-image-ocr-vision-ingestion.md", 16)],
);
let (out, err, ok) = score_run("score-per-class", &doc, &[]);
assert!(ok, "scoring should succeed: {out}{err}");
assert!(
out.contains("recall by defect class"),
"the table is the report: {out}"
);
assert!(
out.contains("1/1 contract-drift") || out.contains("1/1 contract-drift"),
"credits the contract-drift row: {out}"
);
assert!(
out.contains("partial run"),
"a one-commit run says it is partial: {out}"
);
assert!(
!out.contains("overall recall") && !out.contains("average"),
"no averaged headline: {out}"
);
}
#[test]
fn an_unmatched_finding_is_reported_as_unadjudicated() {
let doc = run_doc(
&[SHA_308],
&[(SHA_308, "crates/rto-graph/src/store.rs", 42)],
);
let (out, err, ok) = score_run("score-unadjudicated", &doc, &[]);
assert!(ok, "{out}{err}");
assert!(out.contains("UNADJUDICATED"), "{out}");
assert!(
out.contains("not computable"),
"no adjudicated finding means no precision figure, not 0%: {out}"
);
}
#[test]
fn scoring_against_a_commit_outside_the_corpus_is_refused() {
let head = "0123456789abcdef0123456789abcdef01234567";
let (out, err, ok) = score_run("score-wrong-sha", &run_doc(&[head], &[]), &[]);
assert!(!ok, "must not score: {out}");
assert!(
err.contains("reviewed_sha") && err.contains("silently reports zero"),
"explains the PR-head trap: {err}"
);
}
#[test]
fn a_document_with_the_wrong_schema_is_named_as_such() {
let (_, err, ok) = score_run(
"score-wrong-schema",
"{\"schema\": \"roteiro.review/v1\", \"attempted_shas\": [], \"findings\": []}",
&[],
);
assert!(!ok);
assert!(
err.contains("roteiro.review-run/v1"),
"names the schema it scores: {err}"
);
}
#[test]
fn score_json_carries_the_schema_tag() {
let doc = run_doc(&[SHA_308], &[]);
let (out, err, ok) = score_run("score-json", &doc, &["--json"]);
assert!(ok, "{out}{err}");
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(value["schema"], "roteiro.review-score/v1");
assert!(
value["per_class"].as_array().is_some_and(|a| a.len() == 14),
"every class is present, so two reports line up: {out}"
);
}
#[test]
fn score_and_base_are_mutually_exclusive() {
let dir = fresh_dir("score-conflict");
std::fs::write(dir.join("run.json"), run_doc(&[SHA_308], &[])).expect("write");
let out = roteiro(&dir, &["review", "--score", "run.json", "--base", "main"]);
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("cannot be used with"),
"clap refuses the pair: {}",
String::from_utf8_lossy(&out.stderr)
);
}