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 inline_html(s: &str) -> String {
663 let s = escape_html(s);
664 let s = LINK.replace_all(&s, "<a href=\"$1\">$2</a>").into_owned();
666 let s = BARE_LINK
667 .replace_all(&s, "<a href=\"$1\">$1</a>")
668 .into_owned();
669 let s = emph(&s, '*', "<b>", "</b>");
670 let s = emph(&s, '/', "<i>", "</i>");
671 let s = emph(&s, '_', "<u>", "</u>");
672 let s = emph(&s, '~', "<code>", "</code>");
673 let s = emph(&s, '=', "<code>", "</code>");
674 emph(&s, '+', "<del>", "</del>")
675}
676
677#[must_use]
680pub fn to_html(text: &str) -> String {
681 let mut body: Vec<String> = Vec::new();
682 let mut in_list = false;
683 let mut title = "Org";
684 let close_list = |body: &mut Vec<String>, in_list: &mut bool| {
685 if *in_list {
686 body.push("</ul>".to_string());
687 *in_list = false;
688 }
689 };
690 for raw in text.split('\n') {
691 let line = raw.trim_end();
692 if let Some(rest) = line
693 .strip_prefix("#+title:")
694 .or_else(|| line.strip_prefix("#+TITLE:"))
695 {
696 title = rest.trim();
697 close_list(&mut body, &mut in_list);
698 body.push(format!("<h1>{}</h1>", inline_html(rest.trim())));
699 } else if line.starts_with("#+") {
700 close_list(&mut body, &mut in_list); } else if let Some(level) = headline_level(line) {
702 close_list(&mut body, &mut in_list);
703 let tag = level.min(6);
704 body.push(format!(
705 "<h{tag}>{}</h{tag}>",
706 inline_html(line[level..].trim_start())
707 ));
708 } else if let Some(item) = line
709 .trim_start()
710 .strip_prefix("- ")
711 .or_else(|| line.trim_start().strip_prefix("+ "))
712 {
713 if !in_list {
714 body.push("<ul>".to_string());
715 in_list = true;
716 }
717 body.push(format!("<li>{}</li>", inline_html(item)));
718 } else if line.trim().is_empty() {
719 close_list(&mut body, &mut in_list);
720 } else {
721 close_list(&mut body, &mut in_list);
722 body.push(format!("<p>{}</p>", inline_html(line)));
723 }
724 }
725 close_list(&mut body, &mut in_list);
726 format!(
727 "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n</head>\n<body>\n{}\n</body>\n</html>\n",
728 escape_html(title),
729 body.join("\n")
730 )
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn detects_headline_levels() {
739 assert_eq!(headline_level("* A"), Some(1));
740 assert_eq!(headline_level("*** C"), Some(3));
741 assert_eq!(headline_level("*bold*"), None);
742 assert_eq!(headline_level("not a headline"), None);
743 }
744
745 #[test]
746 fn promote_and_demote_the_whole_subtree() {
747 let text = "* A\n** B\nbody\n* C";
748 let demoted = demote(text, 0).unwrap();
749 assert_eq!(demoted, "** A\n*** B\nbody\n* C");
750 assert_eq!(promote(text, 0), None);
752 assert_eq!(
754 promote("* A\n** B\nbody\n* C", 1).unwrap(),
755 "* A\n* B\nbody\n* C"
756 );
757 }
758
759 #[test]
760 fn cycles_todo_keyword() {
761 let t = "* Task";
762 let t = cycle_todo(t, 0).unwrap();
763 assert_eq!(t, "* TODO Task");
764 let t = cycle_todo(&t, 0).unwrap();
765 assert_eq!(t, "* DONE Task");
766 let t = cycle_todo(&t, 0).unwrap();
767 assert_eq!(t, "* Task");
768 }
769
770 #[test]
771 fn toggles_checkboxes() {
772 assert_eq!(toggle_checkbox("- [ ] a", 0).unwrap(), "- [x] a");
773 assert_eq!(toggle_checkbox("- [x] a", 0).unwrap(), "- [ ] a");
774 assert_eq!(toggle_checkbox("- [-] a", 0).unwrap(), "- [ ] a");
775 assert_eq!(toggle_checkbox("plain", 0), None);
776 }
777
778 #[test]
779 fn propagates_parent_checkbox_state() {
780 let none = "- [ ] call people\n - [ ] Peter\n - [ ] Sarah";
782 assert_eq!(
783 update_statistics(none),
784 "- [ ] call people\n - [ ] Peter\n - [ ] Sarah"
785 );
786 let some = "- [ ] call people\n - [X] Peter\n - [ ] Sarah";
788 assert_eq!(
789 update_statistics(some),
790 "- [-] call people\n - [X] Peter\n - [ ] Sarah"
791 );
792 let all = "- [ ] call people\n - [X] Peter\n - [X] Sarah";
794 assert_eq!(
795 update_statistics(all),
796 "- [X] call people\n - [X] Peter\n - [X] Sarah"
797 );
798 }
799
800 #[test]
801 fn updates_list_item_fraction_cookie() {
802 let t = "- [ ] tasks [/]\n - [X] a\n - [ ] b\n - [X] c";
803 let out = update_statistics(t);
804 assert!(out.starts_with("- [-] tasks [2/3]"), "{out}");
805 }
806
807 #[test]
808 fn updates_headline_cookies_for_todo_children() {
809 let t = "* Organize Party [%]\n** TODO Call people [/]\n*** TODO Peter\n*** DONE Sarah\n** TODO Buy food\n** DONE Talk to neighbor";
811 let out = update_statistics(t);
812 assert!(out.contains("* Organize Party [33%]"), "{out}");
813 assert!(out.contains("** TODO Call people [1/2]"), "{out}");
814 }
815
816 #[test]
817 fn cookie_data_todo_recursive_counts_whole_subtree() {
818 let t = "* Parent [/]\n:PROPERTIES:\n:COOKIE_DATA: todo recursive\n:END:\n** TODO a\n*** DONE b\n** DONE c";
819 let out = update_statistics(t);
820 assert!(out.contains("* Parent [2/3]"), "{out}");
822 }
823
824 #[test]
825 fn moves_subtrees_among_siblings() {
826 let text = "* A\nbody a\n* B\nbody b";
827 let (down, line) = move_subtree_down(text, 0).unwrap();
828 assert_eq!(down, "* B\nbody b\n* A\nbody a");
829 assert_eq!(line, 2);
830 let (up, line) = move_subtree_up(&down, 2).unwrap();
831 assert_eq!(up, text);
832 assert_eq!(line, 0);
833 assert!(move_subtree_down(text, 2).is_none());
835 }
836
837 #[test]
838 fn exports_markdown() {
839 let org = "#+title: Hi\n* Head\n/italic/ and *bold* and [[u][d]]";
840 let md = to_markdown(org);
841 assert!(md.contains("# Hi"));
842 assert!(md.contains("# Head"));
843 assert!(md.contains("*italic*"));
844 assert!(md.contains("**bold**"));
845 assert!(md.contains("[d](u)"));
846 }
847
848 #[test]
849 fn agenda_groups_by_date_and_lists_undated_todos() {
850 let files = vec![
851 (
852 "work.org".to_string(),
853 "* TODO Ship it\nDEADLINE: <2024-08-23 Fri>\n* TODO Loose end\n".to_string(),
854 ),
855 (
856 "home.org".to_string(),
857 "* Meeting\nSCHEDULED: <2024-08-20 Tue>\n".to_string(),
858 ),
859 ];
860 let a = agenda(&files);
861 assert!(a.contains("* 2024-08-20"));
862 assert!(a.contains("- SCHEDULED: Meeting (home.org)"));
863 assert!(a.contains("* 2024-08-23"));
864 assert!(a.contains("- DEADLINE: TODO Ship it (work.org)"));
865 assert!(a.contains("* Unscheduled tasks"));
866 assert!(a.contains("- TODO Loose end (work.org)"));
867 assert!(a.find("2024-08-20").unwrap() < a.find("2024-08-23").unwrap());
869 }
870
871 #[test]
872 fn clock_in_and_out_record_a_duration() {
873 let now_in = "2024-08-23 Fri 10:00";
874 assert_eq!(clock_in(now_in), "CLOCK: [2024-08-23 Fri 10:00]");
875 let text = format!("* Task\n {}\n", clock_in(now_in));
876 let out = clock_out(&text, "2024-08-23 Fri 11:30").unwrap();
877 assert!(out.contains("CLOCK: [2024-08-23 Fri 10:00]--[2024-08-23 Fri 11:30] => 1:30"));
878 assert!(out.contains("\n CLOCK:"));
880 assert!(clock_out(&out, "2024-08-23 Fri 12:00").is_none());
882 }
883
884 #[test]
885 fn clock_out_spans_midnight() {
886 let text = "CLOCK: [2024-08-23 Fri 23:30]";
887 let out = clock_out(text, "2024-08-24 Sat 00:15").unwrap();
888 assert!(out.ends_with("=> 0:45"), "{out}");
889 }
890
891 #[test]
892 fn time_report_sums_clock_durations_per_headline() {
893 let org = "* Task A\nCLOCK: [..]--[..] => 1:30\nCLOCK: [..]--[..] => 0:45\n* Task B\nCLOCK: [..]--[..] => 2:00\n";
894 let r = time_report(org);
895 assert!(r.contains("| Task A | 2:15 |"));
896 assert!(r.contains("| Task B | 2:00 |"));
897 assert!(r.contains("| *Total* | 4:15 |"));
898 }
899
900 #[test]
901 fn exports_html() {
902 let org = "#+title: Hi\n* Head\n- one\n- two\npara";
903 let html = to_html(org);
904 assert!(html.contains("<title>Hi</title>"));
905 assert!(html.contains("<h1>Hi</h1>"));
906 assert!(html.contains("<h1>Head</h1>") || html.contains("<h1>Head</h1>"));
907 assert!(html.contains("<ul>"));
908 assert!(html.contains("<li>one</li>"));
909 assert!(html.contains("<p>para</p>"));
910 }
911}