#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
milestones_dir: Some(tmp.path().join("docs/milestones")),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed_milestone(store: &Store, name: &str) {
crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new())
.create_milestone_file(name, None, None)
.unwrap();
}
fn write(store: &Store, name: &str, contents: &str) {
if let Some(dir) = name.rsplit_once('/').map(|(d, _)| d) {
fs::create_dir_all(store.issues_dir.join(dir)).unwrap();
}
fs::write(store.issues_dir.join(name), contents).unwrap();
}
const CLEAN: &str = "---\nnumber: 1\ntitle: \"A clean issue\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
fn errors(findings: &[TixIssueFinding]) -> Vec<&TixIssueFinding> {
findings
.iter()
.filter(|f| f.severity == Severity::Error)
.collect()
}
#[test]
fn collect_reports_no_error_on_a_clean_store() {
let (tmp, store) = make_store();
let _ = tmp;
write(&store, "alice/2026-01-01-clean.md", CLEAN);
let findings = collect(&store.config, &store).unwrap();
assert_eq!(errors(&findings).len(), 0, "errors: {findings:#?}");
}
#[test]
fn duplicate_numbers_are_errors_on_every_file_that_shares_them() {
let (tmp, store) = make_store();
let _ = tmp;
let first = CLEAN.replace("number: 1", "number: 7");
let second = CLEAN
.replace("number: 1", "number: 7")
.replace("A clean issue", "Another issue");
write(&store, "alice/2026-01-01-a.md", &first);
write(&store, "bob/2026-01-02-b.md", &second);
let findings = collect(&store.config, &store).unwrap();
let duplicates: Vec<&TixIssueFinding> = findings
.iter()
.filter(|f| f.kind == "duplicate_number")
.collect();
assert_eq!(duplicates.len(), 2, "one finding per involved file");
for finding in duplicates {
assert_eq!(finding.severity, Severity::Error);
assert_eq!(finding.value, "7");
assert!(finding.line > 0, "the finding points at the `number:` line");
assert!(
finding.message.contains("alice/2026-01-01-a.md")
&& finding.message.contains("bob/2026-01-02-b.md"),
"the message names every file in the group: {}",
finding.message
);
}
assert_eq!(errors(&findings).len(), 2);
}
#[test]
fn distinct_numbers_are_not_duplicate_findings() {
let (tmp, store) = make_store();
let _ = tmp;
write(&store, "alice/2026-01-01-a.md", CLEAN);
let second = CLEAN
.replace("number: 1", "number: 2")
.replace("A clean issue", "Another issue");
write(&store, "bob/2026-01-02-b.md", &second);
let findings = collect(&store.config, &store).unwrap();
assert!(
!findings.iter().any(|f| f.kind == "duplicate_number"),
"distinct numbers are not a collision: {findings:#?}"
);
}
#[test]
fn an_unset_priority_is_accepted_but_a_typo_is_still_reported() {
let (tmp, store) = make_store();
let _ = tmp;
for (i, value) in ["''", "'hih'", "'urgent'", "'Medium '", "'0'"]
.iter()
.enumerate()
{
write(
&store,
&format!("alice/2026-01-01-p{i}.md"),
&format!(
"---\nnumber: {}\ntitle: \"P{i}\"\nstatus: todo\npriority: {value}\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
i as u32 + 10
),
);
}
let findings = collect(&store.config, &store).unwrap();
let priority_findings: Vec<&str> = findings
.iter()
.filter(|f| f.kind == "priority")
.map(|f| f.value.as_str())
.collect();
assert!(
!priority_findings.contains(&""),
"an unset priority is not an error: {findings:?}"
);
for typo in ["hih", "urgent", "Medium ", "0"] {
assert!(
priority_findings.contains(&typo),
"{typo:?} must still be reported, got {priority_findings:?}"
);
}
}
#[test]
fn collect_flags_invalid_status_and_priority() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-broken.md",
"---\nnumber: 2\ntitle: \"Broken\"\nstatus: shipped\npriority: critical\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
let kinds: Vec<&str> = findings.iter().map(|f| f.kind.as_str()).collect();
assert!(
kinds.contains(&"status"),
"unknown status must be flagged, got {kinds:?}"
);
assert!(
kinds.contains(&"priority"),
"unknown priority must be flagged, got {kinds:?}"
);
assert!(
findings.iter().any(|f| f.kind == "status"
&& f.message
.contains("valid options: todo, in progress, in review, done")),
"status finding must name the valid set, got {findings:?}"
);
}
#[test]
fn collect_flags_an_unrecognised_history_entry_left_in_the_body() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-no-history-section.md",
concat!(
"---\nnumber: 3\ntitle: \"H\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
"## Notes\n",
"\n",
"- 2026-01-01T00:00:00Z - alice - relationship_added - ascii hyphens\n",
),
);
let issue = store.get(crate::storage::IssueSelector::Number(3)).unwrap();
assert!(
issue
.body
.contains("- 2026-01-01T00:00:00Z - alice - relationship_added"),
"precondition — the line must really be body text, got {:?}",
issue.body
);
let findings = collect(&store.config, &store).unwrap();
let flagged: Vec<_> = findings.iter().filter(|f| f.kind == "history").collect();
assert_eq!(flagged.len(), 1, "exactly one finding, {findings:?}");
assert!(
flagged[0].message.contains("em-dash"),
"and it says what is wrong: {}",
flagged[0].message
);
}
#[test]
fn lint_does_not_flag_ordinary_body_bullets() {
let (tmp, store) = make_store();
let _ = tmp;
let bullets = [
"- Reproduction: run it",
"- Expected: it works",
"- 10:00-11:00 sync with infra",
"- 10:00:00.000 elapsed before the timeout",
"- 1234567890 is the upstream ticket",
"- +49-30-12345678 is the hotline",
"- 1.2.3.4.5.6 shipped",
"- ----------",
"- 2026-02-02: review the migration",
"- 2026-01-01..2026-01-05 was the freeze",
"- 2026-02-02 review the migration",
"- 2026-01-01 - owner - status",
"- 2026-01-01 - Bob - Done",
"- 2026-01-02 - alice - reviewed",
"- 2026-01-01 - start - end",
"- 2026-01-01 - v1.2 - released",
];
for (i, bullet) in bullets.iter().enumerate() {
write(
&store,
&format!("alice/2026-01-01-b{i}.md"),
&format!(
"---\nnumber: {}\ntitle: \"B{i}\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## Notes\n\n{bullet}\n",
i as u32 + 4
),
);
}
let findings = collect(&store.config, &store).unwrap();
assert!(
!findings.iter().any(|f| f.kind == "history"),
"ordinary prose must not be reported: {findings:?}"
);
}
#[test]
fn a_trailing_bullet_after_the_last_event_is_retained_and_reported() {
for (name, body) in [
(
"dated-typo",
concat!(
"## History\n",
"\n",
"- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} real\n",
"\n",
"- 2026-02-02T00:00:00Z - alice - relationship_added - ascii\n",
),
),
(
"after-a-real-event",
concat!(
"## History\n",
"\n",
"- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} real\n",
"\n",
"- 2026-02-02 review the migration\n",
),
),
] {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
&format!("alice/2026-01-01-{name}.md"),
&format!(
"---\nnumber: 3\ntitle: \"H\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{body}"
),
);
let issue = store.get(crate::storage::IssueSelector::Number(3)).unwrap();
assert_eq!(
issue.history.unparsed().len(),
1,
"{name}: retained in the section, not demoted — {:?}",
issue.history.unparsed()
);
assert!(
issue.body.is_empty(),
"{name}: and nothing moved into the body, got {:?}",
issue.body
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "history"),
"{name}: and the retention is reported, not silent — {findings:?}"
);
}
}
#[test]
fn lint_flags_the_hand_typing_errors_it_exists_to_catch() {
for line in [
"- 2026-01-01T00:00:00Z - alice - relationship_added - detail",
"- 2026-01-01T00:00:00Z-alice-relationship_added-detail",
"- 2026-01-01 - alice - relationship_added - detail",
"- 2026-01-01T00:00:00+02:00 - alice - relationship_added - detail",
"- 2026-01-01T00:00:00Z - alice - relationship_added - detail",
" - 2026-01-01T00:00:00Z - alice - relationship_added - detail",
"* 2026-01-01T00:00:00Z - alice - relationship_added - detail",
"+ 2026-01-01T00:00:00Z - alice - relationship_added - detail",
"1. 2026-01-01T00:00:00Z - alice - relationship_added - detail",
] {
assert!(
crate::model::looks_like_an_unrecognised_entry(line),
"{line:?} must be reported — it is a mistyped entry"
);
}
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-typo.md",
concat!(
"---\nnumber: 9\ntitle: \"T\"\nstatus: todo\npriority: medium\n",
"created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## Notes\n\n",
"- 2026-01-01T00:00:00Z-alice-relationship_added-detail\n",
),
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "history"),
"the no-space form reaches the user: {findings:?}"
);
}
fn seed(dir: &Store, number: u32, relationships: &str) {
let block = if relationships.is_empty() {
String::new()
} else {
format!("relationships:\n{relationships}")
};
let text = format!(
"---\nnumber: {number}\ntitle: T{number}\nstatus: todo\npriority: medium\n\
labels: []\n{block}created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody {number}.\n"
);
fs::write(
dir.issues_dir.join(format!("2026-01-01-t{number}.md")),
text,
)
.unwrap();
}
fn rel_findings(findings: &[TixIssueFinding]) -> Vec<&TixIssueFinding> {
findings
.iter()
.filter(|f| {
matches!(
f.kind.as_str(),
"self_link"
| "missing_target"
| "conflicting_edge"
| "cycle"
| "unknown_kind"
| "unknown_relation"
| "retired_links_key"
)
})
.collect()
}
#[test]
fn every_storable_relationship_diagnostic_surfaces() {
let cases: Vec<(&str, &str, &str)> = vec![
(
"self_link",
" - id: r1\n from: 1\n to: 1\n kind: blocks\n display: \"x\"\n",
"cannot block itself",
),
(
"missing_target",
" - id: r1\n from: 1\n to: 99\n kind: blocks\n display: \"x\"\n",
"resolves to no issue",
),
(
"conflicting_edge",
" - id: r1\n from: 1\n to: 2\n kind: blocks\n display: \"x\"\n \
- id: r2\n from: 1\n to: 2\n kind: related\n display: \"y\"\n",
"refusing to store it as",
),
("retired_links_key", "", "retired `links:` key"),
];
for (kind, relationships, phrase) in cases.iter() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, relationships);
seed(&store, 2, "");
if *kind == "retired_links_key" {
let f = store.issues_dir.join("2026-01-01-t1.md");
let text = fs::read_to_string(&f).unwrap();
fs::write(
&f,
text.replacen("created: ", "links:\n- '2'\ncreated: ", 1),
)
.unwrap();
}
let findings = collect(&store.config, &store).unwrap();
let rels = rel_findings(&findings);
assert!(
rels.iter().any(|f| f.kind == *kind),
"{kind} must surface; got {:?}",
rels.iter().map(|f| f.kind.clone()).collect::<Vec<_>>()
);
let hit = rels.iter().find(|f| f.kind == *kind).unwrap();
assert!(
hit.message.contains(phrase),
"{kind}: the message must explain the problem: {}",
hit.message
);
assert_eq!(hit.file, "2026-01-01-t1.md", "{kind} must name its file");
assert!(
hit.line > 0,
"{kind} must point at the offending key, not at line 1: {hit:?}"
);
}
}
#[test]
fn a_cycle_on_disk_is_reported_once() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 2\n kind: blocks\n display: \"x\"\n",
);
seed(
&store,
2,
" - id: r2\n from: 2\n to: 1\n kind: blocks\n display: \"y\"\n",
);
let findings = collect(&store.config, &store).unwrap();
let cycles: Vec<&TixIssueFinding> = rel_findings(&findings)
.into_iter()
.filter(|f| f.kind == "cycle")
.collect();
assert_eq!(
cycles.len(),
1,
"one cycle, one finding \u{2014} not one per blocked issue: {:?}",
rel_findings(&findings)
.iter()
.map(|f| (f.kind.clone(), f.message.clone()))
.collect::<Vec<_>>()
);
assert!(
cycles[0].message.contains("->"),
"the cycle must be rendered: {}",
cycles[0].message
);
}
#[test]
fn a_clean_tree_reports_no_relationship_findings() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 2\n kind: blocks\n display: \"x\"\n",
);
seed(
&store,
2,
" - id: r2\n from: 2\n to: 3\n kind: blocks\n display: \"y\"\n",
);
seed(&store, 3, "");
let findings = collect(&store.config, &store).unwrap();
assert!(
rel_findings(&findings).is_empty(),
"a valid graph is silent: {:?}",
rel_findings(&findings)
);
}
#[test]
fn three_seeded_problems_report_exactly_three_findings() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 1\n kind: blocks\n display: \"x\"\n",
);
seed(
&store,
2,
" - id: r2\n from: 2\n to: 98\n kind: blocks\n display: \"y\"\n",
);
seed(
&store,
3,
" - id: r3\n from: 3\n to: 4\n kind: blocks\n display: \"z\"\n",
);
seed(
&store,
4,
" - id: r4\n from: 4\n to: 3\n kind: blocks\n display: \"w\"\n",
);
let f = store.issues_dir.join("2026-01-01-t1.md");
let text = fs::read_to_string(&f).unwrap();
fs::write(
&f,
text.replacen("created: ", "links:\n- '2'\ncreated: ", 1),
)
.unwrap();
let findings = collect(&store.config, &store).unwrap();
let rels = rel_findings(&findings);
let mut kinds: Vec<&str> = rels.iter().map(|f| f.kind.as_str()).collect();
kinds.sort_unstable();
assert_eq!(
kinds,
vec!["cycle", "missing_target", "retired_links_key", "self_link"],
"each root cause once, and nothing implied: {kinds:?}"
);
}
#[test]
fn the_sweep_does_not_re_read_the_tree_through_store_list() {
let source = concat!(
include_str!("../collect.rs"),
include_str!("../validate.rs")
);
let body = &source[source.find("fn collect_relationships").unwrap()
..source.find("fn collect_for_file").unwrap()];
assert!(
!body.contains("store.list()") && !body.contains("graph_source("),
"the sweep must graph the tolerant walk's output, not re-read the tree"
);
}
#[test]
fn a_malformed_file_does_not_stop_the_relationship_sweep() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 1\n kind: blocks\n display: \"x\"\n",
);
fs::write(
store.issues_dir.join("2026-01-01-broken.md"),
"---\nnumber: 99\ntitle: Broken\nstatus: [not, a, string]\n---\n\nBody.\n",
)
.unwrap();
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "parse"),
"the malformed file is still reported as `parse`: {:?}",
findings.iter().map(|f| f.kind.clone()).collect::<Vec<_>>()
);
assert!(
rel_findings(&findings)
.iter()
.any(|f| f.kind == "self_link"),
"and the sweep still runs, finding the self-link in another file: {:?}",
rel_findings(&findings)
);
}
#[test]
fn two_runs_over_the_same_tree_agree_exactly() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 1\n kind: blocks\n display: \"x\"\n",
);
seed(
&store,
2,
" - id: r2\n from: 2\n to: 97\n kind: blocks\n display: \"y\"\n",
);
seed(
&store,
3,
" - id: r3\n from: 3\n to: 4\n kind: blocks\n display: \"z\"\n",
);
seed(
&store,
4,
" - id: r4\n from: 4\n to: 3\n kind: blocks\n display: \"w\"\n",
);
let render = |findings: &[TixIssueFinding]| {
findings
.iter()
.map(|f| format!("{}|{}|{}|{}", f.file, f.kind, f.value, f.message))
.collect::<Vec<_>>()
};
let first = render(&collect(&store.config, &store).unwrap());
let second = render(&collect(&store.config, &store).unwrap());
assert_eq!(first, second, "two runs must produce identical output");
assert!(
!first.is_empty(),
"and the fixture must actually find something"
);
}
#[test]
fn a_relationship_finding_underlines_the_offending_line_not_the_file() {
let (tmp, store) = make_store();
let _ = tmp;
seed(
&store,
1,
" - id: r1\n from: 1\n to: 1\n kind: blocks\n display: \"x\"\n",
);
seed(&store, 2, "");
let findings = collect(&store.config, &store).unwrap();
let finding = rel_findings(&findings)
.into_iter()
.find(|f| f.kind == "self_link")
.expect("a self_link finding");
let text = fs::read_to_string(store.issues_dir.join(&finding.file)).unwrap();
let span = span_for(finding, &text);
let line_no = text[..span.start].lines().count().max(1);
assert_eq!(
line_no, finding.line,
"the span must start on the line the finding recorded"
);
assert!(
span.end <= text.len() && span.end > span.start,
"and must be a real range inside the file: {span:?} of {} bytes",
text.len()
);
assert!(
span.end < text.len(),
"the span must not cover the whole file: {span:?}"
);
let line_start = text[..span.start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line = &text[line_start
..text[line_start..]
.find('\n')
.map(|i| line_start + i)
.unwrap_or(text.len())];
assert!(
line.trim_start()
.starts_with(&format!("{}:", finding.value)),
"the underline must be on the line declaring `{}`, got {line:?}",
finding.value
);
assert!(
!text[span.clone()].trim().is_empty(),
"and it must cover that field's value: {span:?}"
);
}
#[test]
fn an_empty_links_key_is_not_a_finding() {
let (tmp, store) = make_store();
let _ = tmp;
for (i, links) in ["[]", "~", "null", ""].iter().enumerate() {
let n = 10 + i as u32;
write(
&store,
&format!("alice/2026-01-01-empty-{i}.md"),
&format!(
"---\nnumber: {n}\ntitle: \"Empty {i}\"\nstatus: todo\npriority: medium\n\
links: {links}\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
),
);
}
let findings = collect(&store.config, &store).unwrap();
let retired: Vec<&TixIssueFinding> = findings
.iter()
.filter(|f| f.kind == "retired_links_key")
.collect();
assert!(
retired.is_empty(),
"an empty `links` loses nothing, so it is not a finding: {findings:?}"
);
}
#[test]
fn a_populated_links_key_is_still_a_finding() {
let (tmp, store) = make_store();
let _ = tmp;
for (i, links) in ["\n- '12'", "\n- 12\n- 13", " ['12']", " [12]", "\n- task-7"]
.iter()
.enumerate()
{
let n = 20 + i as u32;
write(
&store,
&format!("alice/2026-01-01-full-{i}.md"),
&format!(
"---\nnumber: {n}\ntitle: \"Full {i}\"\nstatus: todo\npriority: medium\n\
links:{links}\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
),
);
}
let findings = collect(&store.config, &store).unwrap();
let retired: Vec<&TixIssueFinding> = findings
.iter()
.filter(|f| f.kind == "retired_links_key")
.collect();
assert_eq!(
retired.len(),
5,
"every populated shape is reported: {findings:?}"
);
}
#[test]
fn collect_flags_a_retired_links_key() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-linked.md",
"---\nnumber: 3\ntitle: \"Linked\"\nstatus: todo\npriority: medium\nlinks:\n- '12'\n- task-7\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
let retired: Vec<&TixIssueFinding> = findings
.iter()
.filter(|f| f.kind == "retired_links_key")
.collect();
assert_eq!(
retired.len(),
1,
"one finding for the retired key: {findings:?}"
);
assert_eq!(retired[0].value, "links", "the finding names the field");
assert!(
retired[0].message.contains("retired"),
"the message explains why: {}",
retired[0].message
);
assert!(retired[0].line > 0, "the finding is located in the file");
}
#[test]
fn collect_reports_unparseable_file_as_missing_finding() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-not-frontmatter.md",
"Just prose, no frontmatter.\n",
);
let findings = collect(&store.config, &store).unwrap();
let errors = errors(&findings);
assert_eq!(errors.len(), 1, "{findings:#?}");
assert_eq!(errors[0].kind, "missing");
assert_eq!(errors[0].file, "alice/2026-01-01-not-frontmatter.md");
}
#[test]
fn collect_flags_unknown_cycle_and_milestone_with_valid_lists() {
let (tmp, mut store) = make_store();
let _ = tmp;
store.config.cycles.insert(
"Cycle 1".to_string(),
crate::config::PlanningCycle::default(),
);
seed_milestone(&store, "v1.0");
write(
&store,
"alice/2026-01-01-planning.md",
"---\nnumber: 4\ntitle: \"Planning\"\nstatus: todo\npriority: medium\ncycles:\n- Nope\n- Cycle 1\nmilestones:\n- v2.0\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "cycles"
&& f.value == "Nope"
&& f.message.contains("valid cycles: Cycle 1")),
"unknown cycle finding must name the valid set, got {findings:?}"
);
assert!(
findings.iter().any(|f| f.kind == "milestones"
&& f.value == "v2.0"
&& f.message.contains("valid milestones: v1.0")),
"unknown milestone finding must name the valid set, got {findings:?}"
);
assert!(
findings
.iter()
.all(|f| f.kind != "cycles" || f.value != "Cycle 1"),
"a configured cycle must not be flagged, got {findings:?}"
);
assert!(
findings
.iter()
.all(|f| f.kind != "milestones" || f.value != "v1.0"),
"a registered milestone must not be flagged, got {findings:?}"
);
}
#[test]
fn collect_flags_unknown_milestone_when_registry_empty() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-orphan.md",
"---\nnumber: 5\ntitle: \"Orphan\"\nstatus: todo\npriority: medium\nmilestones:\n- nowhere\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings
.iter()
.any(|f| f.kind == "milestones"
&& f.message.contains("valid milestones: none configured")),
"an empty registry must render as none configured, got {findings:?}"
);
}
#[test]
fn collect_flags_due_before_start_with_frozen_message() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-dates.md",
"---\nnumber: 6\ntitle: \"Dates\"\nstatus: todo\npriority: medium\nstart_date: 2026-09-18\ndue_date: 2026-09-07\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "due_date"
&& f.value == "2026-09-07"
&& f.message == "due_date \"2026-09-07\" must be `>= start_date` \"2026-09-18\""),
"due-before-start must be flagged with the frozen message, got {findings:?}"
);
assert!(
findings.iter().any(|f| f.kind == "due_date" && f.line > 0),
"the date finding must be located at the due_date line"
);
}
#[test]
fn collect_flags_malformed_calendar_date() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-bad-date.md",
"---\nnumber: 7\ntitle: \"Bad date\"\nstatus: todo\npriority: medium\nstart_date: 2026-13-99\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
findings.iter().any(|f| f.kind == "start_date"
&& f.value == "2026-13-99"
&& f.message == "start_date \"2026-13-99\" must be a `YYYY-MM-DD` calendar date"),
"a non-calendar date must be flagged, got {findings:?}"
);
}
#[test]
fn a_history_section_that_is_not_at_the_front_of_the_body_is_reported() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"2026-01-01-misplaced.md",
"---\nnumber: 1\ntitle: \"Misplaced\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nSome ordinary paragraph.\n\n## History\n\n* 2026-01-01 \u{2014} alice \u{2014} relationship_added\n",
);
let findings = collect(&store.config, &store).unwrap();
let message = findings
.iter()
.find(|f| f.file == "2026-01-01-misplaced.md")
.map(|f| f.message.clone())
.expect("a misplaced `## History` section must be reported");
assert!(
message.contains("must be the first thing in the body"),
"names the rule: {message}"
);
assert!(
message.contains("tix edit --body"),
"names the consequence, so the finding is actionable: {message}"
);
}
#[test]
fn a_history_section_at_the_front_of_the_body_is_not_reported() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"2026-01-01-placed.md",
"---\nnumber: 1\ntitle: \"Placed\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} d\n\nSome ordinary paragraph.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
!findings
.iter()
.any(|f| f.message.contains("first thing in the body")),
"a well-placed section is not a violation: {findings:?}"
);
}
#[test]
fn the_heading_test_matches_the_parser_exactly() {
for (line, expected) in [
("## History", true),
("## History ", true),
("## History\t", true),
("## History \t ", true),
(" ## History", false),
("## History\r", false),
("## History extra", false),
("## History:", false),
] {
assert_eq!(crate::model::is_history_heading(line), expected, "{line:?}");
}
}
#[test]
fn a_fenced_history_heading_is_not_a_misplaced_section() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"2026-01-01-fenced.md",
"---\nnumber: 1\ntitle: \"Fenced\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nAn example file:\n\n```markdown\n## History\n\n- 2026-01-01 - alice - relationship_added\n```\n\nThat is how the section looks.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert!(
!findings.iter().any(|f| f.file == "2026-01-01-fenced.md"),
"nothing inside a fence is a section or an entry: {findings:?}"
);
}
#[test]
fn a_misplaced_history_finding_points_at_the_heading() {
let (tmp, store) = make_store();
let _ = tmp;
let text = "---\nnumber: 1\ntitle: \"Misplaced\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nSome ordinary paragraph.\n\n## History\n\n* 2026-01-01 \u{2014} alice \u{2014} relationship_added\n";
write(&store, "2026-01-01-misplaced.md", text);
let findings = collect(&store.config, &store).unwrap();
let finding = findings
.iter()
.find(|f| f.message.contains("first thing in the body"))
.expect("the misplaced heading is reported");
let heading_line = text
.lines()
.position(|line| line == "## History")
.map(|index| index + 1)
.unwrap();
assert_eq!(
finding.line, heading_line,
"the span names the heading line, not line 1"
);
assert!(finding.column >= 1, "column is 1-based: {}", finding.column);
}
#[test]
fn every_finding_kind_has_a_help_line() {
let literals = [
"status",
"title",
"priority",
"unknown_type",
"created",
"updated",
"estimate",
"unknown_key",
"cycles",
"milestones",
"start_date",
"due_date",
"history",
"related_issue_empty",
"related_issue_unresolved",
"missing",
"parse",
"layout_mismatch",
"duplicate_number",
"foreign_repo_not_linted",
"missing_project_reference",
"missing_epic_reference",
"missing_issue_reference",
"missing_milestone_reference",
"missing_cycle_reference",
];
let relationships = [
"self_link",
"missing_target",
"conflicting_edge",
"cycle",
"unknown_kind",
"unknown_relation",
"retired_links_key",
];
let hierarchy = [
"self_parent",
"unresolved_parent",
"cycle",
"unknown_parent",
];
let workflow = crate::workflow::DiagnosticKind::ALL.map(|kind| kind.as_str());
for kind in literals
.iter()
.copied()
.chain(workflow)
.chain(relationships)
.chain(hierarchy)
{
assert!(
diagnostic_help(kind).is_some(),
"`{kind}` has no `help:` remedy"
);
}
}
#[test]
fn a_rendered_diagnostic_carries_the_id_and_a_help_footer() {
let finding = TixIssueFinding {
severity: Severity::Error,
file: "issues/x.md".into(),
line: 0,
column: 0,
value: "wip".into(),
kind: "status".into(),
message: "valid options: todo, in progress, done".into(),
};
let rendered = render_diagnostic(std::path::Path::new("/nonexistent-tix-lint"), &finding);
assert!(rendered.contains("E-tix-lint"), "{rendered}");
assert!(
rendered.contains("invalid status value 'wip'"),
"{rendered}"
);
assert!(
rendered.contains("set `status:` to one of the board's configured columns"),
"the remedy is attached: {rendered}"
);
let warning = TixIssueFinding {
severity: Severity::Warning,
..finding
};
let rendered = render_diagnostic(std::path::Path::new("/nonexistent-tix-lint"), &warning);
assert!(rendered.contains("W-tix-lint"), "{rendered}");
}