use std::path::Path;
use std::process::Command;
fn qualifier_bin() -> String {
let mut path = std::env::current_exe()
.unwrap()
.parent() .unwrap()
.parent() .unwrap()
.to_path_buf();
path.push("qualifier");
path.to_string_lossy().into_owned()
}
const PROVENANCE_ENV: &[&str] = &[
"QUALIFIER_ISSUER",
"QUALIFIER_ISSUER_TYPE",
"QUALIFIER_SESSION",
"CLAUDECODE",
"CLAUDE_CODE_SESSION_ID",
];
fn qualifier_cmd() -> Command {
let mut cmd = Command::new(qualifier_bin());
for var in PROVENANCE_ENV {
cmd.env_remove(var);
}
cmd
}
fn run_qualifier_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> (String, String, i32) {
let output = qualifier_cmd()
.args(args)
.envs(env.iter().copied())
.current_dir(dir)
.output()
.expect("failed to run qualifier binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code().unwrap_or(-1),
)
}
fn run_qualifier(dir: &Path, args: &[&str]) -> (String, String, i32) {
let output = qualifier_cmd()
.args(args)
.current_dir(dir)
.output()
.expect("failed to run qualifier binary");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let code = output.status.code().unwrap_or(-1);
(stdout, stderr, code)
}
#[test]
fn test_record_and_show_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"lib.rs",
"Well structured code",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record should succeed: {stdout}");
assert!(stdout.contains("lib.rs"));
let qual_path = dir.path().join(".qual");
assert!(qual_path.exists(), "qual file should be created");
let (show_stdout, _, show_code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(show_code, 0, "show should succeed");
assert!(show_stdout.contains("lib.rs"));
}
#[test]
fn test_record_requires_message() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["record", "pass", "foo.rs"]);
assert_ne!(code, 0, "record without message should fail");
assert!(
stderr.contains("message") || stderr.contains("required"),
"error should mention message: {stderr}"
);
}
#[test]
fn test_show_json_output() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"praise",
"api.rs",
"clean API",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "api.rs", "--format", "json"]);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("show --format json should produce valid JSON: {e}\ngot: {stdout}")
});
assert_eq!(parsed["subject"], "api.rs");
assert!(parsed["records"].is_array());
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
}
#[test]
fn test_show_nonexistent_artifact() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["show", "nonexistent.rs"]);
assert_ne!(code, 0, "show nonexistent artifact should fail");
assert!(
stderr.contains("No .qual file") || stderr.contains("nonexistent"),
"error should mention missing qual file: {stderr}"
);
}
#[test]
fn test_record_writes_to_directory_qual_by_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"pass",
"src/foo.rs",
"looks good",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record should succeed");
let dir_qual = dir.path().join("src/.qual");
let one_to_one = dir.path().join("src/foo.rs.qual");
assert!(dir_qual.exists(), "should create directory-level .qual");
assert!(!one_to_one.exists(), "should NOT create 1:1 .qual file");
}
#[test]
fn test_record_respects_existing_1to1_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/foo.rs.qual"), "").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"pass",
"src/foo.rs",
"looks good",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0);
let content = std::fs::read_to_string(dir.path().join("src/foo.rs.qual")).unwrap();
assert!(
!content.is_empty(),
"should have written to existing 1:1 file"
);
let dir_qual = dir.path().join("src/.qual");
assert!(
!dir_qual.exists(),
"should not create dir .qual when 1:1 exists"
);
}
#[test]
fn test_record_file_flag_override() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"src/foo.rs",
"nice",
"--issuer",
"mailto:test@test.com",
"--file",
"custom.qual",
],
);
assert_eq!(code, 0);
let custom = dir.path().join("custom.qual");
assert!(custom.exists(), "--file should write to specified path");
assert!(!dir.path().join("src/.qual").exists());
assert!(!dir.path().join("src/foo.rs.qual").exists());
}
#[test]
fn test_show_finds_annotation_in_directory_qual() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
run_qualifier(
dir.path(),
&[
"record",
"praise",
"src/bar.rs",
"clean code",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "src/bar.rs"]);
assert_eq!(code, 0, "show should find annotation in directory .qual");
assert!(stdout.contains("src/bar.rs"));
}
#[test]
fn test_show_and_reply_in_hidden_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
std::fs::create_dir_all(dir.path().join(".github/workflows")).unwrap();
let (stdout, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
".github/workflows/rust.yml",
"pin action versions",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record should succeed: {stdout}{stderr}");
assert!(dir.path().join(".github/workflows/.qual").exists());
let (stdout, _, code) = run_qualifier(dir.path(), &["show", ".github/workflows/rust.yml"]);
assert_eq!(code, 0);
assert!(
stdout.contains("pin action versions"),
"show should find records under .github/: {stdout}"
);
let id = run_qualifier(
dir.path(),
&["show", ".github/workflows/rust.yml", "--format", "json"],
)
.0;
let v: serde_json::Value = serde_json::from_str(&id).unwrap();
let id = v["records"][0]["id"]
.as_str()
.unwrap_or_else(|| panic!("no id in show json: {v}"))
.to_string();
let (stdout, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"pinned",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "reply by ID should succeed: {stdout}{stderr}");
}
#[test]
fn test_record_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"pass",
"src/deep/module.rs",
"ok",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record should create parent dirs as needed");
assert!(dir.path().join("src/deep/.qual").exists());
}
#[test]
fn test_ls_basic_listing() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"praise",
"foo.rs",
"great",
"--issuer",
"mailto:test@test.com",
],
);
run_qualifier(
dir.path(),
&[
"record",
"concern",
"bar.rs",
"meh",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["ls"]);
assert_eq!(code, 0, "ls should succeed");
assert!(stdout.contains("foo.rs"), "ls should list foo.rs");
assert!(stdout.contains("bar.rs"), "ls should list bar.rs");
}
#[test]
fn test_ls_kind_filter() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"blocker",
"a.rs",
"bad",
"--issuer",
"mailto:test@test.com",
],
);
run_qualifier(
dir.path(),
&[
"record",
"praise",
"b.rs",
"good",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["ls", "--kind", "blocker"]);
assert_eq!(code, 0);
assert!(stdout.contains("a.rs"), "kind filter should show blocker");
assert!(!stdout.contains("b.rs"), "kind filter should hide praise");
}
#[test]
fn test_praise_shows_records() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"praise",
"foo.rs",
"Well structured code",
"--issuer",
"mailto:alice@example.com",
],
);
run_qualifier(
dir.path(),
&[
"record",
"concern",
"foo.rs",
"Missing error handling",
"--issuer",
"mailto:bob@example.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["praise", "foo.rs"]);
assert_eq!(code, 0, "praise should succeed");
assert!(
stdout.contains("foo.rs"),
"should show artifact name: {stdout}"
);
assert!(
stdout.contains("2 records"),
"should show record count: {stdout}"
);
assert!(
stdout.contains("alice@example.com"),
"should show issuer: {stdout}"
);
assert!(
stdout.contains("bob@example.com"),
"should show second issuer: {stdout}"
);
assert!(
stdout.contains("Well structured code"),
"should show summary: {stdout}"
);
}
#[test]
fn test_praise_blame_alias() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"pass",
"foo.rs",
"ok",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, stderr, code) = run_qualifier(dir.path(), &["blame", "foo.rs"]);
assert_eq!(code, 0, "blame alias should succeed");
assert!(
stderr.contains("hint") && stderr.contains("praise"),
"should print hint about praise: {stderr}"
);
assert!(
stdout.contains("foo.rs"),
"should still produce output: {stdout}"
);
}
#[test]
fn test_praise_vcs_without_vcs() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"pass",
"foo.rs",
"ok",
"--issuer",
"mailto:test@test.com",
],
);
let (_, stderr, code) = run_qualifier(dir.path(), &["praise", "foo.rs", "--vcs"]);
assert_ne!(code, 0, "praise --vcs should fail without VCS");
assert!(
stderr.contains("No VCS") || stderr.contains("--vcs"),
"should mention VCS: {stderr}"
);
}
#[test]
fn test_record_batch_validates() {
let dir = tempfile::tempdir().unwrap();
let invalid_json = serde_json::json!({
"kind": "pass",
"location": "test.rs",
"message": ""
});
let output = qualifier_cmd()
.args(["record", "--stdin"])
.current_dir(dir.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
writeln!(stdin, "{}", invalid_json).ok();
}
child.wait_with_output()
})
.expect("failed to run batch mode");
assert!(
!output.status.success(),
"batch mode should reject invalid annotation (empty summary)"
);
}
#[test]
fn test_record_batch_full_record_form() {
let dir = tempfile::tempdir().unwrap();
let invalid_json = serde_json::json!({
"subject": "test.rs",
"body": {
"kind": "pass",
"summary": ""
},
"issuer": "mailto:test@test.com",
"created_at": "2026-01-01T00:00:00Z"
});
let output = qualifier_cmd()
.args(["record", "--stdin"])
.current_dir(dir.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
writeln!(stdin, "{}", invalid_json).ok();
}
child.wait_with_output()
})
.expect("failed to run batch mode");
assert!(
!output.status.success(),
"batch mode should reject full record with empty summary"
);
}
#[test]
fn test_record_with_issuer_type() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"lib.rs",
"Clean code",
"--issuer",
"mailto:test@test.com",
"--issuer-type",
"human",
],
);
assert_eq!(code, 0, "record with --issuer-type should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"issuer_type\":\"human\""),
"annotation should contain issuer_type: {content}"
);
}
#[test]
fn test_record_with_ref() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"pass",
"lib.rs",
"Looks good",
"--issuer",
"mailto:test@test.com",
"--ref",
"git:3aba500",
],
);
assert_eq!(code, 0, "record with --ref should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"ref\":\"git:3aba500\""),
"annotation should contain ref: {content}"
);
}
#[test]
fn test_new_annotations_are_metabox() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"mod.rs",
"nice",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0);
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"metabox\":\"1\""),
"new annotations should be metabox format: {content}"
);
assert!(
content.contains("\"type\":\"annotation\""),
"new annotations should have type field: {content}"
);
}
#[test]
fn test_record_invalid_issuer_type() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"pass",
"lib.rs",
"ok",
"--issuer",
"mailto:test@test.com",
"--issuer-type",
"banana",
],
);
assert_ne!(code, 0, "invalid issuer_type should fail");
assert!(
stderr.contains("issuer_type") || stderr.contains("banana"),
"error should mention invalid issuer_type: {stderr}"
);
}
#[test]
fn test_record_with_span() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"Problematic function",
"--issuer",
"mailto:test@test.com",
"--span",
"42:58",
],
);
assert_eq!(code, 0, "record with --span should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"span\""),
"annotation should contain span: {content}"
);
assert!(
content.contains("\"line\":42"),
"span should contain start line: {content}"
);
assert!(
content.contains("\"line\":58"),
"span should contain end line: {content}"
);
}
#[test]
fn test_record_span_via_location() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs:42:58",
"Problematic function",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record with location-encoded span should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"line\":42"),
"span should contain start line: {content}"
);
assert!(
content.contains("\"line\":58"),
"span should contain end line: {content}"
);
}
#[test]
fn test_record_with_span_and_columns() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"Bad code",
"--issuer",
"mailto:test@test.com",
"--span",
"10.5:20.80",
],
);
assert_eq!(code, 0, "record with --span line.col should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"col\":5"),
"span should contain start col: {content}"
);
assert!(
content.contains("\"col\":80"),
"span should contain end col: {content}"
);
}
#[test]
fn test_show_pretty_shows_source() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(
&src,
"fn main() {\n let x = 1;\n let y = 2;\n let z = 3;\n println!(\"{}\", x + y + z);\n}\n",
)
.unwrap();
run_qualifier(
dir.path(),
&[
"record",
"comment",
"example.rs:3",
"needs a better name",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "example.rs", "--pretty"]);
assert_eq!(code, 0, "show --pretty should succeed: {stdout}");
assert!(
stdout.contains("let y = 2"),
"pretty output should show source line: {stdout}"
);
assert!(
stdout.contains(">"),
"pretty output should have > marker: {stdout}"
);
assert!(
stdout.contains("example.rs"),
"pretty output should show file path: {stdout}"
);
}
#[test]
fn test_show_pretty_json() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(
&src,
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n",
)
.unwrap();
run_qualifier(
dir.path(),
&[
"record",
"comment",
"example.rs:3",
"check this",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(
dir.path(),
&["show", "example.rs", "--format", "json", "--pretty"],
);
assert_eq!(code, 0, "show --pretty --format json should succeed");
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("should produce valid JSON: {e}\ngot: {stdout}"));
let records = parsed["records"]
.as_array()
.expect("should have records array");
assert!(!records.is_empty(), "should have at least one record");
let rec = &records[0];
assert!(
rec.get("context").is_some(),
"record should have context field: {stdout}"
);
let context = &rec["context"];
assert!(
context["lines"].is_array(),
"context should have lines array: {stdout}"
);
let lines = context["lines"].as_array().unwrap();
let span_line = lines.iter().find(|l| l["in_span"] == true);
assert!(span_line.is_some(), "should have an in_span line: {stdout}");
assert_eq!(span_line.unwrap()["line"], 3);
}
#[test]
fn test_show_pretty_file_not_found() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"comment",
"nonexistent.rs:5",
"some comment",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "nonexistent.rs", "--pretty"]);
assert_eq!(
code, 0,
"show --pretty should succeed even without source file"
);
assert!(
stdout.contains("note:"),
"should show a note about missing file: {stdout}"
);
}
#[test]
fn test_show_pretty_no_span() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(&src, "fn main() {}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"pass",
"example.rs",
"general comment",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "example.rs", "--pretty"]);
assert_eq!(code, 0, "show --pretty without span should succeed");
assert!(
!stdout.contains("> "),
"no span means no source context markers: {stdout}"
);
}
#[test]
fn test_record_with_references() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, code1) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"Needs improvement",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code1, 0, "first record should succeed: {stdout1}");
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let (_, _, code2) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"Addressed in latest refactor",
"--issuer",
"mailto:test@test.com",
"--references",
&id,
],
);
assert_eq!(code2, 0, "record with --references should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains(&format!("\"references\":\"{id}\"")),
"annotation should contain references field: {content}"
);
}
#[test]
fn test_show_displays_references() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, _) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"Needs work",
"--issuer",
"mailto:test@test.com",
],
);
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"This was fixed",
"--issuer",
"mailto:test@test.com",
"--references",
&id,
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(code, 0, "show should succeed");
assert!(
stdout.contains("This was fixed"),
"show output should contain referencing record: {stdout}"
);
let lines: Vec<&str> = stdout.lines().collect();
let parent_line = lines.iter().position(|l| l.contains("Needs work"));
let ref_line = lines.iter().position(|l| l.contains("This was fixed"));
assert!(
parent_line.is_some() && ref_line.is_some(),
"should find both parent and referencing record: {stdout}"
);
assert!(
ref_line.unwrap() > parent_line.unwrap(),
"referencing record should appear after parent: {stdout}"
);
}
#[test]
fn test_reply_basic() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, code1) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"needs improvement",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code1, 0, "initial comment should succeed: {stdout1}");
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let prefix = &id[..8];
let (stdout2, _, code2) = run_qualifier(
dir.path(),
&[
"reply",
prefix,
"fixed this",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code2, 0, "reply should succeed: {stdout2}");
assert!(
stdout2.contains("re:"),
"reply output should show re: line: {stdout2}"
);
assert!(
stdout2.contains(prefix),
"reply output should show referenced prefix: {stdout2}"
);
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains(&format!("\"references\":\"{id}\"")),
"reply should set references to full target ID: {content}"
);
let (show_stdout, _, show_code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(show_code, 0);
assert!(
show_stdout.contains("fixed this"),
"show output should contain reply text: {show_stdout}"
);
let lines: Vec<&str> = show_stdout.lines().collect();
let parent_line = lines.iter().position(|l| l.contains("needs improvement"));
let reply_line = lines.iter().position(|l| l.contains("fixed this"));
assert!(
parent_line.is_some() && reply_line.is_some(),
"should find both parent and reply in output: {show_stdout}"
);
assert!(
reply_line.unwrap() > parent_line.unwrap(),
"reply should appear after parent: {show_stdout}"
);
}
#[test]
fn test_reply_inherits_subject() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let (stdout1, _, code1) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"src/parser.rs",
"needs refactoring",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code1, 0);
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let (stdout2, _, code2) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"refactored",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code2, 0, "reply should succeed: {stdout2}");
assert!(
stdout2.contains("src/parser.rs"),
"reply should inherit subject from target: {stdout2}"
);
let (show_stdout, _, show_code) =
run_qualifier(dir.path(), &["show", "src/parser.rs", "--format", "json"]);
assert_eq!(show_code, 0);
let parsed: serde_json::Value = serde_json::from_str(&show_stdout).unwrap();
let records = parsed["records"].as_array().unwrap();
assert_eq!(
records.len(),
2,
"should have 2 records for src/parser.rs: {show_stdout}"
);
}
#[test]
fn test_reply_not_found() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
"deadbeef",
"hello",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "reply to nonexistent ID should fail");
assert!(
stderr.contains("no record found"),
"error should mention no record found: {stderr}"
);
}
#[test]
fn test_reply_with_kind_override() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, _) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"issue here",
"--issuer",
"mailto:test@test.com",
],
);
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let (stdout2, _, code2) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"approved the fix",
"--kind",
"pass",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(
code2, 0,
"reply with --kind override should succeed: {stdout2}"
);
assert!(
stdout2.contains("pass"),
"reply should use overridden kind: {stdout2}"
);
}
#[test]
fn test_reply_by_location() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(
dir.path().join("src/auth.rs"),
"fn login() {}\nfn logout() {}\n",
)
.unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"src/auth.rs:1",
"needs validation",
"--issuer",
"mailto:alice@test.com",
],
);
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"reply",
"src/auth.rs:1",
"added validation",
"--issuer",
"mailto:bob@test.com",
],
);
assert_eq!(code, 0, "reply by location should succeed: {stdout}");
assert!(
stdout.contains("re:"),
"reply output should show re: line: {stdout}"
);
}
#[test]
fn test_reply_by_location_ambiguous() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(
dir.path().join("src/auth.rs"),
"fn login() {}\nfn logout() {}\n",
)
.unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"src/auth.rs",
"first concern",
"--issuer",
"mailto:alice@test.com",
],
);
run_qualifier(
dir.path(),
&[
"record",
"concern",
"src/auth.rs",
"second concern",
"--issuer",
"mailto:bob@test.com",
],
);
let (stdout, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
"src/auth.rs",
"responding",
"--issuer",
"mailto:carol@test.com",
],
);
if code != 0 {
assert!(
stderr.contains("ambiguous"),
"should either succeed or surface ambiguity: stdout={stdout} stderr={stderr}"
);
} else {
assert!(stdout.contains("re:"), "should show re: line: {stdout}");
}
}
#[test]
fn test_show_threads_replies_under_parent() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, _) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"first issue",
"--issuer",
"mailto:alice@test.com",
],
);
let id1 = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id");
run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"second issue",
"--issuer",
"mailto:bob@test.com",
],
);
run_qualifier(
dir.path(),
&[
"reply",
&id1[..8],
"fixed first issue",
"--issuer",
"mailto:bob@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(code, 0);
let lines: Vec<&str> = stdout.lines().collect();
let first_pos = lines
.iter()
.position(|l| l.contains("first issue") && !l.contains("fixed"));
let reply_pos = lines.iter().position(|l| l.contains("fixed first issue"));
let second_pos = lines.iter().position(|l| l.contains("second issue"));
assert!(first_pos.is_some(), "should find first issue: {stdout}");
assert!(reply_pos.is_some(), "should find reply: {stdout}");
assert!(second_pos.is_some(), "should find second issue: {stdout}");
let first = first_pos.unwrap();
let reply = reply_pos.unwrap();
let second = second_pos.unwrap();
assert!(
reply > first && reply < second,
"reply should be threaded between first ({first}) and second ({second}), got reply at {reply}: {stdout}"
);
let reply_line_text = lines[reply];
assert!(
reply_line_text.contains('\u{2514}') || reply_line_text.contains('\u{251c}'),
"reply should have tree branch character: {reply_line_text}"
);
}
#[test]
fn test_resolve_basic() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, code1) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"needs improvement",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code1, 0, "initial comment should succeed: {stdout1}");
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let prefix = &id[..8];
let (stdout2, _, code2) = run_qualifier(
dir.path(),
&[
"resolve",
prefix,
"fixed in PR #42",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code2, 0, "resolve should succeed: {stdout2}");
assert!(
stdout2.contains("resolve"),
"output should show resolve kind: {stdout2}"
);
assert!(
stdout2.contains("supersedes:"),
"output should show supersedes line: {stdout2}"
);
let (show_stdout, _, show_code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(show_code, 0);
assert!(
!show_stdout.contains("needs improvement"),
"superseded record should be hidden from show: {show_stdout}"
);
assert!(
!show_stdout.contains("fixed in PR #42"),
"resolve tombstone should be hidden by default: {show_stdout}"
);
assert!(
show_stdout.contains("Records (0)"),
"no active records should remain: {show_stdout}"
);
let (show_all_stdout, _, show_all_code) =
run_qualifier(dir.path(), &["show", "lib.rs", "--all"]);
assert_eq!(show_all_code, 0);
assert!(
show_all_stdout.contains("resolve"),
"tombstone should appear with --all: {show_all_stdout}"
);
assert!(
show_all_stdout.contains("needs improvement"),
"superseded record should appear with --all: {show_all_stdout}"
);
}
#[test]
fn test_resolve_default_message() {
let dir = tempfile::tempdir().unwrap();
let (stdout1, _, _) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"some issue",
"--issuer",
"mailto:test@test.com",
],
);
let id = stdout1
.lines()
.find(|l| l.contains("id:"))
.and_then(|l| l.split("id:").nth(1))
.map(|s| s.trim().to_string())
.expect("should find id in output");
let (stdout2, _, code2) = run_qualifier(
dir.path(),
&["resolve", &id[..8], "--issuer", "mailto:test@test.com"],
);
assert_eq!(
code2, 0,
"resolve without message should succeed: {stdout2}"
);
assert!(
stdout2.contains("Resolved"),
"default message should be 'Resolved': {stdout2}"
);
}
#[test]
fn test_resolve_not_found() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(
dir.path(),
&["resolve", "deadbeef", "--issuer", "mailto:test@test.com"],
);
assert_ne!(code, 0, "resolve with nonexistent ID should fail");
assert!(
stderr.contains("no record found"),
"error should mention no record found: {stderr}"
);
}
#[test]
fn test_resolve_by_location() {
let dir = tempfile::tempdir().unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs:42",
"buggy",
"--issuer",
"mailto:alice@test.com",
],
);
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"resolve",
"lib.rs:42",
"fixed",
"--issuer",
"mailto:bob@test.com",
],
);
assert_eq!(code, 0, "resolve by location should succeed: {stdout}");
assert!(stdout.contains("resolve"), "should show resolve kind");
assert!(
stdout.contains("supersedes:"),
"should show supersedes line: {stdout}"
);
}
#[test]
fn test_record_concern_auto_populates_content_hash() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(
&src,
"fn main() {\n let x = 1;\n let y = 2;\n println!(\"{}\", x + y);\n}\n",
)
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"example.rs:2",
"needs a better name",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record concern should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"content_hash\":"),
"annotation should contain content_hash: {content}"
);
}
#[test]
fn test_record_suggestion_auto_populates_content_hash() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, "fn foo() {}\nfn bar() {}\nfn baz() {}\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"suggestion",
"lib.rs:1:2",
"Consider combining these",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record suggestion should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"content_hash\":"),
"annotation should contain content_hash: {content}"
);
}
#[test]
fn test_record_span_flag_auto_populates_content_hash() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, "line 1\nline 2\nline 3\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"issue here",
"--issuer",
"mailto:test@test.com",
"--span",
"2",
],
);
assert_eq!(code, 0, "record with --span should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"content_hash\":"),
"annotation should contain content_hash: {content}"
);
}
#[test]
fn test_no_content_hash_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"nonexistent.rs:5",
"some concern",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "record should succeed even without file");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
!content.contains("\"content_hash\":"),
"should not have content_hash when file doesn't exist: {content}"
);
}
#[test]
fn test_review_empty_project() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["review"]);
assert_eq!(code, 0, "review on empty project should succeed");
assert!(
stdout.contains("No .qual files") || stdout.contains("No annotations"),
"should indicate nothing to check: {stdout}"
);
}
#[test]
fn test_review_fresh() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(&src, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"example.rs:2",
"consider logging instead",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["review"]);
assert_eq!(code, 0, "review should succeed");
assert!(
stdout.contains("FRESH"),
"unchanged code should be FRESH: {stdout}"
);
assert!(
stdout.contains("1 fresh"),
"summary should show 1 fresh: {stdout}"
);
}
#[test]
fn test_review_drifted() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(&src, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"example.rs:2",
"consider logging",
"--issuer",
"mailto:test@test.com",
],
);
std::fs::write(&src, "fn main() {\n eprintln!(\"changed\");\n}\n").unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["review"]);
assert_eq!(code, 0, "review should succeed");
assert!(
stdout.contains("DRIFTED"),
"changed code should be DRIFTED: {stdout}"
);
assert!(
stdout.contains("1 drifted"),
"summary should show 1 drifted: {stdout}"
);
}
#[test]
fn test_review_missing() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(&src, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"example.rs:2",
"consider logging",
"--issuer",
"mailto:test@test.com",
],
);
std::fs::remove_file(&src).unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["review"]);
assert_eq!(code, 0, "review should succeed");
assert!(
stdout.contains("MISSING"),
"deleted file should be MISSING: {stdout}"
);
assert!(
stdout.contains("1 missing"),
"summary should show 1 missing: {stdout}"
);
}
#[test]
fn test_review_json_output() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("example.rs");
std::fs::write(&src, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"example.rs:2",
"consider logging",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["review", "--format", "json"]);
assert_eq!(code, 0, "review --format json should succeed");
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!("review --format json should produce valid JSON: {e}\ngot: {stdout}")
});
assert!(parsed.is_array(), "JSON output should be an array");
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1, "should have one result");
let entry = &arr[0];
assert_eq!(entry["status"], "fresh");
assert_eq!(entry["kind"], "concern");
assert!(entry["subject"].as_str().unwrap().contains("example.rs"));
}
#[test]
fn test_review_subject_filter() {
let dir = tempfile::tempdir().unwrap();
let src1 = dir.path().join("a.rs");
std::fs::write(&src1, "fn a() {}\n").unwrap();
let src2 = dir.path().join("b.rs");
std::fs::write(&src2, "fn b() {}\n").unwrap();
run_qualifier(
dir.path(),
&[
"record",
"concern",
"a.rs:1",
"issue in a",
"--issuer",
"mailto:test@test.com",
],
);
run_qualifier(
dir.path(),
&[
"record",
"concern",
"b.rs:1",
"issue in b",
"--issuer",
"mailto:test@test.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["review", "a.rs"]);
assert_eq!(code, 0);
assert!(
stdout.contains("1 annotations checked"),
"should only check 1 annotation: {stdout}"
);
}
#[test]
fn test_review_finds_annotations_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
let src_dir = dir.path().join("src");
std::fs::create_dir(&src_dir).unwrap();
std::fs::write(src_dir.join("lib.rs"), "fn alpha() {}\nfn beta() {}\n").unwrap();
let (_, _, rc) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"src/lib.rs:2",
"look at beta",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(rc, 0, "record should succeed");
let nested = dir.path().join("src");
let (stdout, stderr, code) = run_qualifier(&nested, &["review"]);
assert_eq!(code, 0, "review from subdir should succeed: {stderr}");
assert!(
stdout.contains("FRESH"),
"annotation should be discovered and FRESH from subdir: {stdout}"
);
assert!(
stdout.contains("1 fresh"),
"summary should report 1 fresh from subdir: {stdout}"
);
}
#[test]
fn test_emit_annotation() {
let dir = tempfile::tempdir().unwrap();
let body = serde_json::json!({
"kind": "pass",
"summary": "looks good"
});
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"emit",
"annotation",
"lib.rs",
"--body",
&body.to_string(),
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "emit annotation should succeed: {stdout}");
assert!(stdout.contains("annotation"), "output should mention type");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"type\":\"annotation\""),
"should emit annotation type: {content}"
);
assert!(
content.contains("\"kind\":\"pass\""),
"should preserve body: {content}"
);
}
#[test]
fn test_emit_unknown_type_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let body = serde_json::json!({"foo": "bar", "version": 1});
let (_, _, code) = run_qualifier(
dir.path(),
&[
"emit",
"https://example.com/custom/v1",
"widget.rs",
"--body",
&body.to_string(),
"--issuer",
"https://ci.example.com",
],
);
assert_eq!(code, 0, "emit custom type should succeed");
let qual_path = dir.path().join(".qual");
let content = std::fs::read_to_string(&qual_path).unwrap();
assert!(
content.contains("\"type\":\"https://example.com/custom/v1\""),
"should preserve custom type URI: {content}"
);
assert!(
content.contains("\"foo\":\"bar\""),
"should preserve body verbatim: {content}"
);
}
#[test]
fn test_emit_annotation_validates_body() {
let dir = tempfile::tempdir().unwrap();
let body = serde_json::json!({"summary": "missing kind"});
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"emit",
"annotation",
"lib.rs",
"--body",
&body.to_string(),
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(
code, 0,
"emit annotation with invalid body should fail: stderr={stderr}"
);
}
fn write_qual_with_unknown(dir: &Path, qual_rel_path: &str, subject: &str) -> (String, String) {
let custom_id = "f".repeat(64);
let custom_record = serde_json::json!({
"metabox": "1",
"type": "https://example.com/custom/v1",
"subject": subject,
"issuer": "https://ci.example.com",
"created_at": "2026-04-01T00:00:00Z",
"id": custom_id,
"body": {"foo": "bar"}
});
let qual_path = dir.join(qual_rel_path);
if let Some(parent) = qual_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(
&qual_path,
format!("{}\n", serde_json::to_string(&custom_record).unwrap()),
)
.unwrap();
let (_, _, code) = run_qualifier(
dir,
&[
"record",
"praise",
subject,
"looks good",
"--issuer",
"mailto:test@test.com",
"--file",
qual_rel_path,
],
);
assert_eq!(code, 0, "record helper should succeed");
let contents = std::fs::read_to_string(&qual_path).unwrap();
let annotation_line = contents
.lines()
.find(|l| l.contains("\"type\":\"annotation\""))
.expect("expected an annotation line in the qual file");
let annotation_value: serde_json::Value = serde_json::from_str(annotation_line).unwrap();
let annotation_id = annotation_value["id"].as_str().unwrap().to_string();
(custom_id, annotation_id)
}
#[test]
fn test_show_preserves_unknown_record_type() {
let dir = tempfile::tempdir().unwrap();
let (_custom_id, _annotation_id) = write_qual_with_unknown(dir.path(), "lib.rs.qual", "lib.rs");
let (stdout, stderr, code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(
code, 0,
"show must not crash on unknown record type. stderr={stderr}"
);
assert!(
stdout.contains("lib.rs"),
"stdout should mention subject: {stdout}"
);
assert!(
stdout.contains("looks good") || stdout.contains("praise"),
"annotation should still be displayed: {stdout}"
);
assert!(
stdout.contains("https://example.com/custom/v1"),
"unknown type string should be visible in human output: {stdout}"
);
let (json_stdout, _, json_code) =
run_qualifier(dir.path(), &["show", "lib.rs", "--format", "json"]);
assert_eq!(json_code, 0);
let parsed: serde_json::Value = serde_json::from_str(&json_stdout).unwrap();
let records = parsed["records"].as_array().expect("records array");
assert_eq!(
records.len(),
2,
"both records should be present: {json_stdout}"
);
let unknown = records
.iter()
.find(|r| r["type"].as_str() == Some("https://example.com/custom/v1"))
.expect("unknown record should round-trip through JSON");
assert_eq!(unknown["body"]["foo"], "bar");
assert_eq!(unknown["subject"], "lib.rs");
}
#[test]
fn test_show_omits_dependency_records_in_human_output() {
let dir = tempfile::tempdir().unwrap();
let dep_record = serde_json::json!({
"metabox": "1",
"type": "dependency",
"subject": "app.rs",
"issuer": "https://ci.example.com",
"created_at": "2026-04-01T00:00:00Z",
"id": "d".repeat(64),
"body": {"depends_on": ["lib.rs"]}
});
let qual_path = dir.path().join("app.rs.qual");
std::fs::write(
&qual_path,
format!("{}\n", serde_json::to_string(&dep_record).unwrap()),
)
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"app.rs",
"ships clean",
"--issuer",
"mailto:test@test.com",
"--file",
"app.rs.qual",
],
);
assert_eq!(code, 0, "record helper should succeed");
let (stdout, stderr, code) = run_qualifier(dir.path(), &["show", "app.rs"]);
assert_eq!(
code, 0,
"show must succeed on a subject with mixed records. stderr={stderr}"
);
assert!(
stdout.contains("ships clean") || stdout.contains("praise"),
"annotation should be rendered in human output: {stdout}"
);
assert!(
!stdout.contains("dependency"),
"dependency records must not appear in human `show` output: {stdout}"
);
assert!(
!stdout.contains("[---]"),
"dependency should be silently skipped, not rendered with the unknown-fallback marker: {stdout}"
);
let (json_stdout, _, json_code) =
run_qualifier(dir.path(), &["show", "app.rs", "--format", "json"]);
assert_eq!(json_code, 0);
let parsed: serde_json::Value = serde_json::from_str(&json_stdout).unwrap();
let records = parsed["records"].as_array().expect("records array");
let dep = records
.iter()
.find(|r| r["type"].as_str() == Some("dependency"))
.expect("dependency record should round-trip through JSON output");
assert_eq!(dep["subject"], "app.rs");
assert_eq!(dep["body"]["depends_on"][0], "lib.rs");
}
#[test]
fn test_ls_preserves_unknown_record_type() {
let dir = tempfile::tempdir().unwrap();
let _ = write_qual_with_unknown(dir.path(), "widget.rs.qual", "widget.rs");
let (stdout, stderr, code) = run_qualifier(dir.path(), &["ls"]);
assert_eq!(
code, 0,
"ls must not crash on unknown record type: {stderr}"
);
assert!(
stdout.contains("widget.rs"),
"ls output should list subject: {stdout}"
);
let (json_stdout, _, json_code) = run_qualifier(dir.path(), &["ls", "--format", "json"]);
assert_eq!(json_code, 0);
let parsed: serde_json::Value = serde_json::from_str(&json_stdout).unwrap();
let arr = parsed.as_array().unwrap();
let _widget_entry = arr
.iter()
.find(|e| e["subject"] == "widget.rs")
.expect("widget.rs should appear in ls output");
}
#[test]
fn test_show_filters_by_record_type() {
let dir = tempfile::tempdir().unwrap();
let (custom_id, annotation_id) =
write_qual_with_unknown(dir.path(), "thing.rs.qual", "thing.rs");
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "thing.rs", "--format", "json"]);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["records"].as_array().unwrap().len(), 2);
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"show",
"thing.rs",
"--format",
"json",
"--type",
"annotation",
],
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let records = parsed["records"].as_array().unwrap();
assert_eq!(
records.len(),
1,
"filter should keep only annotations: {stdout}"
);
assert_eq!(records[0]["id"], annotation_id);
assert_eq!(records[0]["type"], "annotation");
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"show",
"thing.rs",
"--format",
"json",
"--type",
"https://example.com/custom/v1",
],
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let records = parsed["records"].as_array().unwrap();
assert_eq!(
records.len(),
1,
"filter should keep only the unknown type: {stdout}"
);
assert_eq!(records[0]["id"], custom_id);
assert_eq!(records[0]["type"], "https://example.com/custom/v1");
let (stdout, _, code) = run_qualifier(
dir.path(),
&[
"show",
"thing.rs",
"--format",
"json",
"--type",
"no-such-type",
],
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
}
#[test]
fn test_compact_preserves_unknown_record_type() {
let dir = tempfile::tempdir().unwrap();
let (custom_id, first_annotation_id) =
write_qual_with_unknown(dir.path(), "keep.rs.qual", "keep.rs");
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"keep.rs",
"even better",
"--issuer",
"mailto:test@test.com",
"--supersedes",
&first_annotation_id,
"--file",
"keep.rs.qual",
],
);
assert_eq!(code, 0, "supersedes record should succeed");
let (_, _, code) = run_qualifier(dir.path(), &["compact", "keep.rs"]);
assert_eq!(code, 0);
let after_prune = std::fs::read_to_string(dir.path().join("keep.rs.qual")).unwrap();
assert!(
after_prune.contains(&custom_id),
"prune compaction must preserve unknown records (spec §3.3.1):\n{after_prune}"
);
assert!(
after_prune.contains("https://example.com/custom/v1"),
"unknown type string should survive prune"
);
let pruned_id_pattern = format!("\"id\":\"{first_annotation_id}\"");
assert!(
!after_prune.contains(&pruned_id_pattern),
"superseded annotation should have been pruned:\n{after_prune}"
);
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"praise",
"keep.rs",
"minor extra",
"--issuer",
"mailto:test@test.com",
"--file",
"keep.rs.qual",
],
);
assert_eq!(code, 0);
let (_, _, code) = run_qualifier(dir.path(), &["compact", "keep.rs", "--snapshot"]);
assert_eq!(code, 0);
let after_snapshot = std::fs::read_to_string(dir.path().join("keep.rs.qual")).unwrap();
assert!(
after_snapshot.contains(&custom_id),
"snapshot compaction must preserve unknown records (spec §3.3.1):\n{after_snapshot}"
);
assert!(
after_snapshot.contains("https://example.com/custom/v1"),
"unknown type string should survive snapshot"
);
assert!(
after_snapshot.contains("\"type\":\"epoch\""),
"snapshot should produce an epoch record:\n{after_snapshot}"
);
}
#[test]
fn test_agents_bare_invocation_succeeds() {
let dir = tempfile::tempdir().unwrap();
let (stdout, stderr, code) = run_qualifier(dir.path(), &["agents"]);
assert_eq!(code, 0, "agents should succeed: stderr={stderr}");
assert!(!stdout.is_empty(), "agents should print something");
}
#[test]
fn test_agents_unknown_topic_returns_error() {
let dir = tempfile::tempdir().unwrap();
let (_stdout, stderr, code) = run_qualifier(dir.path(), &["agents", "bogus-topic"]);
assert_eq!(
code, 1,
"unknown topic should exit 1 like other validation errors: stderr={stderr}"
);
assert!(
stderr.starts_with("qualifier:"),
"stderr should use the standard top-level error prefix: {stderr}"
);
assert!(
stderr.contains("no such topic"),
"stderr should explain: {stderr}"
);
assert!(
stderr.contains("bogus-topic"),
"stderr should name the bad topic: {stderr}"
);
}
#[test]
fn test_agents_all_registered_topics_render() {
let topics = [
"concepts",
"workflows",
"pitfalls",
"record",
"reply",
"resolve",
"emit",
"show",
"ls",
"praise",
"review",
"compact",
];
let dir = tempfile::tempdir().unwrap();
for topic in topics {
let (stdout, stderr, code) = run_qualifier(dir.path(), &["agents", topic]);
assert_eq!(code, 0, "agents {topic} should succeed: stderr={stderr}");
assert!(!stdout.is_empty(), "agents {topic} should print body");
}
}
#[test]
fn test_agents_overview_renders_topics_index() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _stderr, code) = run_qualifier(dir.path(), &["agents"]);
assert_eq!(code, 0);
for topic in [
"concepts",
"workflows",
"pitfalls",
"record",
"reply",
"resolve",
"emit",
"show",
"ls",
"praise",
"review",
"compact",
] {
assert!(
stdout.contains(topic),
"overview should mention topic '{topic}': {stdout}"
);
}
assert!(
!stdout.contains("{{TOPICS}}"),
"sentinel should be substituted: {stdout}"
);
}
#[test]
fn test_agents_orientation_summaries_match_pages() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _stderr, code) = run_qualifier(dir.path(), &["agents"]);
assert_eq!(code, 0);
for needle in [
"Annotation model, kinds, supersession", "Worked recipes for common tasks", "Common mistakes agents make with qualifier", "Record a new annotation", ] {
assert!(
stdout.contains(needle),
"orientation should include summary '{needle}': {stdout}"
);
}
}
#[test]
fn test_agents_conventions_and_batch_pages() {
let dir = tempfile::tempdir().unwrap();
let (out, _, code) = run_qualifier(dir.path(), &["agents", "conventions"]);
assert_eq!(code, 0);
for needle in [
"status:needs-decision",
"reason:wontfix",
"revisit:",
"alternative",
"session:",
"depends-on:",
] {
assert!(out.contains(needle), "conventions page must cover {needle}");
}
let (out, _, code) = run_qualifier(dir.path(), &["agents", "batch"]);
assert_eq!(code, 0);
for needle in [
"\"references\"",
"\"supersedes\"",
"\"kind\": \"resolve\"",
"reason:fixed",
"threads --format json",
"--dry-run",
] {
assert!(out.contains(needle), "batch page must cover {needle}");
}
}
#[test]
fn test_agents_concepts_documents_issuer_env_and_harness() {
let dir = tempfile::tempdir().unwrap();
let (out, _, code) = run_qualifier(dir.path(), &["agents", "concepts"]);
assert_eq!(code, 0);
assert!(out.contains("QUALIFIER_ISSUER_TYPE"));
assert!(out.contains("CLAUDECODE"));
}
fn run_qualifier_stdin(dir: &Path, args: &[&str], input: &str) -> (String, String, i32) {
use std::io::Write;
let mut child = qualifier_cmd()
.args(args)
.current_dir(dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn qualifier");
child
.stdin
.as_mut()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let output = child.wait_with_output().expect("wait_with_output");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code().unwrap_or(-1),
)
}
#[test]
fn test_record_stdin_emits_per_record_human_output() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"concern","location":"foo.rs:1","message":"first issue"}
{"kind":"suggestion","location":"foo.rs:2","message":"second"}
"#;
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--issuer", "mailto:probe@example.com"],
input,
);
assert_eq!(code, 0, "stdin should succeed: stderr={stderr}");
let stdout_lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
stdout_lines.len(),
2,
"expected one stdout line per record, got: {stdout}"
);
assert!(
stdout_lines[0].contains("concern") && stdout_lines[0].contains("first issue"),
"first line: {}",
stdout_lines[0]
);
assert!(
stdout_lines[1].contains("suggestion") && stdout_lines[1].contains("second"),
"second line: {}",
stdout_lines[1]
);
assert!(
stderr.contains("Recorded 2 of 2"),
"summary should be on stderr: {stderr}"
);
assert!(
!stdout.contains("Recorded 2"),
"summary should NOT be on stdout: {stdout}"
);
}
#[test]
fn test_record_stdin_json_format_emits_records() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"x.rs","message":"ok","issuer":"mailto:a@b.com"}"#
.to_string()
+ "\n";
let (stdout, _stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--format", "json"],
&input,
);
assert_eq!(code, 0);
for line in stdout.lines() {
let v: serde_json::Value =
serde_json::from_str(line).expect("each stdout line should be valid JSON");
assert_eq!(v["type"], "annotation");
assert_eq!(v["body"]["kind"], "pass");
assert!(v["id"].as_str().unwrap().len() == 64);
}
}
#[test]
fn test_record_stdin_error_includes_line_number() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":"first"}
{"kind":"concern","location":"b.rs"}
"#;
let (_, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--issuer", "mailto:a@b.com"],
input,
);
assert_ne!(code, 0, "should fail on the bad line");
assert!(
stderr.contains("stdin line 2"),
"error should name the offending line number: {stderr}"
);
}
#[test]
fn test_malformed_project_config_fails_loudly() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(
dir.path().join(".qualifier.toml"),
"this is = not [valid TOML\n",
)
.unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["ls"]);
assert_ne!(code, 0, "malformed config should fail: stderr={stderr}");
assert!(
stderr.starts_with("qualifier:"),
"should use the standard top-level error prefix: {stderr}"
);
assert!(
stderr.to_lowercase().contains("config")
|| stderr.to_lowercase().contains("toml")
|| stderr.contains(".qualifier.toml"),
"error should explain it's a config issue: {stderr}"
);
}
fn git_init(dir: &Path) {
let run = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
run(&["init", "-q", "--initial-branch=main"]);
run(&["config", "user.email", "probe@example.com"]);
run(&["config", "user.name", "probe"]);
run(&["config", "commit.gpgsign", "false"]);
}
fn git_commit_all(dir: &Path, msg: &str) {
let run = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
};
run(&["add", "-A"]);
run(&["commit", "-q", "-m", msg]);
}
#[test]
fn test_diff_added_resolved_drifted() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("main.rs"), "fn alpha() {}\nfn beta() {}\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"main.rs:2",
"look at beta",
"--issuer",
"mailto:probe@example.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"main.rs:1",
"look at alpha too",
"--issuer",
"mailto:probe@example.com",
],
);
assert_eq!(code, 0);
let (_, _, code) = run_qualifier(
dir.path(),
&[
"resolve",
"main.rs:2",
"fixed",
"--issuer",
"mailto:probe@example.com",
],
);
assert_eq!(code, 0);
std::fs::write(
dir.path().join("main.rs"),
"fn alpha() {}\nfn beta() { /* changed */ }\n",
)
.unwrap();
let (stdout, stderr, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_eq!(code, 0, "diff should succeed: stderr={stderr}");
assert!(
stdout.contains("Added on this branch (1)"),
"should report one added record: {stdout}"
);
assert!(
stdout.contains("look at alpha too"),
"added record summary should appear: {stdout}"
);
assert!(
stdout.contains("Resolved on this branch (1)"),
"should report one resolved record: {stdout}"
);
assert!(
stdout.contains("look at beta"),
"resolved record's original summary should appear: {stdout}"
);
assert!(
!stdout.contains("Added on this branch (2)"),
"resolve-kind record must not double-count under Added: {stdout}"
);
}
#[test]
fn test_diff_no_changes() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&["record", "pass", "a.rs", "ok", "--issuer", "mailto:a@b.com"],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_eq!(code, 0);
assert!(
stdout.contains("no annotation changes"),
"no-op diff should say so: {stdout}"
);
}
#[test]
fn test_diff_bad_ref() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
git_commit_all(dir.path(), "init");
let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "no-such-ref"]);
assert_ne!(code, 0);
assert!(
stderr.contains("not found"),
"should report unknown ref: {stderr}"
);
}
#[test]
fn test_diff_requires_git() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_ne!(code, 0);
assert!(
stderr.contains("git") || stderr.contains("VCS"),
"should report missing git repo: {stderr}"
);
}
#[test]
fn test_diff_json_format() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"a.rs",
"smell",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--format", "json"]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("diff --format json");
assert_eq!(v["ref"], "main");
assert!(v["added"].is_array());
assert_eq!(v["added"].as_array().unwrap().len(), 1);
assert!(v["resolved"].is_array());
assert!(v["drifted"].is_array());
}
#[test]
fn test_record_stdin_continue_on_error_keeps_valid_lines() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":"first"}
{"kind":"oops","location":"b.rs"}
{"kind":"concern","location":"c.rs","message":"third"}
"#;
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&[
"record",
"--stdin",
"--continue-on-error",
"--issuer",
"mailto:a@b.com",
],
input,
);
assert_ne!(code, 0, "should exit non-zero when any line fails");
let stdout_lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
stdout_lines.len(),
2,
"two valid lines should each emit one stdout line: {stdout}"
);
assert!(stdout_lines[0].contains("first"));
assert!(stdout_lines[1].contains("third"));
assert!(
stderr.contains("stdin line 2"),
"error should name the line: {stderr}"
);
assert!(
stderr.contains("missing 'message'"),
"error should describe the failure: {stderr}"
);
assert!(
stderr.contains("\"kind\":\"oops\""),
"error should echo the offending input: {stderr}"
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(qual.contains("first"));
assert!(qual.contains("third"));
assert!(!qual.contains("oops"));
}
#[test]
fn test_record_stdin_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":"hi"}
"#;
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&[
"record",
"--stdin",
"--dry-run",
"--issuer",
"mailto:a@b.com",
],
input,
);
assert_eq!(code, 0);
assert!(
stdout.contains("would-record"),
"dry-run output should use 'would-record' verb: {stdout}"
);
assert!(
stderr.contains("dry run"),
"summary should mention dry run: {stderr}"
);
assert!(
!dir.path().join(".qual").exists(),
"dry-run must not write the .qual file"
);
}
#[test]
fn test_record_stdin_dry_run_still_validates() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":""}
"#;
let (_, stderr, code) = run_qualifier_stdin(
dir.path(),
&[
"record",
"--stdin",
"--dry-run",
"--issuer",
"mailto:a@b.com",
],
input,
);
assert_ne!(code, 0, "dry-run must still report validation errors");
assert!(
stderr.contains("summary"),
"error should reference the empty summary: {stderr}"
);
assert!(
!dir.path().join(".qual").exists(),
"no file should be written even on validation success — let alone failure"
);
}
#[test]
fn test_record_stdin_json_errors_are_structured() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":"ok"}
{"kind":"oops","location":"b.rs"}
"#;
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&[
"record",
"--stdin",
"--continue-on-error",
"--format",
"json",
"--issuer",
"mailto:a@b.com",
],
input,
);
assert_ne!(code, 0);
for line in stdout.lines() {
let v: serde_json::Value =
serde_json::from_str(line).expect("stdout line should be valid JSON");
assert_eq!(v["type"], "annotation");
}
let stderr_lines: Vec<&str> = stderr.lines().collect();
assert!(stderr_lines.len() >= 2, "expected error+summary: {stderr}");
let err_obj: serde_json::Value =
serde_json::from_str(stderr_lines[0]).expect("first stderr line should be JSON");
assert_eq!(err_obj["line"], 2);
assert!(err_obj["error"].as_str().unwrap().contains("message"));
assert!(err_obj["input"].as_str().unwrap().contains("oops"));
let summary: serde_json::Value =
serde_json::from_str(stderr_lines.last().unwrap()).expect("trailer should be JSON");
assert_eq!(summary["summary"]["recorded"], 1);
assert_eq!(summary["summary"]["failed"], 1);
assert_eq!(summary["summary"]["total"], 2);
}
#[test]
fn test_record_stdin_default_is_all_or_nothing() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs","message":"ok"}
{"kind":"oops","location":"b.rs"}
{"kind":"concern","location":"c.rs","message":"third"}
"#;
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--issuer", "mailto:a@b.com"],
input,
);
assert_ne!(code, 0);
assert!(stderr.contains("stdin line 2"), "{stderr}");
assert!(
stderr.contains("\"kind\":\"oops\""),
"error echoes input: {stderr}"
);
assert!(stderr.contains("nothing written"), "{stderr}");
assert_eq!(stdout, "", "no record lines when nothing is written");
assert!(
!dir.path().join(".qual").exists(),
"no line may be written when any fails"
);
}
#[test]
fn test_record_stdin_reports_every_failing_line() {
let dir = tempfile::tempdir().unwrap();
let input = r#"{"kind":"pass","location":"a.rs"}
{"kind":"pass","location":"a.rs","message":"ok"}
{"kind":"pass","location":"b.rs"}
"#;
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], input);
assert_ne!(code, 0);
assert!(
stderr.contains("stdin line 1") && stderr.contains("stdin line 3"),
"{stderr}"
);
}
#[test]
fn test_record_stdin_reply_and_resolve_as_plain_records() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
let b = write_id(dir.path(), &["record", "concern", "b.rs", "b leaks"]);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"a.rs\",\"references\":\"{a}\",\"message\":\"confirmed\",\"tags\":[\"triage\"]}}\n\
{{\"kind\":\"resolve\",\"location\":\"b.rs\",\"supersedes\":\"{b}\",\"message\":\"fixed in 1a2b\",\"tags\":[\"reason:fixed\"],\"ref\":\"git:1a2b\"}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_eq!(code, 0, "{stderr}");
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(qual.contains(&format!("\"references\":\"{a}\"")), "{qual}");
assert!(
qual.contains(&format!("\"supersedes\":\"{b}\"")) && qual.contains("reason:fixed"),
"{qual}"
);
let threads = threads_json(dir.path(), &["--all"]);
assert_eq!(threads.len(), 2, "{threads:?}");
let ta = threads.iter().find(|t| t["origin"] == a.as_str()).unwrap();
assert_eq!(ta["open"], true);
assert_eq!(ta["replies"][0]["record"]["body"]["summary"], "confirmed");
let tb = threads.iter().find(|t| t["origin"] == b.as_str()).unwrap();
assert_eq!(tb["open"], false);
assert_eq!(tb["closed_by"]["body"]["summary"], "fixed in 1a2b");
}
#[test]
fn test_record_stdin_reply_and_resolve_keys_are_not_line_shapes() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
let input = format!(
"{{\"reply\":\"{a}\",\"message\":\"x\"}}\n{{\"resolve\":\"{a}\",\"reason\":\"fixed\"}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_ne!(code, 0);
assert!(
stderr.contains("stdin line 1: stdin object missing 'kind'")
&& stderr.contains("stdin line 2: stdin object missing 'kind'"),
"{stderr}"
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert_eq!(qual.lines().count(), 1, "nothing written: {qual}");
}
#[test]
fn test_record_stdin_references_record_created_in_same_batch() {
let dir = tempfile::tempdir().unwrap();
let first = "{\"metabox\":\"1\",\"type\":\"annotation\",\"subject\":\"a.rs\",\
\"issuer\":\"mailto:t@t.com\",\"created_at\":\"2026-01-01T00:00:00Z\",\
\"id\":\"\",\"body\":{\"kind\":\"concern\",\"summary\":\"new finding\"}}\n";
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--dry-run", "--format", "json"],
first,
);
assert_eq!(code, 0, "{stderr}");
let planned: serde_json::Value = serde_json::from_str(stdout.lines().next().unwrap()).unwrap();
let id = planned["id"].as_str().unwrap().to_string();
let input = format!(
"{first}{{\"kind\":\"comment\",\"location\":\"a.rs\",\"references\":\"{id}\",\"message\":\"verified\"}}\n"
);
let (stdout, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--format", "json"],
&input,
);
assert_eq!(code, 0, "{stderr}");
let lines: Vec<serde_json::Value> = stdout
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(lines[0]["id"], id.as_str());
assert_eq!(lines[1]["body"]["references"], id.as_str());
}
#[test]
fn test_record_stdin_double_resolve_fails_atomically() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "x"]);
let input = format!(
"{{\"kind\":\"resolve\",\"location\":\"a.rs\",\"supersedes\":\"{a}\",\"message\":\"fixed\",\"tags\":[\"reason:fixed\"]}}\n\
{{\"kind\":\"resolve\",\"location\":\"a.rs\",\"supersedes\":\"{a}\",\"message\":\"dup\",\"tags\":[\"reason:duplicate\"]}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_ne!(code, 0);
assert!(
stderr.contains("stdin line 2")
&& stderr.contains(&format!("target {} is closed (resolved by ", &a[..8])),
"{stderr}"
);
assert!(!stderr.contains("allow"), "{stderr}");
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(
!qual.contains("\"kind\":\"resolve\""),
"nothing written: {qual}"
);
}
#[test]
fn test_record_stdin_superseded_target_rejected() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "v1"]);
let b = write_id(
dir.path(),
&["record", "concern", "a.rs", "v2", "--supersedes", &a],
);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"a.rs\",\"references\":\"{a}\",\"message\":\"late\"}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_ne!(code, 0);
assert!(
stderr.contains("references: target")
&& stderr.contains(&format!("superseded by {}", &b[..8])),
"{stderr}"
);
assert!(!stderr.contains("allow"), "{stderr}");
}
#[test]
fn test_record_stdin_closed_target_names_closer() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "v1"]);
let closer = write_id(dir.path(), &["resolve", &a[..8], "fixed"]);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"a.rs\",\"references\":\"{a}\",\"message\":\"late\"}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_ne!(code, 0);
assert!(
stderr.contains(&format!("closed (resolved by {})", &closer[..8])),
"{stderr}"
);
assert!(!stderr.contains("allow"), "{stderr}");
}
#[test]
fn test_record_stdin_pointers_must_be_full_ids() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "v1"]);
let unknown = "f".repeat(64);
let input = format!(
"{{\"kind\":\"concern\",\"location\":\"a.rs\",\"message\":\"v2\",\"supersedes\":\"{}\"}}\n\
{{\"kind\":\"comment\",\"location\":\"a.rs\",\"message\":\"re\",\"references\":\"a.rs\"}}\n\
{{\"kind\":\"comment\",\"location\":\"a.rs\",\"message\":\"re\",\"references\":\"{unknown}\"}}\n",
&a[..8]
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &input);
assert_ne!(code, 0);
for line in 1..=3 {
assert!(
stderr
.lines()
.any(|l| l.starts_with(&format!("stdin line {line}:"))
&& l.contains("must be a full record ID")),
"line {line}: {stderr}"
);
}
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert_eq!(qual.lines().count(), 1, "nothing written: {qual}");
}
#[test]
fn test_record_stdin_dry_run_rejects_bad_pointer_ids() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "x"]);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"a.rs\",\"message\":\"m\",\"references\":\"{}\"}}\n\
{{\"kind\":\"comment\",\"location\":\"a.rs\",\"message\":\"m\",\"references\":\"{}\"}}\n",
&a[..8],
"0".repeat(64)
);
let (_, stderr, code) =
run_qualifier_stdin(dir.path(), &["record", "--stdin", "--dry-run"], &input);
assert_ne!(code, 0);
assert_eq!(
stderr.matches("must be a full record ID").count(),
2,
"{stderr}"
);
}
#[test]
fn test_record_stdin_resolve_line_reason_tags_validated() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "x"]);
let unknown = format!(
"{{\"kind\":\"resolve\",\"location\":\"a.rs\",\"supersedes\":\"{a}\",\"message\":\"m\",\"tags\":[\"reason:meh\"]}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &unknown);
assert_ne!(code, 0);
assert!(
stderr.contains("unknown close reason 'meh'") && stderr.contains("wontfix"),
"{stderr}"
);
let two = format!(
"{{\"kind\":\"resolve\",\"location\":\"a.rs\",\"supersedes\":\"{a}\",\"message\":\"m\",\"tags\":[\"reason:fixed\",\"reason:duplicate\"]}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], &two);
assert_ne!(code, 0);
assert!(stderr.contains("one reason"), "{stderr}");
let other = "{\"kind\":\"comment\",\"location\":\"a.rs\",\"message\":\"m\",\"tags\":[\"reason:meh\"]}\n";
let (_, stderr, code) = run_qualifier_stdin(dir.path(), &["record", "--stdin"], other);
assert_eq!(code, 0, "{stderr}");
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(!qual.contains("\"kind\":\"resolve\""), "{qual}");
}
#[test]
fn test_record_stdin_continue_on_error_writes_valid_lines_past_closed_target() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
let b = write_id(dir.path(), &["record", "concern", "b.rs", "b leaks"]);
write_id(dir.path(), &["resolve", &b[..8], "already fixed"]);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"a.rs\",\"references\":\"{a}\",\"message\":\"confirmed\"}}\n\
{{\"kind\":\"resolve\",\"location\":\"b.rs\",\"supersedes\":\"{b}\",\"message\":\"fixed\",\"tags\":[\"reason:fixed\"]}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--continue-on-error"],
&input,
);
assert_ne!(code, 0);
assert!(
stderr.contains("stdin line 2") && stderr.contains("closed"),
"{stderr}"
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(
qual.contains("confirmed"),
"the valid line is still written under --continue-on-error: {qual}"
);
assert_eq!(qual.matches("\"kind\":\"resolve\"").count(), 1, "{qual}");
}
#[test]
fn test_record_stdin_json_summary_written_field() {
let dir = tempfile::tempdir().unwrap();
let ok_input = "{\"kind\":\"pass\",\"location\":\"a.rs\",\"message\":\"ok\"}\n";
let (_, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--format", "json"],
ok_input,
);
assert_eq!(code, 0, "{stderr}");
let summary: serde_json::Value =
serde_json::from_str(stderr.lines().next_back().unwrap()).unwrap();
assert_eq!(summary["summary"]["written"], true, "{summary}");
let dir2 = tempfile::tempdir().unwrap();
let bad_input = "{\"kind\":\"pass\",\"location\":\"a.rs\",\"message\":\"ok\"}\n{\"kind\":\"oops\",\"location\":\"b.rs\"}\n";
let (_, stderr2, code2) = run_qualifier_stdin(
dir2.path(),
&["record", "--stdin", "--format", "json"],
bad_input,
);
assert_ne!(code2, 0);
let summary2: serde_json::Value =
serde_json::from_str(stderr2.lines().next_back().unwrap()).unwrap();
assert_eq!(summary2["summary"]["written"], false, "{summary2}");
}
fn diff_merge_base_setup(dir: &Path) {
git_init(dir);
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir)
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir)
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir,
&[
"record",
"concern",
"feat.rs",
"feature finding",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir, "feat record");
Command::new("git")
.args(["checkout", "-q", "main"])
.current_dir(dir)
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir,
&[
"record",
"concern",
"main.rs",
"post-fork main finding",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir, "main record after fork");
Command::new("git")
.args(["checkout", "-q", "feat"])
.current_dir(dir)
.status()
.unwrap();
}
#[test]
fn test_diff_default_uses_merge_base() {
let dir = tempfile::tempdir().unwrap();
diff_merge_base_setup(dir.path());
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_eq!(code, 0);
assert!(
stdout.contains("merge-base"),
"default header should mention merge-base: {stdout}"
);
assert!(
stdout.contains("feature finding"),
"feature record should be Added: {stdout}"
);
assert!(
!stdout.contains("post-fork main finding"),
"main-only record after fork must not show up under merge-base default: {stdout}"
);
}
#[test]
fn test_diff_from_tip_includes_post_fork_main_records() {
let dir = tempfile::tempdir().unwrap();
diff_merge_base_setup(dir.path());
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--from-tip"]);
assert_eq!(code, 0);
assert!(
stdout.contains("(tip)"),
"header should reflect --from-tip: {stdout}"
);
assert!(
stdout.contains("post-fork main finding"),
"from-tip diff should surface main-only records: {stdout}"
);
}
#[test]
fn test_diff_fail_on_kind_exits_nonzero() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"blocker",
"x.rs",
"ship-stop",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
let (stdout, stderr, code) =
run_qualifier(dir.path(), &["diff", "main", "--fail-on", "blocker"]);
assert_ne!(code, 0, "should fail when a blocker is added");
assert!(
stdout.contains("blocker"),
"diff should still print the offending record before failing: {stdout}"
);
assert!(
stderr.contains("--fail-on"),
"error message should reference the flag that triggered it: {stderr}"
);
let (_, _, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on", "fail"]);
assert_eq!(code, 0, "no `fail` records added; should pass");
}
#[test]
fn test_diff_fail_on_multiple_kinds() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"fail",
"x.rs",
"broke",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
let (_, _, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on", "blocker,fail"]);
assert_ne!(code, 0, "comma-separated list should match `fail` records");
}
#[test]
fn test_diff_fail_on_drift() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("m.rs"), "a\nb\nc\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"m.rs:2",
"look",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
std::fs::write(dir.path().join("m.rs"), "a\nB\nc\n").unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on-drift"]);
assert_ne!(code, 0, "should exit non-zero when drift is present");
assert!(
stderr.contains("drifted") || stderr.contains("drift"),
"error should reference drift: {stderr}"
);
}
#[test]
fn test_diff_kind_filter() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"a.rs",
"concern1",
"--issuer",
"mailto:a@b.com",
],
);
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"blocker",
"a.rs",
"blocker1",
"--issuer",
"mailto:a@b.com",
],
);
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"suggestion",
"a.rs",
"suggestion1",
"--issuer",
"mailto:a@b.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--kind", "concern"]);
assert_eq!(code, 0);
assert!(
stdout.contains("concern1"),
"concern should appear: {stdout}"
);
assert!(
!stdout.contains("blocker1"),
"blocker should be filtered out: {stdout}"
);
assert!(
!stdout.contains("suggestion1"),
"suggestion should be filtered out: {stdout}"
);
assert!(
stdout.contains("Added on this branch (1)"),
"should show only one match: {stdout}"
);
}
#[test]
fn test_diff_issuer_type_filter() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"a.rs",
"from-ai",
"--issuer",
"mailto:bot@example.com",
"--issuer-type",
"ai",
],
);
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"a.rs",
"from-human",
"--issuer",
"mailto:dev@example.com",
"--issuer-type",
"human",
],
);
let (stdout, _, _) = run_qualifier(dir.path(), &["diff", "main", "--issuer-type", "ai"]);
assert!(
stdout.contains("from-ai"),
"ai record should appear: {stdout}"
);
assert!(
!stdout.contains("from-human"),
"human record should be filtered: {stdout}"
);
}
#[test]
fn test_diff_subjects_only() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
for (subject, summary) in [("a.rs", "1"), ("b.rs", "2"), ("a.rs", "3")] {
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
subject,
summary,
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
}
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--subjects-only"]);
assert_eq!(code, 0);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines,
vec!["a.rs", "b.rs"],
"should be deduped + sorted: {stdout}"
);
}
#[test]
fn test_diff_resolved_inlines_closer_summary() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("x.rs"), "fn x() {}\n").unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"x.rs:1",
"needs work",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"resolve",
"x.rs:1",
"fixed in PR #42",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_eq!(code, 0);
assert!(
stdout.contains("resolved by"),
"should mention closer: {stdout}"
);
assert!(
stdout.contains("fixed in PR #42"),
"closer summary should be inlined: {stdout}"
);
}
#[test]
fn test_diff_drift_includes_span_snippet() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(
dir.path().join("m.rs"),
"fn alpha() {}\nfn beta() {}\nfn gamma() {}\n",
)
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"m.rs:2",
"watch beta",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
std::fs::write(
dir.path().join("m.rs"),
"fn alpha() {}\nfn beta() { /* changed */ }\nfn gamma() {}\n",
)
.unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]);
assert_eq!(code, 0);
assert!(
stdout.contains("watch beta"),
"should print original summary: {stdout}"
);
assert!(
stdout.contains("fn beta() { /* changed */ }"),
"should print current span content as a snippet: {stdout}"
);
assert!(
stdout.contains("> 2"),
"snippet should mark line 2: {stdout}"
);
}
fn run_qualifier_with_columns(dir: &Path, args: &[&str], columns: usize) -> (String, String, i32) {
let output = qualifier_cmd()
.args(args)
.current_dir(dir)
.env("COLUMNS", columns.to_string())
.output()
.expect("failed to run qualifier binary");
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code().unwrap_or(-1),
)
}
#[test]
fn test_diff_human_output_fits_80_cols() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
let nested = dir.path().join("src/cli/commands/agents/pages");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("record.md"), "line one\nline two\nline three\n").unwrap();
let long_summary = "AnnotationBody field declaration order silently determines MCF canonical IDs — \
make the invariant load-bearing in code or in a doc-test";
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"suggestion",
"src/cli/commands/agents/pages/record.md:2",
long_summary,
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
git_commit_all(dir.path(), "baseline");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"src/cli/commands/agents/pages/record.md:1",
"this is a deliberately long summary intended to overflow the single-line budget on \
any narrow terminal so the wrapping path is exercised",
"--issuer",
"mailto:a@b.com",
],
);
assert_eq!(code, 0);
std::fs::write(
nested.join("record.md"),
"line one\nDIFFERENT line two\nline three\n",
)
.unwrap();
let (stdout, _, code) = run_qualifier_with_columns(dir.path(), &["diff", "main"], 80);
assert_eq!(code, 0);
for line in stdout.lines() {
let chars = line.chars().count();
assert!(
chars <= 80,
"line exceeds 80-col budget ({chars} chars): {line:?}"
);
}
assert!(stdout.contains("AnnotationBody field declaration order"));
assert!(stdout.contains("deliberately long summary"));
}
#[test]
fn test_diff_json_includes_base_and_from_tip() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
Command::new("git")
.args(["commit", "-q", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.status()
.unwrap();
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir.path())
.status()
.unwrap();
let (_, _, _) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"x.rs",
"y",
"--issuer",
"mailto:a@b.com",
],
);
let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--format", "json"]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(v["ref"], "main");
assert_eq!(v["from_tip"], false);
let base = v["base"].as_str().expect("base should be a sha string");
assert_eq!(base.len(), 40, "base should be a full sha: {base}");
assert!(v["added"].is_array());
}
#[test]
fn test_top_level_help_shows_agents_group() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _stderr, code) = run_qualifier(dir.path(), &["--help"]);
assert_eq!(code, 0);
assert!(
stdout.contains("For AI agents:"),
"help should show the agents group header: {stdout}"
);
assert!(
stdout.contains("If you are an AI coding agent, run `qualifier agents` first"),
"help should print the agent directive above the subcommand list: {stdout}"
);
assert!(
stdout.contains("agents") && stdout.contains("Read this before recording annotations"),
"help should mention the agents subcommand with imperative description: {stdout}"
);
}
#[test]
fn test_init_help_runs() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--help"]);
assert_eq!(code, 0, "init --help should succeed");
assert!(stdout.contains("--yes"), "help should mention --yes flag");
assert!(
stdout.contains("--dry-run"),
"help should mention --dry-run flag"
);
}
#[test]
fn test_init_appears_in_top_level_help() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["--help"]);
assert_eq!(code, 0);
assert!(
stdout.contains("Initialize:"),
"top-level help should have Initialize section"
);
assert!(
stdout.contains("init"),
"top-level help should list init command"
);
}
#[test]
fn test_init_yes_and_dry_run_conflict() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["init", "--yes", "--dry-run"]);
assert_ne!(code, 0, "conflicting flags should fail");
assert!(
stderr.contains("--dry-run") || stderr.contains("--yes") || stderr.contains("conflict"),
"stderr should explain the conflict: {stderr}"
);
}
#[test]
fn test_init_non_tty_without_flag_refuses() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["init"]);
assert_ne!(code, 0, "should refuse to prompt without a TTY");
assert!(
stderr.contains("TTY") || stderr.contains("tty"),
"stderr should mention TTY: {stderr}"
);
assert!(
stderr.contains("--yes") || stderr.contains("--dry-run"),
"stderr should suggest --yes or --dry-run: {stderr}"
);
}
#[test]
fn test_init_git_creates_gitattributes() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0, "init --yes should succeed: {stdout}");
let gitattr = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
assert!(
gitattr.contains("*.qual merge=union"),
".gitattributes should contain the rule: {gitattr:?}"
);
}
#[test]
fn test_init_git_idempotent_gitattributes() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".gitattributes"), "*.qual merge=union\n").unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let gitattr = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
assert_eq!(
gitattr, "*.qual merge=union\n",
".gitattributes should be unchanged byte-for-byte"
);
}
#[test]
fn test_init_hg_prints_hint_no_gitattributes() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".hg")).unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
assert!(stdout.contains("hg"), "stdout should mention hg: {stdout}");
assert!(
!dir.path().join(".gitattributes").exists(),
".gitattributes should not be created for hg repos"
);
}
#[test]
fn test_init_no_vcs_prints_note() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
assert!(
stdout.contains("no VCS detected"),
"stdout should explain no-VCS skip: {stdout}"
);
}
#[test]
fn test_init_patches_existing_agents_md() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "# AGENTS.md\n\nBe nice.\n").unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
assert!(
content.contains("qualifier agents"),
"AGENTS.md should contain the directive: {content:?}"
);
assert!(
content.starts_with("# AGENTS.md\n\nBe nice.\n"),
"prior content should be preserved"
);
}
#[test]
fn test_init_patches_multiple_agent_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "# AGENTS\n").unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "# Claude\n").unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
for name in ["AGENTS.md", "CLAUDE.md"] {
let content = std::fs::read_to_string(dir.path().join(name)).unwrap();
assert!(
content.contains("qualifier agents"),
"{name} should contain directive: {content:?}"
);
assert_eq!(
content.matches("qualifier agents").count(),
1,
"{name} should have directive exactly once"
);
}
}
#[test]
fn test_init_skips_agent_file_with_directive() {
let dir = tempfile::tempdir().unwrap();
let original = "# AGENTS.md\n\nSee qualifier agents.\n";
std::fs::write(dir.path().join("AGENTS.md"), original).unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
assert_eq!(content, original, "AGENTS.md should be unchanged");
}
#[test]
fn test_init_patches_copilot_instructions() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".github")).unwrap();
std::fs::write(
dir.path().join(".github/copilot-instructions.md"),
"# Copilot\n",
)
.unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let content =
std::fs::read_to_string(dir.path().join(".github/copilot-instructions.md")).unwrap();
assert!(content.contains("qualifier agents"));
}
#[test]
fn test_init_creates_agents_md_when_none_exist() {
let dir = tempfile::tempdir().unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let path = dir.path().join("AGENTS.md");
assert!(path.exists(), "AGENTS.md should have been created");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("qualifier agents"));
assert!(!dir.path().join("CLAUDE.md").exists());
assert!(!dir.path().join("GEMINI.md").exists());
}
#[test]
fn test_init_walks_cursor_rules_one_level() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".cursor/rules")).unwrap();
std::fs::create_dir_all(dir.path().join(".cursor/rules/nested")).unwrap();
std::fs::write(dir.path().join(".cursor/rules/main.md"), "# main\n").unwrap();
std::fs::write(dir.path().join(".cursor/rules/style.mdc"), "# style\n").unwrap();
std::fs::write(dir.path().join(".cursor/rules/README.txt"), "# unrelated\n").unwrap();
std::fs::write(dir.path().join(".cursor/rules/nested/deep.md"), "# deep\n").unwrap();
let (_, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
let main = std::fs::read_to_string(dir.path().join(".cursor/rules/main.md")).unwrap();
let style = std::fs::read_to_string(dir.path().join(".cursor/rules/style.mdc")).unwrap();
assert!(main.contains("qualifier agents"));
assert!(style.contains("qualifier agents"));
let readme = std::fs::read_to_string(dir.path().join(".cursor/rules/README.txt")).unwrap();
let deep = std::fs::read_to_string(dir.path().join(".cursor/rules/nested/deep.md")).unwrap();
assert!(
!readme.contains("qualifier agents"),
"README.txt must not be touched"
);
assert!(
!deep.contains("qualifier agents"),
"nested file must not be touched"
);
}
#[test]
fn test_init_summary_counts_fresh_git() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
assert!(
stdout.contains("applied 2") && stdout.contains("skipped 0"),
"summary should report 2 applied / 0 skipped: {stdout}"
);
}
#[test]
fn test_init_summary_counts_idempotent() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::write(dir.path().join(".gitattributes"), "*.qual merge=union\n").unwrap();
std::fs::write(
dir.path().join("AGENTS.md"),
"# AGENTS\n\nSee qualifier agents.\n",
)
.unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--yes"]);
assert_eq!(code, 0);
assert!(
stdout.contains("applied 0") && stdout.contains("skipped 2"),
"summary should report 0 applied / 2 skipped: {stdout}"
);
}
#[test]
fn test_init_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let before_listing: std::collections::BTreeSet<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
let (stdout, _, code) = run_qualifier(dir.path(), &["init", "--dry-run"]);
assert_eq!(code, 0);
assert!(
stdout.contains("would apply 2"),
"stdout should report what would change: {stdout}"
);
let after_listing: std::collections::BTreeSet<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(
before_listing, after_listing,
"dry-run must not create files"
);
assert!(
!dir.path().join(".gitattributes").exists(),
"dry-run must not create .gitattributes"
);
assert!(
!dir.path().join("AGENTS.md").exists(),
"dry-run must not create AGENTS.md"
);
}
fn write_id(dir: &Path, args: &[&str]) -> String {
let mut full: Vec<&str> = args.to_vec();
full.extend_from_slice(&["--issuer", "mailto:test@test.com"]);
let (stdout, stderr, code) = run_qualifier(dir, &full);
assert_eq!(code, 0, "{args:?} failed: {stderr}");
stdout
.lines()
.find_map(|l| l.trim().strip_prefix("id:"))
.map(|s| s.trim().to_string())
.expect("id: line in output")
}
#[test]
fn test_ambiguous_id_prefix_lists_candidates() {
let dir = tempfile::tempdir().unwrap();
let line = |id: &str, subject: &str, summary: &str| {
format!(
"{{\"metabox\":\"1\",\"type\":\"annotation\",\"subject\":\"{subject}\",\
\"issuer\":\"mailto:t@t.com\",\"created_at\":\"2026-01-01T00:00:00Z\",\
\"id\":\"{id}\",\"body\":{{\"kind\":\"concern\",\"summary\":\"{summary}\"}}}}\n"
)
};
let qual = line(&format!("abcd1111{}", "0".repeat(56)), "a.rs", "first")
+ &line(&format!("abcd2222{}", "0".repeat(56)), "b.rs", "second");
std::fs::write(dir.path().join(".qual"), qual).unwrap();
let (_, stderr, code) = run_qualifier(
dir.path(),
&["reply", "abcd", "x", "--issuer", "mailto:test@test.com"],
);
assert_ne!(code, 0);
assert!(stderr.contains("ambiguous prefix 'abcd'"), "{stderr}");
assert!(
stderr.contains("[abcd1111] concern")
&& stderr.contains("a.rs")
&& stderr.contains("\"first\""),
"{stderr}"
);
assert!(
stderr.contains("[abcd2222] concern")
&& stderr.contains("b.rs")
&& stderr.contains("\"second\""),
"{stderr}"
);
}
#[test]
fn test_record_stdin_rejects_file_flag() {
let dir = tempfile::tempdir().unwrap();
let input = "{\"kind\":\"concern\",\"location\":\"a.rs\",\"message\":\"x\"}\n";
let (_, stderr, code) = run_qualifier_stdin(
dir.path(),
&["record", "--stdin", "--file", "out.qual"],
input,
);
assert_ne!(code, 0);
assert!(
stderr.contains("--file is not supported with --stdin"),
"{stderr}"
);
assert!(!dir.path().join(".qual").exists(), "nothing written");
assert!(!dir.path().join("out.qual").exists(), "nothing written");
}
#[test]
fn test_reply_to_superseded_record_names_successor() {
let dir = tempfile::tempdir().unwrap();
let old = write_id(
dir.path(),
&["record", "concern", "lib.rs", "original wording"],
);
let new = write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"better wording",
"--supersedes",
&old,
],
);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&old[..8],
"agreed",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "reply to a superseded record must fail");
assert!(stderr.contains("superseded by"), "{stderr}");
assert!(
stderr.contains(&new[..8]),
"error should name the live record: {stderr}"
);
assert!(stderr.contains("better wording"), "{stderr}");
assert!(
stderr.contains(&format!("target the live record {new}")),
"error prints the live record's full ID: {stderr}"
);
assert!(
!stderr.contains("allow"),
"no override flag exists: {stderr}"
);
}
#[test]
fn test_reply_to_superseded_chain_names_tip() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "lib.rs", "v1"]);
let b = write_id(
dir.path(),
&["record", "concern", "lib.rs", "v2", "--supersedes", &a],
);
let c = write_id(
dir.path(),
&["record", "concern", "lib.rs", "v3", "--supersedes", &b],
);
let (_, stderr, code) = run_qualifier(
dir.path(),
&["reply", &a[..8], "late", "--issuer", "mailto:test@test.com"],
);
assert_ne!(code, 0);
assert!(
stderr.contains(&c[..8]),
"error should name the chain tip: {stderr}"
);
}
#[test]
fn test_reply_to_resolved_record_says_closed() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(
dir.path(),
&["record", "concern", "lib.rs", "leaks a handle"],
);
let closer = write_id(dir.path(), &["resolve", &id[..8], "fixed"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"late reply",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(
stderr.contains(&format!(
"target {} is closed (resolved by {}); reply to {closer} to comment on the \
closed thread, or record a new record that supersedes {closer} to reopen it",
&id[..8],
&closer[..8],
)),
"error prints the closer's full ID: {stderr}"
);
assert!(
!stderr.contains("allow"),
"no override flag exists: {stderr}"
);
assert!(
!stderr.contains("superseded by"),
"a resolve closes a record; it is not a successor to reply to: {stderr}"
);
}
fn full_id_after(text: &str, marker: &str) -> String {
let rest = &text[text.find(marker).expect(marker) + marker.len()..];
let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
assert_eq!(id.len(), 64, "expected a full ID after {marker:?}: {text}");
id
}
#[test]
fn test_closed_and_superseded_errors_print_usable_full_ids() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "leaks"]);
write_id(dir.path(), &["resolve", &id[..8], "fixed"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"late",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
let closer = full_id_after(&stderr, "reply to ");
write_id(dir.path(), &["reply", &closer, "context"]);
let closer2 = full_id_after(&stderr, "supersedes ");
assert_eq!(closer, closer2);
let reopened = write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"still leaks",
"--supersedes",
&closer,
],
);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"re",
"--references",
&id,
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
let live = full_id_after(&stderr, "target the live record ");
assert_eq!(live, reopened);
write_id(
dir.path(),
&["record", "comment", "lib.rs", "re", "--references", &live],
);
}
#[test]
fn test_resolve_twice_fails() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "flaky test"]);
write_id(dir.path(), &["resolve", &id[..8], "fixed"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"resolve",
&id[..8],
"fixed again",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "second resolve must fail");
assert!(stderr.contains("closed"), "{stderr}");
assert!(!stderr.contains("allow"), "{stderr}");
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert_eq!(qual.matches("\"kind\":\"resolve\"").count(), 1, "{qual}");
}
#[test]
fn test_reply_to_closing_resolve_joins_closed_thread() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "old finding"]);
let closer = write_id(dir.path(), &["resolve", &id[..8], "fixed"]);
let reply = write_id(
dir.path(),
&["reply", &closer[..8], "context for the record"],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(
qual.contains(&format!("\"references\":\"{closer}\"")),
"{qual}"
);
let threads = threads_json(dir.path(), &["--all"]);
assert_eq!(threads.len(), 1, "{threads:?}");
let t = &threads[0];
assert_eq!(t["open"], false);
assert_eq!(t["root"]["id"], id.as_str());
assert_eq!(t["closed_by"]["id"], closer.as_str());
assert_eq!(t["replies"][0]["record"]["id"], reply.as_str());
assert!(
threads_json(dir.path(), &[]).is_empty(),
"the thread stays closed"
);
}
#[test]
fn test_record_superseding_resolve_reopens_thread() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(
dir.path(),
&["record", "concern", "lib.rs", "leaks a handle"],
);
let closer = write_id(dir.path(), &["resolve", &id[..8], "fixed"]);
let reopened = write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"still leaks on the error path",
"--supersedes",
&closer,
],
);
let threads = threads_json(dir.path(), &[]);
assert_eq!(threads.len(), 1, "{threads:?}");
let t = &threads[0];
assert_eq!(t["open"], true);
assert_eq!(t["origin"], id.as_str());
assert_eq!(t["root"]["id"], reopened.as_str());
assert!(t["closed_by"].is_null());
let history: Vec<&str> = t["history"]
.as_array()
.unwrap()
.iter()
.map(|r| r["id"].as_str().unwrap())
.collect();
assert_eq!(history, vec![id.as_str(), closer.as_str()], "{t}");
}
#[test]
fn test_resolve_reason_adds_tag() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(
dir.path(),
&["record", "concern", "lib.rs", "dup of another"],
);
write_id(
dir.path(),
&["resolve", &id[..8], "same as 1a2b", "--reason", "duplicate"],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(qual.contains("\"reason:duplicate\""), "{qual}");
}
#[test]
fn test_resolve_reason_rejects_unknown() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "x"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"resolve",
&id[..8],
"--reason",
"meh",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(
stderr.contains("fixed") && stderr.contains("wontfix"),
"should list valid reasons: {stderr}"
);
}
#[test]
fn test_resolve_reason_not_duplicated_with_tag() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "x"]);
write_id(
dir.path(),
&[
"resolve",
&id[..8],
"--reason",
"fixed",
"--tag",
"reason:fixed",
],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert_eq!(qual.matches("reason:fixed").count(), 1, "{qual}");
}
#[test]
fn test_resolve_rejects_conflicting_reason_tag() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "x"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"resolve",
&id[..8],
"--reason",
"fixed",
"--tag",
"reason:duplicate",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(stderr.contains("one reason"), "{stderr}");
}
#[test]
fn test_resolve_rejects_unknown_reason_tag() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "x"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"resolve",
&id[..8],
"--tag",
"reason:meh",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(stderr.contains("unknown close reason"), "{stderr}");
}
#[test]
fn test_record_resolve_kind_validates_reason_tags() {
let dir = tempfile::tempdir().unwrap();
let id = write_id(dir.path(), &["record", "concern", "lib.rs", "x"]);
for (tags, needle) in [
(vec!["reason:meh"], "unknown close reason 'meh'"),
(vec!["reason:fixed", "reason:duplicate"], "one reason"),
] {
let mut args = vec!["record", "resolve", "lib.rs", "closed", "--supersedes", &id];
for t in &tags {
args.extend_from_slice(&["--tag", t]);
}
args.extend_from_slice(&["--issuer", "mailto:test@test.com"]);
let (_, stderr, code) = run_qualifier(dir.path(), &args);
assert_ne!(code, 0, "{tags:?}");
assert!(stderr.contains(needle), "{tags:?}: {stderr}");
}
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&id[..8],
"closing",
"--kind",
"resolve",
"--tag",
"reason:meh",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(stderr.contains("unknown close reason 'meh'"), "{stderr}");
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(!qual.contains("\"kind\":\"resolve\""), "{qual}");
write_id(
dir.path(),
&[
"record",
"resolve",
"lib.rs",
"closed",
"--supersedes",
&id,
"--tag",
"reason:fixed",
],
);
assert!(qual_contents(dir.path()).contains("reason:fixed"));
}
#[test]
fn test_record_supersedes_requires_full_id() {
let dir = tempfile::tempdir().unwrap();
let old = write_id(dir.path(), &["record", "concern", "lib.rs", "first"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"second",
"--supersedes",
&old[..8],
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "an ID prefix is not a full ID");
assert!(
stderr.contains("--supersedes") && stderr.contains("must be a full record ID"),
"{stderr}"
);
write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"second",
"--supersedes",
&old,
],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(
qual.contains(&format!("\"supersedes\":\"{old}\"")),
"{qual}"
);
}
#[test]
fn test_record_references_requires_full_id() {
let dir = tempfile::tempdir().unwrap();
let root = write_id(dir.path(), &["record", "concern", "lib.rs", "first"]);
for value in [&root[..6], "lib.rs"] {
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"see also",
"--references",
value,
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "{value}");
assert!(
stderr.contains("--references") && stderr.contains("must be a full record ID"),
"{value}: {stderr}"
);
}
write_id(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"see also",
"--references",
&root,
],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(
qual.contains(&format!("\"references\":\"{root}\"")),
"{qual}"
);
}
#[test]
fn test_reply_supersedes_requires_full_id() {
let dir = tempfile::tempdir().unwrap();
let root = write_id(dir.path(), &["record", "concern", "lib.rs", "first"]);
let r1 = write_id(dir.path(), &["reply", &root[..8], "draft reply"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"reply",
&root[..8],
"edited reply",
"--supersedes",
&r1[..8],
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(
stderr.contains("--supersedes") && stderr.contains("must be a full record ID"),
"{stderr}"
);
write_id(
dir.path(),
&["reply", &root[..8], "edited reply", "--supersedes", &r1],
);
let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(qual.contains(&format!("\"supersedes\":\"{r1}\"")), "{qual}");
}
#[test]
fn test_record_supersedes_rejects_superseded_target() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "lib.rs", "v1"]);
let b = write_id(
dir.path(),
&["record", "concern", "lib.rs", "v2", "--supersedes", &a],
);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"v2-fork",
"--supersedes",
&a,
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(
code, 0,
"superseding an already-superseded record forks the chain"
);
assert!(stderr.contains("--supersedes"), "{stderr}");
assert!(stderr.contains(&b[..8]), "{stderr}");
assert!(!stderr.contains("allow"), "{stderr}");
}
#[test]
fn test_record_references_closed_record_names_closer() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "lib.rs", "v1"]);
let closer = write_id(dir.path(), &["resolve", &a[..8], "fixed"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"comment",
"lib.rs",
"late",
"--references",
&a,
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(
stderr.contains("--references")
&& stderr.contains(&format!("closed (resolved by {})", &closer[..8])),
"{stderr}"
);
}
#[test]
fn test_record_pointer_short_or_unknown_id_rejected() {
let dir = tempfile::tempdir().unwrap();
write_id(dir.path(), &["record", "concern", "lib.rs", "v1"]);
let unknown = "f".repeat(64);
for value in ["abc", unknown.as_str(), &unknown.to_uppercase()] {
let (_, stderr, code) = run_qualifier(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"v2",
"--supersedes",
value,
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0, "{value}");
assert!(
stderr.contains("--supersedes") && stderr.contains("must be a full record ID"),
"{value}: {stderr}"
);
}
}
fn qual_contents(dir: &Path) -> String {
std::fs::read_to_string(dir.join(".qual")).unwrap()
}
#[test]
fn test_env_issuer_and_type_apply() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&["record", "concern", "lib.rs", "from env"],
&[
("QUALIFIER_ISSUER", "mailto:bot@example.com"),
("QUALIFIER_ISSUER_TYPE", "ai"),
],
);
assert_eq!(code, 0, "{stderr}");
let qual = qual_contents(dir.path());
assert!(
qual.contains("\"issuer\":\"mailto:bot@example.com\""),
"{qual}"
);
assert!(qual.contains("\"issuer_type\":\"ai\""), "{qual}");
}
#[test]
fn test_flags_override_env() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"flags win",
"--issuer",
"mailto:human@example.com",
"--issuer-type",
"human",
],
&[
("QUALIFIER_ISSUER", "mailto:bot@example.com"),
("QUALIFIER_ISSUER_TYPE", "ai"),
("CLAUDECODE", "1"),
],
);
assert_eq!(code, 0, "{stderr}");
let qual = qual_contents(dir.path());
assert!(
qual.contains("\"issuer\":\"mailto:human@example.com\""),
"{qual}"
);
assert!(qual.contains("\"issuer_type\":\"human\""), "{qual}");
}
#[test]
fn test_claude_code_harness_detected_on_every_write_verb() {
let dir = tempfile::tempdir().unwrap();
let env = [("CLAUDECODE", "1"), ("CLAUDE_CODE_SESSION_ID", "sess-42")];
let args_issuer = ["--issuer", "mailto:test@test.com"];
let mut record_args = vec!["record", "concern", "lib.rs", "root"];
record_args.extend_from_slice(&args_issuer);
let (out, stderr, code) = run_qualifier_env(dir.path(), &record_args, &env);
assert_eq!(code, 0, "{stderr}");
let id = out
.lines()
.find_map(|l| l.trim().strip_prefix("id:"))
.unwrap()
.trim()
.to_string();
let mut reply_args = vec!["reply", &id[..8], "a reply"];
reply_args.extend_from_slice(&args_issuer);
assert_eq!(run_qualifier_env(dir.path(), &reply_args, &env).2, 0);
let mut resolve_args = vec!["resolve", &id[..8], "done"];
resolve_args.extend_from_slice(&args_issuer);
assert_eq!(run_qualifier_env(dir.path(), &resolve_args, &env).2, 0);
let qual = qual_contents(dir.path());
assert_eq!(qual.matches("\"issuer_type\":\"ai\"").count(), 3, "{qual}");
assert_eq!(
qual.matches("session:claude-code:sess-42").count(),
3,
"{qual}"
);
}
#[test]
fn test_qualifier_session_overrides_harness_session() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"x",
"--issuer",
"mailto:test@test.com",
],
&[
("CLAUDECODE", "1"),
("CLAUDE_CODE_SESSION_ID", "sess-42"),
("QUALIFIER_SESSION", "ci:run-7"),
],
);
assert_eq!(code, 0, "{stderr}");
let qual = qual_contents(dir.path());
assert!(qual.contains("\"session:ci:run-7\""), "{qual}");
assert!(!qual.contains("sess-42"), "{qual}");
}
#[test]
fn test_session_tag_not_duplicated() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"x",
"--issuer",
"mailto:test@test.com",
"--tag",
"session:claude-code:sess-42",
],
&[("CLAUDECODE", "1"), ("CLAUDE_CODE_SESSION_ID", "sess-42")],
);
assert_eq!(code, 0, "{stderr}");
assert_eq!(
qual_contents(dir.path())
.matches("session:claude-code:sess-42")
.count(),
1
);
}
#[test]
fn test_empty_env_vars_are_unset() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"x",
"--issuer",
"mailto:test@test.com",
],
&[
("QUALIFIER_ISSUER_TYPE", ""),
("QUALIFIER_SESSION", " "),
("CLAUDECODE", ""),
],
);
assert_eq!(code, 0, "{stderr}");
let qual = qual_contents(dir.path());
assert!(!qual.contains("issuer_type"), "{qual}");
assert!(!qual.contains("session:"), "{qual}");
}
#[test]
fn test_invalid_env_issuer_type_names_variable() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier_env(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"x",
"--issuer",
"mailto:test@test.com",
],
&[("QUALIFIER_ISSUER_TYPE", "robot")],
);
assert_ne!(code, 0);
assert!(stderr.contains("QUALIFIER_ISSUER_TYPE"), "{stderr}");
}
#[test]
fn test_stdin_batch_uses_env_defaults() {
let dir = tempfile::tempdir().unwrap();
let input = "{\"kind\":\"concern\",\"location\":\"a.rs\",\"message\":\"batched\"}\n";
let mut child = qualifier_cmd()
.args(["record", "--stdin"])
.envs([
("QUALIFIER_ISSUER", "mailto:bot@example.com"),
("CLAUDECODE", "1"),
("CLAUDE_CODE_SESSION_ID", "s1"),
])
.current_dir(dir.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let qual = qual_contents(dir.path());
assert!(
qual.contains("mailto:bot@example.com")
&& qual.contains("\"issuer_type\":\"ai\"")
&& qual.contains("session:claude-code:s1"),
"{qual}"
);
}
fn threads_json(dir: &Path, args: &[&str]) -> Vec<serde_json::Value> {
let mut full = vec!["threads", "--format", "json"];
full.extend_from_slice(args);
let (stdout, stderr, code) = run_qualifier(dir, &full);
assert_eq!(code, 0, "{stderr}");
serde_json::from_str::<serde_json::Value>(&stdout)
.expect("threads --format json prints one JSON array")
.as_array()
.unwrap()
.clone()
}
#[test]
fn test_threads_lists_open_threads_with_replies() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
write_id(dir.path(), &["reply", &a[..8], "confirmed in a_test"]);
let b = write_id(dir.path(), &["record", "concern", "b.rs", "b leaks"]);
write_id(dir.path(), &["resolve", &b[..8], "fixed"]);
let (stdout, stderr, code) = run_qualifier(dir.path(), &["threads"]);
assert_eq!(code, 0, "{stderr}");
assert!(
stdout.contains("a is racy") && stdout.contains("confirmed in a_test"),
"{stdout}"
);
assert!(
!stdout.contains("b leaks"),
"closed threads hidden by default: {stdout}"
);
}
#[test]
fn test_threads_and_reply_discover_project_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let threads = threads_json(&sub, &[]);
assert_eq!(threads.len(), 1, "{threads:?}");
assert_eq!(
threads[0]["root"]["subject"], "a.rs",
"subject stays relative to root"
);
let (stdout, stderr, code) = run_qualifier(
&sub,
&[
"reply",
&a[..8],
"confirmed",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains("a.rs"), "{stdout}");
assert!(
!sub.join(".qual").exists(),
"reply from a subdirectory must not create a .qual under sub/"
);
let root_qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(root_qual.contains("confirmed"), "{root_qual}");
let b = write_id(dir.path(), &["record", "concern", "b.rs", "b leaks"]);
let (_, stderr, code) = run_qualifier(
&sub,
&[
"resolve",
&b[..8],
"fixed",
"--issuer",
"mailto:test@test.com",
"--reason",
"fixed",
],
);
assert_eq!(code, 0, "{stderr}");
assert!(
!sub.join(".qual").exists(),
"resolve from a subdirectory must not create a .qual under sub/"
);
let root_qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(root_qual.contains("\"kind\":\"resolve\""), "{root_qual}");
let c = write_id(dir.path(), &["record", "concern", "c.rs", "c waits"]);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"../c.rs\",\"references\":\"{c}\",\"message\":\"batch confirmed\"}}\n"
);
let (_, stderr, code) = run_qualifier_stdin(&sub, &["record", "--stdin"], &input);
assert_eq!(code, 0, "{stderr}");
assert!(
!sub.join(".qual").exists(),
"batch reply from a subdirectory must not create a .qual under sub/"
);
let root_qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap();
assert!(root_qual.contains("batch confirmed"), "{root_qual}");
}
fn subdir_project(root: &Path) -> std::path::PathBuf {
git_init(root);
let net = root.join("src/net");
std::fs::create_dir_all(&net).unwrap();
std::fs::write(net.join("tcp.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
net
}
#[test]
fn test_record_from_subdirectory_stores_root_relative_subject() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
let (stdout, stderr, code) = run_qualifier(
&net,
&[
"record",
"concern",
"tcp.rs:2",
"b is racy",
"--issuer",
"mailto:test@test.com",
"--format",
"json",
],
);
assert_eq!(code, 0, "{stderr}");
let rec: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(rec["subject"], "src/net/tcp.rs", "{stdout}");
assert!(
rec["body"]["span"]["content_hash"].is_string(),
"span hash computed against the real file: {stdout}"
);
let qual = std::fs::read_to_string(net.join(".qual")).unwrap();
assert!(qual.contains("\"subject\":\"src/net/tcp.rs\""), "{qual}");
assert!(!net.join("src").exists(), "no nested src/net/src tree");
}
#[test]
fn test_threads_location_from_subdirectory_is_cwd_relative() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:2", "b is racy"],
);
let threads = threads_json(&net, &["tcp.rs"]);
assert_eq!(threads.len(), 1, "{threads:?}");
let threads = threads_json(&net, &["tcp.rs:2"]);
assert_eq!(threads.len(), 1, "{threads:?}");
let threads = threads_json(&net, &["*.rs"]);
assert_eq!(threads.len(), 1, "globs are CWD-relative too: {threads:?}");
let threads = threads_json(&dir.path().join("src"), &["../src/net"]);
assert_eq!(threads.len(), 1, "{threads:?}");
let threads = threads_json(dir.path(), &["src/net/tcp.rs"]);
assert_eq!(threads.len(), 1, "{threads:?}");
}
#[test]
fn test_reply_and_resolve_locations_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
let a = write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:2", "b is racy"],
);
let (stdout, stderr, code) = run_qualifier(
&net,
&[
"reply",
"tcp.rs:2",
"confirmed",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains(&format!("re: {}", &a[..8])), "{stdout}");
let (stdout, stderr, code) = run_qualifier(
&net,
&[
"resolve",
"tcp.rs:2",
"fixed",
"--issuer",
"mailto:test@test.com",
],
);
assert_eq!(code, 0, "{stderr}");
assert!(
stdout.contains(&format!("supersedes: {}", &a[..8])),
"{stdout}"
);
assert!(!net.join("src").exists(), "no nested src/net/src tree");
let qual = std::fs::read_to_string(net.join(".qual")).unwrap();
assert_eq!(qual.lines().count(), 3, "{qual}");
}
#[test]
fn test_batch_from_subdirectory_normalizes_locations() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
let a = write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:1", "a leaks"],
);
let input = format!(
"{{\"kind\":\"comment\",\"location\":\"tcp.rs:1\",\"references\":\"{a}\",\"message\":\"seen\"}}\n\
{{\"kind\":\"resolve\",\"location\":\"tcp.rs\",\"supersedes\":\"{a}\",\"message\":\"fixed\",\"tags\":[\"reason:fixed\"]}}\n"
);
let (stdout, stderr, code) = run_qualifier_stdin(&net, &["record", "--stdin"], &input);
assert_eq!(code, 0, "{stdout}\n{stderr}");
assert!(!net.join("src").exists(), "no nested src/net/src tree");
let qual = std::fs::read_to_string(net.join(".qual")).unwrap();
assert_eq!(qual.lines().count(), 3, "{qual}");
assert!(
qual.lines()
.all(|l| l.contains("\"subject\":\"src/net/tcp.rs\"")),
"{qual}"
);
}
#[test]
fn test_location_escaping_project_root_errors() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
let (_, stderr, code) = run_qualifier(
&net,
&[
"record",
"concern",
"../../../outside.rs",
"x",
"--issuer",
"mailto:test@test.com",
],
);
assert_ne!(code, 0);
assert!(stderr.contains("outside the project root"), "{stderr}");
let (_, stderr, code) = run_qualifier(&net, &["threads", "../../../outside.rs"]);
assert_ne!(code, 0);
assert!(stderr.contains("outside the project root"), "{stderr}");
}
#[test]
fn test_batch_dry_run_creates_no_directories() {
let dir = tempfile::tempdir().unwrap();
let input = "{\"kind\":\"concern\",\"location\":\"new/dir/x.rs\",\"message\":\"x\"}\n";
let (_, stderr, code) =
run_qualifier_stdin(dir.path(), &["record", "--stdin", "--dry-run"], input);
assert_eq!(code, 0, "{stderr}");
assert!(
!dir.path().join("new").exists(),
"--dry-run must not create directories"
);
}
#[test]
fn test_location_target_skips_resolve_records() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
write_id(dir.path(), &["resolve", &a[..8], "fixed"]);
let (_, stderr, code) = run_qualifier(
dir.path(),
&["reply", "a.rs", "more", "--issuer", "mailto:test@test.com"],
);
assert_ne!(code, 0, "a resolve record is not a location target");
assert!(stderr.contains("no active record"), "{stderr}");
}
#[test]
fn test_show_praise_compact_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let net = subdir_project(dir.path());
let a = write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:2", "b is racy"],
);
write_id(
dir.path(),
&[
"record",
"concern",
"src/net/tcp.rs:2",
"b is racy (edited)",
"--supersedes",
&a,
],
);
let (stdout, stderr, code) = run_qualifier(&net, &["show", "tcp.rs", "--pretty"]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains("src/net/tcp.rs"), "{stdout}");
assert!(
stdout.contains("fn b() {}"),
"pretty context found: {stdout}"
);
let (stdout, stderr, code) = run_qualifier(&net, &["praise", "tcp.rs"]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains("b is racy (edited)"), "{stdout}");
let (stdout, stderr, code) = run_qualifier(&net, &["compact", "tcp.rs", "--dry-run"]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains("1 superseded"), "{stdout}");
let (stdout, stderr, code) = run_qualifier(&net, &["ls"]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.contains("src/net/tcp.rs"), "{stdout}");
}
fn thread_summaries(threads: &[serde_json::Value]) -> Vec<String> {
let mut s: Vec<String> = threads
.iter()
.map(|t| t["root"]["body"]["summary"].as_str().unwrap().to_string())
.collect();
s.sort();
s
}
fn location_fixture(dir: &Path) {
for (loc, msg) in [
("src/net/tcp.rs:50", "in range"),
("src/net/tcp.rs:10", "out of range"),
("src/net/tcp.rs", "file level"),
("src/net", "net dir"),
("src", "src dir"),
("src/ne", "sibling prefix"),
("src/other.rs", "other file"),
] {
write_id(dir, &["record", "concern", loc, msg]);
}
}
#[test]
fn test_threads_span_filter_includes_file_level_and_ancestor_threads() {
let dir = tempfile::tempdir().unwrap();
location_fixture(dir.path());
let threads = threads_json(dir.path(), &["src/net/tcp.rs:40:80"]);
assert_eq!(
thread_summaries(&threads),
["file level", "in range", "net dir", "src dir"],
"span-less roots on the file and roots on ancestor directories match"
);
}
#[test]
fn test_threads_path_filter_includes_ancestor_threads() {
let dir = tempfile::tempdir().unwrap();
location_fixture(dir.path());
let threads = threads_json(dir.path(), &["src/net/tcp.rs"]);
assert_eq!(
thread_summaries(&threads),
[
"file level",
"in range",
"net dir",
"out of range",
"src dir"
],
);
let threads = threads_json(dir.path(), &["src/net"]);
assert_eq!(
thread_summaries(&threads),
[
"file level",
"in range",
"net dir",
"out of range",
"src dir"
],
"a directory filter matches descendants and ancestors, not siblings"
);
}
#[test]
fn test_threads_id_filter_matches_every_record_role() {
let dir = tempfile::tempdir().unwrap();
let origin = write_id(dir.path(), &["record", "concern", "a.rs", "v1"]);
let middle = write_id(
dir.path(),
&["record", "concern", "a.rs", "v2", "--supersedes", &origin],
);
let root = write_id(
dir.path(),
&["record", "concern", "a.rs", "v3", "--supersedes", &middle],
);
let reply = write_id(dir.path(), &["reply", &root[..8], "seen"]);
write_id(dir.path(), &["record", "concern", "b.rs", "unrelated"]);
for (role, id) in [
("origin", &origin),
("history", &middle),
("root", &root),
("reply", &reply),
] {
let threads = threads_json(dir.path(), &[&id[..8]]);
assert_eq!(threads.len(), 1, "{role}: {threads:?}");
assert_eq!(threads[0]["origin"], origin.as_str(), "{role}");
}
let closer = write_id(dir.path(), &["resolve", &root[..8], "fixed"]);
let threads = threads_json(dir.path(), &["--all", &closer[..8]]);
assert_eq!(threads.len(), 1, "closed_by: {threads:?}");
assert_eq!(threads[0]["origin"], origin.as_str());
assert!(
threads_json(dir.path(), &[&closer[..8]]).is_empty(),
"closed threads stay hidden without --all"
);
}
#[test]
fn test_threads_all_tag_matches_closing_resolve() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
write_id(
dir.path(),
&["resolve", &a[..8], "fixed", "--tag", "session:test:s1"],
);
let threads = threads_json(dir.path(), &["--all", "--tag", "session:test:*"]);
assert_eq!(threads.len(), 1, "{threads:?}");
}
#[test]
fn test_threads_all_includes_closed() {
let dir = tempfile::tempdir().unwrap();
let b = write_id(dir.path(), &["record", "concern", "b.rs", "b leaks"]);
write_id(dir.path(), &["resolve", &b[..8], "fixed"]);
let (stdout, _, code) = run_qualifier(dir.path(), &["threads", "--all"]);
assert_eq!(code, 0);
assert!(
stdout.contains("b leaks") && stdout.contains("(closed)"),
"{stdout}"
);
}
#[test]
fn test_threads_json_shape() {
let dir = tempfile::tempdir().unwrap();
let a = write_id(dir.path(), &["record", "concern", "a.rs", "a is racy"]);
let r = write_id(dir.path(), &["reply", &a[..8], "confirmed"]);
let threads = threads_json(dir.path(), &[]);
assert_eq!(threads.len(), 1);
let t = &threads[0];
assert_eq!(t["origin"], a.as_str());
assert_eq!(t["open"], true);
assert_eq!(t["root"]["id"], a.as_str());
assert!(t["closed_by"].is_null());
assert_eq!(t["replies"][0]["active"], true);
assert_eq!(t["replies"][0]["record"]["id"], r.as_str());
assert!(t["latest_at"].is_string());
}
#[test]
fn test_threads_json_empty_is_array() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["threads", "--format", "json"]);
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "[]");
}
#[test]
fn test_threads_location_filters() {
let dir = tempfile::tempdir().unwrap();
write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:15:30", "tcp span"],
);
write_id(
dir.path(),
&["record", "concern", "src/net/tcp.rs:40", "tcp later"],
);
write_id(dir.path(), &["record", "concern", "src/db.rs", "db"]);
write_id(dir.path(), &["record", "concern", "docs/spec.md", "spec"]);
let summaries = |args: &[&str]| -> Vec<String> {
threads_json(dir.path(), args)
.iter()
.map(|t| t["root"]["body"]["summary"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(summaries(&["src/db.rs"]), vec!["db"]);
assert_eq!(summaries(&["src/net"]).len(), 2, "directory prefix");
assert_eq!(summaries(&["src/*.rs"]), vec!["db"], "* does not cross /");
assert_eq!(summaries(&["src/**/*.rs"]).len(), 3);
assert_eq!(
summaries(&["src/net/tcp.rs:10:20"]),
vec!["tcp span"],
"span overlap"
);
assert_eq!(
summaries(&["docs/spec.md", "src/db.rs"]).len(),
2,
"any location matches"
);
}
#[test]
fn test_threads_kind_tag_and_issuer_type_filters() {
let dir = tempfile::tempdir().unwrap();
write_id(
dir.path(),
&["record", "blocker", "a.rs", "must fix", "--tag", "review"],
);
let c = write_id(
dir.path(),
&["record", "concern", "b.rs", "maybe", "--issuer-type", "ai"],
);
write_id(
dir.path(),
&[
"reply",
&c[..8],
"needs a call",
"--tag",
"status:needs-decision",
],
);
assert_eq!(threads_json(dir.path(), &["--kind", "blocker"]).len(), 1);
assert_eq!(
threads_json(dir.path(), &["--kind", "blocker,concern"]).len(),
2
);
assert_eq!(threads_json(dir.path(), &["--tag", "review"]).len(), 1);
assert_eq!(
threads_json(dir.path(), &["--tag", "status:*"]).len(),
1,
"tags on live replies count"
);
assert_eq!(
threads_json(dir.path(), &["--tag", "review", "--tag", "status:*"]).len(),
0,
"repeated tags must all match"
);
assert_eq!(threads_json(dir.path(), &["--issuer-type", "ai"]).len(), 1);
}
#[test]
fn test_threads_history_shows_superseded_root_chain() {
let dir = tempfile::tempdir().unwrap();
let old = write_id(dir.path(), &["record", "concern", "a.rs", "old summary"]);
let new = write_id(
dir.path(),
&[
"record",
"concern",
"a.rs",
"new summary",
"--supersedes",
&old,
],
);
write_id(dir.path(), &["reply", &new[..8], "a reply"]);
let threads = threads_json(dir.path(), &[]);
assert_eq!(threads.len(), 1);
let history = threads[0]["history"].as_array().unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0]["id"], old.as_str());
let (stdout, _, code) = run_qualifier(dir.path(), &["threads", "--all"]);
assert_eq!(code, 0);
assert!(
stdout.contains("old summary") && stdout.contains("(superseded)"),
"{stdout}"
);
}
#[test]
fn test_threads_status_uses_latest_tag() {
let dir = tempfile::tempdir().unwrap();
let c = write_id(dir.path(), &["record", "concern", "a.rs", "sync or async?"]);
write_id(
dir.path(),
&[
"reply",
&c[..8],
"owner should decide",
"--tag",
"status:needs-decision:mailto:owner@example.com",
],
);
assert_eq!(
threads_json(dir.path(), &["--status", "needs-decision"]).len(),
1,
"addressee suffix still matches"
);
write_id(
dir.path(),
&["reply", &c[..8], "async", "--tag", "status:decided"],
);
assert_eq!(
threads_json(dir.path(), &["--status", "needs-decision"]).len(),
0,
"a later status wins"
);
assert_eq!(threads_json(dir.path(), &["--status", "decided"]).len(), 1);
}
fn changed_since_setup(dir: &Path) {
git_init(dir);
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(dir.join("b.rs"), "fn b() {}\n").unwrap();
write_id(dir, &["record", "blocker", "a.rs", "a must fix"]);
write_id(dir, &["record", "concern", "b.rs", "b smells"]);
git_commit_all(dir, "init");
Command::new("git")
.args(["checkout", "-q", "-b", "feat"])
.current_dir(dir)
.status()
.unwrap();
std::fs::write(dir.join("a.rs"), "fn a() { todo!() }\n").unwrap();
}
#[test]
fn test_threads_changed_since() {
let dir = tempfile::tempdir().unwrap();
changed_since_setup(dir.path());
let threads = threads_json(dir.path(), &["--changed-since", "main"]);
let summaries: Vec<&str> = threads
.iter()
.map(|t| t["root"]["body"]["summary"].as_str().unwrap())
.collect();
assert_eq!(summaries, vec!["a must fix"]);
}
#[test]
fn test_threads_changed_since_outside_git_errors() {
let dir = tempfile::tempdir().unwrap();
let (_, stderr, code) = run_qualifier(dir.path(), &["threads", "--changed-since", "main"]);
assert_ne!(code, 0);
assert!(stderr.contains("git"), "{stderr}");
}
#[test]
fn test_threads_summary_silent_when_nothing() {
let dir = tempfile::tempdir().unwrap();
let (stdout, _, code) = run_qualifier(dir.path(), &["threads", "--summary"]);
assert_eq!(code, 0);
assert_eq!(stdout, "");
}
#[test]
fn test_threads_summary_counts_changed_files_and_decisions() {
let dir = tempfile::tempdir().unwrap();
changed_since_setup(dir.path());
let b = threads_json(dir.path(), &["b.rs"])[0]["root"]["id"]
.as_str()
.unwrap()
.to_string();
write_id(
dir.path(),
&[
"reply",
&b[..8],
"needs a call",
"--tag",
"status:needs-decision",
],
);
let (stdout, stderr, code) = run_qualifier(dir.path(), &["threads", "--summary"]);
assert_eq!(code, 0, "{stderr}");
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "{stdout}");
assert_eq!(
lines[0],
"qualifier: 1 blocker and 0 concerns open on files changed since main"
);
assert_eq!(
lines[1],
"qualifier: 1 thread waiting on a decision (qualifier threads --status needs-decision)"
);
}
#[test]
fn test_threads_summary_outside_git_counts_everything() {
let dir = tempfile::tempdir().unwrap();
write_id(dir.path(), &["record", "concern", "a.rs", "x"]);
let (stdout, _, code) = run_qualifier(dir.path(), &["threads", "--summary"]);
assert_eq!(code, 0);
assert_eq!(
stdout.trim(),
"qualifier: 0 blockers and 1 concern open (project-wide)"
);
}
#[test]
fn test_threads_summary_on_base_branch_counts_project_wide() {
let dir = tempfile::tempdir().unwrap();
git_init(dir.path());
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
write_id(dir.path(), &["record", "blocker", "a.rs", "a must fix"]);
git_commit_all(dir.path(), "init");
let (stdout, stderr, code) = run_qualifier(dir.path(), &["threads", "--summary"]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(
stdout.trim(),
"qualifier: 1 blocker and 0 concerns open (project-wide)"
);
}
#[test]
fn test_show_marks_non_human_issuer_type() {
let dir = tempfile::tempdir().unwrap();
write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"from an agent",
"--issuer-type",
"ai",
],
);
write_id(
dir.path(),
&[
"record",
"concern",
"lib.rs",
"from a person",
"--issuer-type",
"human",
],
);
write_id(dir.path(), &["record", "concern", "lib.rs", "unspecified"]);
let (stdout, _, code) = run_qualifier(dir.path(), &["show", "lib.rs"]);
assert_eq!(code, 0);
let line = |needle: &str| {
stdout
.lines()
.find(|l| l.contains(needle))
.unwrap()
.to_string()
};
assert!(line("from an agent").contains("test (ai)"), "{stdout}");
assert!(!line("from a person").contains('('), "{stdout}");
assert!(!line("unspecified").contains('('), "{stdout}");
}