use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Priority {
Critical,
High,
Medium,
Low,
}
impl Priority {
pub const ALL: [Priority; 4] = [
Priority::Critical,
Priority::High,
Priority::Medium,
Priority::Low,
];
pub fn name(self) -> &'static str {
match self {
Priority::Critical => "critical",
Priority::High => "high",
Priority::Medium => "medium",
Priority::Low => "low",
}
}
pub fn parse(s: &str) -> Option<Priority> {
Self::ALL.into_iter().find(|p| p.name() == s)
}
}
const SECTIONS: [(&str, Priority); 4] = [
("Critical", Priority::Critical),
("High", Priority::High),
("Medium", Priority::Medium),
("Low", Priority::Low),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub line: usize,
pub priority: Priority,
pub done: bool,
pub text: String,
pub tags: Vec<String>,
pub due: Option<String>,
pub gh: Option<u64>,
pub id: Option<String>,
pub group: Option<String>,
pub description: Vec<String>,
}
impl Item {
pub fn key(&self) -> String {
if let Some(id) = &self.id {
return format!("id:{id}");
}
match self.gh {
Some(n) => format!("gh:{n}"),
None => self.text_key(),
}
}
fn text_key(&self) -> String {
let text = normal_text(&self.text);
let reserved = matches!(text.as_str(), "ci" | "deps")
|| ["campaign:", "workflow:", "item:", "id:", "gh:"]
.iter()
.any(|p| text.starts_with(p));
match reserved {
true => format!("item:{text}"),
false => text,
}
}
pub fn is_task(&self, key: &str, text: &str) -> bool {
self.id.as_ref().is_some_and(|id| key == format!("id:{id}"))
|| self.gh.is_some_and(|n| key == format!("gh:{n}"))
|| self.text_key() == key
|| (!text.trim().is_empty() && normal_text(&self.text) == normal_text(text))
}
}
pub fn normal_text(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Warning,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Warning => "warning",
Severity::Error => "error",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub line: usize,
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Default)]
pub struct Parsed {
pub items: Vec<Item>,
pub diagnostics: Vec<Diagnostic>,
pub done_sections: Vec<DoneSection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoneSection {
pub first: usize,
pub last: usize,
pub open: Vec<(usize, String)>,
}
impl Parsed {
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
fn report(&mut self, line: usize, severity: Severity, message: impl Into<String>) {
self.diagnostics.push(Diagnostic {
line,
severity,
message: message.into(),
});
}
}
#[derive(Clone, Copy)]
enum Place {
Preamble,
Known(Priority),
Done,
Other,
}
pub fn parse(text: &str) -> Parsed {
let mut out = Parsed::default();
let mut place = Place::Preamble;
let mut seen_sections: HashMap<Priority, usize> = HashMap::new();
let mut title_line: Option<usize> = None;
let mut first_content = true;
let mut fence: Option<(u8, usize, usize)> = None;
let mut blanks = 0;
let mut open: Option<Item> = None;
let mut ignored_bullets = 0;
let mut group: Option<String> = None;
for (index, raw) in text.lines().enumerate() {
let n = index + 1;
let line = raw.trim_end();
if line.is_empty() {
blanks += 1;
continue;
}
let indented = line.starts_with(' ') || line.starts_with('\t');
if indented {
match open.as_mut() {
Some(item) => {
item.description
.extend(std::iter::repeat_n(String::new(), blanks));
item.description.push(raw.to_string());
}
None if matches!(place, Place::Known(_)) && fence.is_none() => {
out.report(n, Severity::Warning, "indented line is not under an item");
}
None => {}
}
blanks = 0;
continue;
}
blanks = 0;
finish(&mut open, &mut out);
if first_content {
first_content = false;
if line != "# TODO" {
out.report(n, Severity::Error, "the file must start with `# TODO`");
}
}
match fence {
Some((mark, len, _)) => {
if fence_run(line, mark).is_some_and(|n| n >= len && n == line.len()) {
fence = None;
}
continue;
}
None => {
if let Some((mark, len)) = b"`~"
.iter()
.copied()
.find_map(|m| fence_run(line, m).filter(|n| *n >= 3).map(|n| (m, n)))
{
fence = Some((mark, len, n));
continue;
}
}
}
if line.starts_with("# ") {
match title_line {
Some(first) => out.report(
n,
Severity::Error,
format!("a second `#` heading; the title is on line {first}"),
),
None => title_line = Some(n),
}
continue;
}
let heading = match line.strip_prefix("## ") {
Some(name) => Some((name, true)),
None => line
.strip_prefix("##")
.filter(|rest| is_priority_name(rest.trim()))
.map(|name| (name, false)),
};
if let Some((name, spaced)) = heading {
if matches!(place, Place::Done)
&& let Some(section) = out.done_sections.last_mut()
{
section.last = n - 1;
}
place = section_heading(name.trim(), spaced, n, &mut seen_sections, &mut out);
if matches!(place, Place::Done) {
out.done_sections.push(DoneSection {
first: n,
last: text.lines().count(),
open: Vec::new(),
});
}
group = None;
continue;
}
if let Some(name) = line
.strip_prefix("###")
.filter(|rest| rest.is_empty() || rest.starts_with(' '))
{
group = Some(name.trim().to_string()).filter(|g| !g.is_empty());
continue;
}
if line.starts_with('#') {
continue;
}
match place {
Place::Known(priority) => {
if let Some(mut item) = item_line(line, n, priority, &mut out) {
item.group = group.clone();
open = Some(item);
}
}
Place::Preamble | Place::Done | Place::Other => {
if is_checkbox(line) {
let msg = match place {
Place::Preamble => "item outside a section is ignored",
Place::Done => "`## Done` is not part of format v2; `pma prune` removes it",
_ => "item in this section is ignored; use Critical, High, Medium or Low",
};
out.report(n, Severity::Warning, msg);
if let (Place::Done, Some(' '), Some(section)) =
(place, checkbox_mark(line), out.done_sections.last_mut())
{
section.open.push((n, line.to_string()));
}
} else if is_bullet(line) {
ignored_bullets += 1;
}
}
}
}
finish(&mut open, &mut out);
if let Some((_, _, line)) = fence {
out.report(
line,
Severity::Error,
"this code fence is never closed, so no line after it is read",
);
}
if first_content {
out.report(1, Severity::Error, "the file must start with `# TODO`");
}
if out.items.is_empty() && ignored_bullets > 0 && seen_sections.is_empty() {
out.report(
1,
Severity::Warning,
format!(
"no items; {ignored_bullets} list entries outside Critical, High, Medium and Low are ignored"
),
);
}
check_duplicates(&mut out);
out
}
fn fence_run(line: &str, mark: u8) -> Option<usize> {
let n = line.bytes().take_while(|&b| b == mark).count();
(n > 0).then_some(n)
}
fn finish(open: &mut Option<Item>, out: &mut Parsed) {
if let Some(item) = open.take() {
out.items.push(item);
}
}
fn is_priority_name(name: &str) -> bool {
SECTIONS
.iter()
.any(|(known, _)| known.eq_ignore_ascii_case(name))
}
fn section_heading(
name: &str,
spaced: bool,
n: usize,
seen: &mut HashMap<Priority, usize>,
out: &mut Parsed,
) -> Place {
if name == "Done" {
return Place::Done;
}
let Some((canonical, priority)) = SECTIONS
.iter()
.find(|(known, _)| known.eq_ignore_ascii_case(name))
else {
return Place::Other;
};
if !spaced || name != *canonical {
out.report(
n,
Severity::Error,
format!("write the heading as `## {canonical}`"),
);
}
if let Some(first) = seen.insert(*priority, n) {
out.report(
n,
Severity::Error,
format!("`## {canonical}` appears again; the first is on line {first}"),
);
}
Place::Known(*priority)
}
fn is_bullet(line: &str) -> bool {
line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")
}
fn is_checkbox(line: &str) -> bool {
checkbox_mark(line).is_some_and(|mark| matches!(mark, ' ' | 'x' | 'X'))
}
fn checkbox_mark(line: &str) -> Option<char> {
let rest = line
.strip_prefix(['-', '*', '+'])
.or_else(|| {
let digits = line.trim_start_matches(|c: char| c.is_ascii_digit());
(digits.len() < line.len())
.then(|| digits.strip_prefix(['.', ')']))
.flatten()
})?
.trim_start();
let mark = rest.strip_prefix('[')?.chars().next()?;
rest[1 + mark.len_utf8()..].starts_with(']').then_some(mark)
}
fn item_line(line: &str, n: usize, priority: Priority, out: &mut Parsed) -> Option<Item> {
let canonical = match line.get(..5) {
Some("- [ ]") => Some(false),
Some("- [x]") => Some(true),
_ => None,
};
let rest = &line[if canonical.is_some() { 5 } else { 0 }..];
let Some(done) = canonical.filter(|_| rest.is_empty() || rest.starts_with(' ')) else {
if is_checkbox(line) {
out.report(
n,
Severity::Error,
"write items as `- [ ] text` or `- [x] text`",
);
} else if is_bullet(line) {
out.report(
n,
Severity::Error,
"a list entry in this section must be a `- [ ]` item",
);
} else {
out.report(n, Severity::Warning, "text in this section is not an item");
}
return None;
};
let words: Vec<&str> = rest.split_whitespace().collect();
let mut split = words.len();
while split > 0 && is_token(words[split - 1]) {
split -= 1;
}
let mut item = Item {
line: n,
priority,
done,
text: words[..split].join(" "),
tags: Vec::new(),
due: None,
gh: None,
id: None,
group: None,
description: Vec::new(),
};
for word in &words[split..] {
if let Some(date) = word.strip_prefix("due:") {
if crate::dates::parse(date).is_none() {
out.report(
n,
Severity::Error,
format!("`{word}` is not a date; write `due:YYYY-MM-DD`"),
);
} else if item.due.replace(date.to_string()).is_some() {
out.report(n, Severity::Error, "more than one `due:`");
}
} else if let Some(number) = word.strip_prefix("gh:") {
match number.parse::<u64>() {
Ok(v) if v > 0 && !number.starts_with('0') => {
if item.gh.replace(v).is_some() {
out.report(n, Severity::Error, "more than one `gh:`");
}
}
_ => out.report(
n,
Severity::Error,
format!("`{word}` is not an issue number"),
),
}
} else if let Some(id) = caret_id(word) {
if item.id.replace(id.to_string()).is_some() {
out.report(n, Severity::Error, "more than one id");
}
} else if !item.tags.iter().any(|t| t == &word[1..]) {
item.tags.push(word[1..].to_string());
}
}
if let Some(last) = words[..split].last()
&& last.len() > 1
&& last.starts_with('#')
&& last[1..].bytes().all(|b| b.is_ascii_digit())
{
out.report(
n,
Severity::Warning,
format!(
"`{last}` is text; write `gh:{}` to link the issue",
&last[1..]
),
);
}
if item.text.is_empty() {
out.report(n, Severity::Error, "the item has no text");
}
Some(item)
}
pub fn is_token(word: &str) -> bool {
if word.starts_with("due:") || word.starts_with("gh:") || caret_id(word).is_some() {
return true;
}
let Some(tag) = word.strip_prefix('#') else {
return false;
};
tag.starts_with(|c: char| c.is_ascii_alphabetic())
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
const ID_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
const ID_LEN: usize = 5;
fn is_id(s: &str) -> bool {
s.len() == ID_LEN && s.bytes().all(|b| ID_ALPHABET.contains(&b))
}
fn caret_id(word: &str) -> Option<&str> {
word.strip_prefix('^').filter(|id| is_id(id))
}
pub fn new_id(text: &str) -> String {
use std::hash::{BuildHasher, Hasher};
let taken: Vec<String> = parse(text).items.into_iter().filter_map(|i| i.id).collect();
let state = std::collections::hash_map::RandomState::new();
for n in 0u64.. {
let mut h = state.build_hasher();
h.write_u64(n);
h.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
);
let mut bits = h.finish();
let id: String = (0..ID_LEN)
.map(|_| {
let c = ID_ALPHABET[(bits & 31) as usize] as char;
bits >>= 5;
c
})
.collect();
if !taken.contains(&id) {
return id;
}
}
unreachable!("an unused id exists")
}
pub fn mint_id(text: &str, key: &str, task_text: &str) -> Option<(String, String)> {
let parsed = parse(text);
if parsed.has_errors() {
return None;
}
let item = parsed
.items
.into_iter()
.find(|i| !i.done && i.id.is_none() && i.is_task(key, task_text))?;
let id = new_id(text);
let edited = add_token(text, item.line, &format!("^{id}"))?;
Some((edited, id))
}
pub fn mark_done(text: &str, key: &str, task_text: &str) -> Option<String> {
let item = parse(text)
.items
.into_iter()
.find(|i| !i.done && i.is_task(key, task_text))?;
let mut lines: Vec<&str> = text.split_inclusive('\n').collect();
let target = format!("- [x]{}", &lines[item.line - 1][5..]);
lines[item.line - 1] = ⌖
Some(lines.concat())
}
#[derive(Debug, PartialEq)]
pub struct Pruned {
pub text: String,
pub items: Vec<Item>,
pub done_sections: Vec<DoneSection>,
}
pub fn prune(text: &str) -> Pruned {
let parsed = parse(text);
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let mut drop = vec![false; lines.len()];
let items: Vec<Item> = parsed.items.into_iter().filter(|i| i.done).collect();
for item in &items {
drop[item.line - 1..item.line + item.description.len()].fill(true);
}
for &DoneSection { first, last, .. } in &parsed.done_sections {
let mut start = first - 1;
if last == lines.len() {
while start > 0 && lines[start - 1].trim().is_empty() {
start -= 1;
}
}
drop[start..last].fill(true);
}
Pruned {
text: lines
.iter()
.zip(&drop)
.filter(|(_, d)| !**d)
.map(|(l, _)| *l)
.collect(),
items,
done_sections: parsed.done_sections,
}
}
pub fn remove_done(text: &str, key: &str, task_text: &str) -> Option<String> {
let item = parse(text)
.items
.into_iter()
.find(|i| i.done && i.is_task(key, task_text))?;
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let span = item.line - 1..item.line + item.description.len();
Some(
lines
.iter()
.enumerate()
.filter(|(i, _)| !span.contains(i))
.map(|(_, l)| *l)
.collect(),
)
}
pub fn insert(
text: &str,
priority: Priority,
item: &str,
description: Option<&str>,
) -> Option<String> {
let want = SECTIONS.iter().find(|(_, p)| *p == priority)?.0;
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let heading = lines.iter().position(|l| {
l.trim_end()
.strip_prefix("## ")
.is_some_and(|name| name.trim() == want)
})?;
let mut at = lines.len();
for (i, line) in lines.iter().enumerate().skip(heading + 1) {
let trimmed = line.trim_end();
if trimmed.starts_with("## ") || trimmed.starts_with("### ") {
at = i;
break;
}
}
while at > heading + 1 && lines[at - 1].trim().is_empty() {
at -= 1;
}
let mut written = String::from("- [ ] ");
written.push_str(item.trim());
written.push('\n');
for line in description.unwrap_or_default().lines() {
written.push_str(" ");
written.push_str(line.trim_end());
written.push('\n');
}
let mut out: String = lines[..at].concat();
if !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&written);
let rest: String = lines[at..].concat();
if !rest.is_empty() {
out.push('\n');
out.push_str(&rest);
}
Some(out)
}
pub fn add_token(text: &str, line: usize, token: &str) -> Option<String> {
if !parse(text).items.iter().any(|i| i.line == line) {
return None;
}
let mut lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect();
let target = lines.get_mut(line - 1)?;
let body = target.trim_end_matches(['\r', '\n']).trim_end().to_string();
let ending = target[target.trim_end_matches(['\r', '\n']).len()..].to_string();
*target = format!("{body} {token}{ending}");
Some(lines.concat())
}
pub fn save(path: &std::path::Path, text: &str) -> std::io::Result<()> {
use std::io::Write;
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "TODO.md".into());
let tmp = target.with_file_name(format!(".{name}.pma-tmp"));
let written = (|| {
let mut file = std::fs::File::create(&tmp)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
if let Ok(meta) = std::fs::metadata(&target) {
std::fs::set_permissions(&tmp, meta.permissions())?;
}
std::fs::rename(&tmp, &target)
})();
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
fn check_duplicates(out: &mut Parsed) {
let mut texts: HashMap<String, usize> = HashMap::new();
let mut issues: HashMap<u64, usize> = HashMap::new();
let mut ids: HashMap<&str, usize> = HashMap::new();
let mut found = Vec::new();
for item in &out.items {
if !item.done
&& !item.text.is_empty()
&& let Some(first) = texts.insert(item.text.to_lowercase(), item.line)
{
found.push((
item.line,
format!("same text as the open item on line {first}"),
));
}
if let Some(gh) = item.gh
&& let Some(first) = issues.insert(gh, item.line)
{
found.push((item.line, format!("`gh:{gh}` is also on line {first}")));
}
if let Some(id) = item.id.as_deref()
&& let Some(first) = ids.insert(id, item.line)
{
found.push((item.line, format!("id `^{id}` is also on line {first}")));
}
}
for (line, message) in found {
out.report(line, Severity::Error, message);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn messages(text: &str) -> Vec<(usize, Severity, String)> {
parse(text)
.diagnostics
.into_iter()
.map(|d| (d.line, d.severity, d.message))
.collect()
}
fn assert_clean(text: &str) -> Parsed {
let parsed = parse(text);
assert!(
parsed.diagnostics.is_empty(),
"unexpected diagnostics: {:?}",
parsed.diagnostics
);
parsed
}
fn assert_reports(text: &str, line: usize, severity: Severity, fragment: &str) {
let found = messages(text);
assert!(
found
.iter()
.any(|(l, s, m)| *l == line && *s == severity && m.contains(fragment)),
"no {severity} containing {fragment:?} on line {line}; got {found:?}"
);
}
const SPEC_EXAMPLE: &str = "# TODO
## Critical
- [ ] segfault on empty input #bug gh:42
## High
- [ ] support ggml 0.9 due:2026-10-01
## Medium
- [ ] flaky test on linux #urgent
## Low
- [x] drop python 3.9
";
#[test]
fn spec_example_parses_clean() {
let parsed = assert_clean(SPEC_EXAMPLE);
let summary: Vec<_> = parsed
.items
.iter()
.map(|i| (i.line, i.priority, i.done, i.text.as_str()))
.collect();
assert_eq!(
summary,
vec![
(5, Priority::Critical, false, "segfault on empty input"),
(9, Priority::High, false, "support ggml 0.9"),
(13, Priority::Medium, false, "flaky test on linux"),
(17, Priority::Low, true, "drop python 3.9"),
]
);
assert_eq!(parsed.items[0].tags, ["bug"]);
assert_eq!(parsed.items[0].gh, Some(42));
assert_eq!(parsed.items[1].due.as_deref(), Some("2026-10-01"));
assert_eq!(parsed.items[2].tags, ["urgent"]);
}
#[test]
fn only_trailing_words_are_tokens() {
let parsed = assert_clean(
"# TODO\n\n## High\n\n- [ ] port C# #bindings to due:parser module #api\n",
);
let item = &parsed.items[0];
assert_eq!(item.text, "port C# #bindings to due:parser module");
assert_eq!(item.tags, ["api"]);
assert_eq!(item.due, None);
}
#[test]
fn description_is_carried_verbatim_across_blank_lines() {
let parsed =
assert_clean("# TODO\n\n## Low\n\n- [ ] item\n first\n\n second\n\n- [ ] next\n");
assert_eq!(parsed.items[0].description, [" first", "", " second"]);
assert!(parsed.items[1].description.is_empty());
}
#[test]
fn crlf_line_endings_are_accepted() {
let parsed = assert_clean("# TODO\r\n\r\n## High\r\n\r\n- [ ] item #tag\r\n");
assert_eq!(parsed.items[0].tags, ["tag"]);
}
#[test]
fn other_sections_and_deeper_headings() {
let text =
"# TODO\n\n## Notes\n\nprose\n- [ ] ignored\n\n## High\n\n### parser\n\n- [ ] kept\n";
let parsed = parse(text);
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].priority, Priority::High);
assert_reports(text, 6, Severity::Warning, "ignored");
assert_eq!(parsed.diagnostics.len(), 1);
}
#[test]
fn items_take_the_nearest_level_three_heading_in_their_section() {
let text = "# TODO\n\n## High\n\n- [ ] ungrouped\n\n### Security Hardening \n\n- [ ] a\n\n#### detail\n\n- [ ] b\n\n### \n\n- [ ] c\n\n## Low\n\n- [ ] d\n\n### Later\n\n- [ ] e\n";
let groups: Vec<_> = parse(text)
.items
.into_iter()
.map(|i| (i.text, i.group))
.collect();
let g = |s: &str| Some(s.to_string());
assert_eq!(
groups,
[
("ungrouped".into(), None),
("a".into(), g("Security Hardening")),
("b".into(), g("Security Hardening")),
("c".into(), None),
("d".into(), None),
("e".into(), g("Later")),
]
);
}
#[test]
fn a_file_whose_tasks_are_all_plain_bullets_is_flagged() {
let text = "# TODO\n\n## Ideas\n\n- one\n- two\n\n```\n- fenced\n```\n";
assert_reports(text, 1, Severity::Warning, "no items; 2 list entries");
assert_clean("# TODO\n\n## Ideas\n\n- one\n\n## Low\n\n- [ ] real\n");
assert_clean("# TODO\n\n## High\n");
assert_clean("# TODO\n\n## Low\n\n## Declined\n\n- not doing this\n");
}
#[test]
fn fenced_code_is_not_structure() {
let parsed = assert_clean(
"# TODO\n\n## High\n\n```\n## Done\n- [ ] not an item\n```\n\n- [ ] real\n",
);
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].priority, Priority::High);
}
#[test]
fn a_fence_closes_only_on_its_own_kind_and_length() {
let item = |text: &str| parse(text).items.len();
let four = "# TODO\n\n## High\n\n````\n```\n- [ ] hidden\n````\n\n- [ ] real\n";
assert_eq!(item(four), 1);
assert_clean(four);
let tilde = "# TODO\n\n## High\n\n~~~\n```\n- [ ] hidden\n~~~\n\n- [ ] real\n";
assert_eq!(item(tilde), 1);
assert_clean(tilde);
let longer = "# TODO\n\n## High\n\n```\n- [ ] hidden\n```` x\n`````\n- [ ] real\n";
assert_eq!(item(longer), 1);
assert_clean(longer);
}
#[test]
fn an_unclosed_fence_is_an_error_at_its_opening_line() {
let text = "# TODO\n\n## High\n\n- [ ] seen\n\n```rust\n- [ ] hidden\n";
assert_reports(text, 7, Severity::Error, "never closed");
assert_eq!(parse(text).items.len(), 1);
let mismatched = "# TODO\n\n## High\n\n````\n```\n";
assert_reports(mismatched, 5, Severity::Error, "never closed");
}
#[test]
fn title_is_required() {
assert_reports("## High\n", 1, Severity::Error, "must start with `# TODO`");
assert_reports(
"\n\nintro\n# TODO\n",
3,
Severity::Error,
"must start with `# TODO`",
);
assert_reports("", 1, Severity::Error, "must start with `# TODO`");
assert_reports(
"# TODO\n\n# Other\n",
3,
Severity::Error,
"second `#` heading",
);
assert_reports(
"```\n# TODO\n```\n",
1,
Severity::Error,
"must start with `# TODO`",
);
assert_eq!(
messages("# Tasks\n").len(),
1,
"a wrong title is reported once"
);
}
#[test]
fn section_names_are_exact() {
assert_reports(
"# TODO\n\n## critical\n",
3,
Severity::Error,
"`## Critical`",
);
for bad in ["##Critical", "##critical", "##High "] {
let text = format!("# TODO\n\n{bad}\n\n- [ ] item\n");
assert_eq!(messages(&text).len(), 1, "{bad} is reported once");
assert_reports(&text, 3, Severity::Error, "write the heading as `## ");
assert_eq!(parse(&text).items.len(), 1, "{bad} opens its section");
}
assert_clean("# TODO\n\n##Notes\n\n## Low\n");
assert_reports(
"# TODO\n\n## High\n\n## High\n",
5,
Severity::Error,
"first is on line 3",
);
}
#[test]
fn noncanonical_items_are_errors() {
for bad in [
"* [ ] x", "- [X] x", "-[ ] x", "1. [ ] x", "+ [x] x", "- [ ]x",
] {
let text = format!("# TODO\n\n## High\n\n{bad}\n");
assert_reports(&text, 5, Severity::Error, "write items as");
assert!(parse(&text).items.is_empty(), "{bad} was read as an item");
}
assert_reports(
"# TODO\n\n## High\n\n- plain bullet\n",
5,
Severity::Error,
"must be a `- [ ]` item",
);
assert_reports(
"# TODO\n\n## High\n\nsome prose\n",
5,
Severity::Warning,
"not an item",
);
assert_reports(
"# TODO\n\n## High\n\n**bold** prose\n",
5,
Severity::Warning,
"not an item",
);
assert_reports(
"# TODO\n\n## High\n\n stray\n",
5,
Severity::Warning,
"not under an item",
);
}
#[test]
fn tokens_are_validated() {
let item = |s: &str| format!("# TODO\n\n## High\n\n- [ ] thing {s}\n");
assert_reports(&item("due:2026-02-30"), 5, Severity::Error, "not a date");
assert_reports(&item("due:tomorrow"), 5, Severity::Error, "not a date");
assert_reports(
&item("due:2026-01-01 due:2026-01-02"),
5,
Severity::Error,
"more than one `due:`",
);
assert_reports(&item("gh:0"), 5, Severity::Error, "not an issue number");
assert_reports(&item("gh:x1"), 5, Severity::Error, "not an issue number");
assert_reports(
&item("gh:1 gh:2"),
5,
Severity::Error,
"more than one `gh:`",
);
assert_reports(&item("#42"), 5, Severity::Warning, "write `gh:42`");
assert_clean(&item("due:2028-02-29"));
}
#[test]
fn items_need_text_and_may_be_finished_in_place() {
assert_clean("# TODO\n\n## Low\n\n- [x] closed\n");
assert_reports(
"# TODO\n\n## Low\n\n- [ ] #only-tags\n",
5,
Severity::Error,
"no text",
);
assert_reports("# TODO\n\n## Low\n\n- [ ]\n", 5, Severity::Error, "no text");
}
#[test]
fn mark_done_ticks_the_item_in_place() {
let text = "# TODO\n\n## High\n\n- [ ] first #bug\n why\n- [ ] Second gh:4\n- [x] old\n";
let out = mark_done(text, "first", "").unwrap();
assert_eq!(
out,
"# TODO\n\n## High\n\n- [x] first #bug\n why\n- [ ] Second gh:4\n- [x] old\n"
);
assert_clean(&out);
assert_eq!(
mark_done(text, "old", "old"),
None,
"finished items are not open"
);
assert_eq!(mark_done(text, "missing", ""), None);
assert!(
mark_done(text, "second", "SECOND").is_some(),
"a key that gained gh:N still matches by text"
);
assert_eq!(
mark_done("# TODO\r\n\r\n## Low\r\n- [ ] a gh:2", "gh:2", "").unwrap(),
"# TODO\r\n\r\n## Low\r\n- [x] a gh:2"
);
let fenced = "# TODO\n\n## Low\n\n```\n- [ ] a\n```\n- [ ] a\n";
assert!(
mark_done(fenced, "a", "")
.unwrap()
.ends_with("```\n- [ ] a\n```\n- [x] a\n")
);
}
#[test]
fn prune_removes_finished_items_with_their_descriptions() {
let text = "# TODO\r\n\r\n## High\r\n\r\n- [x] done #bug\r\n why\r\n\r\n more\r\n- [ ] open\r\n kept\r\n\r\n## Low\r\n\r\n```\r\n- [x] fenced\r\n```\r\n- [x] last";
let pruned = prune(text);
assert_eq!(
pruned.text,
"# TODO\r\n\r\n## High\r\n\r\n- [ ] open\r\n kept\r\n\r\n## Low\r\n\r\n```\r\n- [x] fenced\r\n```\r\n"
);
let removed: Vec<_> = pruned
.items
.iter()
.map(|i| (i.line, i.text.as_str()))
.collect();
assert_eq!(removed, [(5, "done"), (17, "last")]);
assert!(pruned.done_sections.is_empty());
assert_clean(&pruned.text);
assert_eq!(prune(&pruned.text).text, pruned.text);
}
#[test]
fn prune_removes_done_sections_whole() {
let text = "# TODO\n\n## Low\n\n- [ ] a\n\n## Done\n\n### old\n\n- [x] b\n why\nprose\n- [ ] open\n```\n## Notes\n```\n\n## Notes\n\nkept\n\n## Done\n\n- [x] c\n\n";
assert_reports(text, 11, Severity::Warning, "`pma prune` removes it");
let pruned = prune(text);
let spans: Vec<_> = pruned
.done_sections
.iter()
.map(|d| (d.first, d.last, d.open.clone()))
.collect();
assert_eq!(
spans,
[
(7, 18, vec![(14, "- [ ] open".to_string())]),
(23, 26, vec![])
]
);
assert!(pruned.items.is_empty());
assert_eq!(
pruned.text,
"# TODO\n\n## Low\n\n- [ ] a\n\n## Notes\n\nkept\n"
);
assert_clean(&pruned.text);
}
#[test]
fn remove_done_takes_one_finished_item_and_its_description() {
let text = "# TODO\n\n## High\n\n- [x] done\n why\n- [x] other\n- [ ] open\n";
assert_eq!(
remove_done(text, "done", "done").unwrap(),
"# TODO\n\n## High\n\n- [x] other\n- [ ] open\n"
);
assert_eq!(
remove_done(text, "open", "open"),
None,
"an open item stays"
);
assert_eq!(remove_done(text, "missing", "missing"), None);
}
#[test]
fn add_token_appends_to_item_lines_only() {
let text = "# TODO\r\n\r\n## Critical\r\n\r\n- [ ] crash #bug \r\n- [ ] last";
let out = add_token(text, 5, "gh:12").unwrap();
assert_eq!(
out,
"# TODO\r\n\r\n## Critical\r\n\r\n- [ ] crash #bug gh:12\r\n- [ ] last"
);
assert_eq!(parse(&out).items[0].gh, Some(12));
assert!(
add_token(text, 6, "gh:3")
.unwrap()
.ends_with("- [ ] last gh:3")
);
assert_eq!(add_token(text, 3, "gh:1"), None, "a heading is not an item");
assert_eq!(add_token(text, 99, "gh:1"), None);
}
#[test]
fn save_replaces_the_file_and_keeps_its_mode_and_link() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("pma-todo-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let real = dir.join("real.md");
std::fs::write(&real, "# TODO\n\nold, and longer than the new text\n").unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap();
let link = dir.join("TODO.md");
std::os::unix::fs::symlink(&real, &link).unwrap();
save(&link, "# TODO\n").unwrap();
assert_eq!(std::fs::read_to_string(&real).unwrap(), "# TODO\n");
assert!(
std::fs::symlink_metadata(&link).unwrap().is_symlink(),
"the link is written through, not replaced"
);
let mode = std::fs::metadata(&real).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o640);
let left: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(left.len(), 2, "no temporary file is left: {left:?}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_id_is_the_items_identity() {
let parsed = assert_clean(
"# TODO\n\n## High\n\n- [ ] fix the lexer #bug ^7hq2m gh:4\n- [ ] other\n",
);
let item = &parsed.items[0];
assert_eq!(
(item.text.as_str(), item.id.as_deref(), item.gh),
("fix the lexer", Some("7hq2m"), Some(4))
);
assert_eq!(item.key(), "id:7hq2m");
for (key, text) in [
("id:7hq2m", ""),
("gh:4", ""),
("fix the lexer", ""),
("x", "Fix The Lexer"),
] {
assert!(item.is_task(key, text), "{key} {text}");
}
assert!(!item.is_task("id:7hq2n", ""));
assert_eq!(parsed.items[1].key(), "other");
}
#[test]
fn a_caret_word_is_text_unless_it_is_an_id() {
for (line, id, text) in [
("- [ ] bump serde to ^1.2", None, "bump serde to ^1.2"),
("- [ ] shout ^HELLO", None, "shout ^HELLO"),
("- [ ] short ^7hq2", None, "short ^7hq2"),
("- [ ] has an l ^7hqlm", None, "has an l ^7hqlm"),
(
"- [ ] ^7hq2m earlier is text",
None,
"^7hq2m earlier is text",
),
("- [ ] maybe ^maybe", Some("maybe"), "maybe"),
("- [ ] real #bug ^7hq2m", Some("7hq2m"), "real"),
] {
let parsed = assert_clean(&format!("# TODO\n\n## High\n\n{line}\n"));
let item = &parsed.items[0];
assert_eq!(
(item.id.as_deref(), item.text.as_str()),
(id, text),
"{line}"
);
}
assert_reports(
"# TODO\n\n## High\n\n- [ ] ^7hq2m\n",
5,
Severity::Error,
"no text",
);
assert_reports(
"# TODO\n\n## High\n\n- [ ] two ^7hq2m ^7hq2n\n",
5,
Severity::Error,
"more than one id",
);
}
#[test]
fn an_id_is_unique_in_its_file() {
assert_reports(
"# TODO\n\n## High\n\n- [ ] a ^7hq2m\n- [x] b ^7hq2m\n",
6,
Severity::Error,
"id `^7hq2m` is also on line 5",
);
}
#[test]
fn mint_id_writes_a_fresh_id_once() {
let text = "# TODO\n\n## High\n\n- [ ] first ^00000\n- [ ] second #bug\n why\n";
let (edited, id) = mint_id(text, "second", "").unwrap();
assert!(is_id(&id) && id != "00000", "{id}");
assert_eq!(
edited,
format!("# TODO\n\n## High\n\n- [ ] first ^00000\n- [ ] second #bug ^{id}\n why\n")
);
assert_eq!(parse(&edited).items[1].key(), format!("id:{id}"));
assert_eq!(mint_id(&edited, "second", ""), None, "it has one");
assert_eq!(mint_id(text, "first", ""), None, "it had one");
assert_eq!(mint_id(text, "missing", ""), None);
assert_eq!(
mint_id("# TODO\n\n## High\n\n- [ ] a\n- [ ] a\n", "a", ""),
None
);
let ids: std::collections::BTreeSet<String> = (0..200).map(|_| new_id(text)).collect();
assert!(ids.len() > 190, "ids are drawn at random: {}", ids.len());
assert!(ids.iter().all(|id| is_id(id)), "{ids:?}");
}
#[test]
fn duplicates_break_identity() {
let text = "# TODO\n\n## High\n\n- [ ] Fix it gh:3\n\n## Low\n\n- [ ] fix it\n- [ ] other gh:3\n- [x] fix it\n";
assert_reports(text, 9, Severity::Error, "open item on line 5");
assert_reports(text, 10, Severity::Error, "also on line 5");
assert_eq!(
parse(text).diagnostics.len(),
2,
"a finished repeat is not a duplicate"
);
}
}