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
4//! into sections by ATX headings; `- [ ]` / `- [x]` list items in a section
5//! are tasks; lines indented under a task are its details (opaque markdown).
6//! A heading containing `[x]` marks a done section — every task in it counts
7//! as done. Sections that contain tasks render as kanban columns; prose-only
8//! sections render as ordinary markdown below the board.
9//!
10//! This module is the first "custom renderer" for a well-known filename;
11//! `ui::custom_renderer` is the registry that routes filenames here.
12
13use maud::{
14 Markup,
15 html,
16};
17
18use crate::ui::render_markdown;
19
20pub struct Section {
21 /// Heading level (1–6); 0 for the implicit preamble section.
22 pub level: u8,
23 /// Heading text with any `[x]` / `✓` done-marker stripped.
24 pub title: String,
25 /// Done section: tasks in it count as done regardless of their checkbox.
26 pub done: bool,
27 pub tasks: Vec<Task>,
28 /// Non-task content of the section, verbatim (used for the notes area).
29 pub prose: String,
30}
31
32pub struct Task {
33 pub done: bool,
34 pub title: String,
35 /// Raw markdown block of nested/indented lines, dedented one level.
36 pub details: String,
37}
38
39/// Whether a path names a `TODO.md` (any directory, any case).
40pub fn is_todo_md(path: &str) -> bool {
41 std::path::Path::new(path)
42 .file_name()
43 .is_some_and(|n| n.eq_ignore_ascii_case("TODO.md"))
44}
45
46/// Parse a todo-md document into sections. The preamble (content before the
47/// first heading) becomes a level-0 section with an empty title.
48pub fn parse(text: &str) -> Vec<Section> {
49 let mut sections = vec![Section {
50 level: 0,
51 title: String::new(),
52 done: false,
53 tasks: Vec::new(),
54 prose: String::new(),
55 }];
56 let mut in_fence = false;
57 // Index into the current section's tasks while detail lines may still
58 // attach; None once a non-indented line ends the task block.
59 let mut open_task = false;
60
61 for line in text.lines() {
62 let cur = sections.last_mut().expect("never empty");
63 if line.trim_start().starts_with("```") {
64 in_fence = !in_fence;
65 }
66 if in_fence || line.trim_start().starts_with("```") {
67 // Fenced content is never structural.
68 append_line(cur, open_task, line);
69 continue;
70 }
71
72 if let Some((level, rest)) = heading(line) {
73 let done = rest.contains("[x]") || rest.contains("[X]") || rest.contains('✓');
74 let title = rest
75 .replace("[x]", "")
76 .replace("[X]", "")
77 .replace('✓', "")
78 .trim()
79 .to_string();
80 sections.push(Section {
81 level,
82 title,
83 done,
84 tasks: Vec::new(),
85 prose: String::new(),
86 });
87 open_task = false;
88 continue;
89 }
90
91 if let Some((done, title)) = task_line(line) {
92 cur.tasks.push(Task {
93 done,
94 title: title.to_string(),
95 details: String::new(),
96 });
97 open_task = true;
98 continue;
99 }
100
101 // Indented (or blank) lines under a task are its details; anything
102 // else is section prose and closes the task block.
103 if open_task && !line.is_empty() && !line.starts_with(" ") {
104 open_task = false;
105 }
106 append_line(cur, open_task, line);
107 }
108
109 for s in &mut sections {
110 if s.done {
111 for t in &mut s.tasks {
112 t.done = true;
113 }
114 }
115 for t in &mut s.tasks {
116 // Drop trailing blank lines swallowed while the task was open.
117 while t.details.ends_with('\n') {
118 t.details.pop();
119 }
120 }
121 }
122 sections
123}
124
125fn append_line(section: &mut Section, to_task: bool, line: &str) {
126 let target = if to_task {
127 let t = section.tasks.last_mut().expect("open task exists");
128 &mut t.details
129 } else {
130 &mut section.prose
131 };
132 // Dedent detail lines one level (2–4 spaces) so they render as their own
133 // markdown rather than a code block.
134 let line = if to_task {
135 let spaces = line.len() - line.trim_start_matches(' ').len();
136 &line[spaces.min(4).min(line.len())..]
137 } else {
138 line
139 };
140 target.push_str(line);
141 target.push('\n');
142}
143
144fn heading(line: &str) -> Option<(u8, &str)> {
145 let hashes = line.bytes().take_while(|&b| b == b'#').count();
146 if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
147 Some((hashes as u8, line[hashes..].trim()))
148 } else {
149 None
150 }
151}
152
153fn task_line(line: &str) -> Option<(bool, &str)> {
154 let open = line.strip_prefix("- [ ] ");
155 let done = line.strip_prefix("- [x] ").or(line.strip_prefix("- [X] "));
156 match (open, done) {
157 (Some(rest), _) => Some((false, rest.trim())),
158 (_, Some(rest)) => Some((true, rest.trim())),
159 _ => None,
160 }
161}
162
163/// Render a todo-md document as a kanban board (columns = task-bearing
164/// sections) with prose sections as a notes area below. `None` if the file
165/// contains no tasks at all — callers fall back to plain markdown.
166pub fn render_board(text: &str) -> Option<Markup> {
167 let sections = parse(text);
168 if sections.iter().all(|s| s.tasks.is_empty()) {
169 return None;
170 }
171
172 // Prose-only sections (and stray preamble/column prose) become one
173 // markdown notes blob, headings preserved.
174 let mut notes = String::new();
175 for s in &sections {
176 let prose_empty = s.prose.trim().is_empty();
177 if s.tasks.is_empty() && s.level > 0 && prose_empty {
178 continue; // empty section: nothing to show either way
179 }
180 if s.tasks.is_empty() {
181 if s.level > 0 {
182 notes.push_str(&format!("{} {}\n", "#".repeat(s.level as usize), s.title));
183 }
184 if !prose_empty {
185 notes.push_str(&s.prose);
186 notes.push('\n');
187 }
188 }
189 }
190
191 Some(html! {
192 div.kanban {
193 @for s in sections.iter().filter(|s| !s.tasks.is_empty()) {
194 div.col {
195 h3 {
196 @if s.title.is_empty() { "Tasks" } @else { (s.title) }
197 span.count {
198 @if s.done {
199 ({ s.tasks.len().to_string() }) " done"
200 } @else {
201 ({ s.tasks.iter().filter(|t| !t.done).count().to_string() })
202 " open"
203 }
204 }
205 }
206 @for t in &s.tasks {
207 div.card.done[t.done] {
208 div.title {
209 input type="checkbox" disabled checked[t.done];
210 span { (render_markdown(&t.title)) }
211 }
212 @if !t.details.trim().is_empty() {
213 details {
214 summary { "details" }
215 div.card-details { (render_markdown(&t.details)) }
216 }
217 }
218 }
219 }
220 }
221 }
222 }
223 @if !notes.trim().is_empty() {
224 details.todo-notes {
225 summary { "notes" }
226 div.md-body { (render_markdown(&notes)) }
227 }
228 }
229 })
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 const DOC: &str = "\
237intro prose
238
239# Now
240
241- [ ] first task
242 - a detail line
243 - another `detail`
244- [x] finished task
245
246# Done [x]
247
248- [ ] moved here, checkbox stale
249
250# Notes
251
252just prose, no tasks
253
254```
255# not a heading
256- [ ] not a task
257```
258";
259
260 #[test]
261 fn parses_sections_tasks_details() {
262 let s = parse(DOC);
263 assert_eq!(s.len(), 4); // preamble + 3 headings
264 assert_eq!(s[0].level, 0);
265 assert_eq!(s[0].prose.trim(), "intro prose");
266
267 assert_eq!(s[1].title, "Now");
268 assert!(!s[1].done);
269 assert_eq!(s[1].tasks.len(), 2);
270 assert_eq!(s[1].tasks[0].title, "first task");
271 assert!(!s[1].tasks[0].done);
272 assert_eq!(s[1].tasks[0].details, "- a detail line\n- another `detail`");
273 assert!(s[1].tasks[1].done);
274
275 // Done section: heading marker wins over the task's own checkbox.
276 assert_eq!(s[2].title, "Done");
277 assert!(s[2].done);
278 assert!(s[2].tasks[0].done);
279
280 // Fenced content is not structural.
281 assert_eq!(s[3].title, "Notes");
282 assert!(s[3].tasks.is_empty());
283 assert!(s[3].prose.contains("# not a heading"));
284 }
285
286 #[test]
287 fn todo_md_by_filename() {
288 assert!(is_todo_md("TODO.md"));
289 assert!(is_todo_md("docs/todo.MD"));
290 assert!(!is_todo_md("TODO.txt"));
291 assert!(!is_todo_md("NOT-TODO.md"));
292 }
293
294 #[test]
295 fn board_renders_columns_and_falls_back() {
296 let board = render_board(DOC).expect("has tasks").into_string();
297 assert!(board.contains("kanban"));
298 assert!(board.contains("Now"));
299 assert!(board.contains("first task"));
300 assert!(board.contains("just prose"), "notes area kept: {board}");
301 assert!(render_board("# readme\n\nonly prose\n").is_none());
302 }
303}