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-check-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: Thing\n\
\n\
## Decision\n\
\n\
The design centres on [[src/lib.rs#Thing]].\n";
#[test]
fn worktree_check_gates_a_drift_introducing_edit_that_committed_ignores() {
let dir = fresh_dir("worktree");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", "pub struct Thing;\n");
write(&dir, "docs/adr/0001-thing.md", ADR);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(
roteiro(&dir, &["check"]).status.success(),
"clean worktree check should pass"
);
assert!(
roteiro(&dir, &["check", "--committed"]).status.success(),
"clean committed check should pass"
);
write(&dir, "src/lib.rs", "pub struct Other;\n");
let worktree = roteiro(&dir, &["check"]);
assert!(
!worktree.status.success(),
"worktree check must fail on the drift about to be committed: {}",
String::from_utf8_lossy(&worktree.stderr)
);
assert!(
roteiro(&dir, &["check", "--committed"]).status.success(),
"committed check should still pass — HEAD is unchanged"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn staged_check_validates_the_index_not_the_working_tree() {
let dir = fresh_dir("staged");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", "pub struct Thing;\n");
write(&dir, "docs/adr/0001-thing.md", ADR);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
write(&dir, "src/lib.rs", "pub struct Other;\n");
git(&dir, &["add", "src/lib.rs"]);
write(&dir, "src/lib.rs", "pub struct Thing;\n");
assert!(
roteiro(&dir, &["check"]).status.success(),
"worktree check should pass (Thing is present on disk)"
);
let staged = roteiro(&dir, &["check", "--staged"]);
assert!(
!staged.status.success(),
"staged check must fail on drift the commit would record: {}",
String::from_utf8_lossy(&staged.stderr)
);
assert!(
String::from_utf8_lossy(&staged.stderr).contains("does not resolve"),
"reports the dangling staged link: {}",
String::from_utf8_lossy(&staged.stderr)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_validates_blueprint_links_like_adrs() {
let dir = fresh_dir("blueprint");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", "pub struct Widget;\n");
write(
&dir,
"docs/plans/widget.md",
"# Widget — Technical Implementation Plan\n\n\
## 1. Design\n\nThe core type is [[src/lib.rs#Widget]].\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let ok = roteiro(&dir, &["check", "--committed"]);
assert!(
ok.status.success(),
"blueprint with a resolvable link should pass: {}",
String::from_utf8_lossy(&ok.stderr)
);
assert!(
String::from_utf8_lossy(&ok.stdout).contains("1 blueprint(s)"),
"check reports the blueprint: {}",
String::from_utf8_lossy(&ok.stdout)
);
write(
&dir,
"docs/plans/widget.md",
"# Widget — Technical Implementation Plan\n\n\
## 1. Design\n\nGone: [[src/lib.rs#Ghost]].\n",
);
git(&dir, &["commit", "-qam", "dangle"]);
let bad = roteiro(&dir, &["check", "--committed"]);
assert!(
!bad.status.success(),
"a blueprint link to a missing symbol must fail check"
);
assert!(
String::from_utf8_lossy(&bad.stderr).contains("does not resolve"),
"reports the dangling blueprint link: {}",
String::from_utf8_lossy(&bad.stderr)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_fails_when_two_adr_files_share_an_adr_id() {
let dir = fresh_dir("duplicate-adr-id");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", "pub struct Thing;\n");
write(
&dir,
"docs/adr/0016-audio-metadata.md",
"---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n\
# ADR-0016: Audio metadata extraction\n\n## Decision\n\nFormat reads are cheap.\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let ok = roteiro(&dir, &["check", "--committed"]);
assert!(
ok.status.success(),
"a single ADR-0016 should pass: {}",
String::from_utf8_lossy(&ok.stderr)
);
write(
&dir,
"docs/adr/0016-speculative-decoding.md",
"---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n\
# ADR-0016: MTP speculative decoding\n\n## Decision\n\nOpt-in only.\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "second 0016"]);
let bad = roteiro(&dir, &["check", "--committed"]);
let stderr = String::from_utf8_lossy(&bad.stderr).into_owned();
assert!(
!bad.status.success(),
"two ADRs on one id must fail check; stdout: {}",
String::from_utf8_lossy(&bad.stdout)
);
assert!(
stderr.contains("duplicate-adr-id"),
"labelled as a duplicate id: {stderr}"
);
assert!(stderr.contains("0016"), "names the shared id: {stderr}");
assert!(
stderr.contains("docs/adr/0016-audio-metadata.md"),
"names the first file: {stderr}"
);
assert!(
stderr.contains("docs/adr/0016-speculative-decoding.md"),
"names the second file: {stderr}"
);
let json = roteiro(&dir, &["check", "--committed", "--json"]);
let stdout = String::from_utf8_lossy(&json.stdout).into_owned();
assert!(
stdout.contains("duplicate-adr-id"),
"json report carries the kind: {stdout}"
);
std::fs::remove_file(dir.join("docs/adr/0016-speculative-decoding.md")).expect("rm");
write(
&dir,
"docs/adr/0017-speculative-decoding.md",
"---\nadr-id: \"0017\"\nstatus: Accepted\n---\n\n\
# ADR-0017: MTP speculative decoding\n\n## Decision\n\nOpt-in only.\n",
);
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "renumber"]);
let fixed = roteiro(&dir, &["check", "--committed"]);
assert!(
fixed.status.success(),
"distinct ids pass: {}",
String::from_utf8_lossy(&fixed.stderr)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_reports_an_allow_that_carries_no_justification() {
let dir = fresh_dir("unjustified-allow");
git(&dir, &["init", "-q"]);
write(
&dir,
"src/lib.rs",
"// the prefix is intentional here\n\
#[allow(clippy::struct_field_names)]\n\
pub struct Justified {\n pub a_x: u8,\n}\n\
\n\
#[allow(clippy::cast_precision_loss)]\n\
pub fn bare(n: u64) -> f64 {\n n as f64\n}\n",
);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
let out = roteiro(&dir, &["check", "--json"]);
let report: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("check --json is valid JSON");
let kinds: Vec<&str> = report["violations"]
.as_array()
.expect("violations")
.iter()
.filter_map(|v| v["kind"].as_str())
.collect();
assert_eq!(
kinds,
["unjustified-allow"],
"exactly the bare allow, and nothing else: {report}"
);
let message = report["violations"][0]["message"].as_str().unwrap_or("");
assert!(
message.contains("src/lib.rs:7:"),
"the finding names the line a reader must open: {message}"
);
assert!(
!message.contains("remove") && !message.contains("add a comment"),
"the message states what is wrong, not what to type: {message}"
);
assert!(!out.status.success(), "check must fail on it: {out:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn staging_a_file_does_not_change_what_check_says_about_it() {
let dir = fresh_dir("staged-drift");
git(&dir, &["init", "-q"]);
write(&dir, "src/lib.rs", "pub struct Thing;\n");
write(&dir, "docs/adr/0001-thing.md", ADR);
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
assert!(roteiro(&dir, &["sync"]).status.success(), "initial sync");
write(
&dir,
"docs/adr/0002-new.md",
"---\n\
adr-id: \"0002\"\n\
status: Accepted\n\
---\n\
\n\
# ADR-0002: New\n\
\n\
## Decision\n\
\n\
This one names [[src/lib.rs#Absent]].\n",
);
let untracked = roteiro(&dir, &["check"]);
let untracked_out = String::from_utf8_lossy(&untracked.stdout).to_string();
assert!(
!untracked.status.success(),
"an untracked ADR with a dangling link is drift: {untracked_out}"
);
git(&dir, &["add", "docs/adr/0002-new.md"]);
let staged = roteiro(&dir, &["check"]);
let staged_out = String::from_utf8_lossy(&staged.stdout).to_string();
assert!(
!staged.status.success(),
"staging must not clear the drift — the tree is unchanged, only the index \
moved, and `git add` is the last thing you do before committing: {staged_out}"
);
assert_eq!(
untracked.status.code(),
staged.status.code(),
"the same tree must gate the same way staged or not"
);
assert_eq!(
untracked_out, staged_out,
"and must say the same thing about it"
);
std::fs::remove_dir_all(&dir).ok();
}