use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fs;
use std::ops::Range;
use std::path::{Path, PathBuf};
use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
use crate::commands::create::planner_for;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority};
use crate::planning::Planner;
use crate::related::{self, RelatedRef};
use crate::relationships::{IssueView, RelationshipGraph};
use crate::storage::Store;
use crate::workflow::{DiagnosticKind, validate_config};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug)]
pub struct TixIssueFinding {
pub severity: Severity,
pub file: String,
pub line: usize,
pub column: usize,
pub value: String,
pub kind: String,
pub message: String,
}
pub fn run(config: &Config, store: &Store) -> Result<bool> {
run_with(config, store, config.lint_foreign_repositories)
}
pub fn run_with(config: &Config, store: &Store, foreign: bool) -> Result<bool> {
let findings = collect_with(config, store, foreign)?;
if findings.is_empty() {
println!("Lint OK: {} issues checked.", issue_files(store)?.len());
return Ok(false);
}
for finding in &findings {
render_diagnostic(&store.issues_dir, finding);
}
let errors = findings
.iter()
.filter(|f| f.severity == Severity::Error)
.count();
let warnings = findings.len() - errors;
if errors == 0 {
println!(
"Lint OK with {warnings} warning{}: {} issues checked.",
if warnings == 1 { "" } else { "s" },
issue_files(store)?.len()
);
}
Ok(errors > 0)
}
pub fn collect(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
collect_with(config, store, config.lint_foreign_repositories)
}
pub fn collect_with(config: &Config, store: &Store, foreign: bool) -> Result<Vec<TixIssueFinding>> {
let mut findings = collect_local(config, store)?;
if foreign {
let mut visited = HashSet::new();
if let Some(root) = Config::project_root(&store.issues_dir) {
visited.insert(root);
}
collect_foreign(store, &mut visited, &mut findings);
}
Ok(findings)
}
fn collect_local(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
let files = issue_files(store)?;
let planner = planner_for(config);
let milestone_names = planner.milestone_names().unwrap_or_default();
let mut findings: Vec<TixIssueFinding> = Vec::new();
let mut parsed: Vec<ParsedFile> = Vec::new();
for rel in &files {
if let Some(entry) = collect_for_file(
config,
store,
&planner,
&milestone_names,
rel,
&mut findings,
)? {
parsed.push(entry);
}
}
collect_relationships(&parsed, store, &mut findings);
collect_hierarchy(&parsed, &mut findings);
collect_related_issues(&parsed, store, &mut findings);
collect_layout_mismatch(config, &parsed, &mut findings);
collect_workflow_config(config, &mut findings);
collect_workflow_direct_edits(config, &parsed, &mut findings);
Ok(findings)
}
fn collect_foreign(
store: &Store,
visited: &mut HashSet<PathBuf>,
findings: &mut Vec<TixIssueFinding>,
) {
const MAX_REPOS: usize = 64;
let Ok(issues) = store.list_all() else {
return;
};
let mut candidates: Vec<PathBuf> = Vec::new();
let mut remote: Vec<String> = Vec::new();
let mut missing: Vec<String> = Vec::new();
for issue in &issues {
let base = related::base_dir(&store.issues_dir, &issue.filename);
for r in related::classify_all(&issue.related_issues) {
match &r {
RelatedRef::Path { .. } => {
if let Some(target) = related::resolve_path(&base, &r) {
if !target.exists() {
missing.push(r.display());
continue;
}
let start = if target.is_dir() {
target
} else {
target
.parent()
.map_or_else(|| target.clone(), Path::to_path_buf)
};
if let Some(root) = Config::project_root(&start) {
candidates.push(root);
}
}
}
RelatedRef::Url { .. } => remote.push(r.display()),
RelatedRef::Local(_) => {}
}
}
}
candidates.sort();
candidates.dedup();
let mut linted = 0usize;
for root in candidates {
if linted >= MAX_REPOS {
break;
}
if visited.contains(&root) {
continue;
}
visited.insert(root.clone());
match lint_foreign_repo(&root) {
Ok((label, mut foreign)) => {
for f in &mut foreign {
f.file = if f.file.is_empty() {
format!("[{label}]")
} else {
format!("[{label}] {}", f.file)
};
}
findings.extend(foreign);
linted += 1;
}
Err(reason) => findings.push(foreign_note(root.to_string_lossy().to_string(), reason)),
}
}
remote.sort();
remote.dedup();
for url in remote {
findings.push(foreign_note(
url.clone(),
format!("{url}: not linted (remote)"),
));
}
missing.sort();
missing.dedup();
for path in missing {
findings.push(foreign_note(
path.clone(),
format!("{path}: related path does not exist"),
));
}
}
fn lint_foreign_repo(root: &Path) -> std::result::Result<(String, Vec<TixIssueFinding>), String> {
let cfg_path = root.join("tix.toml");
let mut cfg = Config::load_with_warning(&cfg_path, |_| {})
.map_err(|e| format!("{}: not linted ({e})", root.display()))?;
if cfg.issues_dir.is_relative() {
cfg.issues_dir = root.join(&cfg.issues_dir);
}
if !cfg.issues_dir.is_dir() {
return Err(format!(
"{}: not linted (issues dir {} is not a directory)",
root.display(),
cfg.issues_dir.display()
));
}
let store = Store::open(&cfg).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
let foreign =
collect_local(&cfg, &store).map_err(|e| format!("{}: not linted ({e})", root.display()))?;
let label = root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("foreign")
.to_string();
Ok((label, foreign))
}
fn foreign_note(value: String, message: String) -> TixIssueFinding {
TixIssueFinding {
severity: Severity::Warning,
file: String::new(),
line: 0,
column: 0,
value,
kind: "foreign_repo_not_linted".to_string(),
message,
}
}
fn collect_workflow_config(config: &Config, findings: &mut Vec<TixIssueFinding>) {
let mut by_board: BTreeMap<String, Vec<String>> = BTreeMap::new();
for diagnostic in validate_config(config) {
match diagnostic.kind {
DiagnosticKind::UnmappedColumnDefault => {
let (board, column) = match diagnostic.subject.rsplit_once('.') {
Some((board, column)) => (board.to_string(), column.to_string()),
None => (diagnostic.subject.clone(), String::new()),
};
by_board.entry(board).or_default().push(column);
}
_ => findings.push(TixIssueFinding {
severity: Severity::Error,
file: String::new(),
line: 0,
column: 0,
value: diagnostic.subject.clone(),
kind: diagnostic.kind.as_str().to_string(),
message: diagnostic.message,
}),
}
}
for (board, columns) in by_board {
let count = columns.len();
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: String::new(),
line: 0,
column: 0,
value: board.clone(),
kind: DiagnosticKind::UnmappedColumnDefault.as_str().to_string(),
message: format!(
"{count} of {count} column(s) on board `{board}` have no `category_map` \
entry and resolve to the safe default `{}`: {}. This is valid \
configuration โ it is what every repository has โ and it does not \
affect the exit code. To map them, add a \
[kanban.{board}.category_map] table.",
config.categories.default,
columns.join(", ")
),
});
}
}
fn collect_workflow_direct_edits(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
if parsed.is_empty() {
return;
}
let (_, resolution) = crate::workflow::active_resolution(config);
let issues: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
for finding in crate::workflow::lint_direct_edits(&issues, &resolution, config) {
let Some(entry) = parsed.iter().find(|p| p.issue.number == finding.number) else {
continue;
};
let value = match &finding.recorded {
Some(recorded) => format!("{recorded} -> {}", finding.current),
None => finding.current.clone(),
};
findings.push(finding_at(
&entry.rel,
&entry.text,
finding.kind.as_str(),
"status",
&value,
finding.message,
));
}
}
fn collect_hierarchy(parsed: &[ParsedFile], findings: &mut Vec<TixIssueFinding>) {
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
let snapshot: Vec<Issue> = parsed.iter().map(|p| p.issue.clone()).collect();
let result = crate::hierarchy::validate_hierarchy(&snapshot);
for diagnostic in &result.errors {
let kind = diagnostic.kind.as_str();
if let Some(entry) = by_number.get(&diagnostic.issue) {
findings.push(finding_at(
&entry.rel,
&entry.text,
kind,
&diagnostic.field,
&entry
.issue
.parent
.map(|p| p.to_string())
.unwrap_or_default(),
diagnostic.message.clone(),
));
}
}
}
fn collect_relationships(
parsed: &[ParsedFile],
store: &Store,
findings: &mut Vec<TixIssueFinding>,
) {
let done_column = store.relationship_done_column();
let by_number: BTreeMap<u32, &ParsedFile> =
parsed.iter().map(|p| (p.issue.number, p)).collect();
let views: Vec<IssueView<'_>> = parsed.iter().map(|p| p.issue.issue_view()).collect();
let graph = RelationshipGraph::new(&views, &done_column);
let result = graph.validate_snapshot();
for diagnostic in &result.errors {
let kind = diagnostic.kind.as_str();
match diagnostic.issue.and_then(|n| by_number.get(&n)) {
Some(entry) => findings.push(finding_at(
&entry.rel,
&entry.text,
kind,
&diagnostic.field,
&diagnostic.field,
diagnostic.message.clone(),
)),
None => findings.push(TixIssueFinding {
severity: Severity::Error,
file: String::new(),
line: 0,
column: 0,
value: diagnostic.field.clone(),
kind: kind.to_string(),
message: diagnostic.message.clone(),
}),
}
}
}
fn collect_related_issues(
parsed: &[ParsedFile],
store: &Store,
findings: &mut Vec<TixIssueFinding>,
) {
for entry in parsed {
for raw in &entry.issue.related_issues {
let kind = if raw.trim().is_empty() {
Some("related_issue_empty")
} else {
let r = crate::related::classify(raw);
match r.local_selector() {
Some(_) if crate::related::resolve_local(store, raw).is_err() => {
Some("related_issue_unresolved")
}
_ => None,
}
};
let Some(kind) = kind else { continue };
let (line, column) =
locate_value(&entry.text, "related_issues").map_or((0, 0), |l| (l.line, l.column));
let message = match kind {
"related_issue_empty" => {
"empty related-issue entry; remove it or name an issue".to_string()
}
_ => format!(
"related issue `{raw}` matches no issue in this store; it may be a \
path or URL (which lint does not resolve) or a typo"
),
};
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: entry.rel.clone(),
line,
column,
value: raw.clone(),
kind: kind.to_string(),
message,
});
}
}
}
fn collect_layout_mismatch(
config: &Config,
parsed: &[ParsedFile],
findings: &mut Vec<TixIssueFinding>,
) {
let default = crate::config::LayoutConfig::default();
if config.layout.directory == default.directory && config.layout.filename == default.filename {
return;
}
for entry in parsed {
let expected = crate::layout::render_directory(&config.layout, &entry.issue);
let current = entry
.issue
.filename
.rsplit_once('/')
.map_or("", |(dir, _)| dir);
if current != expected {
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: entry.rel.clone(),
line: 0,
column: 0,
value: entry.issue.filename.clone(),
kind: "layout_mismatch".to_string(),
message: format!(
"directory does not match `[layout]`: expected `{expected}`; run `tix layout migrate`"
),
});
}
}
}
pub fn collect_file(config: &Config, store: &Store, rel: &str) -> Result<Vec<TixIssueFinding>> {
let planner = planner_for(config);
let milestone_names = planner.milestone_names().unwrap_or_default();
let mut findings = Vec::new();
collect_for_file(
config,
store,
&planner,
&milestone_names,
rel,
&mut findings,
)?;
Ok(findings)
}
fn issue_files(store: &Store) -> Result<Vec<String>> {
let mut files: Vec<String> = Vec::new();
collect_issue_files(&store.issues_dir, "", &mut files)?;
files.sort();
Ok(files)
}
fn collect_issue_files(dir: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
if path.is_dir() {
if name.starts_with('.') || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
continue;
}
let child = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
collect_issue_files(&path, &child, files)?;
} else if path.is_file() && name.ends_with(".md") {
files.push(if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
});
}
}
Ok(())
}
struct ParsedFile {
rel: String,
text: String,
issue: Issue,
}
fn collect_for_file(
config: &Config,
store: &Store,
planner: &Planner,
milestone_names: &[String],
rel: &str,
findings: &mut Vec<TixIssueFinding>,
) -> Result<Option<ParsedFile>> {
let text = match fs::read_to_string(store.issues_dir.join(rel)) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
findings.push(TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line: 0,
column: 0,
value: String::new(),
kind: "parse".to_string(),
message: "file is not valid UTF-8".to_string(),
});
return Ok(None);
}
Err(err) => return Err(TixError::Io(err)),
};
let issue = match Issue::parse(&text) {
Ok(issue) => issue,
Err(TixError::Parse(msg)) => {
findings.push(TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line: 0,
column: 0,
value: String::new(),
kind: if text.starts_with("---\n") {
"parse".to_string()
} else {
"missing".to_string()
},
message: msg,
});
return Ok(None);
}
Err(other) => return Err(other),
};
validate_issue(
config,
planner,
milestone_names,
rel,
&text,
&issue,
findings,
);
unknown_frontmatter_keys(config, rel, &text, &issue, findings);
Ok(Some(ParsedFile {
rel: rel.to_string(),
text,
issue,
}))
}
fn unknown_frontmatter_keys(
config: &Config,
rel: &str,
text: &str,
issue: &Issue,
findings: &mut Vec<TixIssueFinding>,
) {
let declared = config.custom_attributes();
for key in issue.extra_frontmatter.keys() {
if declared.iter().any(|d| d == key) {
continue;
}
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: rel.to_string(),
line: locate_key_line(text, key),
column: 1,
value: key.clone(),
kind: "unknown_key".to_string(),
message: format!(
"unknown top-level frontmatter key `{key}`: it is preserved on write but \
nothing reads it. If that is deliberate, declare it in \
`[custom_attributes]` in tix.toml; if not, it is probably a typo."
),
});
}
}
fn locate_key_line(text: &str, key: &str) -> usize {
let frontmatter = match text.strip_prefix("---\n") {
Some(rest) => rest,
None => return 0,
};
for (offset, line) in frontmatter.lines().enumerate() {
if line == key
|| line.starts_with(&format!("{key}:"))
|| line.starts_with(&format!("\"{key}\":"))
{
return offset + 2; }
if line == "---" {
break;
}
}
0
}
fn validate_issue(
config: &Config,
planner: &Planner,
milestone_names: &[String],
rel: &str,
text: &str,
issue: &Issue,
findings: &mut Vec<TixIssueFinding>,
) {
let status = issue.status.display();
let columns = &config.default_config().columns;
if !columns.iter().any(|c| c == status) {
findings.push(value_finding(
rel,
text,
"status",
status,
format!("valid options: {}", columns.join(", ")),
));
}
if issue.title.trim().is_empty() {
findings.push(value_finding(
rel,
text,
"title",
&issue.title,
"title is required".to_string(),
));
}
if let Priority::Other(unknown) = &issue.priority
&& !issue.priority.is_undefined()
{
findings.push(value_finding(
rel,
text,
"priority",
unknown,
"valid options: '' (unset), low, medium, high".to_string(),
));
}
if let Some(t) = issue
.type_
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
let allowed = config.custom_types();
if !allowed.iter().any(|a| a.eq_ignore_ascii_case(t)) {
let located = locate_value(text, "type");
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: rel.to_string(),
line: located.as_ref().map_or(0, |l| l.line),
column: located.as_ref().map_or(0, |l| l.column),
value: t.to_string(),
kind: "unknown_type".to_string(),
message: format!(
"unknown issue type `{t}`; valid types: {}. Add it to \
`custom_types` in tix.toml to accept it.",
allowed.join(", ")
),
});
}
}
if !is_iso_date(&issue.created) {
findings.push(value_finding(
rel,
text,
"created",
&issue.created,
"expected a date in `YYYY-MM-DD` format".to_string(),
));
}
if !is_iso_date(&issue.updated) {
findings.push(value_finding(
rel,
text,
"updated",
&issue.updated,
"expected a date in `YYYY-MM-DD` format".to_string(),
));
}
if let Some(estimate) = &issue.estimate
&& estimate.parse::<f64>().is_err()
{
findings.push(value_finding(
rel,
text,
"estimate",
estimate,
"estimate must be numeric".to_string(),
));
}
for (index, line) in issue.body.lines().enumerate() {
if crate::model::is_history_heading(line) {
findings.push(value_finding(
rel,
text,
"history",
line.trim(),
"a `## History` section must be the first thing in the body; \
this one is treated as body text, so its entries are not in the \
audit trail and the next `tix edit --body` deletes them"
.to_string(),
));
let _ = index;
}
}
for line in issue.body.lines() {
if crate::model::looks_like_an_unrecognised_entry(line) {
findings.push(value_finding(
rel,
text,
"history",
line.trim(),
"looks like a `## History` entry but was not recognised \u{2014} \
entries need U+2014 em-dash separators, not ASCII hyphens"
.to_string(),
));
}
}
for line in issue.history.unparsed() {
let finding = value_finding(
rel,
text,
"history",
line,
"unparseable `## History` entry \u{2014} kept verbatim, never repaired; \
expected `- <timestamp> \u{2014} <actor> \u{2014} <kind> \u{2014} <detail>` \
with U+2014 em-dash separators"
.to_string(),
);
findings.push(finding);
}
if let Some(cycles) = &issue.cycles {
for name in cycles {
if planner.cycle(name).is_none() {
let known = planner.cycle_names();
let list = if known.is_empty() {
"none configured".to_string()
} else {
known.join(", ")
};
findings.push(value_finding(
rel,
text,
"cycles",
name,
format!("unknown cycle โ valid cycles: {list}"),
));
}
}
}
if let Some(milestones) = &issue.milestones {
for name in milestones {
if planner.milestone(name).is_err() || !milestone_names.iter().any(|m| m == name) {
let list = if milestone_names.is_empty() {
"none configured".to_string()
} else {
milestone_names.join(", ")
};
findings.push(value_finding(
rel,
text,
"milestones",
name,
format!("unknown milestone โ valid milestones: {list}"),
));
}
}
}
if let Err(TixError::Config(msg)) =
crate::model::validate_issue_dates(issue.start_date.as_deref(), issue.due_date.as_deref())
{
let (kind, value) = if msg.starts_with("start_date") {
("start_date", issue.start_date.as_deref().unwrap_or(""))
} else {
("due_date", issue.due_date.as_deref().unwrap_or(""))
};
findings.push(value_finding(rel, text, kind, value, msg));
}
}
fn value_finding(
rel: &str,
text: &str,
kind: &str,
value: &str,
message: String,
) -> TixIssueFinding {
finding_at(rel, text, kind, kind, value, message)
}
fn finding_at(
rel: &str,
text: &str,
kind: &str,
locate_key: &str,
value: &str,
message: String,
) -> TixIssueFinding {
let (line, column) = locate_value(text, locate_key)
.map(|l| (l.line, l.column))
.unwrap_or((0, 0));
TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line,
column,
value: value.to_string(),
kind: kind.to_string(),
message,
}
}
fn locate_value(text: &str, key: &str) -> Option<Located> {
let prefix = format!("{key}:");
let fm_end = text[4..].find("\n---").map(|i| i + 4).unwrap_or(text.len());
let fm = &text[..fm_end];
let mut line_start = 0usize;
for (idx, line) in fm.split_inclusive('\n').enumerate() {
let content = line.trim_end_matches(['\n', '\r']);
if let Some(rest) = content.trim_start().strip_prefix(&prefix) {
let pad = content.len() - content.trim_start().len();
let value_skip = rest.len() - rest.trim_start().len();
let start = line_start + pad + prefix.len() + value_skip;
return Some(Located {
line: idx + 1,
column: start - line_start + 1,
});
}
line_start += line.len();
}
None
}
struct Located {
line: usize,
column: usize,
}
fn is_iso_date(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
for &c in &b[0..4] {
if !c.is_ascii_digit() {
return false;
}
}
for &c in &[b[5], b[6], b[8], b[9]] {
if !c.is_ascii_digit() {
return false;
}
}
let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
(1..=12).contains(&month) && (1..=31).contains(&day)
}
fn render_diagnostic(issues_dir: &Path, f: &TixIssueFinding) {
let renderer = Renderer::styled();
let title = match f.kind.as_str() {
"missing" => "missing frontmatter".to_string(),
"parse" => "parse error".to_string(),
_ => format!("invalid {} value '{}'", f.kind, f.value),
};
let (level, id) = match f.severity {
Severity::Error => (Level::ERROR, "E-tix-lint"),
Severity::Warning => (Level::WARNING, "W-tix-lint"),
};
let text = fs::read_to_string(issues_dir.join(&f.file)).ok();
let group = match text {
Some(text) => level.primary_title(title).id(id).element(
Snippet::source(text.clone())
.line_start(1)
.path(&f.file)
.annotation(
AnnotationKind::Primary
.span(span_for(f, &text))
.label(&f.message),
),
),
None => {
let mut head = title;
if !f.message.is_empty() {
head.push_str(": ");
head.push_str(&f.message);
}
if f.file.is_empty() {
Group::with_title(level.primary_title(head).id(id))
} else {
Group::with_title(level.primary_title(format!("{}: {head}", f.file)).id(id))
}
}
};
eprintln!("{}", renderer.render(&[group]));
}
fn span_for(f: &TixIssueFinding, text: &str) -> Range<usize> {
if f.line > 0 {
let mut line_start = 0usize;
for (idx, line) in text.split_inclusive('\n').enumerate() {
if idx + 1 == f.line {
let start = line_start + (f.column - 1).min(line.len());
let end = line_start + line.trim_end_matches(['\n', '\r']).len();
return start..end.max(start);
}
line_start += line.len();
}
let first = text.lines().next().map(str::len).unwrap_or(0);
return 0..if first > 0 { first } else { text.len() };
}
let first = text.lines().next().map(str::len).unwrap_or(0);
0..if first > 0 { first } else { text.len() }
}
#[cfg(test)]
mod tests {
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 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 = include_str!("lint.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:?}");
}
}
}