Skip to main content

pma_core/
todo.rs

1//! Parser and linter for the TODO.md format, v2.
2//!
3//! The format is specified in `docs/dev/design.md`. Parsing is line-based so
4//! that later edits can rewrite single lines without re-rendering content the
5//! parser does not own.
6
7use std::collections::HashMap;
8use std::fmt;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
11pub enum Priority {
12    Critical,
13    High,
14    Medium,
15    Low,
16}
17
18impl Priority {
19    pub const ALL: [Priority; 4] = [
20        Priority::Critical,
21        Priority::High,
22        Priority::Medium,
23        Priority::Low,
24    ];
25
26    pub fn name(self) -> &'static str {
27        match self {
28            Priority::Critical => "critical",
29            Priority::High => "high",
30            Priority::Medium => "medium",
31            Priority::Low => "low",
32        }
33    }
34
35    pub fn parse(s: &str) -> Option<Priority> {
36        Self::ALL.into_iter().find(|p| p.name() == s)
37    }
38}
39
40const SECTIONS: [(&str, Priority); 4] = [
41    ("Critical", Priority::Critical),
42    ("High", Priority::High),
43    ("Medium", Priority::Medium),
44    ("Low", Priority::Low),
45];
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Item {
49    /// 1-based line number of the item line.
50    pub line: usize,
51    pub priority: Priority,
52    pub done: bool,
53    pub text: String,
54    pub tags: Vec<String>,
55    /// `YYYY-MM-DD`, already validated.
56    pub due: Option<String>,
57    pub gh: Option<u64>,
58    /// `id:xxxxx`, minted by `pma`; already validated.
59    pub id: Option<String>,
60    /// Text of the nearest `###` heading above the item in its section.
61    pub group: Option<String>,
62    /// Indented lines under the item, verbatim.
63    pub description: Vec<String>,
64}
65
66impl Item {
67    /// Identity across scans and edits: `id:`, else `gh:N`, else the text in
68    /// comparable form. Text that reads as another kind of task's key, such
69    /// as an item named `CI`, is prefixed with `item:`, so it cannot be taken
70    /// for a signal, a campaign, a workflow unit or an id.
71    pub fn key(&self) -> String {
72        if let Some(id) = &self.id {
73            return format!("id:{id}");
74        }
75        match self.gh {
76            Some(n) => format!("gh:{n}"),
77            None => self.text_key(),
78        }
79    }
80
81    fn text_key(&self) -> String {
82        let text = normal_text(&self.text);
83        let reserved = matches!(text.as_str(), "ci" | "deps")
84            || ["campaign:", "workflow:", "item:", "id:", "gh:"]
85                .iter()
86                .any(|p| text.starts_with(p));
87        match reserved {
88            true => format!("item:{text}"),
89            false => text,
90        }
91    }
92
93    /// Whether this is the task known by `key` or by `text`. Every name the
94    /// item has answers: its id, its issue and its text, since a key recorded
95    /// before an id or an issue was written into the line names it by one of
96    /// the others.
97    pub fn is_task(&self, key: &str, text: &str) -> bool {
98        self.id.as_ref().is_some_and(|id| key == format!("id:{id}"))
99            || self.gh.is_some_and(|n| key == format!("gh:{n}"))
100            || self.text_key() == key
101            || (!text.trim().is_empty() && normal_text(&self.text) == normal_text(text))
102    }
103}
104
105/// Item text in comparable form: lowercase, single-spaced.
106pub fn normal_text(text: &str) -> String {
107    text.split_whitespace()
108        .collect::<Vec<_>>()
109        .join(" ")
110        .to_lowercase()
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
114pub enum Severity {
115    Warning,
116    Error,
117}
118
119impl fmt::Display for Severity {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(match self {
122            Severity::Warning => "warning",
123            Severity::Error => "error",
124        })
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Diagnostic {
130    pub line: usize,
131    pub severity: Severity,
132    pub message: String,
133}
134
135#[derive(Debug, Default)]
136pub struct Parsed {
137    pub items: Vec<Item>,
138    pub diagnostics: Vec<Diagnostic>,
139    /// Each `## Done` section, a v1 leftover.
140    pub done_sections: Vec<DoneSection>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct DoneSection {
145    /// 1-based lines of the heading and of the section's last line.
146    pub first: usize,
147    pub last: usize,
148    /// Line number and text of each `- [ ]` item, which v1 did not allow here.
149    pub open: Vec<(usize, String)>,
150}
151
152impl Parsed {
153    pub fn has_errors(&self) -> bool {
154        self.diagnostics
155            .iter()
156            .any(|d| d.severity == Severity::Error)
157    }
158
159    fn report(&mut self, line: usize, severity: Severity, message: impl Into<String>) {
160        self.diagnostics.push(Diagnostic {
161            line,
162            severity,
163            message: message.into(),
164        });
165    }
166}
167
168/// Where the parser is: before any `##`, in a priority section, in v1's
169/// `## Done`, or in another section.
170#[derive(Clone, Copy)]
171enum Place {
172    Preamble,
173    Known(Priority),
174    Done,
175    Other,
176}
177
178pub fn parse(text: &str) -> Parsed {
179    let mut out = Parsed::default();
180    let mut place = Place::Preamble;
181    let mut seen_sections: HashMap<Priority, usize> = HashMap::new();
182    let mut title_line: Option<usize> = None;
183    let mut first_content = true;
184    let mut fence = Fence::default();
185    // Blank lines seen since the open item's last line. Kept so a description
186    // that spans a blank line is carried verbatim.
187    let mut blanks = 0;
188    let mut open: Option<Item> = None;
189    // Plain bullets outside the known sections, reported when nothing else is
190    // an item: that file's tasks are invisible to pma.
191    let mut ignored_bullets = 0;
192    let mut group: Option<String> = None;
193
194    for (index, raw) in text.lines().enumerate() {
195        let n = index + 1;
196        let line = raw.trim_end();
197
198        if line.is_empty() {
199            blanks += 1;
200            continue;
201        }
202
203        let indented = line.starts_with(' ') || line.starts_with('\t');
204        if indented {
205            match open.as_mut() {
206                Some(item) => {
207                    item.description
208                        .extend(std::iter::repeat_n(String::new(), blanks));
209                    item.description.push(raw.to_string());
210                }
211                None if matches!(place, Place::Known(_)) && fence.0.is_none() => {
212                    out.report(n, Severity::Warning, "indented line is not under an item");
213                }
214                None => {}
215            }
216            blanks = 0;
217            continue;
218        }
219        blanks = 0;
220        finish(&mut open, &mut out);
221
222        if first_content {
223            first_content = false;
224            if line != "# TODO" {
225                out.report(n, Severity::Error, "the file must start with `# TODO`");
226            }
227        }
228
229        if fence.step(line, n) {
230            continue;
231        }
232
233        if line.starts_with("# ") {
234            // A wrong first title is already reported above.
235            match title_line {
236                Some(first) => out.report(
237                    n,
238                    Severity::Error,
239                    format!("a second `#` heading; the title is on line {first}"),
240                ),
241                None => title_line = Some(n),
242            }
243            continue;
244        }
245
246        // `##Critical` is not a Markdown heading, but its author meant one.
247        let heading = match line.strip_prefix("## ") {
248            Some(name) => Some((name, true)),
249            None => line
250                .strip_prefix("##")
251                .filter(|rest| is_priority_name(rest.trim()))
252                .map(|name| (name, false)),
253        };
254        if let Some((name, spaced)) = heading {
255            if matches!(place, Place::Done)
256                && let Some(section) = out.done_sections.last_mut()
257            {
258                section.last = n - 1;
259            }
260            place = section_heading(name.trim(), spaced, n, &mut seen_sections, &mut out);
261            if matches!(place, Place::Done) {
262                out.done_sections.push(DoneSection {
263                    first: n,
264                    last: text.lines().count(),
265                    open: Vec::new(),
266                });
267            }
268            group = None;
269            continue;
270        }
271        // `###` alone clears the group; trimming removed any trailing space.
272        if let Some(name) = line
273            .strip_prefix("###")
274            .filter(|rest| rest.is_empty() || rest.starts_with(' '))
275        {
276            group = Some(name.trim().to_string()).filter(|g| !g.is_empty());
277            continue;
278        }
279        if line.starts_with('#') {
280            // `####` and deeper change neither the section nor the group.
281            continue;
282        }
283
284        match place {
285            Place::Known(priority) => {
286                if let Some(mut item) = item_line(line, n, priority, &mut out) {
287                    item.group = group.clone();
288                    open = Some(item);
289                }
290            }
291            Place::Preamble | Place::Done | Place::Other => {
292                if is_checkbox(line) {
293                    let msg = match place {
294                        Place::Preamble => "item outside a section is ignored",
295                        Place::Done => "`## Done` is not part of format v2; `pma prune` removes it",
296                        _ => "item in this section is ignored; use Critical, High, Medium or Low",
297                    };
298                    out.report(n, Severity::Warning, msg);
299                    if let (Place::Done, Some(' '), Some(section)) =
300                        (place, checkbox_mark(line), out.done_sections.last_mut())
301                    {
302                        section.open.push((n, line.to_string()));
303                    }
304                } else if is_bullet(line) {
305                    ignored_bullets += 1;
306                }
307            }
308        }
309    }
310    finish(&mut open, &mut out);
311
312    if let Some((_, _, line)) = fence.0 {
313        out.report(
314            line,
315            Severity::Error,
316            "this code fence is never closed, so no line after it is read",
317        );
318    }
319    if first_content {
320        out.report(1, Severity::Error, "the file must start with `# TODO`");
321    }
322    // A file with priority sections has adopted the format, so bullets outside
323    // them are deliberate: notes, declined work, ideas.
324    if out.items.is_empty() && ignored_bullets > 0 && seen_sections.is_empty() {
325        out.report(
326            1,
327            Severity::Warning,
328            format!(
329                "no items; {ignored_bullets} list entries outside Critical, High, Medium and Low are ignored"
330            ),
331        );
332    }
333    check_duplicates(&mut out);
334    out
335}
336
337/// The open code fence: its character, its length and its line. It closes
338/// only on a line of the same character at least as long, as CommonMark has it.
339#[derive(Default)]
340struct Fence(Option<(u8, usize, usize)>);
341
342impl Fence {
343    /// Whether `line`, trimmed and unindented, opens, is inside or closes a
344    /// fence. `n` is its line number.
345    fn step(&mut self, line: &str, n: usize) -> bool {
346        match self.0 {
347            Some((mark, len, _)) => {
348                if fence_run(line, mark).is_some_and(|k| k >= len && k == line.len()) {
349                    self.0 = None;
350                }
351                true
352            }
353            None => match b"`~"
354                .iter()
355                .copied()
356                .find_map(|m| fence_run(line, m).filter(|k| *k >= 3).map(|k| (m, k)))
357            {
358                Some((mark, len)) => {
359                    self.0 = Some((mark, len, n));
360                    true
361                }
362                None => false,
363            },
364        }
365    }
366}
367
368/// How many of `mark` the line starts with, if any.
369fn fence_run(line: &str, mark: u8) -> Option<usize> {
370    let n = line.bytes().take_while(|&b| b == mark).count();
371    (n > 0).then_some(n)
372}
373
374fn finish(open: &mut Option<Item>, out: &mut Parsed) {
375    if let Some(item) = open.take() {
376        out.items.push(item);
377    }
378}
379
380fn is_priority_name(name: &str) -> bool {
381    SECTIONS
382        .iter()
383        .any(|(known, _)| known.eq_ignore_ascii_case(name))
384}
385
386fn section_heading(
387    name: &str,
388    spaced: bool,
389    n: usize,
390    seen: &mut HashMap<Priority, usize>,
391    out: &mut Parsed,
392) -> Place {
393    if name == "Done" {
394        return Place::Done;
395    }
396    let Some((canonical, priority)) = SECTIONS
397        .iter()
398        .find(|(known, _)| known.eq_ignore_ascii_case(name))
399    else {
400        return Place::Other;
401    };
402    if !spaced || name != *canonical {
403        out.report(
404            n,
405            Severity::Error,
406            format!("write the heading as `## {canonical}`"),
407        );
408    }
409    if let Some(first) = seen.insert(*priority, n) {
410        out.report(
411            n,
412            Severity::Error,
413            format!("`## {canonical}` appears again; the first is on line {first}"),
414        );
415    }
416    Place::Known(*priority)
417}
418
419fn is_bullet(line: &str) -> bool {
420    line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")
421}
422
423/// Recognises any checkbox list entry, canonical or not.
424fn is_checkbox(line: &str) -> bool {
425    checkbox_mark(line).is_some_and(|mark| matches!(mark, ' ' | 'x' | 'X'))
426}
427
428fn checkbox_mark(line: &str) -> Option<char> {
429    let rest = line
430        .strip_prefix(['-', '*', '+'])
431        .or_else(|| {
432            let digits = line.trim_start_matches(|c: char| c.is_ascii_digit());
433            (digits.len() < line.len())
434                .then(|| digits.strip_prefix(['.', ')']))
435                .flatten()
436        })?
437        .trim_start();
438    let mark = rest.strip_prefix('[')?.chars().next()?;
439    rest[1 + mark.len_utf8()..].starts_with(']').then_some(mark)
440}
441
442fn item_line(line: &str, n: usize, priority: Priority, out: &mut Parsed) -> Option<Item> {
443    let canonical = match line.get(..5) {
444        Some("- [ ]") => Some(false),
445        Some("- [x]") => Some(true),
446        _ => None,
447    };
448    let rest = &line[if canonical.is_some() { 5 } else { 0 }..];
449    let Some(done) = canonical.filter(|_| rest.is_empty() || rest.starts_with(' ')) else {
450        if is_checkbox(line) {
451            out.report(
452                n,
453                Severity::Error,
454                "write items as `- [ ] text` or `- [x] text`",
455            );
456        } else if is_bullet(line) {
457            out.report(
458                n,
459                Severity::Error,
460                "a list entry in this section must be a `- [ ]` item",
461            );
462        } else {
463            out.report(n, Severity::Warning, "text in this section is not an item");
464        }
465        return None;
466    };
467
468    let words: Vec<&str> = rest.split_whitespace().collect();
469    let mut split = words.len();
470    while split > 0 && is_token(words[split - 1]) {
471        split -= 1;
472    }
473
474    let mut item = Item {
475        line: n,
476        priority,
477        done,
478        text: words[..split].join(" "),
479        tags: Vec::new(),
480        due: None,
481        gh: None,
482        id: None,
483        group: None,
484        description: Vec::new(),
485    };
486
487    for word in &words[split..] {
488        if let Some(date) = word.strip_prefix("due:") {
489            if crate::dates::parse(date).is_none() {
490                out.report(
491                    n,
492                    Severity::Error,
493                    format!("`{word}` is not a date; write `due:YYYY-MM-DD`"),
494                );
495            } else if item.due.replace(date.to_string()).is_some() {
496                out.report(n, Severity::Error, "more than one `due:`");
497            }
498        } else if let Some(number) = word.strip_prefix("gh:") {
499            match number.parse::<u64>() {
500                Ok(v) if v > 0 && !number.starts_with('0') => {
501                    if item.gh.replace(v).is_some() {
502                        out.report(n, Severity::Error, "more than one `gh:`");
503                    }
504                }
505                _ => out.report(
506                    n,
507                    Severity::Error,
508                    format!("`{word}` is not an issue number"),
509                ),
510            }
511        } else if let Some(id) = caret_id(word) {
512            if item.id.replace(id.to_string()).is_some() {
513                out.report(n, Severity::Error, "more than one id");
514            }
515        } else if !item.tags.iter().any(|t| t == &word[1..]) {
516            item.tags.push(word[1..].to_string());
517        }
518    }
519
520    if let Some(last) = words[..split].last()
521        && last.len() > 1
522        && last.starts_with('#')
523        && last[1..].bytes().all(|b| b.is_ascii_digit())
524    {
525        out.report(
526            n,
527            Severity::Warning,
528            format!(
529                "`{last}` is text; write `gh:{}` to link the issue",
530                &last[1..]
531            ),
532        );
533    }
534
535    if item.text.is_empty() {
536        out.report(n, Severity::Error, "the item has no text");
537    }
538    Some(item)
539}
540
541/// A trailing token: `#tag`, `due:...`, `gh:...` or an id, `^k3f9q`. A caret
542/// word that is not an id, such as `^1.2`, is text. Malformed `due:` and `gh:`
543/// values still count, so they are reported rather than read as text.
544pub fn is_token(word: &str) -> bool {
545    if word.starts_with("due:") || word.starts_with("gh:") || caret_id(word).is_some() {
546        return true;
547    }
548    let Some(tag) = word.strip_prefix('#') else {
549        return false;
550    };
551    tag.starts_with(|c: char| c.is_ascii_alphabetic())
552        && tag
553            .chars()
554            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
555}
556
557/// Characters an id is written in: Crockford's base32, lowercase, without
558/// `i`, `l`, `o` and `u`, so an id is not misread as a digit or a word.
559const ID_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
560
561/// Characters in an id. 32^5 values keep a clash between two branches that
562/// each mint in one 200-item file near 1 in 1,600.
563const ID_LEN: usize = 5;
564
565fn is_id(s: &str) -> bool {
566    s.len() == ID_LEN && s.bytes().all(|b| ID_ALPHABET.contains(&b))
567}
568
569/// The id in a word written `^k3f9q`, as Obsidian marks a block, if it is one.
570fn caret_id(word: &str) -> Option<&str> {
571    word.strip_prefix('^').filter(|id| is_id(id))
572}
573
574/// A fresh id no item in `text` holds. Random rather than sequential, so two
575/// branches that each add an item do not mint the same one.
576pub fn new_id(text: &str) -> String {
577    use std::hash::{BuildHasher, Hasher};
578    let taken: Vec<String> = parse(text).items.into_iter().filter_map(|i| i.id).collect();
579    let state = std::collections::hash_map::RandomState::new();
580    for n in 0u64.. {
581        let mut h = state.build_hasher();
582        h.write_u64(n);
583        h.write_u128(
584            std::time::SystemTime::now()
585                .duration_since(std::time::UNIX_EPOCH)
586                .map_or(0, |d| d.as_nanos()),
587        );
588        let mut bits = h.finish();
589        let id: String = (0..ID_LEN)
590            .map(|_| {
591                let c = ID_ALPHABET[(bits & 31) as usize] as char;
592                bits >>= 5;
593                c
594            })
595            .collect();
596        if !taken.contains(&id) {
597            return id;
598        }
599    }
600    unreachable!("an unused id exists")
601}
602
603/// Writes a fresh `^id` at the end of the open item `is_task(key, task_text)`
604/// names, and returns the new text and the id. `None` when no open item
605/// matches, the item already has one, or the file has lint errors, where an
606/// item cannot be told apart from its duplicate.
607pub fn mint_id(text: &str, key: &str, task_text: &str) -> Option<(String, String)> {
608    let parsed = parse(text);
609    if parsed.has_errors() {
610        return None;
611    }
612    let item = parsed
613        .items
614        .into_iter()
615        .find(|i| !i.done && i.id.is_none() && i.is_task(key, task_text))?;
616    let id = new_id(text);
617    let edited = add_token(text, item.line, &format!("^{id}"))?;
618    Some((edited, id))
619}
620
621/// Ticks the open item that `is_task(key, task_text)` finished, where it
622/// stands. Other bytes are unchanged. `None` when no open item matches.
623pub fn mark_done(text: &str, key: &str, task_text: &str) -> Option<String> {
624    let item = parse(text)
625        .items
626        .into_iter()
627        .find(|i| !i.done && i.is_task(key, task_text))?;
628    let mut lines: Vec<&str> = text.split_inclusive('\n').collect();
629    let target = format!("- [x]{}", &lines[item.line - 1][5..]);
630    lines[item.line - 1] = &target;
631    Some(lines.concat())
632}
633
634#[derive(Debug, PartialEq)]
635pub struct Pruned {
636    pub text: String,
637    pub items: Vec<Item>,
638    /// Each removed `## Done` section.
639    pub done_sections: Vec<DoneSection>,
640}
641
642/// Removes finished items with their descriptions, and each `## Done`
643/// section whole. Blank lines before a section that ends the file go too.
644/// Other lines are unchanged.
645pub fn prune(text: &str) -> Pruned {
646    let parsed = parse(text);
647    let lines: Vec<&str> = text.split_inclusive('\n').collect();
648    let mut drop = vec![false; lines.len()];
649    let items: Vec<Item> = parsed.items.into_iter().filter(|i| i.done).collect();
650    for item in &items {
651        drop[item.line - 1..item.line + item.description.len()].fill(true);
652    }
653    for &DoneSection { first, last, .. } in &parsed.done_sections {
654        let mut start = first - 1;
655        if last == lines.len() {
656            while start > 0 && lines[start - 1].trim().is_empty() {
657                start -= 1;
658            }
659        }
660        drop[start..last].fill(true);
661    }
662    Pruned {
663        text: lines
664            .iter()
665            .zip(&drop)
666            .filter(|(_, d)| !**d)
667            .map(|(l, _)| *l)
668            .collect(),
669        items,
670        done_sections: parsed.done_sections,
671    }
672}
673
674/// Removes the finished item that `is_task(key, task_text)` names, with its
675/// description. `None` when no finished item matches.
676pub fn remove_done(text: &str, key: &str, task_text: &str) -> Option<String> {
677    let item = parse(text)
678        .items
679        .into_iter()
680        .find(|i| i.done && i.is_task(key, task_text))?;
681    let lines: Vec<&str> = text.split_inclusive('\n').collect();
682    let span = item.line - 1..item.line + item.description.len();
683    Some(
684        lines
685            .iter()
686            .enumerate()
687            .filter(|(i, _)| !span.contains(i))
688            .map(|(_, l)| *l)
689            .collect(),
690    )
691}
692
693/// Inserts an item into its priority section, with its description indented
694/// under it. `None` when the section heading is absent: `pma` adds items, not
695/// headings, so a file that does not declare the section is left alone.
696///
697/// The item goes before the section's first `###` heading, or at the end of the
698/// section when it has none. Appending at the end would put it under whatever
699/// group heading happened to be last and mislabel it.
700pub fn insert(
701    text: &str,
702    priority: Priority,
703    item: &str,
704    description: Option<&str>,
705) -> Option<String> {
706    let want = SECTIONS.iter().find(|(_, p)| *p == priority)?.0;
707    let lines: Vec<&str> = text.split_inclusive('\n').collect();
708    // A heading inside a code fence is not one; the parser would not see an
709    // item inserted under it.
710    let mut fence = Fence::default();
711    let headings: Vec<(usize, &str)> = lines
712        .iter()
713        .enumerate()
714        .filter_map(|(i, l)| {
715            let line = l.trim_end();
716            let plain = !line.is_empty() && !line.starts_with([' ', '\t']);
717            (plain && !fence.step(line, i + 1)).then_some((i, line))
718        })
719        .filter(|(_, l)| l.starts_with("## ") || l.starts_with("### "))
720        .collect();
721    let heading = headings
722        .iter()
723        .find(|(_, l)| {
724            l.strip_prefix("## ")
725                .is_some_and(|name| name.trim() == want)
726        })?
727        .0;
728    let at = headings
729        .iter()
730        .find(|(i, _)| *i > heading)
731        .map_or(lines.len(), |(i, _)| *i);
732    let mut at = at;
733    // Back over the blank lines that separate one section from the next, so
734    // the item lands with the section it belongs to.
735    while at > heading + 1 && lines[at - 1].trim().is_empty() {
736        at -= 1;
737    }
738    let mut written = String::from("- [ ] ");
739    written.push_str(item.trim());
740    written.push('\n');
741    for line in description.unwrap_or_default().lines() {
742        written.push_str("  ");
743        written.push_str(line.trim_end());
744        written.push('\n');
745    }
746    let mut out: String = lines[..at].concat();
747    if !out.ends_with("\n\n") {
748        out.push('\n');
749    }
750    out.push_str(&written);
751    let rest: String = lines[at..].concat();
752    if !rest.is_empty() {
753        out.push('\n');
754        out.push_str(&rest);
755    }
756    Some(out)
757}
758
759/// Appends ` token` to the item on 1-based `line`, keeping the line ending.
760/// `None` when that line is not an item.
761pub fn add_token(text: &str, line: usize, token: &str) -> Option<String> {
762    if !parse(text).items.iter().any(|i| i.line == line) {
763        return None;
764    }
765    let mut lines: Vec<String> = text.split_inclusive('\n').map(String::from).collect();
766    let target = lines.get_mut(line - 1)?;
767    let body = target.trim_end_matches(['\r', '\n']).trim_end().to_string();
768    let ending = target[target.trim_end_matches(['\r', '\n']).len()..].to_string();
769    *target = format!("{body} {token}{ending}");
770    Some(lines.concat())
771}
772
773/// Replaces a TODO.md with `text` atomically: a temporary file in the same
774/// directory is written, flushed to disk and renamed over it, so a crash leaves
775/// the old file or the new one, never a truncated one. A symlink is followed,
776/// and the file keeps its permissions.
777pub fn save(path: &std::path::Path, text: &str) -> std::io::Result<()> {
778    use std::io::Write;
779    let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
780    let name = target
781        .file_name()
782        .map(|n| n.to_string_lossy().into_owned())
783        .unwrap_or_else(|| "TODO.md".into());
784    let tmp = target.with_file_name(format!(".{name}.pma-tmp"));
785    let written = (|| {
786        let mut file = std::fs::File::create(&tmp)?;
787        file.write_all(text.as_bytes())?;
788        file.sync_all()?;
789        if let Ok(meta) = std::fs::metadata(&target) {
790            std::fs::set_permissions(&tmp, meta.permissions())?;
791        }
792        std::fs::rename(&tmp, &target)
793    })();
794    if written.is_err() {
795        let _ = std::fs::remove_file(&tmp);
796    }
797    written
798}
799
800/// Unsynced items are identified by their normalised text, and synced ones by
801/// `gh:N`, so either repeating breaks identity.
802fn check_duplicates(out: &mut Parsed) {
803    let mut texts: HashMap<String, usize> = HashMap::new();
804    let mut issues: HashMap<u64, usize> = HashMap::new();
805    let mut ids: HashMap<&str, usize> = HashMap::new();
806    let mut found = Vec::new();
807
808    for item in &out.items {
809        if !item.done
810            && !item.text.is_empty()
811            && let Some(first) = texts.insert(item.text.to_lowercase(), item.line)
812        {
813            found.push((
814                item.line,
815                format!("same text as the open item on line {first}"),
816            ));
817        }
818        if let Some(gh) = item.gh
819            && let Some(first) = issues.insert(gh, item.line)
820        {
821            found.push((item.line, format!("`gh:{gh}` is also on line {first}")));
822        }
823        if let Some(id) = item.id.as_deref()
824            && let Some(first) = ids.insert(id, item.line)
825        {
826            found.push((item.line, format!("id `^{id}` is also on line {first}")));
827        }
828    }
829    for (line, message) in found {
830        out.report(line, Severity::Error, message);
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    fn messages(text: &str) -> Vec<(usize, Severity, String)> {
839        parse(text)
840            .diagnostics
841            .into_iter()
842            .map(|d| (d.line, d.severity, d.message))
843            .collect()
844    }
845
846    fn assert_clean(text: &str) -> Parsed {
847        let parsed = parse(text);
848        assert!(
849            parsed.diagnostics.is_empty(),
850            "unexpected diagnostics: {:?}",
851            parsed.diagnostics
852        );
853        parsed
854    }
855
856    fn assert_reports(text: &str, line: usize, severity: Severity, fragment: &str) {
857        let found = messages(text);
858        assert!(
859            found
860                .iter()
861                .any(|(l, s, m)| *l == line && *s == severity && m.contains(fragment)),
862            "no {severity} containing {fragment:?} on line {line}; got {found:?}"
863        );
864    }
865
866    const SPEC_EXAMPLE: &str = "# TODO
867
868## Critical
869
870- [ ] segfault on empty input #bug gh:42
871
872## High
873
874- [ ] support ggml 0.9 due:2026-10-01
875
876## Medium
877
878- [ ] flaky test on linux #urgent
879
880## Low
881
882- [x] drop python 3.9
883";
884
885    #[test]
886    fn spec_example_parses_clean() {
887        let parsed = assert_clean(SPEC_EXAMPLE);
888        let summary: Vec<_> = parsed
889            .items
890            .iter()
891            .map(|i| (i.line, i.priority, i.done, i.text.as_str()))
892            .collect();
893        assert_eq!(
894            summary,
895            vec![
896                (5, Priority::Critical, false, "segfault on empty input"),
897                (9, Priority::High, false, "support ggml 0.9"),
898                (13, Priority::Medium, false, "flaky test on linux"),
899                (17, Priority::Low, true, "drop python 3.9"),
900            ]
901        );
902        assert_eq!(parsed.items[0].tags, ["bug"]);
903        assert_eq!(parsed.items[0].gh, Some(42));
904        assert_eq!(parsed.items[1].due.as_deref(), Some("2026-10-01"));
905        assert_eq!(parsed.items[2].tags, ["urgent"]);
906    }
907
908    #[test]
909    fn only_trailing_words_are_tokens() {
910        let parsed = assert_clean(
911            "# TODO\n\n## High\n\n- [ ] port C# #bindings to due:parser module #api\n",
912        );
913        let item = &parsed.items[0];
914        assert_eq!(item.text, "port C# #bindings to due:parser module");
915        assert_eq!(item.tags, ["api"]);
916        assert_eq!(item.due, None);
917    }
918
919    #[test]
920    fn description_is_carried_verbatim_across_blank_lines() {
921        let parsed =
922            assert_clean("# TODO\n\n## Low\n\n- [ ] item\n  first\n\n    second\n\n- [ ] next\n");
923        assert_eq!(parsed.items[0].description, ["  first", "", "    second"]);
924        assert!(parsed.items[1].description.is_empty());
925    }
926
927    #[test]
928    fn crlf_line_endings_are_accepted() {
929        let parsed = assert_clean("# TODO\r\n\r\n## High\r\n\r\n- [ ] item #tag\r\n");
930        assert_eq!(parsed.items[0].tags, ["tag"]);
931    }
932
933    #[test]
934    fn other_sections_and_deeper_headings() {
935        let text =
936            "# TODO\n\n## Notes\n\nprose\n- [ ] ignored\n\n## High\n\n### parser\n\n- [ ] kept\n";
937        let parsed = parse(text);
938        assert_eq!(parsed.items.len(), 1);
939        assert_eq!(parsed.items[0].priority, Priority::High);
940        assert_reports(text, 6, Severity::Warning, "ignored");
941        assert_eq!(parsed.diagnostics.len(), 1);
942    }
943
944    #[test]
945    fn items_take_the_nearest_level_three_heading_in_their_section() {
946        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";
947        let groups: Vec<_> = parse(text)
948            .items
949            .into_iter()
950            .map(|i| (i.text, i.group))
951            .collect();
952        let g = |s: &str| Some(s.to_string());
953        assert_eq!(
954            groups,
955            [
956                ("ungrouped".into(), None),
957                ("a".into(), g("Security Hardening")),
958                ("b".into(), g("Security Hardening")),
959                ("c".into(), None),
960                ("d".into(), None),
961                ("e".into(), g("Later")),
962            ]
963        );
964    }
965
966    #[test]
967    fn a_file_whose_tasks_are_all_plain_bullets_is_flagged() {
968        let text = "# TODO\n\n## Ideas\n\n- one\n- two\n\n```\n- fenced\n```\n";
969        assert_reports(text, 1, Severity::Warning, "no items; 2 list entries");
970        assert_clean("# TODO\n\n## Ideas\n\n- one\n\n## Low\n\n- [ ] real\n");
971        assert_clean("# TODO\n\n## High\n");
972        assert_clean("# TODO\n\n## Low\n\n## Declined\n\n- not doing this\n");
973    }
974
975    /// A heading in a code fence is not a section: an item written under it
976    /// would be invisible, and each resumed pass would add another copy.
977    #[test]
978    fn insert_skips_headings_in_code_fences() {
979        let text = "# TODO\n\n## Notes\n\n```\n## High\n```\n\n## High\n\n- [ ] old\n\n## Low\n";
980        let out = insert(text, Priority::High, "new", None).unwrap();
981        assert!(out.contains("```\n## High\n```\n\n## High\n"), "{out}");
982        let new = parse(&out).items.into_iter().find(|i| i.text == "new");
983        assert_eq!(new.map(|i| i.priority), Some(Priority::High), "{out}");
984        // A fenced heading does not end the section either.
985        let text = "# TODO\n\n## High\n\n- [ ] old\n\n```\n## Low\n```\n";
986        let out = insert(text, Priority::High, "new", None).unwrap();
987        assert!(out.ends_with("```\n## Low\n```\n\n- [ ] new\n"), "{out}");
988    }
989
990    #[test]
991    fn fenced_code_is_not_structure() {
992        let parsed = assert_clean(
993            "# TODO\n\n## High\n\n```\n## Done\n- [ ] not an item\n```\n\n- [ ] real\n",
994        );
995        assert_eq!(parsed.items.len(), 1);
996        assert_eq!(parsed.items[0].priority, Priority::High);
997    }
998
999    #[test]
1000    fn a_fence_closes_only_on_its_own_kind_and_length() {
1001        let item = |text: &str| parse(text).items.len();
1002        // Three backticks do not close four, and backticks do not close tildes.
1003        let four = "# TODO\n\n## High\n\n````\n```\n- [ ] hidden\n````\n\n- [ ] real\n";
1004        assert_eq!(item(four), 1);
1005        assert_clean(four);
1006        let tilde = "# TODO\n\n## High\n\n~~~\n```\n- [ ] hidden\n~~~\n\n- [ ] real\n";
1007        assert_eq!(item(tilde), 1);
1008        assert_clean(tilde);
1009        // A longer closing run closes; one followed by text does not.
1010        let longer = "# TODO\n\n## High\n\n```\n- [ ] hidden\n```` x\n`````\n- [ ] real\n";
1011        assert_eq!(item(longer), 1);
1012        assert_clean(longer);
1013    }
1014
1015    #[test]
1016    fn an_unclosed_fence_is_an_error_at_its_opening_line() {
1017        let text = "# TODO\n\n## High\n\n- [ ] seen\n\n```rust\n- [ ] hidden\n";
1018        assert_reports(text, 7, Severity::Error, "never closed");
1019        assert_eq!(parse(text).items.len(), 1);
1020        let mismatched = "# TODO\n\n## High\n\n````\n```\n";
1021        assert_reports(mismatched, 5, Severity::Error, "never closed");
1022    }
1023
1024    #[test]
1025    fn title_is_required() {
1026        assert_reports("## High\n", 1, Severity::Error, "must start with `# TODO`");
1027        assert_reports(
1028            "\n\nintro\n# TODO\n",
1029            3,
1030            Severity::Error,
1031            "must start with `# TODO`",
1032        );
1033        assert_reports("", 1, Severity::Error, "must start with `# TODO`");
1034        assert_reports(
1035            "# TODO\n\n# Other\n",
1036            3,
1037            Severity::Error,
1038            "second `#` heading",
1039        );
1040        assert_reports(
1041            "```\n# TODO\n```\n",
1042            1,
1043            Severity::Error,
1044            "must start with `# TODO`",
1045        );
1046        assert_eq!(
1047            messages("# Tasks\n").len(),
1048            1,
1049            "a wrong title is reported once"
1050        );
1051    }
1052
1053    #[test]
1054    fn section_names_are_exact() {
1055        assert_reports(
1056            "# TODO\n\n## critical\n",
1057            3,
1058            Severity::Error,
1059            "`## Critical`",
1060        );
1061        for bad in ["##Critical", "##critical", "##High "] {
1062            let text = format!("# TODO\n\n{bad}\n\n- [ ] item\n");
1063            assert_eq!(messages(&text).len(), 1, "{bad} is reported once");
1064            assert_reports(&text, 3, Severity::Error, "write the heading as `## ");
1065            assert_eq!(parse(&text).items.len(), 1, "{bad} opens its section");
1066        }
1067        assert_clean("# TODO\n\n##Notes\n\n## Low\n");
1068        assert_reports(
1069            "# TODO\n\n## High\n\n## High\n",
1070            5,
1071            Severity::Error,
1072            "first is on line 3",
1073        );
1074    }
1075
1076    #[test]
1077    fn noncanonical_items_are_errors() {
1078        for bad in [
1079            "* [ ] x", "- [X] x", "-[ ] x", "1. [ ] x", "+ [x] x", "- [ ]x",
1080        ] {
1081            let text = format!("# TODO\n\n## High\n\n{bad}\n");
1082            assert_reports(&text, 5, Severity::Error, "write items as");
1083            assert!(parse(&text).items.is_empty(), "{bad} was read as an item");
1084        }
1085        assert_reports(
1086            "# TODO\n\n## High\n\n- plain bullet\n",
1087            5,
1088            Severity::Error,
1089            "must be a `- [ ]` item",
1090        );
1091        assert_reports(
1092            "# TODO\n\n## High\n\nsome prose\n",
1093            5,
1094            Severity::Warning,
1095            "not an item",
1096        );
1097        assert_reports(
1098            "# TODO\n\n## High\n\n**bold** prose\n",
1099            5,
1100            Severity::Warning,
1101            "not an item",
1102        );
1103        assert_reports(
1104            "# TODO\n\n## High\n\n  stray\n",
1105            5,
1106            Severity::Warning,
1107            "not under an item",
1108        );
1109    }
1110
1111    #[test]
1112    fn tokens_are_validated() {
1113        let item = |s: &str| format!("# TODO\n\n## High\n\n- [ ] thing {s}\n");
1114        assert_reports(&item("due:2026-02-30"), 5, Severity::Error, "not a date");
1115        assert_reports(&item("due:tomorrow"), 5, Severity::Error, "not a date");
1116        assert_reports(
1117            &item("due:2026-01-01 due:2026-01-02"),
1118            5,
1119            Severity::Error,
1120            "more than one `due:`",
1121        );
1122        assert_reports(&item("gh:0"), 5, Severity::Error, "not an issue number");
1123        assert_reports(&item("gh:x1"), 5, Severity::Error, "not an issue number");
1124        assert_reports(
1125            &item("gh:1 gh:2"),
1126            5,
1127            Severity::Error,
1128            "more than one `gh:`",
1129        );
1130        assert_reports(&item("#42"), 5, Severity::Warning, "write `gh:42`");
1131        assert_clean(&item("due:2028-02-29"));
1132    }
1133
1134    #[test]
1135    fn items_need_text_and_may_be_finished_in_place() {
1136        assert_clean("# TODO\n\n## Low\n\n- [x] closed\n");
1137        assert_reports(
1138            "# TODO\n\n## Low\n\n- [ ] #only-tags\n",
1139            5,
1140            Severity::Error,
1141            "no text",
1142        );
1143        assert_reports("# TODO\n\n## Low\n\n- [ ]\n", 5, Severity::Error, "no text");
1144    }
1145
1146    #[test]
1147    fn mark_done_ticks_the_item_in_place() {
1148        let text = "# TODO\n\n## High\n\n- [ ] first #bug\n  why\n- [ ] Second  gh:4\n- [x] old\n";
1149        let out = mark_done(text, "first", "").unwrap();
1150        assert_eq!(
1151            out,
1152            "# TODO\n\n## High\n\n- [x] first #bug\n  why\n- [ ] Second  gh:4\n- [x] old\n"
1153        );
1154        assert_clean(&out);
1155        assert_eq!(
1156            mark_done(text, "old", "old"),
1157            None,
1158            "finished items are not open"
1159        );
1160        assert_eq!(mark_done(text, "missing", ""), None);
1161        assert!(
1162            mark_done(text, "second", "SECOND").is_some(),
1163            "a key that gained gh:N still matches by text"
1164        );
1165        assert_eq!(
1166            mark_done("# TODO\r\n\r\n## Low\r\n- [ ] a gh:2", "gh:2", "").unwrap(),
1167            "# TODO\r\n\r\n## Low\r\n- [x] a gh:2"
1168        );
1169        let fenced = "# TODO\n\n## Low\n\n```\n- [ ] a\n```\n- [ ] a\n";
1170        assert!(
1171            mark_done(fenced, "a", "")
1172                .unwrap()
1173                .ends_with("```\n- [ ] a\n```\n- [x] a\n")
1174        );
1175    }
1176
1177    #[test]
1178    fn prune_removes_finished_items_with_their_descriptions() {
1179        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";
1180        let pruned = prune(text);
1181        assert_eq!(
1182            pruned.text,
1183            "# 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"
1184        );
1185        let removed: Vec<_> = pruned
1186            .items
1187            .iter()
1188            .map(|i| (i.line, i.text.as_str()))
1189            .collect();
1190        assert_eq!(removed, [(5, "done"), (17, "last")]);
1191        assert!(pruned.done_sections.is_empty());
1192        assert_clean(&pruned.text);
1193        assert_eq!(prune(&pruned.text).text, pruned.text);
1194    }
1195
1196    #[test]
1197    fn prune_removes_done_sections_whole() {
1198        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";
1199        assert_reports(text, 11, Severity::Warning, "`pma prune` removes it");
1200        let pruned = prune(text);
1201        let spans: Vec<_> = pruned
1202            .done_sections
1203            .iter()
1204            .map(|d| (d.first, d.last, d.open.clone()))
1205            .collect();
1206        assert_eq!(
1207            spans,
1208            [
1209                (7, 18, vec![(14, "- [ ] open".to_string())]),
1210                (23, 26, vec![])
1211            ]
1212        );
1213        assert!(pruned.items.is_empty());
1214        assert_eq!(
1215            pruned.text,
1216            "# TODO\n\n## Low\n\n- [ ] a\n\n## Notes\n\nkept\n"
1217        );
1218        assert_clean(&pruned.text);
1219    }
1220
1221    #[test]
1222    fn remove_done_takes_one_finished_item_and_its_description() {
1223        let text = "# TODO\n\n## High\n\n- [x] done\n  why\n- [x] other\n- [ ] open\n";
1224        assert_eq!(
1225            remove_done(text, "done", "done").unwrap(),
1226            "# TODO\n\n## High\n\n- [x] other\n- [ ] open\n"
1227        );
1228        assert_eq!(
1229            remove_done(text, "open", "open"),
1230            None,
1231            "an open item stays"
1232        );
1233        assert_eq!(remove_done(text, "missing", "missing"), None);
1234    }
1235
1236    #[test]
1237    fn add_token_appends_to_item_lines_only() {
1238        let text = "# TODO\r\n\r\n## Critical\r\n\r\n- [ ] crash #bug  \r\n- [ ] last";
1239        let out = add_token(text, 5, "gh:12").unwrap();
1240        assert_eq!(
1241            out,
1242            "# TODO\r\n\r\n## Critical\r\n\r\n- [ ] crash #bug gh:12\r\n- [ ] last"
1243        );
1244        assert_eq!(parse(&out).items[0].gh, Some(12));
1245        assert!(
1246            add_token(text, 6, "gh:3")
1247                .unwrap()
1248                .ends_with("- [ ] last gh:3")
1249        );
1250        assert_eq!(add_token(text, 3, "gh:1"), None, "a heading is not an item");
1251        assert_eq!(add_token(text, 99, "gh:1"), None);
1252    }
1253
1254    #[test]
1255    fn save_replaces_the_file_and_keeps_its_mode_and_link() {
1256        use std::os::unix::fs::PermissionsExt;
1257        let dir = std::env::temp_dir().join(format!("pma-todo-save-{}", std::process::id()));
1258        let _ = std::fs::remove_dir_all(&dir);
1259        std::fs::create_dir_all(&dir).unwrap();
1260        let real = dir.join("real.md");
1261        std::fs::write(&real, "# TODO\n\nold, and longer than the new text\n").unwrap();
1262        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap();
1263        let link = dir.join("TODO.md");
1264        std::os::unix::fs::symlink(&real, &link).unwrap();
1265
1266        save(&link, "# TODO\n").unwrap();
1267        assert_eq!(std::fs::read_to_string(&real).unwrap(), "# TODO\n");
1268        assert!(
1269            std::fs::symlink_metadata(&link).unwrap().is_symlink(),
1270            "the link is written through, not replaced"
1271        );
1272        let mode = std::fs::metadata(&real).unwrap().permissions().mode() & 0o777;
1273        assert_eq!(mode, 0o640);
1274        let left: Vec<_> = std::fs::read_dir(&dir)
1275            .unwrap()
1276            .map(|e| e.unwrap().file_name())
1277            .collect();
1278        assert_eq!(left.len(), 2, "no temporary file is left: {left:?}");
1279        let _ = std::fs::remove_dir_all(dir);
1280    }
1281
1282    /// An id, a trailing `^k3f9q`, names the item whatever its text or its
1283    /// issue, and every name the item has still answers.
1284    #[test]
1285    fn an_id_is_the_items_identity() {
1286        let parsed = assert_clean(
1287            "# TODO\n\n## High\n\n- [ ] fix the lexer #bug ^7hq2m gh:4\n- [ ] other\n",
1288        );
1289        let item = &parsed.items[0];
1290        assert_eq!(
1291            (item.text.as_str(), item.id.as_deref(), item.gh),
1292            ("fix the lexer", Some("7hq2m"), Some(4))
1293        );
1294        assert_eq!(item.key(), "id:7hq2m");
1295        for (key, text) in [
1296            ("id:7hq2m", ""),
1297            ("gh:4", ""),
1298            ("fix the lexer", ""),
1299            ("x", "Fix The Lexer"),
1300        ] {
1301            assert!(item.is_task(key, text), "{key} {text}");
1302        }
1303        assert!(!item.is_task("id:7hq2n", ""));
1304        assert_eq!(parsed.items[1].key(), "other");
1305    }
1306
1307    /// Only a trailing caret word of five id characters is an id. Any other
1308    /// caret word, such as a version constraint, is text.
1309    #[test]
1310    fn a_caret_word_is_text_unless_it_is_an_id() {
1311        for (line, id, text) in [
1312            ("- [ ] bump serde to ^1.2", None, "bump serde to ^1.2"),
1313            ("- [ ] shout ^HELLO", None, "shout ^HELLO"),
1314            ("- [ ] short ^7hq2", None, "short ^7hq2"),
1315            ("- [ ] has an l ^7hqlm", None, "has an l ^7hqlm"),
1316            (
1317                "- [ ] ^7hq2m earlier is text",
1318                None,
1319                "^7hq2m earlier is text",
1320            ),
1321            ("- [ ] maybe ^maybe", Some("maybe"), "maybe"),
1322            ("- [ ] real #bug ^7hq2m", Some("7hq2m"), "real"),
1323        ] {
1324            let parsed = assert_clean(&format!("# TODO\n\n## High\n\n{line}\n"));
1325            let item = &parsed.items[0];
1326            assert_eq!(
1327                (item.id.as_deref(), item.text.as_str()),
1328                (id, text),
1329                "{line}"
1330            );
1331        }
1332        assert_reports(
1333            "# TODO\n\n## High\n\n- [ ] ^7hq2m\n",
1334            5,
1335            Severity::Error,
1336            "no text",
1337        );
1338        assert_reports(
1339            "# TODO\n\n## High\n\n- [ ] two ^7hq2m ^7hq2n\n",
1340            5,
1341            Severity::Error,
1342            "more than one id",
1343        );
1344    }
1345
1346    #[test]
1347    fn an_id_is_unique_in_its_file() {
1348        assert_reports(
1349            "# TODO\n\n## High\n\n- [ ] a ^7hq2m\n- [x] b ^7hq2m\n",
1350            6,
1351            Severity::Error,
1352            "id `^7hq2m` is also on line 5",
1353        );
1354    }
1355
1356    /// `pma` writes an id at the end of the item a task names, once, and never
1357    /// into a file whose items cannot be told apart.
1358    #[test]
1359    fn mint_id_writes_a_fresh_id_once() {
1360        let text = "# TODO\n\n## High\n\n- [ ] first ^00000\n- [ ] second #bug\n  why\n";
1361        let (edited, id) = mint_id(text, "second", "").unwrap();
1362        assert!(is_id(&id) && id != "00000", "{id}");
1363        assert_eq!(
1364            edited,
1365            format!("# TODO\n\n## High\n\n- [ ] first ^00000\n- [ ] second #bug ^{id}\n  why\n")
1366        );
1367        assert_eq!(parse(&edited).items[1].key(), format!("id:{id}"));
1368        assert_eq!(mint_id(&edited, "second", ""), None, "it has one");
1369        assert_eq!(mint_id(text, "first", ""), None, "it had one");
1370        assert_eq!(mint_id(text, "missing", ""), None);
1371        assert_eq!(
1372            mint_id("# TODO\n\n## High\n\n- [ ] a\n- [ ] a\n", "a", ""),
1373            None
1374        );
1375        let ids: std::collections::BTreeSet<String> = (0..200).map(|_| new_id(text)).collect();
1376        assert!(ids.len() > 190, "ids are drawn at random: {}", ids.len());
1377        assert!(ids.iter().all(|id| is_id(id)), "{ids:?}");
1378    }
1379
1380    #[test]
1381    fn duplicates_break_identity() {
1382        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";
1383        assert_reports(text, 9, Severity::Error, "open item on line 5");
1384        assert_reports(text, 10, Severity::Error, "also on line 5");
1385        assert_eq!(
1386            parse(text).diagnostics.len(),
1387            2,
1388            "a finished repeat is not a duplicate"
1389        );
1390    }
1391}