Skip to main content

vix_org/
lib.rs

1//! Basic Org-mode operations: headline structure editing and lightweight export.
2//!
3//! This is a pragmatic subset of Org (<https://orgmode.org/>), not a complete
4//! implementation. The structural helpers operate on the whole buffer text plus
5//! a 0-based cursor line and return the rewritten text (and, where the cursor
6//! should follow a moved subtree, its new line). The exporters turn Org markup
7//! into Markdown or a small standalone HTML document.
8//!
9//! All functions are pure so they can be unit-tested without a live editor.
10
11#![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
20/// The TODO keywords Org cycles through (besides the empty state).
21const TODO: &str = "TODO";
22const DONE: &str = "DONE";
23
24/// The number of leading `*` of a headline (followed by a space), or `None` for
25/// a non-headline line.
26#[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/// The `[start, end)` line range of the subtree rooted at `line` — the headline
37/// plus every following line until the next headline of the same or higher level
38/// (a smaller or equal star count). `None` if `line` is not a headline.
39#[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/// Promote (shallower, fewer stars) every headline in the subtree at `line`.
53/// No-op returning `None` if not on a headline or any headline is already level 1.
54#[must_use]
55pub fn promote(text: &str, line: usize) -> Option<String> {
56    reindent_subtree(text, line, false)
57}
58
59/// Demote (deeper, more stars) every headline in the subtree at `line`.
60#[must_use]
61pub fn demote(text: &str, line: usize) -> Option<String> {
62    reindent_subtree(text, line, true)
63}
64
65/// Shared promote/demote: add or remove one leading `*` on each headline in the
66/// subtree. Promoting a level-1 headline is refused (returns `None`).
67fn 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/// Cycle the TODO state of the headline at `line`: none → `TODO` → `DONE` → none.
91/// `None` if `line` is not a headline.
92#[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); // include the space
98    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/// Toggle a list checkbox on the line at `line`: `[ ]` ⇄ `[x]` (treating `[-]`
118/// and `[X]` as checked). `None` if the line has no checkbox.
119#[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..]; // skip "[m]"
128    lines[line] = format!("{}[{new_mark}]{rest}", &target[..lead_end]);
129    Some(lines.join("\n"))
130}
131
132// ----- Statistics cookies & checkbox propagation ----------------------------
133
134/// A statistics cookie: `[/]`/`[n/m]` (fraction) or `[%]`/`[n%]` (percent).
135static COOKIE: LazyLock<Regex> =
136    LazyLock::new(|| Regex::new(r"\[\d*/\d*\]|\[\d*%\]").expect("cookie regex"));
137
138/// The indentation (leading-whitespace width) of a line.
139fn indent_of(line: &str) -> usize {
140    line.len() - line.trim_start().len()
141}
142
143/// Rewrite the first statistics cookie in `line` for `done`/`total`, preserving
144/// its kind (`[n/m]` vs `[pct%]`). Percent truncates toward zero (Org's form);
145/// `total == 0` yields `0%` or `0/0`. Lines without a cookie are returned as-is.
146fn 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
159/// Replace a checkbox's mark character on a line that already has one.
160fn 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
168/// The TODO state of a headline: `Some(true)` if it carries the DONE keyword,
169/// `Some(false)` for TODO, `None` if it has no TODO keyword (or isn't a headline).
170fn 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
182/// Parse a `:COOKIE_DATA:` property from a headline's drawer lines into
183/// `(count_todo, recursive)`. `None` when the property is absent (caller infers).
184fn 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/// Recompute every checkbox parent state and every statistics cookie in `text`,
208/// matching Org's behavior:
209///
210/// * A checkbox list item with sub-items is set from its **direct** children —
211///   all checked → `[X]`, none → `[ ]`, otherwise → `[-]`.
212/// * A `[/]`/`[%]` cookie in a list item counts that item's direct child
213///   checkboxes.
214/// * A cookie in a headline counts either child checkboxes or child TODO
215///   headlines. The `:COOKIE_DATA:` property (`checkbox`/`todo`, plus
216///   `recursive`) resolves the ambiguity; absent it, a body with top-level
217///   checkboxes counts checkboxes, otherwise direct child TODO headlines.
218///
219/// Pure: returns the rewritten buffer (line count unchanged).
220#[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
228/// One checkbox list item: its line, indent, and current mark.
229struct Checkbox {
230    line: usize,
231    indent: usize,
232    mark: char,
233}
234
235/// Propagate checkbox parent states and list-item cookies (first pass).
236fn 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    // Parent of each item = nearest preceding item with smaller indent, with the
255    // nesting stack reset whenever a headline separates two items.
256    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    // Process deepest items first so a parent sees its children's final marks.
280    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
306/// Update statistics cookies that live in headlines (second pass).
307fn 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        // The subtree runs until the next headline of the same or higher level.
315        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            // Top-level checkboxes in the body (the shallowest indent).
343            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/// Move the subtree at `line` down past its next sibling, returning the new text
374/// and the subtree's new starting line. `None` if there is no following sibling.
375#[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; // no sibling of the same level follows
382    }
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]); // sibling first
387    out.extend_from_slice(&lines[start..end]); // then this subtree
388    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/// Move the subtree at `line` up past its previous sibling, returning the new
394/// text and the subtree's new starting line. `None` if there is no prior sibling.
395#[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    // Find the previous sibling's start: scan back to a headline of the same
401    // level, bailing if a higher-level (parent) headline appears first.
402    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]); // this subtree first
418    out.extend_from_slice(&lines[prev..start]); // then the previous sibling
419    out.extend_from_slice(&lines[end..]);
420    Some((out.join("\n"), prev))
421}
422
423// ----- Agenda & time tracking -----------------------------------------------
424
425/// Extract the `YYYY-MM-DD` date from the first `<…>`/`[…]` timestamp in `s`.
426fn 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/// Compile an **agenda** from `(filename, content)` Org documents: `DEADLINE:` and
438/// `SCHEDULED:` planning lines grouped by date, plus `TODO` headlines that have no
439/// date. Returns an Org document (open it in a buffer). Pure and testable.
440#[must_use]
441pub fn agenda(files: &[(String, String)]) -> String {
442    let mut dated: Vec<(String, String, String, String)> = Vec::new(); // date, kind, headline, file
443    let mut undated: Vec<(String, String)> = Vec::new(); // headline, file
444    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
483/// Minutes in a `CLOCK:` line's explicit `=> H:MM` total, if present.
484fn 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
491/// Render `minutes` as `H:MM`.
492fn hhmm(minutes: u32) -> String {
493    format!("{}:{:02}", minutes / 60, minutes % 60)
494}
495
496/// Build a **time-tracking report** from `content`: sum each headline's `CLOCK:`
497/// durations (the `=> H:MM` totals Org writes) into a table with a grand total.
498/// Pure and testable.
499#[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// ----- Clocking -------------------------------------------------------------
527
528/// An Org clock-in line for the timestamp `now` (e.g. `2024-08-23 Fri 10:00`).
529#[must_use]
530pub fn clock_in(now: &str) -> String {
531    format!("CLOCK: [{now}]")
532}
533
534/// Whether `line` is an *open* clock entry (`CLOCK: [..]` with no end yet).
535fn 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
540/// The start timestamp inside a `CLOCK: [start]` line.
541fn 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
550/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
551fn 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
560/// Total minutes for an Org timestamp `YYYY-MM-DD … HH:MM` (date + trailing time).
561fn 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/// Close the most recent open `CLOCK:` entry in `text` with end timestamp `now`,
574/// appending the `=> H:MM` duration. Returns the rewritten text, or `None` if
575/// there is no open clock entry.
576#[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
593// ----- Export ---------------------------------------------------------------
594
595static 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
600/// Apply an inline-emphasis substitution for a single marker char, mapping
601/// `<m>text<m>` to `open`…`close`. Word-ish: the marker hugs the text.
602fn 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
609/// Convert Org inline markup (links and emphasis) to Markdown.
610fn 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/// Convert Org text to Markdown (a pragmatic, line-oriented mapping).
621#[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            // Drop block delimiters; their inner lines pass through as-is.
642        } 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
652/// HTML-escape the five significant characters.
653fn escape_html(s: &str) -> String {
654    s.replace('&', "&amp;")
655        .replace('<', "&lt;")
656        .replace('>', "&gt;")
657        .replace('"', "&quot;")
658        .replace('\'', "&#39;")
659}
660
661/// Convert Org inline markup to HTML (escaping text first).
662fn inline_html(s: &str) -> String {
663    let s = escape_html(s);
664    // Links: the regex ran on escaped text, so brackets are intact.
665    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/// Convert Org text to a small standalone HTML document (a pragmatic subset:
678/// headlines, paragraphs, and bullet lists).
679#[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); // ignore other keywords/blocks
701        } 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        // Promote refuses when a level-1 headline is in the subtree.
751        assert_eq!(promote(text, 0), None);
752        // But a level-2 subtree promotes fine.
753        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        // None checked → parent empty.
781        let none = "- [ ] call people\n  - [ ] Peter\n  - [ ] Sarah";
782        assert_eq!(
783            update_statistics(none),
784            "- [ ] call people\n  - [ ] Peter\n  - [ ] Sarah"
785        );
786        // Some checked → parent partial.
787        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        // All checked → parent checked.
793        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        // The manual's example: percent on the parent, fraction on the child.
810        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        // Three TODO entries in the subtree (a, b, c); two are DONE.
821        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        // No sibling below the last subtree.
834        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        // Dates are sorted ascending: 08-20 before 08-23.
868        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        // Indentation of the original clock line is preserved.
879        assert!(out.contains("\n  CLOCK:"));
880        // No open clock → None.
881        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}