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