1use std::fmt::Write;
9
10use crate::types::{ClockEntry, DayAgenda, Task, TaskWithOffset};
11
12fn is_invisible_formatting(ch: char) -> bool {
18 matches!(ch,
19 '\u{200b}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' )
24}
25
26fn 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
47fn 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("&"),
57 '<' => out.push_str("<"),
58 '>' => out.push_str(">"),
59 '"' => out.push_str("""),
60 '\'' => out.push_str("'"),
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
80trait 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 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
235fn 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 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
345pub fn render_days_markdown(days: &[DayAgenda]) -> String {
347 render_days(days, &MdFormat)
348}
349
350pub fn render_days_html(days: &[DayAgenda]) -> String {
352 render_days(days, &HtmlFormat)
353}
354
355pub fn render_markdown(tasks: &[Task]) -> String {
357 render_tasks(tasks, &MdFormat)
358}
359
360pub 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>"), "<script>");
373 assert_eq!(html_escape("A & B"), "A & 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"); assert_eq!(html_escape("A\u{007f}B"), "AB"); assert_eq!(html_escape("line1\nline2\tx"), "line1\nline2\tx");
382 }
383
384 #[test]
385 fn escapes_drop_invisible_bidi_formatting() {
386 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 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: <2025-10-01 Wed></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 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 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("<script>"));
588 assert!(output.contains("Test & Task"));
589 }
590}