1#![warn(clippy::pedantic)]
12#![forbid(unsafe_code)]
13#![deny(missing_docs)]
14
15use std::fmt::Write as _;
16use std::sync::LazyLock;
17
18use regex::Regex;
19
20const TODO: &str = "TODO";
22const DONE: &str = "DONE";
23
24#[must_use]
27pub fn headline_level(line: &str) -> Option<usize> {
28 let stars = line.len() - line.trim_start_matches('*').len();
29 if stars > 0 && line[stars..].starts_with(' ') {
30 Some(stars)
31 } else {
32 None
33 }
34}
35
36#[must_use]
40pub fn subtree_range(lines: &[&str], line: usize) -> Option<(usize, usize)> {
41 let level = headline_level(lines.get(line)?)?;
42 let mut end = line + 1;
43 while end < lines.len() {
44 if headline_level(lines[end]).is_some_and(|l| l <= level) {
45 break;
46 }
47 end += 1;
48 }
49 Some((line, end))
50}
51
52#[must_use]
55pub fn promote(text: &str, line: usize) -> Option<String> {
56 reindent_subtree(text, line, false)
57}
58
59#[must_use]
61pub fn demote(text: &str, line: usize) -> Option<String> {
62 reindent_subtree(text, line, true)
63}
64
65fn reindent_subtree(text: &str, line: usize, deeper: bool) -> Option<String> {
68 let lines: Vec<&str> = text.split('\n').collect();
69 let (start, end) = subtree_range(&lines, line)?;
70 if !deeper
71 && lines[start..end]
72 .iter()
73 .any(|l| headline_level(l) == Some(1))
74 {
75 return None;
76 }
77 let mut out: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
78 for l in out.iter_mut().take(end).skip(start) {
79 if headline_level(l).is_some() {
80 if deeper {
81 l.insert(0, '*');
82 } else {
83 l.remove(0);
84 }
85 }
86 }
87 Some(out.join("\n"))
88}
89
90#[must_use]
93pub fn cycle_todo(text: &str, line: usize) -> Option<String> {
94 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
95 let target = lines.get(line)?;
96 let stars = headline_level(target)?;
97 let (prefix, rest) = target.split_at(stars + 1); let new_rest = if let Some(after) = rest.strip_prefix(&format!("{TODO} ")) {
99 format!("{DONE} {after}")
100 } else if rest == TODO {
101 DONE.to_string()
102 } else if let Some(after) = rest.strip_prefix(&format!("{DONE} ")) {
103 after.to_string()
104 } else if rest == DONE {
105 String::new()
106 } else {
107 format!("{TODO} {rest}")
108 };
109 lines[line] = format!("{prefix}{new_rest}");
110 Some(lines.join("\n"))
111}
112
113static CHECKBOX: LazyLock<Regex> = LazyLock::new(|| {
114 Regex::new(r"^(\s*(?:[-+*]|\d+[.)])\s+)\[([ xX-])\]").expect("checkbox regex")
115});
116
117#[must_use]
120pub fn toggle_checkbox(text: &str, line: usize) -> Option<String> {
121 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
122 let target = lines.get(line)?;
123 let caps = CHECKBOX.captures(target)?;
124 let mark = caps.get(2)?.as_str();
125 let new_mark = if mark == " " { "x" } else { " " };
126 let lead_end = caps.get(1)?.end();
127 let rest = &target[lead_end + 3..]; lines[line] = format!("{}[{new_mark}]{rest}", &target[..lead_end]);
129 Some(lines.join("\n"))
130}
131
132static COOKIE: LazyLock<Regex> =
136 LazyLock::new(|| Regex::new(r"\[\d*/\d*\]|\[\d*%\]").expect("cookie regex"));
137
138fn indent_of(line: &str) -> usize {
140 line.len() - line.trim_start().len()
141}
142
143fn set_cookie(line: &str, done: usize, total: usize) -> String {
147 let Some(m) = COOKIE.find(line) else {
148 return line.to_string();
149 };
150 let replacement = if m.as_str().contains('%') {
151 let pct = (done * 100).checked_div(total).unwrap_or(0);
152 format!("[{pct}%]")
153 } else {
154 format!("[{done}/{total}]")
155 };
156 format!("{}{replacement}{}", &line[..m.start()], &line[m.end()..])
157}
158
159fn set_checkbox_mark(line: &str, mark: char) -> String {
161 let Some(caps) = CHECKBOX.captures(line) else {
162 return line.to_string();
163 };
164 let lead_end = caps.get(1).map_or(0, |g| g.end());
165 format!("{}[{mark}]{}", &line[..lead_end], &line[lead_end + 3..])
166}
167
168fn headline_todo(line: &str) -> Option<bool> {
171 let stars = headline_level(line)?;
172 let kw = line[stars..].split_whitespace().next()?;
173 if kw == DONE {
174 Some(true)
175 } else if kw == TODO {
176 Some(false)
177 } else {
178 None
179 }
180}
181
182fn cookie_data(drawer: &[String]) -> Option<(bool, bool)> {
185 for line in drawer {
186 let t = line.trim();
187 if t.eq_ignore_ascii_case(":END:") {
188 break;
189 }
190 if let Some(rest) = t.get(..13)
191 && rest.eq_ignore_ascii_case(":COOKIE_DATA:")
192 {
193 let value = t[13..].to_ascii_lowercase();
194 let recursive = value.contains("recursive");
195 if value.contains("todo") {
196 return Some((true, recursive));
197 }
198 if value.contains("checkbox") {
199 return Some((false, recursive));
200 }
201 return Some((false, recursive));
202 }
203 }
204 None
205}
206
207#[must_use]
221pub fn update_statistics(text: &str) -> String {
222 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
223 update_checkboxes(&mut lines);
224 update_headline_cookies(&mut lines);
225 lines.join("\n")
226}
227
228struct Checkbox {
230 line: usize,
231 indent: usize,
232 mark: char,
233}
234
235fn update_checkboxes(lines: &mut [String]) {
237 let mut items: Vec<Checkbox> = Vec::new();
238 for (i, l) in lines.iter().enumerate() {
239 if headline_level(l).is_some() {
240 continue;
241 }
242 if let Some(c) = CHECKBOX.captures(l) {
243 let mark = c
244 .get(2)
245 .and_then(|g| g.as_str().chars().next())
246 .unwrap_or(' ');
247 items.push(Checkbox {
248 line: i,
249 indent: indent_of(l),
250 mark,
251 });
252 }
253 }
254 let mut parent: Vec<Option<usize>> = vec![None; items.len()];
257 let mut stack: Vec<usize> = Vec::new();
258 for k in 0..items.len() {
259 if k > 0
260 && (items[k - 1].line + 1..items[k].line).any(|li| headline_level(&lines[li]).is_some())
261 {
262 stack.clear();
263 }
264 while stack
265 .last()
266 .is_some_and(|&top| items[top].indent >= items[k].indent)
267 {
268 stack.pop();
269 }
270 parent[k] = stack.last().copied();
271 stack.push(k);
272 }
273 let mut children: Vec<Vec<usize>> = vec![Vec::new(); items.len()];
274 for (k, p) in parent.iter().enumerate() {
275 if let Some(p) = *p {
276 children[p].push(k);
277 }
278 }
279 let mut order: Vec<usize> = (0..items.len()).collect();
281 order.sort_by_key(|&k| std::cmp::Reverse(items[k].indent));
282 for k in order {
283 if children[k].is_empty() {
284 continue;
285 }
286 let total = children[k].len();
287 let done = children[k]
288 .iter()
289 .filter(|&&c| matches!(items[c].mark, 'x' | 'X'))
290 .count();
291 let any_partial = children[k].iter().any(|&c| items[c].mark == '-');
292 let new_mark = if done == total {
293 'X'
294 } else if done == 0 && !any_partial {
295 ' '
296 } else {
297 '-'
298 };
299 items[k].mark = new_mark;
300 let li = items[k].line;
301 lines[li] = set_checkbox_mark(&lines[li], new_mark);
302 lines[li] = set_cookie(&lines[li], done, total);
303 }
304}
305
306fn update_headline_cookies(lines: &mut [String]) {
308 let levels: Vec<Option<usize>> = lines.iter().map(|l| headline_level(l)).collect();
309 for h in 0..lines.len() {
310 let Some(level) = levels[h] else { continue };
311 if !COOKIE.is_match(&lines[h]) {
312 continue;
313 }
314 let mut end = h + 1;
316 while end < lines.len() && levels[end].is_none_or(|l| l > level) {
317 end += 1;
318 }
319 let drawer: Vec<String> = lines[h + 1..end].to_vec();
320 let body_end = (h + 1..end).find(|&j| levels[j].is_some()).unwrap_or(end);
321 let has_checkboxes = (h + 1..body_end).any(|j| CHECKBOX.is_match(&lines[j]));
322 let (count_todo, recursive) = cookie_data(&drawer).unwrap_or((!has_checkboxes, false));
323 let (done, total) = if count_todo {
324 let mut d = 0;
325 let mut t = 0;
326 for j in h + 1..end {
327 let direct = levels[j] == Some(level + 1);
328 let counted = if recursive {
329 levels[j].is_some()
330 } else {
331 direct
332 };
333 if counted && let Some(is_done) = headline_todo(&lines[j]) {
334 t += 1;
335 if is_done {
336 d += 1;
337 }
338 }
339 }
340 (d, t)
341 } else {
342 let cbs: Vec<(usize, char)> = (h + 1..body_end)
344 .filter_map(|j| {
345 CHECKBOX
346 .captures(&lines[j])
347 .and_then(|c| c.get(2))
348 .map(|g| {
349 (
350 indent_of(&lines[j]),
351 g.as_str().chars().next().unwrap_or(' '),
352 )
353 })
354 })
355 .collect();
356 cbs.iter()
357 .map(|(i, _)| *i)
358 .min()
359 .map_or((0, 0), |min_indent| {
360 let top: Vec<char> = cbs
361 .iter()
362 .filter(|(i, _)| *i == min_indent)
363 .map(|(_, m)| *m)
364 .collect();
365 let d = top.iter().filter(|m| matches!(m, 'x' | 'X')).count();
366 (d, top.len())
367 })
368 };
369 lines[h] = set_cookie(&lines[h], done, total);
370 }
371}
372
373#[must_use]
376pub fn move_subtree_down(text: &str, line: usize) -> Option<(String, usize)> {
377 let lines: Vec<&str> = text.split('\n').collect();
378 let level = headline_level(lines.get(line)?)?;
379 let (start, end) = subtree_range(&lines, line)?;
380 if end >= lines.len() || headline_level(lines[end]) != Some(level) {
381 return None; }
383 let (_, sib_end) = subtree_range(&lines, end)?;
384 let mut out: Vec<&str> = Vec::with_capacity(lines.len());
385 out.extend_from_slice(&lines[..start]);
386 out.extend_from_slice(&lines[end..sib_end]); out.extend_from_slice(&lines[start..end]); out.extend_from_slice(&lines[sib_end..]);
389 let new_start = start + (sib_end - end);
390 Some((out.join("\n"), new_start))
391}
392
393#[must_use]
396pub fn move_subtree_up(text: &str, line: usize) -> Option<(String, usize)> {
397 let lines: Vec<&str> = text.split('\n').collect();
398 let level = headline_level(lines.get(line)?)?;
399 let (start, end) = subtree_range(&lines, line)?;
400 let mut prev = None;
403 for i in (0..start).rev() {
404 if let Some(l) = headline_level(lines[i]) {
405 if l < level {
406 break;
407 }
408 if l == level {
409 prev = Some(i);
410 break;
411 }
412 }
413 }
414 let prev = prev?;
415 let mut out: Vec<&str> = Vec::with_capacity(lines.len());
416 out.extend_from_slice(&lines[..prev]);
417 out.extend_from_slice(&lines[start..end]); out.extend_from_slice(&lines[prev..start]); out.extend_from_slice(&lines[end..]);
420 Some((out.join("\n"), prev))
421}
422
423fn first_date(s: &str) -> Option<String> {
427 let start = s.find(['<', '['])?;
428 let date: String = s[start + 1..].chars().take(10).collect();
429 let b = date.as_bytes();
430 if date.len() == 10 && b[4] == b'-' && b[7] == b'-' && b[..4].iter().all(u8::is_ascii_digit) {
431 Some(date)
432 } else {
433 None
434 }
435}
436
437#[must_use]
441pub fn agenda(files: &[(String, String)]) -> String {
442 let mut dated: Vec<(String, String, String, String)> = Vec::new(); let mut undated: Vec<(String, String)> = Vec::new(); for (name, content) in files {
445 let mut current = String::new();
446 for line in content.lines() {
447 if let Some(level) = headline_level(line) {
448 current = line[level..].trim().to_string();
449 if current.split_whitespace().next() == Some("TODO") {
450 undated.push((current.clone(), name.clone()));
451 }
452 continue;
453 }
454 let trimmed = line.trim();
455 for kind in ["DEADLINE", "SCHEDULED"] {
456 if let Some(rest) = trimmed.strip_prefix(&format!("{kind}:"))
457 && let Some(date) = first_date(rest)
458 {
459 dated.push((date, kind.to_string(), current.clone(), name.clone()));
460 }
461 }
462 }
463 }
464 dated.sort();
465 let mut out = String::from("#+title: Agenda\n");
466 let mut last = String::new();
467 for (date, kind, headline, file) in &dated {
468 if *date != last {
469 let _ = writeln!(out, "\n* {date}");
470 last.clone_from(date);
471 }
472 let _ = writeln!(out, "- {kind}: {headline} ({file})");
473 }
474 if !undated.is_empty() {
475 out.push_str("\n* Unscheduled tasks\n");
476 for (headline, file) in &undated {
477 let _ = writeln!(out, "- {headline} ({file})");
478 }
479 }
480 out
481}
482
483fn clock_minutes(line: &str) -> Option<u32> {
485 let rest = line.trim().strip_prefix("CLOCK:")?;
486 let after = &rest[rest.find("=>")? + 2..];
487 let (h, m) = after.trim().split_once(':')?;
488 Some(h.trim().parse::<u32>().ok()? * 60 + m.trim().parse::<u32>().ok()?)
489}
490
491fn hhmm(minutes: u32) -> String {
493 format!("{}:{:02}", minutes / 60, minutes % 60)
494}
495
496#[must_use]
500pub fn time_report(content: &str) -> String {
501 let mut current = String::from("(top level)");
502 let mut totals: Vec<(String, u32)> = Vec::new();
503 for line in content.lines() {
504 if let Some(level) = headline_level(line) {
505 current = line[level..].trim().to_string();
506 continue;
507 }
508 if let Some(min) = clock_minutes(line) {
509 if let Some(entry) = totals.iter_mut().find(|(h, _)| *h == current) {
510 entry.1 += min;
511 } else {
512 totals.push((current.clone(), min));
513 }
514 }
515 }
516 let mut out = String::from("| Headline | Time |\n|----------|------|\n");
517 let mut grand = 0;
518 for (headline, min) in &totals {
519 let _ = writeln!(out, "| {headline} | {} |", hhmm(*min));
520 grand += min;
521 }
522 let _ = writeln!(out, "| *Total* | {} |", hhmm(grand));
523 out
524}
525
526#[must_use]
530pub fn clock_in(now: &str) -> String {
531 format!("CLOCK: [{now}]")
532}
533
534fn is_open_clock(line: &str) -> bool {
536 let t = line.trim();
537 t.starts_with("CLOCK:") && t.contains('[') && t.ends_with(']') && !t.contains("--")
538}
539
540fn clock_start(line: &str) -> Option<String> {
542 let inner = line
543 .trim()
544 .strip_prefix("CLOCK:")?
545 .trim()
546 .strip_prefix('[')?;
547 Some(inner[..inner.find(']')?].to_string())
548}
549
550fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
552 let y = if m <= 2 { y - 1 } else { y };
553 let era = (if y >= 0 { y } else { y - 399 }) / 400;
554 let yoe = y - era * 400;
555 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
556 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
557 era * 146_097 + doe - 719_468
558}
559
560fn timestamp_minutes(ts: &str) -> Option<i64> {
562 let date = ts.get(0..10)?;
563 let mut dp = date.split('-');
564 let y: i64 = dp.next()?.parse().ok()?;
565 let m: i64 = dp.next()?.parse().ok()?;
566 let d: i64 = dp.next()?.parse().ok()?;
567 let (h, mi) = ts.rsplit(' ').next()?.split_once(':')?;
568 let h: i64 = h.trim().parse().ok()?;
569 let mi: i64 = mi.trim().parse().ok()?;
570 Some(days_from_civil(y, m, d) * 1440 + h * 60 + mi)
571}
572
573#[must_use]
577pub fn clock_out(text: &str, now: &str) -> Option<String> {
578 let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
579 let idx = lines.iter().rposition(|l| is_open_clock(l))?;
580 let start = clock_start(&lines[idx])?;
581 let minutes = match (timestamp_minutes(now), timestamp_minutes(&start)) {
582 (Some(n), Some(s)) => u32::try_from((n - s).max(0)).unwrap_or(0),
583 _ => 0,
584 };
585 let lead: String = lines[idx]
586 .chars()
587 .take_while(|c| c.is_whitespace())
588 .collect();
589 lines[idx] = format!("{lead}CLOCK: [{start}]--[{now}] => {}", hhmm(minutes));
590 Some(lines.join("\n"))
591}
592
593static LINK: LazyLock<Regex> =
596 LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\[([^\]]+)\]\]").expect("link regex"));
597static BARE_LINK: LazyLock<Regex> =
598 LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").expect("bare link regex"));
599
600fn emph(input: &str, marker: char, open: &str, close: &str) -> String {
603 let m = regex::escape(&marker.to_string());
604 let re = Regex::new(&format!(r"{m}([^{m}\s][^{m}]*?){m}")).expect("emph regex");
605 re.replace_all(input, format!("{open}$1{close}"))
606 .into_owned()
607}
608
609fn inline_md(s: &str) -> String {
611 let s = LINK.replace_all(s, "[$2]($1)").into_owned();
612 let s = BARE_LINK.replace_all(&s, "<$1>").into_owned();
613 let s = emph(&s, '*', "**", "**");
614 let s = emph(&s, '/', "*", "*");
615 let s = emph(&s, '~', "`", "`");
616 let s = emph(&s, '=', "`", "`");
617 emph(&s, '+', "~~", "~~")
618}
619
620#[must_use]
622pub fn to_markdown(text: &str) -> String {
623 let mut out: Vec<String> = Vec::new();
624 for raw in text.split('\n') {
625 let line = raw.trim_end();
626 if let Some(rest) = line
627 .strip_prefix("#+title:")
628 .or_else(|| line.strip_prefix("#+TITLE:"))
629 {
630 out.push(format!("# {}", rest.trim()));
631 } else if let Some(rest) = line
632 .strip_prefix("#+author:")
633 .or_else(|| line.strip_prefix("#+AUTHOR:"))
634 {
635 out.push(format!("*{}*", rest.trim()));
636 } else if line.starts_with("#+BEGIN_")
637 || line.starts_with("#+END_")
638 || line.starts_with("#+begin_")
639 || line.starts_with("#+end_")
640 {
641 } else if let Some(level) = headline_level(line) {
643 let rest = line[level..].trim_start();
644 out.push(format!("{} {}", "#".repeat(level), inline_md(rest)));
645 } else {
646 out.push(inline_md(line));
647 }
648 }
649 out.join("\n")
650}
651
652fn escape_html(s: &str) -> String {
654 s.replace('&', "&")
655 .replace('<', "<")
656 .replace('>', ">")
657 .replace('"', """)
658 .replace('\'', "'")
659}
660
661fn safe_href(url: &str) -> String {
667 let lower = url.trim_start().to_ascii_lowercase();
668 let ok = lower.starts_with("http://")
669 || lower.starts_with("https://")
670 || lower.starts_with("mailto:")
671 || lower.starts_with("file:")
672 || lower.starts_with('#')
673 || lower.starts_with('/')
674 || !lower.contains(':'); if ok { url.to_string() } else { "#".to_string() }
676}
677
678fn inline_html(s: &str) -> String {
680 use regex::Captures;
681 let s = escape_html(s);
682 let s = LINK
685 .replace_all(&s, |c: &Captures| {
686 format!("<a href=\"{}\">{}</a>", safe_href(&c[1]), &c[2])
687 })
688 .into_owned();
689 let s = BARE_LINK
690 .replace_all(&s, |c: &Captures| {
691 format!("<a href=\"{}\">{}</a>", safe_href(&c[1]), &c[1])
692 })
693 .into_owned();
694 let s = emph(&s, '*', "<b>", "</b>");
695 let s = emph(&s, '/', "<i>", "</i>");
696 let s = emph(&s, '_', "<u>", "</u>");
697 let s = emph(&s, '~', "<code>", "</code>");
698 let s = emph(&s, '=', "<code>", "</code>");
699 emph(&s, '+', "<del>", "</del>")
700}
701
702#[must_use]
705pub fn to_html(text: &str) -> String {
706 let mut body: Vec<String> = Vec::new();
707 let mut in_list = false;
708 let mut title = "Org";
709 let close_list = |body: &mut Vec<String>, in_list: &mut bool| {
710 if *in_list {
711 body.push("</ul>".to_string());
712 *in_list = false;
713 }
714 };
715 for raw in text.split('\n') {
716 let line = raw.trim_end();
717 if let Some(rest) = line
718 .strip_prefix("#+title:")
719 .or_else(|| line.strip_prefix("#+TITLE:"))
720 {
721 title = rest.trim();
722 close_list(&mut body, &mut in_list);
723 body.push(format!("<h1>{}</h1>", inline_html(rest.trim())));
724 } else if line.starts_with("#+") {
725 close_list(&mut body, &mut in_list); } else if let Some(level) = headline_level(line) {
727 close_list(&mut body, &mut in_list);
728 let tag = level.min(6);
729 body.push(format!(
730 "<h{tag}>{}</h{tag}>",
731 inline_html(line[level..].trim_start())
732 ));
733 } else if let Some(item) = line
734 .trim_start()
735 .strip_prefix("- ")
736 .or_else(|| line.trim_start().strip_prefix("+ "))
737 {
738 if !in_list {
739 body.push("<ul>".to_string());
740 in_list = true;
741 }
742 body.push(format!("<li>{}</li>", inline_html(item)));
743 } else if line.trim().is_empty() {
744 close_list(&mut body, &mut in_list);
745 } else {
746 close_list(&mut body, &mut in_list);
747 body.push(format!("<p>{}</p>", inline_html(line)));
748 }
749 }
750 close_list(&mut body, &mut in_list);
751 format!(
752 "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n</head>\n<body>\n{}\n</body>\n</html>\n",
753 escape_html(title),
754 body.join("\n")
755 )
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn html_export_neutralizes_dangerous_link_schemes() {
764 let danger = [
765 "[[javascript:alert(1)][x]]",
766 "[[JavaScript:alert(document.cookie)][x]]",
767 "[[ javascript:alert(1)][x]]",
768 "[[data:text/html,<script>1</script>][x]]",
769 "[[vbscript:msgbox][x]]",
770 "[[javascript:alert(1)]]", ];
772 for org in danger {
773 let html = to_html(org);
774 let lower = html.to_ascii_lowercase();
775 assert!(!lower.contains("href=\"javascript"), "leaked scheme: {html}");
776 assert!(!lower.contains("href=\"data:"), "leaked data: {html}");
777 assert!(!lower.contains("href=\"vbscript"), "leaked vbscript: {html}");
778 }
779 assert!(to_html("[[mailto:a@b.test][mail]]").contains("href=\"mailto:a@b.test\""));
782 assert!(to_html("[[#section][jump]]").contains("href=\"#section\""));
783 assert_eq!(safe_href("https://x.test"), "https://x.test");
785 assert_eq!(safe_href("javascript:alert(1)"), "#");
786 }
787
788 proptest::proptest! {
789 #[test]
792 fn to_html_never_emits_active_script_hrefs(s in ".*") {
793 let html = to_html(&s).to_ascii_lowercase();
794 proptest::prop_assert!(!html.contains("href=\"javascript"), "{html}");
795 proptest::prop_assert!(!html.contains("href=\"data:"), "{html}");
796 proptest::prop_assert!(!html.contains("href=\"vbscript"), "{html}");
797 }
798
799 #[test]
801 fn safe_href_neutralizes_non_allowlisted_schemes(scheme in "[a-zA-Z]{2,12}", rest in ".*") {
802 let url = format!("{scheme}:{rest}");
803 let out = safe_href(&url);
804 let lower = scheme.to_ascii_lowercase();
805 let allowed = matches!(lower.as_str(), "http" | "https" | "mailto" | "file");
806 if !allowed {
807 proptest::prop_assert_eq!(out, "#".to_string(), "unallowed scheme leaked: {}", url);
808 }
809 }
810 }
811
812 #[test]
813 fn detects_headline_levels() {
814 assert_eq!(headline_level("* A"), Some(1));
815 assert_eq!(headline_level("*** C"), Some(3));
816 assert_eq!(headline_level("*bold*"), None);
817 assert_eq!(headline_level("not a headline"), None);
818 }
819
820 #[test]
821 fn promote_and_demote_the_whole_subtree() {
822 let text = "* A\n** B\nbody\n* C";
823 let demoted = demote(text, 0).unwrap();
824 assert_eq!(demoted, "** A\n*** B\nbody\n* C");
825 assert_eq!(promote(text, 0), None);
827 assert_eq!(
829 promote("* A\n** B\nbody\n* C", 1).unwrap(),
830 "* A\n* B\nbody\n* C"
831 );
832 }
833
834 #[test]
835 fn cycles_todo_keyword() {
836 let t = "* Task";
837 let t = cycle_todo(t, 0).unwrap();
838 assert_eq!(t, "* TODO Task");
839 let t = cycle_todo(&t, 0).unwrap();
840 assert_eq!(t, "* DONE Task");
841 let t = cycle_todo(&t, 0).unwrap();
842 assert_eq!(t, "* Task");
843 }
844
845 #[test]
846 fn toggles_checkboxes() {
847 assert_eq!(toggle_checkbox("- [ ] a", 0).unwrap(), "- [x] a");
848 assert_eq!(toggle_checkbox("- [x] a", 0).unwrap(), "- [ ] a");
849 assert_eq!(toggle_checkbox("- [-] a", 0).unwrap(), "- [ ] a");
850 assert_eq!(toggle_checkbox("plain", 0), None);
851 }
852
853 #[test]
854 fn propagates_parent_checkbox_state() {
855 let none = "- [ ] call people\n - [ ] Peter\n - [ ] Sarah";
857 assert_eq!(
858 update_statistics(none),
859 "- [ ] call people\n - [ ] Peter\n - [ ] Sarah"
860 );
861 let some = "- [ ] call people\n - [X] Peter\n - [ ] Sarah";
863 assert_eq!(
864 update_statistics(some),
865 "- [-] call people\n - [X] Peter\n - [ ] Sarah"
866 );
867 let all = "- [ ] call people\n - [X] Peter\n - [X] Sarah";
869 assert_eq!(
870 update_statistics(all),
871 "- [X] call people\n - [X] Peter\n - [X] Sarah"
872 );
873 }
874
875 #[test]
876 fn updates_list_item_fraction_cookie() {
877 let t = "- [ ] tasks [/]\n - [X] a\n - [ ] b\n - [X] c";
878 let out = update_statistics(t);
879 assert!(out.starts_with("- [-] tasks [2/3]"), "{out}");
880 }
881
882 #[test]
883 fn updates_headline_cookies_for_todo_children() {
884 let t = "* Organize Party [%]\n** TODO Call people [/]\n*** TODO Peter\n*** DONE Sarah\n** TODO Buy food\n** DONE Talk to neighbor";
886 let out = update_statistics(t);
887 assert!(out.contains("* Organize Party [33%]"), "{out}");
888 assert!(out.contains("** TODO Call people [1/2]"), "{out}");
889 }
890
891 #[test]
892 fn cookie_data_todo_recursive_counts_whole_subtree() {
893 let t = "* Parent [/]\n:PROPERTIES:\n:COOKIE_DATA: todo recursive\n:END:\n** TODO a\n*** DONE b\n** DONE c";
894 let out = update_statistics(t);
895 assert!(out.contains("* Parent [2/3]"), "{out}");
897 }
898
899 #[test]
900 fn moves_subtrees_among_siblings() {
901 let text = "* A\nbody a\n* B\nbody b";
902 let (down, line) = move_subtree_down(text, 0).unwrap();
903 assert_eq!(down, "* B\nbody b\n* A\nbody a");
904 assert_eq!(line, 2);
905 let (up, line) = move_subtree_up(&down, 2).unwrap();
906 assert_eq!(up, text);
907 assert_eq!(line, 0);
908 assert!(move_subtree_down(text, 2).is_none());
910 }
911
912 #[test]
913 fn exports_markdown() {
914 let org = "#+title: Hi\n* Head\n/italic/ and *bold* and [[u][d]]";
915 let md = to_markdown(org);
916 assert!(md.contains("# Hi"));
917 assert!(md.contains("# Head"));
918 assert!(md.contains("*italic*"));
919 assert!(md.contains("**bold**"));
920 assert!(md.contains("[d](u)"));
921 }
922
923 #[test]
924 fn agenda_groups_by_date_and_lists_undated_todos() {
925 let files = vec![
926 (
927 "work.org".to_string(),
928 "* TODO Ship it\nDEADLINE: <2024-08-23 Fri>\n* TODO Loose end\n".to_string(),
929 ),
930 (
931 "home.org".to_string(),
932 "* Meeting\nSCHEDULED: <2024-08-20 Tue>\n".to_string(),
933 ),
934 ];
935 let a = agenda(&files);
936 assert!(a.contains("* 2024-08-20"));
937 assert!(a.contains("- SCHEDULED: Meeting (home.org)"));
938 assert!(a.contains("* 2024-08-23"));
939 assert!(a.contains("- DEADLINE: TODO Ship it (work.org)"));
940 assert!(a.contains("* Unscheduled tasks"));
941 assert!(a.contains("- TODO Loose end (work.org)"));
942 assert!(a.find("2024-08-20").unwrap() < a.find("2024-08-23").unwrap());
944 }
945
946 #[test]
947 fn clock_in_and_out_record_a_duration() {
948 let now_in = "2024-08-23 Fri 10:00";
949 assert_eq!(clock_in(now_in), "CLOCK: [2024-08-23 Fri 10:00]");
950 let text = format!("* Task\n {}\n", clock_in(now_in));
951 let out = clock_out(&text, "2024-08-23 Fri 11:30").unwrap();
952 assert!(out.contains("CLOCK: [2024-08-23 Fri 10:00]--[2024-08-23 Fri 11:30] => 1:30"));
953 assert!(out.contains("\n CLOCK:"));
955 assert!(clock_out(&out, "2024-08-23 Fri 12:00").is_none());
957 }
958
959 #[test]
960 fn clock_out_spans_midnight() {
961 let text = "CLOCK: [2024-08-23 Fri 23:30]";
962 let out = clock_out(text, "2024-08-24 Sat 00:15").unwrap();
963 assert!(out.ends_with("=> 0:45"), "{out}");
964 }
965
966 #[test]
967 fn time_report_sums_clock_durations_per_headline() {
968 let org = "* Task A\nCLOCK: [..]--[..] => 1:30\nCLOCK: [..]--[..] => 0:45\n* Task B\nCLOCK: [..]--[..] => 2:00\n";
969 let r = time_report(org);
970 assert!(r.contains("| Task A | 2:15 |"));
971 assert!(r.contains("| Task B | 2:00 |"));
972 assert!(r.contains("| *Total* | 4:15 |"));
973 }
974
975 #[test]
976 fn exports_html() {
977 let org = "#+title: Hi\n* Head\n- one\n- two\npara";
978 let html = to_html(org);
979 assert!(html.contains("<title>Hi</title>"));
980 assert!(html.contains("<h1>Hi</h1>"));
981 assert!(html.contains("<h1>Head</h1>") || html.contains("<h1>Head</h1>"));
982 assert!(html.contains("<ul>"));
983 assert!(html.contains("<li>one</li>"));
984 assert!(html.contains("<p>para</p>"));
985 }
986}