anvilsign in

collin/anvil

1//! todo-md parsing and kanban rendering.
2//!
3//! Implements the todo-md spec (the `todo-md` repo). A `TODO.md` is split into
4//! sections by the shallowest heading level used (the *column level*):
5//!
6//! - **Sections** — headings at the column level (e.g. every `#`). They become
7//! kanban columns. A heading containing `[x]` marks a done section; every
8//! task in it counts as done.
9//! - **Tasks** come in two forms inside a section:
10//! - **checkbox tasks** — `- [ ]` / `- [x]` list items; lines indented under
11//! them are their details (opaque markdown).
12//! - **tickets** — a heading nested *deeper* than the column level (e.g. a
13//! `## Implement Navbar` under `# Done`). The heading is the ticket title
14//! and everything beneath it, until the next heading, is its detail body.
15//! A ticket is done if its section is done or its own heading carries
16//! `[x]`.
17//! - Prose-only sections render as ordinary markdown below the board.
18//!
19//! This module is the first "custom renderer" for a well-known filename; the
20//! filename → renderer match in `ui::blob` is the registry that routes here.
21
22use maud::{
23 Markup,
24 html,
25};
26
27use crate::ui::render_markdown;
28
29pub struct Section {
30 /// Heading level (1–6); 0 for the implicit preamble section.
31 pub level: u8,
32 /// Heading text with any `[x]` / `✓` done-marker stripped.
33 pub title: String,
34 /// Done section: tasks in it count as done regardless of their checkbox.
35 pub done: bool,
36 pub tasks: Vec<Task>,
37 /// Non-task content of the section, verbatim (used for the notes area).
38 pub prose: String,
39}
40
41pub struct Task {
42 pub done: bool,
43 pub title: String,
44 /// Raw markdown block: a checkbox task's indented lines (dedented one
45 /// level) or a ticket's body beneath its heading.
46 pub details: String,
47 /// True when the task came from a nested heading (a ticket) rather than a
48 /// `- [ ]` checkbox line.
49 pub ticket: bool,
50}
51
52/// Where subsequent non-structural lines accumulate.
53#[derive(Clone, Copy, Eq, PartialEq)]
54enum Mode {
55 /// Section prose (no open task).
56 Prose,
57 /// Indented detail lines of a checkbox task (dedented one level).
58 TaskDetail,
59 /// Body of a ticket — runs until the next heading; verbatim.
60 TicketBody,
61}
62
63/// Whether a path names a `TODO.md` (any directory, any case).
64pub fn is_todo_md(path: &str) -> bool {
65 std::path::Path::new(path)
66 .file_name()
67 .is_some_and(|n| n.eq_ignore_ascii_case("TODO.md"))
68}
69
70/// The shallowest heading level in the document (the column level), or 0 if
71/// there are no headings. Fenced regions are skipped.
72fn column_level(text: &str) -> u8 {
73 let mut in_fence = false;
74 let mut min = 0u8;
75 for line in text.lines() {
76 if line.trim_start().starts_with("```") {
77 in_fence = !in_fence;
78 continue;
79 }
80 if in_fence {
81 continue;
82 }
83 if let Some((level, _)) = heading(line)
84 && (min == 0 || level < min)
85 {
86 min = level;
87 }
88 }
89 min
90}
91
92/// Parse a todo-md document into sections. The preamble (content before the
93/// first heading) becomes a level-0 section with an empty title.
94pub fn parse(text: &str) -> Vec<Section> {
95 let col = column_level(text);
96 let mut sections = vec![Section {
97 level: 0,
98 title: String::new(),
99 done: false,
100 tasks: Vec::new(),
101 prose: String::new(),
102 }];
103 let mut in_fence = false;
104 let mut mode = Mode::Prose;
105
106 for line in text.lines() {
107 let fence_toggle = line.trim_start().starts_with("```");
108 let structural = !in_fence && !fence_toggle;
109 if fence_toggle {
110 in_fence = !in_fence;
111 }
112
113 if structural {
114 if let Some((level, rest)) = heading(line) {
115 let cur = sections.last_mut().expect("never empty");
116 if col != 0 && level > col {
117 // A heading deeper than the column level is a ticket in
118 // the current section.
119 cur.tasks.push(Task {
120 done: cur.done || marked_done(rest),
121 title: strip_marker(rest),
122 details: String::new(),
123 ticket: true,
124 });
125 mode = Mode::TicketBody;
126 } else {
127 sections.push(Section {
128 level,
129 title: strip_marker(rest),
130 done: marked_done(rest),
131 tasks: Vec::new(),
132 prose: String::new(),
133 });
134 mode = Mode::Prose;
135 }
136 continue;
137 }
138
139 // Inside a ticket body only a heading (handled above) ends it;
140 // everything else — prose, checkbox lines, blanks — is body.
141 if mode != Mode::TicketBody {
142 if let Some((done, title)) = task_line(line) {
143 sections.last_mut().expect("never empty").tasks.push(Task {
144 done,
145 title: title.to_string(),
146 details: String::new(),
147 ticket: false,
148 });
149 mode = Mode::TaskDetail;
150 continue;
151 }
152 // A non-indented, non-blank line ends a checkbox task's
153 // details and reverts to section prose.
154 if mode == Mode::TaskDetail && !line.is_empty() && !line.starts_with(" ") {
155 mode = Mode::Prose;
156 }
157 }
158 }
159
160 append_line(sections.last_mut().expect("never empty"), mode, line);
161 }
162
163 for s in &mut sections {
164 if s.done {
165 for t in &mut s.tasks {
166 t.done = true;
167 }
168 }
169 for t in &mut s.tasks {
170 // Drop trailing blank lines swallowed while the task was open.
171 while t.details.ends_with('\n') {
172 t.details.pop();
173 }
174 }
175 }
176 sections
177}
178
179fn append_line(section: &mut Section, mode: Mode, line: &str) {
180 match mode {
181 Mode::Prose => {
182 section.prose.push_str(line);
183 section.prose.push('\n');
184 }
185 Mode::TaskDetail => {
186 let t = section.tasks.last_mut().expect("open task exists");
187 // Dedent one level (2–4 spaces) so details render as their own
188 // markdown rather than a code block.
189 let spaces = line.len() - line.trim_start_matches(' ').len();
190 t.details.push_str(&line[spaces.min(4).min(line.len())..]);
191 t.details.push('\n');
192 }
193 Mode::TicketBody => {
194 let t = section.tasks.last_mut().expect("open ticket exists");
195 t.details.push_str(line);
196 t.details.push('\n');
197 }
198 }
199}
200
201fn heading(line: &str) -> Option<(u8, &str)> {
202 let hashes = line.bytes().take_while(|&b| b == b'#').count();
203 if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
204 Some((hashes as u8, line[hashes..].trim()))
205 } else {
206 None
207 }
208}
209
210/// Whether a heading's text carries a done marker (`[x]`, ASCII; `✓` accepted
211/// for todomd compatibility).
212fn marked_done(heading_rest: &str) -> bool {
213 heading_rest.contains("[x]") || heading_rest.contains("[X]") || heading_rest.contains('✓')
214}
215
216fn strip_marker(heading_rest: &str) -> String {
217 heading_rest
218 .replace("[x]", "")
219 .replace("[X]", "")
220 .replace('✓', "")
221 .trim()
222 .to_string()
223}
224
225fn task_line(line: &str) -> Option<(bool, &str)> {
226 let open = line.strip_prefix("- [ ] ");
227 let done = line.strip_prefix("- [x] ").or(line.strip_prefix("- [X] "));
228 match (open, done) {
229 (Some(rest), _) => Some((false, rest.trim())),
230 (_, Some(rest)) => Some((true, rest.trim())),
231 _ => None,
232 }
233}
234
235/// The **add** operation: append a ticket — a nested heading (`## <title>`,
236/// one level below the column headings) — to the end of the section named
237/// `section`, touching no other byte of the document (the todo-md round-trip
238/// rule). A ticket is the richer card style: it inherits done-ness from its
239/// column, so it carries no checkbox. Returns `None` when the title is blank,
240/// the document has no column headings to nest under, or no such section exists.
241pub fn add_task(text: &str, section: &str, title: &str) -> Option<String> {
242 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
243 if title.is_empty() {
244 return None;
245 }
246
247 // `split('\n')` (not `lines()`) so reconstruction is byte-exact.
248 let lines: Vec<&str> = text.split('\n').collect();
249 let col = column_level(text);
250 // A ticket nests one level below the columns; with no headings at all
251 // there's no column to nest it under.
252 if col == 0 {
253 return None;
254 }
255
256 // The target span: [start, end) of the section's body lines.
257 let mut start = None;
258 let mut end = lines.len();
259 let mut in_fence = false;
260 for (i, raw) in lines.iter().enumerate() {
261 let line = raw.trim_end_matches('\r');
262 if line.trim_start().starts_with("```") {
263 in_fence = !in_fence;
264 continue;
265 }
266 if in_fence {
267 continue;
268 }
269 if let Some((level, rest)) = heading(line)
270 && level == col
271 {
272 match start {
273 None if strip_marker(rest) == section => start = Some(i + 1),
274 Some(_) => {
275 end = i;
276 break;
277 }
278 None => {}
279 }
280 }
281 }
282 let start = start?;
283
284 let ticket = format!("{} {title}", "#".repeat(col as usize + 1));
285
286 let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
287 match (start..end).rev().find(|&i| !lines[i].trim().is_empty()) {
288 // After the section's last non-blank line, with a blank line before it
289 // so the heading stands on its own.
290 Some(i) => {
291 out.insert(i + 1, ticket);
292 out.insert(i + 1, String::new());
293 }
294 // Empty section: a blank line, then the ticket, right after the heading.
295 None => {
296 out.insert(start, ticket);
297 out.insert(start, String::new());
298 }
299 }
300 Some(out.join("\n"))
301}
302
303/// The section names a task can be added to: the column-level headings, in
304/// document order, stripped of any done marker. These are exactly the names
305/// [`add_task`] accepts. Empty when the document has no headings.
306pub fn task_sections(text: &str) -> Vec<String> {
307 let col = column_level(text);
308 if col == 0 {
309 return Vec::new();
310 }
311 let mut in_fence = false;
312 let mut out = Vec::new();
313 for line in text.lines() {
314 if line.trim_start().starts_with("```") {
315 in_fence = !in_fence;
316 continue;
317 }
318 if in_fence {
319 continue;
320 }
321 if let Some((level, rest)) = heading(line)
322 && level == col
323 {
324 out.push(strip_marker(rest));
325 }
326 }
327 out
328}
329
330/// Render a todo-md document as a kanban board (columns = task-bearing
331/// sections) with prose sections as a notes area below. `None` if the file
332/// contains no tasks at all — callers fall back to plain markdown.
333pub fn render_board(text: &str) -> Option<Markup> {
334 let sections = parse(text);
335 if sections.iter().all(|s| s.tasks.is_empty()) {
336 return None;
337 }
338
339 // Prose-only sections (and stray preamble/column prose) become one
340 // markdown notes blob, headings preserved.
341 let mut notes = String::new();
342 for s in &sections {
343 let prose_empty = s.prose.trim().is_empty();
344 if s.tasks.is_empty() && s.level > 0 && prose_empty {
345 continue; // empty section: nothing to show either way
346 }
347 if s.tasks.is_empty() {
348 if s.level > 0 {
349 notes.push_str(&format!("{} {}\n", "#".repeat(s.level as usize), s.title));
350 }
351 if !prose_empty {
352 notes.push_str(&s.prose);
353 notes.push('\n');
354 }
355 }
356 }
357
358 Some(html! {
359 div.kanban {
360 @for s in sections.iter().filter(|s| !s.tasks.is_empty()) {
361 div.col {
362 h3 {
363 @if s.title.is_empty() { "Tasks" } @else { (s.title) }
364 span.count {
365 @if s.done {
366 ({ s.tasks.len().to_string() }) " done"
367 } @else {
368 ({ s.tasks.iter().filter(|t| !t.done).count().to_string() })
369 " open"
370 }
371 }
372 }
373 @for t in &s.tasks {
374 div.card.done[t.done] {
375 div.title { (render_markdown(&t.title)) }
376 @if !t.details.trim().is_empty() {
377 details {
378 summary { "details" }
379 div.card-details { (render_markdown(&t.details)) }
380 }
381 }
382 }
383 }
384 }
385 }
386 }
387 @if !notes.trim().is_empty() {
388 details.todo-notes {
389 summary { "notes" }
390 div.md-body { (render_markdown(&notes)) }
391 }
392 }
393 })
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 const DOC: &str = "\
401intro prose
402
403# Now
404
405- [ ] first task
406 - a detail line
407 - another `detail`
408- [x] finished task
409
410# Done [x]
411
412- [ ] moved here, checkbox stale
413
414## Implement Navbar
415
416Sticky top bar with the repo switcher.
417
418- shipped behind a flag
419- needs a follow-up for mobile
420
421# Notes
422
423just prose, no tasks
424
425```
426# not a heading
427- [ ] not a task
428```
429";
430
431 #[test]
432 fn parses_sections_tasks_details() {
433 let s = parse(DOC);
434 assert_eq!(s.len(), 4); // preamble + 3 column headings (## is a ticket)
435 assert_eq!(s[0].level, 0);
436 assert_eq!(s[0].prose.trim(), "intro prose");
437
438 assert_eq!(s[1].title, "Now");
439 assert!(!s[1].done);
440 assert_eq!(s[1].tasks.len(), 2);
441 assert_eq!(s[1].tasks[0].title, "first task");
442 assert!(!s[1].tasks[0].done);
443 assert!(!s[1].tasks[0].ticket);
444 assert_eq!(s[1].tasks[0].details, "- a detail line\n- another `detail`");
445 assert!(s[1].tasks[1].done);
446
447 // Done section: heading marker wins over the task's own checkbox.
448 assert_eq!(s[2].title, "Done");
449 assert!(s[2].done);
450 assert_eq!(s[2].tasks.len(), 2); // checkbox task + the ticket
451 assert!(s[2].tasks[0].done);
452 assert!(!s[2].tasks[0].ticket);
453 }
454
455 #[test]
456 fn nested_heading_is_a_ticket() {
457 let s = parse(DOC);
458 let ticket = &s[2].tasks[1];
459 assert!(ticket.ticket);
460 assert_eq!(ticket.title, "Implement Navbar");
461 // Done inherited from the `# Done [x]` section.
462 assert!(ticket.done);
463 assert!(ticket.details.contains("Sticky top bar"));
464 assert!(ticket.details.contains("- needs a follow-up for mobile"));
465 // The body must not leak into a sibling section.
466 assert!(!ticket.details.contains("just prose"));
467 }
468
469 #[test]
470 fn ticket_own_done_marker() {
471 let s = parse("# Backlog\n\n## Fix login [x]\n\nbody\n\n## Add search\n\nbody\n");
472 assert!(!s[1].done); // section is open
473 assert_eq!(s[1].tasks.len(), 2);
474 assert!(s[1].tasks[0].done); // ticket marked done on its own heading
475 assert_eq!(s[1].tasks[0].title, "Fix login");
476 assert!(!s[1].tasks[1].done);
477 }
478
479 #[test]
480 fn todo_md_by_filename() {
481 assert!(is_todo_md("TODO.md"));
482 assert!(is_todo_md("docs/todo.MD"));
483 assert!(!is_todo_md("TODO.txt"));
484 assert!(!is_todo_md("NOT-TODO.md"));
485 }
486
487 #[test]
488 fn board_renders_columns_tickets_and_falls_back() {
489 let board = render_board(DOC).expect("has tasks").into_string();
490 assert!(board.contains("kanban"));
491 assert!(board.contains("Now"));
492 assert!(board.contains("first task"));
493 // Tickets render as ordinary cards — no checkbox input, no special
494 // card class. (The DOC text mentions "checkbox", so check elements.)
495 assert!(board.contains("Implement Navbar"));
496 assert!(!board.contains("<input"), "no checkbox clutter: {board}");
497 assert!(!board.contains("ticket\""), "no ticket card class: {board}");
498 assert!(board.contains("just prose"), "notes area kept: {board}");
499 assert!(render_board("# readme\n\nonly prose\n").is_none());
500 }
501
502 #[test]
503 fn add_task_appends_ticket_within_section_byte_exactly() {
504 let out = add_task(DOC, "Now", "new ticket").unwrap();
505 // Lands as a nested heading after the section's last non-blank line,
506 // padded by a blank line, before the next column heading.
507 assert!(out.contains("- [x] finished task\n\n## new ticket\n\n# Done"));
508 // Round-trip rule: removing the inserted ticket restores the original.
509 assert_eq!(out.replacen("## new ticket\n\n", "", 1), DOC);
510 }
511
512 #[test]
513 fn add_task_ticket_level_tracks_the_column_level() {
514 // Columns at `##` ⇒ tickets nest at `###`.
515 let doc = "## Backlog\n\n### Existing\n\nbody\n";
516 let out = add_task(doc, "Backlog", "New one").unwrap();
517 assert!(out.contains("body\n\n### New one"));
518 }
519
520 #[test]
521 fn add_task_to_done_section_inherits_done_no_checkbox() {
522 let out = add_task(DOC, "Done", "tidy up").unwrap();
523 assert!(out.contains("## tidy up"));
524 assert!(!out.contains("- [ ] tidy up") && !out.contains("- [x] tidy up"));
525 // Parses as a ticket under the done column, so it reads as done.
526 let done = parse(&out).into_iter().find(|s| s.title == "Done").unwrap();
527 assert!(
528 done.tasks
529 .iter()
530 .any(|t| t.ticket && t.title == "tidy up" && t.done)
531 );
532 }
533
534 #[test]
535 fn add_task_into_empty_section_inserts_blank_then_ticket() {
536 let doc = "# Now\n# Done\n";
537 assert_eq!(
538 add_task(doc, "Now", "first").unwrap(),
539 "# Now\n\n## first\n# Done\n"
540 );
541 }
542
543 #[test]
544 fn add_task_rejects_missing_section_blank_title_and_headingless() {
545 assert!(add_task(DOC, "Nonexistent", "x").is_none());
546 assert!(add_task(DOC, "Now", " ").is_none());
547 assert!(add_task("no headings here\n", "Whatever", "x").is_none());
548 }
549
550 #[test]
551 fn added_ticket_renders_as_a_board_card() {
552 let out = add_task(DOC, "Now", "Wire uploads").unwrap();
553 let board = render_board(&out).expect("has tasks").into_string();
554 assert!(board.contains("Wire uploads"));
555 }
556
557 #[test]
558 fn task_sections_lists_column_headings_stripped() {
559 assert_eq!(task_sections(DOC), ["Now", "Done", "Notes"]);
560 assert!(task_sections("# readme\n\nonly prose\n") == ["readme"]);
561 assert!(task_sections("no headings at all\n").is_empty());
562 }
563}