use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn mapped_repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"triage\", \"wip\", \"review\", \"shipped\", \"abandoned\"]\n\
done_column = \"shipped\"\n\n\
[kanban.default.category_map]\n\
triage = \"backlog\"\n\
wip = \"active\"\n\
review = \"active\"\n\
shipped = \"completed\"\n\
abandoned = \"canceled\"\n",
)
.unwrap();
(tmp, dir)
}
fn issue_file(dir: &Path, title: &str) -> PathBuf {
let slug = title.to_lowercase().replace(' ', "-");
let issues = dir.join("docs").join("issues");
let mut stack = vec![issues];
while let Some(next) = stack.pop() {
for entry in fs::read_dir(&next).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with('.')
|| name.starts_with(".tix-tmp-")
|| name.ends_with("-assets")
{
continue;
}
stack.push(path);
} else if name.contains(&slug) {
return path;
}
}
}
panic!("no issue file matching {title:?}");
}
fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
let mut stack = vec![dir.join("docs").join("issues")];
while let Some(cur) = stack.pop() {
for entry in fs::read_dir(&cur).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
out.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
out.sort();
out
}
#[test]
fn t029_the_safe_default_is_reported_without_failing_the_exit_code() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
tix().current_dir(&dir).arg("init").assert().success();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha"])
.assert()
.success();
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.success()
.code(0)
.stderr(predicate::str::contains("unmapped_column_default"))
.stderr(predicate::str::contains("board `default`"))
.stderr(predicate::str::contains("todo"))
.stderr(predicate::str::contains("in review"))
.stderr(predicate::str::contains("does not affect the exit code"))
.stdout(predicate::str::contains("Lint OK with 1 warning"));
}
#[test]
fn t029_a_fully_mapped_board_reports_no_workflow_finding() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha"])
.assert()
.success();
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.success()
.stdout(predicate::str::contains("Lint OK:"))
.stderr(predicate::str::contains("unmapped_column_default").not());
}
#[test]
fn t029_a_hand_edited_status_is_reported_exactly_once() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
let file = issue_file(&dir, "Alpha");
let text = fs::read_to_string(&file).unwrap();
assert!(
text.contains("transition_accepted"),
"precondition: a decision was recorded to contradict"
);
fs::write(&file, text.replace("status: wip", "status: review")).unwrap();
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.failure()
.stderr(predicate::str::contains("direct_edit_policy_violation"))
.stderr(predicate::str::contains("wip"))
.stderr(predicate::str::contains("review"))
.stderr(predicate::str::contains("History records"))
.stderr(predicate::str::contains("outside `tix`"));
}
#[test]
fn t029_lint_writes_nothing_about_a_hand_edited_file() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
let file = issue_file(&dir, "Alpha");
let text = fs::read_to_string(&file).unwrap();
fs::write(&file, text.replace("status: wip", "status: review")).unwrap();
let before = snapshot(&dir);
tix().current_dir(&dir).arg("lint").assert().failure();
assert_eq!(
snapshot(&dir),
before,
"C-005: lint is observational. It must not repair the file, append a History \
entry, rewrite an existing one, or touch anything else."
);
}
#[test]
fn t029_a_consistent_history_reports_nothing() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("direct_edit_policy_violation").not());
}
#[test]
fn t029_an_issue_never_moved_reports_nothing() {
let (_tmp, dir) = mapped_repo();
for title in ["Alpha", "Bravo", "Charlie"] {
tix()
.current_dir(&dir)
.args(["create", "--title", title, "--status", "wip"])
.assert()
.success();
}
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.success()
.stderr(predicate::str::contains("direct_edit_policy_violation").not());
}
#[test]
fn t029_lint_is_deterministic_across_runs() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
for (n, title) in [(2, "Bravo"), (3, "Charlie")] {
tix()
.current_dir(&dir)
.args(["create", "--title", title, "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", &n.to_string(), "wip"])
.assert()
.success();
let file = issue_file(&dir, title);
let text = fs::read_to_string(&file).unwrap();
fs::write(&file, text.replace("status: wip", "status: review")).unwrap();
}
let run = || {
let out = tix().current_dir(&dir).arg("lint").output().unwrap();
String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr)
};
let first = run();
let second = run();
assert_eq!(
first, second,
"two runs over the same tree must be byte-identical"
);
assert!(
first.matches("direct_edit_policy_violation").count() == 2,
"and both edited files are reported: {first}"
);
}
#[test]
fn t029_a_tightened_category_map_reports_a_historical_decision() {
let (_tmp, dir) = mapped_repo();
tix()
.current_dir(&dir)
.args(["create", "--title", "Alpha", "--status", "triage"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "wip"])
.assert()
.success();
tix()
.current_dir(&dir)
.args(["move", "1", "review"])
.assert()
.success();
let config = fs::read_to_string(dir.join("tix.toml")).unwrap();
fs::write(
dir.join("tix.toml"),
config.replace("review = \"active\"", "review = \"backlog\""),
)
.unwrap();
let before = snapshot(&dir);
let out = tix().current_dir(&dir).arg("lint").output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("direct_edit_policy_violation")
&& stderr.contains("no longer permitted")
&& stderr.contains("`wip` -> `review`"),
"the recorded decision is now against policy, and the finding says so: {stderr}"
);
assert_eq!(
snapshot(&dir),
before,
"and the record is still the record: nothing is rewritten"
);
}
#[test]
fn t029_a_load_fatal_configuration_is_refused_before_lint_runs() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join("docs").join("issues")).unwrap();
fs::write(
dir.join("tix.toml"),
"issues_dir = \"docs/issues\"\n\n\
[kanban.default]\n\
columns = [\"a\", \"b\"]\n\
done_column = \"b\"\n\n\
[kanban.default.category_map]\n\
a = \"backlog\"\n\
b = \"active\"\n",
)
.unwrap();
tix()
.current_dir(&dir)
.arg("lint")
.assert()
.failure()
.stderr(predicate::str::contains("missing_completion_category"));
}