1use 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 pub line: usize,
51 pub priority: Priority,
52 pub done: bool,
53 pub text: String,
54 pub tags: Vec<String>,
55 pub due: Option<String>,
57 pub gh: Option<u64>,
58 pub id: Option<String>,
60 pub group: Option<String>,
62 pub description: Vec<String>,
64}
65
66impl Item {
67 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 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
105pub 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 pub done_sections: Vec<DoneSection>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct DoneSection {
145 pub first: usize,
147 pub last: usize,
148 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#[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 let mut blanks = 0;
188 let mut open: Option<Item> = None;
189 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 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 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 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 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 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#[derive(Default)]
340struct Fence(Option<(u8, usize, usize)>);
341
342impl Fence {
343 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
368fn 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
423fn 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
541pub 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
557const ID_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
560
561const 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
569fn caret_id(word: &str) -> Option<&str> {
571 word.strip_prefix('^').filter(|id| is_id(id))
572}
573
574pub 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
603pub 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
621pub 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] = ⌖
631 Some(lines.concat())
632}
633
634#[derive(Debug, PartialEq)]
635pub struct Pruned {
636 pub text: String,
637 pub items: Vec<Item>,
638 pub done_sections: Vec<DoneSection>,
640}
641
642pub 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
674pub 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
693pub 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 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 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
759pub 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
773pub 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
800fn 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 #[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 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 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 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 #[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 #[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 #[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}