Skip to main content

markdown_org_extract/
render.rs

1//! Rendering tasks and agendas as markdown or HTML.
2//!
3//! These functions produce the text the CLI writes to `--output`; embedders
4//! that build their own UI can ignore them and read the structures directly.
5//! Headings are sanitised here: characters that render as nothing but reorder
6//! or hide surrounding text are dropped rather than passed through.
7
8use std::fmt::Write;
9
10use crate::types::{ClockEntry, DayAgenda, Task, TaskWithOffset};
11
12/// Characters that render as nothing yet change how the surrounding text is
13/// displayed: the bidirectional overrides / isolates, which can reorder a
14/// heading so it reads differently from the bytes it contains, and the
15/// zero-width space, which can hide a word boundary. Dropped from rendered
16/// output so what the reader sees matches what the note says.
17fn is_invisible_formatting(ch: char) -> bool {
18    matches!(ch,
19        '\u{200b}'                  // ZERO WIDTH SPACE
20        | '\u{200e}' | '\u{200f}'   // LRM, RLM
21        | '\u{202a}'..='\u{202e}'   // LRE, RLE, PDF, LRO, RLO
22        | '\u{2066}'..='\u{2069}'   // LRI, RLI, FSI, PDI
23    )
24}
25
26/// Escape markdown special characters in plain text. Used for headings and
27/// labels that originate from user input — keeps formatting from being broken
28/// or hijacked (e.g. a heading containing `*` would otherwise render as italic).
29/// Invisible bidirectional formatting is dropped for the same reason it is
30/// dropped from HTML output: it rewrites how the line reads without showing up
31/// in it.
32fn md_escape(s: &str) -> String {
33    let mut out = String::with_capacity(s.len());
34    for ch in s.chars() {
35        match ch {
36            '\\' | '`' | '*' | '_' | '#' | '[' | ']' | '<' | '>' | '|' => {
37                out.push('\\');
38                out.push(ch);
39            }
40            c if is_invisible_formatting(c) => {}
41            _ => out.push(ch),
42        }
43    }
44    out
45}
46
47/// Escape HTML special characters in pre-existing text content.
48/// Also drops C0 control characters (except `\t \n \r`), DEL, and the
49/// invisible bidirectional formatting characters, to protect downstream
50/// renderers from null bytes and from glyphs that silently reorder the text
51/// sneaked through markdown.
52fn html_escape(s: &str) -> String {
53    let mut out = String::with_capacity(s.len());
54    for ch in s.chars() {
55        match ch {
56            '&' => out.push_str("&amp;"),
57            '<' => out.push_str("&lt;"),
58            '>' => out.push_str("&gt;"),
59            '"' => out.push_str("&quot;"),
60            '\'' => out.push_str("&#39;"),
61            '\t' | '\n' | '\r' => out.push(ch),
62            c if (c as u32) < 0x20 || c == '\u{7f}' => {}
63            c if is_invisible_formatting(c) => {}
64            _ => out.push(ch),
65        }
66    }
67    out
68}
69
70fn offset_suffix(days_offset: Option<i64>) -> Option<String> {
71    days_offset.map(|offset| {
72        if offset > 0 {
73            format!(" (in {offset} days)")
74        } else {
75            format!(" ({} days ago)", -offset)
76        }
77    })
78}
79
80/// Common formatting strategy for one output format (Markdown or HTML).
81///
82/// All `render_*` entry points delegate field traversal to `write_task`, which
83/// drives this trait's methods. Adding a new `Task` field means touching
84/// `write_task` once instead of four renderers.
85trait TaskFormat {
86    fn doc_open(&self, title: &str) -> String;
87    fn doc_close(&self, out: &mut String);
88    fn day_header(&self, out: &mut String, date: &str);
89    fn section(&self, out: &mut String, title: &str);
90    fn after_section(&self, out: &mut String);
91    fn task_heading(&self, out: &mut String, level: u8, heading: &str, days_offset: Option<i64>);
92    /// Single `Label: value` field. `code` requests inline-code wrapping
93    /// for formats that support it (Markdown); HTML ignores the hint.
94    fn field(&self, out: &mut String, label: &str, value: &str, code: bool);
95    fn clocks_open(&self, out: &mut String);
96    fn clock_complete(&self, out: &mut String, start: &str, end: &str, duration: Option<&str>);
97    fn clock_active(&self, out: &mut String, start: &str);
98    fn clocks_close(&self, out: &mut String);
99    fn content(&self, out: &mut String, body: &str);
100}
101
102struct MdFormat;
103struct HtmlFormat;
104
105impl TaskFormat for MdFormat {
106    fn doc_open(&self, title: &str) -> String {
107        format!("# {title}\n\n")
108    }
109    fn doc_close(&self, _out: &mut String) {}
110
111    fn day_header(&self, out: &mut String, date: &str) {
112        let _ = writeln!(out, "## {date}\n");
113    }
114    fn section(&self, out: &mut String, title: &str) {
115        let _ = write!(out, "### {title}\n\n");
116    }
117    fn after_section(&self, out: &mut String) {
118        out.push('\n');
119    }
120
121    fn task_heading(&self, out: &mut String, level: u8, heading: &str, days_offset: Option<i64>) {
122        let hashes: String = "#".repeat(level as usize);
123        let _ = write!(out, "{hashes} {}", md_escape(heading));
124        if let Some(suffix) = offset_suffix(days_offset) {
125            let _ = write!(out, "{suffix}");
126        }
127        out.push('\n');
128    }
129
130    fn field(&self, out: &mut String, label: &str, value: &str, code: bool) {
131        if code {
132            let _ = writeln!(out, "**{label}:** `{value}`");
133        } else {
134            let _ = writeln!(out, "**{label}:** {value}");
135        }
136    }
137
138    fn clocks_open(&self, out: &mut String) {
139        out.push_str("\n**Clock:**\n");
140    }
141    fn clock_complete(&self, out: &mut String, start: &str, end: &str, duration: Option<&str>) {
142        match duration {
143            Some(dur) => {
144                let _ = writeln!(out, "- `{start}` → `{end}` ({dur})");
145            }
146            None => {
147                let _ = writeln!(out, "- `{start}` → `{end}`");
148            }
149        }
150    }
151    fn clock_active(&self, out: &mut String, start: &str) {
152        let _ = writeln!(out, "- `{start}` (active)");
153    }
154    fn clocks_close(&self, _out: &mut String) {}
155
156    fn content(&self, out: &mut String, body: &str) {
157        if body.is_empty() {
158            out.push('\n');
159        } else {
160            let _ = write!(out, "\n{body}\n\n");
161        }
162    }
163}
164
165impl TaskFormat for HtmlFormat {
166    fn doc_open(&self, title: &str) -> String {
167        format!("<html><body><h1>{title}</h1>\n")
168    }
169    fn doc_close(&self, out: &mut String) {
170        out.push_str("</body></html>");
171    }
172
173    fn day_header(&self, out: &mut String, date: &str) {
174        let _ = writeln!(out, "<h2>{}</h2>", html_escape(date));
175    }
176    fn section(&self, out: &mut String, title: &str) {
177        let _ = writeln!(out, "<h3>{title}</h3>");
178    }
179    fn after_section(&self, _out: &mut String) {}
180
181    fn task_heading(&self, out: &mut String, level: u8, heading: &str, days_offset: Option<i64>) {
182        let _ = write!(out, "<h{level}>{}", html_escape(heading));
183        if let Some(suffix) = offset_suffix(days_offset) {
184            let _ = write!(out, "{}", html_escape(&suffix));
185        }
186        let _ = writeln!(out, "</h{level}>");
187    }
188
189    fn field(&self, out: &mut String, label: &str, value: &str, _code: bool) {
190        let _ = writeln!(
191            out,
192            "<p><strong>{label}:</strong> {}</p>",
193            html_escape(value)
194        );
195    }
196
197    fn clocks_open(&self, out: &mut String) {
198        out.push_str("<p><strong>Clock:</strong></p>\n<ul>\n");
199    }
200    fn clock_complete(&self, out: &mut String, start: &str, end: &str, duration: Option<&str>) {
201        match duration {
202            Some(dur) => {
203                let _ = writeln!(
204                    out,
205                    "<li>{} → {} ({})</li>",
206                    html_escape(start),
207                    html_escape(end),
208                    html_escape(dur)
209                );
210            }
211            None => {
212                let _ = writeln!(
213                    out,
214                    "<li>{} → {}</li>",
215                    html_escape(start),
216                    html_escape(end)
217                );
218            }
219        }
220    }
221    fn clock_active(&self, out: &mut String, start: &str) {
222        let _ = writeln!(out, "<li>{} (active)</li>", html_escape(start));
223    }
224    fn clocks_close(&self, out: &mut String) {
225        out.push_str("</ul>\n");
226    }
227
228    fn content(&self, out: &mut String, body: &str) {
229        if !body.is_empty() {
230            let _ = writeln!(out, "<p>{}</p>", html_escape(body));
231        }
232    }
233}
234
235/// Write one Task to `out` using the supplied format strategy.
236///
237/// `level` controls heading depth (2 for top-level lists, 4 for day-agenda
238/// sub-sections). `include_history` toggles fields that are only meaningful in
239/// the "all tasks" view -- `Created`, `Total Time`, `Clock:` -- so day agendas
240/// stay focused on the schedule.
241fn write_task<F: TaskFormat>(
242    out: &mut String,
243    task: &Task,
244    days_offset: Option<i64>,
245    level: u8,
246    include_history: bool,
247    fmt: &F,
248) {
249    fmt.task_heading(out, level, &task.heading, days_offset);
250
251    let file_value = format!("{}:{}", task.file, task.line);
252    fmt.field(out, "File", &file_value, true);
253
254    if let Some(ref t) = task.task_type {
255        fmt.field(out, "Type", &t.to_string(), false);
256    }
257    if let Some(ref p) = task.priority {
258        fmt.field(out, "Priority", &p.to_string(), false);
259    }
260    if include_history {
261        if let Some(ref c) = task.created {
262            fmt.field(out, "Created", c, true);
263        }
264    }
265    if let Some(ref ts) = task.timestamp {
266        fmt.field(out, "Time", ts, true);
267    }
268    if include_history {
269        if let Some(ref total) = task.total_clock_time {
270            fmt.field(out, "Total Time", total, false);
271        }
272        if let Some(ref clocks) = task.clocks {
273            write_clocks(out, clocks, fmt);
274        }
275    }
276
277    fmt.content(out, &task.content);
278}
279
280fn write_clocks<F: TaskFormat>(out: &mut String, clocks: &[ClockEntry], fmt: &F) {
281    fmt.clocks_open(out);
282    for clock in clocks {
283        match (&clock.end, &clock.duration) {
284            (Some(end), Some(dur)) => fmt.clock_complete(out, &clock.start, end, Some(dur)),
285            (Some(end), None) => fmt.clock_complete(out, &clock.start, end, None),
286            (None, _) => fmt.clock_active(out, &clock.start),
287        }
288    }
289    fmt.clocks_close(out);
290}
291
292fn write_day_section<F: TaskFormat>(
293    out: &mut String,
294    title: &str,
295    tasks: &[TaskWithOffset],
296    fmt: &F,
297) {
298    if tasks.is_empty() {
299        return;
300    }
301    fmt.section(out, title);
302    for two in tasks {
303        write_task(out, &two.task, two.days_offset, 4, false, fmt);
304    }
305    fmt.after_section(out);
306}
307
308fn render_days<F: TaskFormat>(days: &[DayAgenda], fmt: &F) -> String {
309    let mut output = fmt.doc_open("Agenda");
310
311    for day in days {
312        fmt.day_header(&mut output, &day.date);
313
314        write_day_section(&mut output, "Overdue", &day.overdue, fmt);
315
316        // "Scheduled" header is shared by timed + no-time groups: print it once
317        // if either is non-empty, then list both without a second header.
318        if !day.scheduled_timed.is_empty() || !day.scheduled_no_time.is_empty() {
319            fmt.section(&mut output, "Scheduled");
320            for two in &day.scheduled_timed {
321                write_task(&mut output, &two.task, two.days_offset, 4, false, fmt);
322            }
323            for two in &day.scheduled_no_time {
324                write_task(&mut output, &two.task, two.days_offset, 4, false, fmt);
325            }
326            fmt.after_section(&mut output);
327        }
328
329        write_day_section(&mut output, "Upcoming", &day.upcoming, fmt);
330    }
331
332    fmt.doc_close(&mut output);
333    output
334}
335
336fn render_tasks<F: TaskFormat>(tasks: &[Task], fmt: &F) -> String {
337    let mut output = fmt.doc_open("Tasks");
338    for task in tasks {
339        write_task(&mut output, task, None, 2, true, fmt);
340    }
341    fmt.doc_close(&mut output);
342    output
343}
344
345/// Render day agendas as Markdown
346pub fn render_days_markdown(days: &[DayAgenda]) -> String {
347    render_days(days, &MdFormat)
348}
349
350/// Render day agendas as HTML
351pub fn render_days_html(days: &[DayAgenda]) -> String {
352    render_days(days, &HtmlFormat)
353}
354
355/// Render tasks as Markdown
356pub fn render_markdown(tasks: &[Task]) -> String {
357    render_tasks(tasks, &MdFormat)
358}
359
360/// Render tasks as HTML
361pub fn render_html(tasks: &[Task]) -> String {
362    render_tasks(tasks, &HtmlFormat)
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::types::{CancelledSpelling, Priority, TaskType};
369
370    #[test]
371    fn test_html_escape() {
372        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
373        assert_eq!(html_escape("A & B"), "A &amp; B");
374    }
375
376    #[test]
377    fn test_html_escape_strips_control_chars() {
378        assert_eq!(html_escape("A\u{0000}B"), "AB");
379        assert_eq!(html_escape("A\u{0007}B"), "AB"); // BEL
380        assert_eq!(html_escape("A\u{007f}B"), "AB"); // DEL
381        assert_eq!(html_escape("line1\nline2\tx"), "line1\nline2\tx");
382    }
383
384    #[test]
385    fn escapes_drop_invisible_bidi_formatting() {
386        // A heading carrying RLO reads back-to-front while the bytes say
387        // otherwise; a zero-width space hides a word boundary. Both render as
388        // nothing, so neither renderer may pass them through.
389        let sneaky = "safe\u{202e}txt.exe\u{202c}\u{200b}end\u{2066}x\u{2069}";
390        assert_eq!(html_escape(sneaky), "safetxt.exeendx");
391        assert_eq!(md_escape(sneaky), "safetxt.exeendx");
392        // Ordinary text, including non-Latin scripts, is untouched.
393        assert_eq!(html_escape("Отчёт за июль"), "Отчёт за июль");
394        assert_eq!(md_escape("Отчёт за июль"), "Отчёт за июль");
395    }
396
397    #[test]
398    fn test_render_markdown_basic() {
399        let tasks = vec![Task {
400            file: "test.md".to_string(),
401            root: None,
402            line: 1,
403            heading: "Test Task".to_string(),
404            content: "Description".to_string(),
405            task_type: Some(TaskType::Todo),
406            priority: Some(Priority::A),
407            created: None,
408            timestamp: None,
409            timestamp_type: None,
410            timestamp_active: None,
411            timestamp_date: None,
412            timestamp_time: None,
413            timestamp_end_time: None,
414            timestamp_repeater: None,
415            timestamp_next: None,
416            timestamp_next_after: None,
417            clocks: None,
418            total_clock_time: None,
419            properties: None,
420        }];
421
422        let output = render_markdown(&tasks);
423        assert!(output.contains("# Tasks"));
424        assert!(output.contains("## Test Task"));
425        assert!(output.contains("**Type:** TODO"));
426        assert!(output.contains("**Priority:** A"));
427    }
428
429    #[test]
430    fn test_md_escape_specials() {
431        assert_eq!(md_escape("plain"), "plain");
432        assert_eq!(md_escape("a*b"), "a\\*b");
433        assert_eq!(md_escape("a_b"), "a\\_b");
434        assert_eq!(md_escape("# hi"), "\\# hi");
435        assert_eq!(md_escape("[link]"), "\\[link\\]");
436        assert_eq!(md_escape("<tag>"), "\\<tag\\>");
437        assert_eq!(md_escape("a|b"), "a\\|b");
438        assert_eq!(md_escape("`code`"), "\\`code\\`");
439        assert_eq!(md_escape("back\\slash"), "back\\\\slash");
440    }
441
442    #[test]
443    fn test_render_markdown_escapes_heading() {
444        let tasks = vec![Task {
445            file: "test.md".to_string(),
446            root: None,
447            line: 1,
448            heading: "Fix *important* [#issue]".to_string(),
449            content: String::new(),
450            task_type: None,
451            priority: None,
452            created: None,
453            timestamp: None,
454            timestamp_type: None,
455            timestamp_active: None,
456            timestamp_date: None,
457            timestamp_time: None,
458            timestamp_end_time: None,
459            timestamp_repeater: None,
460            timestamp_next: None,
461            timestamp_next_after: None,
462            clocks: None,
463            total_clock_time: None,
464            properties: None,
465        }];
466        let out = render_markdown(&tasks);
467        assert!(
468            out.contains("## Fix \\*important\\* \\[\\#issue\\]"),
469            "heading must be escaped: {out}"
470        );
471    }
472
473    fn fixture_task() -> Task {
474        Task {
475            file: "notes.md".to_string(),
476            root: None,
477            line: 42,
478            heading: "Test task".to_string(),
479            content: "Body text.".to_string(),
480            task_type: Some(TaskType::Todo),
481            priority: Some(Priority::A),
482            created: Some("CREATED: [2025-09-01 Mon]".to_string()),
483            timestamp: Some("DEADLINE: <2025-10-01 Wed>".to_string()),
484            timestamp_type: Some("DEADLINE".to_string()),
485            timestamp_active: Some(true),
486            timestamp_date: Some("2025-10-01".to_string()),
487            timestamp_time: None,
488            timestamp_end_time: None,
489            timestamp_repeater: None,
490            timestamp_next: None,
491            timestamp_next_after: None,
492            clocks: None,
493            total_clock_time: None,
494            properties: None,
495        }
496    }
497
498    #[test]
499    fn snapshot_render_markdown_full_task() {
500        let out = render_markdown(&[fixture_task()]);
501        let expected = "# Tasks\n\n\
502## Test task\n\
503**File:** `notes.md:42`\n\
504**Type:** TODO\n\
505**Priority:** A\n\
506**Created:** `CREATED: [2025-09-01 Mon]`\n\
507**Time:** `DEADLINE: <2025-10-01 Wed>`\n\
508\n\
509Body text.\n\n";
510        assert_eq!(out, expected);
511    }
512
513    #[test]
514    fn snapshot_render_html_full_task() {
515        let out = render_html(&[fixture_task()]);
516        let expected = "<html><body><h1>Tasks</h1>\n\
517<h2>Test task</h2>\n\
518<p><strong>File:</strong> notes.md:42</p>\n\
519<p><strong>Type:</strong> TODO</p>\n\
520<p><strong>Priority:</strong> A</p>\n\
521<p><strong>Created:</strong> CREATED: [2025-09-01 Mon]</p>\n\
522<p><strong>Time:</strong> DEADLINE: &lt;2025-10-01 Wed&gt;</p>\n\
523<p>Body text.</p>\n\
524</body></html>";
525        assert_eq!(out, expected);
526    }
527
528    #[test]
529    fn render_task_cancelled_json_serialises_correctly() {
530        // ADR-0015 wire contract: the cancelled TaskType variant must serialise
531        // to the JSON string "CANCELLED" via the hand-written `Serialize` impl,
532        // preserving the original double-L spelling (ADR-0021).
533        let mut task = fixture_task();
534        task.heading = "Foo".to_string();
535        task.task_type = Some(TaskType::Cancelled(CancelledSpelling::DoubleL));
536
537        let rendered = serde_json::to_string(&task).expect("Task serialises");
538        assert!(
539            rendered.contains(r#""task_type":"CANCELLED""#),
540            "expected task_type CANCELLED in JSON, got: {rendered}",
541        );
542    }
543
544    #[test]
545    fn render_task_canceled_single_l_json_preserves_spelling() {
546        // ADR-0021: the single-L spelling is preserved, not normalised to
547        // double-L. ADR-0015 wire contract: the cancelled TaskType variant
548        // serialises to a plain JSON string ("CANCELED") via the hand-written
549        // `Serialize` impl.
550        let mut task = fixture_task();
551        task.heading = "Foo".to_string();
552        task.task_type = Some(TaskType::Cancelled(CancelledSpelling::SingleL));
553
554        let rendered = serde_json::to_string(&task).expect("Task serialises");
555        assert!(
556            rendered.contains(r#""task_type":"CANCELED""#),
557            "expected task_type CANCELED (single-L) in JSON, got: {rendered}",
558        );
559        assert!(
560            !rendered.contains(r#""task_type":"CANCELLED""#),
561            "single-L spelling must not be normalised to double-L, got: {rendered}",
562        );
563    }
564
565    #[test]
566    fn test_render_html_escapes() {
567        let tasks = vec![Task {
568            file: "<script>.md".to_string(),
569            root: None,
570            line: 1,
571            heading: "Test & Task".to_string(),
572            content: String::new(),
573            task_type: None,
574            priority: None,
575            created: None,
576            timestamp: None,
577            timestamp_type: None,
578            timestamp_active: None,
579            timestamp_date: None,
580            timestamp_time: None,
581            timestamp_end_time: None,
582            timestamp_repeater: None,
583            timestamp_next: None,
584            timestamp_next_after: None,
585            clocks: None,
586            total_clock_time: None,
587            properties: None,
588        }];
589
590        let output = render_html(&tasks);
591        assert!(output.contains("&lt;script&gt;"));
592        assert!(output.contains("Test &amp; Task"));
593    }
594}