use crate::descmd::{find_section, find_step, find_task};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
}
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
fn err(findings: &mut Vec<Finding>, message: String) {
findings.push(Finding {
severity: Severity::Error,
message,
});
}
fn warn(findings: &mut Vec<Finding>, message: String) {
findings.push(Finding {
severity: Severity::Warning,
message,
});
}
pub fn lint_description(desc: &str) -> Vec<Finding> {
let mut findings = Vec::new();
if !desc.trim().is_empty() && !find_section(desc, "Spec").2 {
warn(&mut findings, "no ## Spec section".to_string());
}
let (plan_start, plan_end, has_plan) = find_section(desc, "Plan");
if has_plan {
lint_plan(&desc[plan_start..plan_end], &mut findings);
}
let (act_start, act_end, has_activity) = find_section(desc, "Activity Log");
if has_activity {
lint_activity(&desc[act_start..act_end], &mut findings);
}
findings
}
fn lint_plan(plan: &str, findings: &mut Vec<Finding>) {
let mut numbers: Vec<i32> = Vec::new();
let mut in_task = false;
let mut unreachable_steps = 0usize;
for line in plan.lines() {
let trimmed = line.trim_end();
if let Some(rest) = trimmed.strip_prefix("### ") {
match rest
.strip_prefix("Task ")
.and_then(|r| r.split_once(':'))
.and_then(|(n, _)| n.trim().parse::<i32>().ok())
{
Some(n) => {
numbers.push(n);
in_task = true;
}
None => {
err(
findings,
format!("plan heading {trimmed:?} is not \"### Task N: <title>\""),
);
in_task = false;
}
}
}
if !in_task && (line.starts_with("- [ ] ") || line.starts_with("- [x] ")) {
unreachable_steps += 1;
}
let lstripped = line.trim_start();
if line.starts_with("- [") && !line.starts_with("- [ ] ") && !line.starts_with("- [x] ") {
err(
findings,
format!("malformed checkbox {trimmed:?} (want \"- [ ] \" or \"- [x] \")"),
);
} else if lstripped != line && lstripped.starts_with("- [") {
warn(
findings,
format!("indented checkbox {:?} is a child bullet — tick cannot reach it", lstripped.trim_end()),
);
}
}
if unreachable_steps > 0 {
warn(
findings,
format!(
"{unreachable_steps} step(s) outside any \"### Task N:\" heading — \
tick cannot reach them"
),
);
}
for (i, n) in numbers.iter().enumerate() {
let expected = (i + 1) as i32;
if *n != expected {
warn(
findings,
format!("plan tasks are numbered {numbers:?}; expected 1..{}", numbers.len()),
);
break;
}
}
for n in &numbers {
let (task_start, task_end, ok) = find_task(plan, *n);
if !ok {
continue; }
if find_step(&plan[task_start..task_end], 1).is_none() {
warn(findings, format!("Task {n} has no steps"));
}
}
}
fn lint_activity(activity: &str, findings: &mut Vec<Finding>) {
for line in activity.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("- ") {
continue;
}
let parses = trimmed
.strip_prefix("- ")
.and_then(|rest| rest.split_once(" — "))
.map(|(stamp, _)| {
chrono::NaiveDateTime::parse_from_str(stamp.trim(), "%Y-%m-%dT%H:%MZ").is_ok()
|| chrono::DateTime::parse_from_rfc3339(stamp.trim()).is_ok()
})
.unwrap_or(false);
if !parses {
warn(
findings,
format!("activity entry {trimmed:?} does not parse as \"- <ts> — <msg>\""),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn errors(desc: &str) -> usize {
lint_description(desc)
.iter()
.filter(|f| f.severity == Severity::Error)
.count()
}
#[test]
fn a_conforming_description_is_clean() {
let d = "## Spec\n\ns\n\n## Plan\n\n### Task 1: a\n\n- [ ] **Step 1: x**\n\n\
## Activity Log\n\n- 2026-08-02T10:00Z — started\n";
assert!(lint_description(d).is_empty(), "{:?}", lint_description(d));
}
#[test]
fn empty_description_is_clean() {
assert!(lint_description("").is_empty());
}
#[test]
fn bad_task_heading_is_an_error() {
let d = "## Plan\n\n### Step one\n\n- [ ] x\n";
assert_eq!(errors(d), 1);
}
#[test]
fn malformed_checkbox_is_an_error() {
let d = "## Plan\n\n### Task 1: a\n\n- [X] step\n";
assert_eq!(errors(d), 1);
}
#[test]
fn indented_checkbox_is_a_warning_not_an_error() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] real step\n - [ ] hidden\n";
assert_eq!(errors(d), 0);
assert!(lint_description(d)
.iter()
.any(|f| f.severity == Severity::Warning && f.message.contains("indented")));
}
#[test]
fn skipped_task_number_is_a_warning() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] x\n\n### Task 3: b\n\n- [ ] y\n";
let f = lint_description(d);
assert!(f.iter().any(|f| f.severity == Severity::Warning));
assert_eq!(errors(d), 0);
}
#[test]
fn flat_checklist_without_tasks_is_flagged() {
let d = "## Plan\n\n- [ ] first\n- [ ] second\n";
let f = lint_description(d);
assert!(
f.iter().any(|f| f.message.contains("tick cannot reach")),
"{f:?}"
);
}
#[test]
fn steps_inside_tasks_are_not_flagged_as_unreachable() {
let d = "## Plan\n\n### Task 1: a\n\n- [ ] in task\n";
assert!(!lint_description(d)
.iter()
.any(|f| f.message.contains("tick cannot reach")));
}
#[test]
fn stepless_task_is_a_warning() {
let d = "## Plan\n\n### Task 1: a\n\nprose only\n";
let f = lint_description(d);
assert!(f.iter().any(|f| f.severity == Severity::Warning && f.message.contains("no steps")));
}
#[test]
fn unparseable_activity_line_is_a_warning() {
let d = "## Activity Log\n\n- yesterday: did stuff\n";
let f = lint_description(d);
assert_eq!(f.len(), 2, "{f:?}"); assert!(f.iter().all(|f| f.severity == Severity::Warning));
}
}