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            clocks: None,
417            total_clock_time: None,
418            properties: None,
419        }];
420
421        let output = render_markdown(&tasks);
422        assert!(output.contains("# Tasks"));
423        assert!(output.contains("## Test Task"));
424        assert!(output.contains("**Type:** TODO"));
425        assert!(output.contains("**Priority:** A"));
426    }
427
428    #[test]
429    fn test_md_escape_specials() {
430        assert_eq!(md_escape("plain"), "plain");
431        assert_eq!(md_escape("a*b"), "a\\*b");
432        assert_eq!(md_escape("a_b"), "a\\_b");
433        assert_eq!(md_escape("# hi"), "\\# hi");
434        assert_eq!(md_escape("[link]"), "\\[link\\]");
435        assert_eq!(md_escape("<tag>"), "\\<tag\\>");
436        assert_eq!(md_escape("a|b"), "a\\|b");
437        assert_eq!(md_escape("`code`"), "\\`code\\`");
438        assert_eq!(md_escape("back\\slash"), "back\\\\slash");
439    }
440
441    #[test]
442    fn test_render_markdown_escapes_heading() {
443        let tasks = vec![Task {
444            file: "test.md".to_string(),
445            root: None,
446            line: 1,
447            heading: "Fix *important* [#issue]".to_string(),
448            content: String::new(),
449            task_type: None,
450            priority: None,
451            created: None,
452            timestamp: None,
453            timestamp_type: None,
454            timestamp_active: None,
455            timestamp_date: None,
456            timestamp_time: None,
457            timestamp_end_time: None,
458            timestamp_repeater: None,
459            timestamp_next: None,
460            clocks: None,
461            total_clock_time: None,
462            properties: None,
463        }];
464        let out = render_markdown(&tasks);
465        assert!(
466            out.contains("## Fix \\*important\\* \\[\\#issue\\]"),
467            "heading must be escaped: {out}"
468        );
469    }
470
471    fn fixture_task() -> Task {
472        Task {
473            file: "notes.md".to_string(),
474            root: None,
475            line: 42,
476            heading: "Test task".to_string(),
477            content: "Body text.".to_string(),
478            task_type: Some(TaskType::Todo),
479            priority: Some(Priority::A),
480            created: Some("CREATED: [2025-09-01 Mon]".to_string()),
481            timestamp: Some("DEADLINE: <2025-10-01 Wed>".to_string()),
482            timestamp_type: Some("DEADLINE".to_string()),
483            timestamp_active: Some(true),
484            timestamp_date: Some("2025-10-01".to_string()),
485            timestamp_time: None,
486            timestamp_end_time: None,
487            timestamp_repeater: None,
488            timestamp_next: None,
489            clocks: None,
490            total_clock_time: None,
491            properties: None,
492        }
493    }
494
495    #[test]
496    fn snapshot_render_markdown_full_task() {
497        let out = render_markdown(&[fixture_task()]);
498        let expected = "# Tasks\n\n\
499## Test task\n\
500**File:** `notes.md:42`\n\
501**Type:** TODO\n\
502**Priority:** A\n\
503**Created:** `CREATED: [2025-09-01 Mon]`\n\
504**Time:** `DEADLINE: <2025-10-01 Wed>`\n\
505\n\
506Body text.\n\n";
507        assert_eq!(out, expected);
508    }
509
510    #[test]
511    fn snapshot_render_html_full_task() {
512        let out = render_html(&[fixture_task()]);
513        let expected = "<html><body><h1>Tasks</h1>\n\
514<h2>Test task</h2>\n\
515<p><strong>File:</strong> notes.md:42</p>\n\
516<p><strong>Type:</strong> TODO</p>\n\
517<p><strong>Priority:</strong> A</p>\n\
518<p><strong>Created:</strong> CREATED: [2025-09-01 Mon]</p>\n\
519<p><strong>Time:</strong> DEADLINE: &lt;2025-10-01 Wed&gt;</p>\n\
520<p>Body text.</p>\n\
521</body></html>";
522        assert_eq!(out, expected);
523    }
524
525    #[test]
526    fn render_task_cancelled_json_serialises_correctly() {
527        // ADR-0015 wire contract: the cancelled TaskType variant must serialise
528        // to the JSON string "CANCELLED" via the hand-written `Serialize` impl,
529        // preserving the original double-L spelling (ADR-0021).
530        let mut task = fixture_task();
531        task.heading = "Foo".to_string();
532        task.task_type = Some(TaskType::Cancelled(CancelledSpelling::DoubleL));
533
534        let rendered = serde_json::to_string(&task).expect("Task serialises");
535        assert!(
536            rendered.contains(r#""task_type":"CANCELLED""#),
537            "expected task_type CANCELLED in JSON, got: {rendered}",
538        );
539    }
540
541    #[test]
542    fn render_task_canceled_single_l_json_preserves_spelling() {
543        // ADR-0021: the single-L spelling is preserved, not normalised to
544        // double-L. ADR-0015 wire contract: the cancelled TaskType variant
545        // serialises to a plain JSON string ("CANCELED") via the hand-written
546        // `Serialize` impl.
547        let mut task = fixture_task();
548        task.heading = "Foo".to_string();
549        task.task_type = Some(TaskType::Cancelled(CancelledSpelling::SingleL));
550
551        let rendered = serde_json::to_string(&task).expect("Task serialises");
552        assert!(
553            rendered.contains(r#""task_type":"CANCELED""#),
554            "expected task_type CANCELED (single-L) in JSON, got: {rendered}",
555        );
556        assert!(
557            !rendered.contains(r#""task_type":"CANCELLED""#),
558            "single-L spelling must not be normalised to double-L, got: {rendered}",
559        );
560    }
561
562    #[test]
563    fn test_render_html_escapes() {
564        let tasks = vec![Task {
565            file: "<script>.md".to_string(),
566            root: None,
567            line: 1,
568            heading: "Test & Task".to_string(),
569            content: String::new(),
570            task_type: None,
571            priority: None,
572            created: None,
573            timestamp: None,
574            timestamp_type: None,
575            timestamp_active: None,
576            timestamp_date: None,
577            timestamp_time: None,
578            timestamp_end_time: None,
579            timestamp_repeater: None,
580            timestamp_next: None,
581            clocks: None,
582            total_clock_time: None,
583            properties: None,
584        }];
585
586        let output = render_html(&tasks);
587        assert!(output.contains("&lt;script&gt;"));
588        assert!(output.contains("Test &amp; Task"));
589    }
590}