Skip to main content

markdown_org_extract/
clock.rs

1//! Org-mode `CLOCK:` entries — extraction and duration arithmetic.
2//!
3//! A task may carry any number of clock lines recording time spent on it.
4//! This module finds them, sums their durations and formats the total the way
5//! org-mode does (`HH:MM`).
6
7use regex::Regex;
8use std::sync::LazyLock;
9
10use crate::regex_limits::{compile_bounded, CLOCK_BODY_MAX};
11use crate::types::ClockEntry;
12
13/// Regex for CLOCK entries: `CLOCK: [timestamp]--[timestamp] => duration`
14///
15/// Supports both square brackets (org-mode inactive timestamps) and angle
16/// brackets (active timestamps), but the opening and closing bracket of each
17/// timestamp must match — `[…>` or `<…]` are rejected as malformed. Inner
18/// bodies capped at `CLOCK_BODY_MAX` chars to bound the work done on
19/// malformed input.
20static CLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
21    compile_bounded(&format!(
22        r"CLOCK:\s*(?:\[([^\]<>]{{1,{CLOCK_BODY_MAX}}})\]|<([^\]<>]{{1,{CLOCK_BODY_MAX}}})>)(?:--(?:\[([^\]<>]{{1,{CLOCK_BODY_MAX}}})\]|<([^\]<>]{{1,{CLOCK_BODY_MAX}}})>))?(?:\s*=>\s*([0-9]{{1,5}}:[0-9]{{1,2}}))?"
23    ))
24});
25
26/// Extract all CLOCK entries from text.
27///
28/// Two forms are recognized:
29/// - **Closed**: `CLOCK: [start]--[end] =>  HH:MM` — yields all three fields
30///   filled (`start`, `Some(end)`, `Some(duration)`).
31/// - **Open**: `CLOCK: [start]` — represents an in-progress interval that has
32///   not yet been closed; yields `start` only with `end = None` and
33///   `duration = None`.
34///
35/// The duration tail (`=> HH:MM`) is optional even on closed clocks; org-mode
36/// inserts it automatically but does not require it for the line to parse.
37pub fn extract_clocks(text: &str) -> Vec<ClockEntry> {
38    let clocks: Vec<ClockEntry> = CLOCK_RE
39        .captures_iter(text)
40        .filter_map(|cap| {
41            // Skip silently if the regex is ever changed so the start
42            // alternatives are no longer guaranteed — never panic on input.
43            let start = cap.get(1).or_else(|| cap.get(2))?.as_str().to_string();
44            let end = cap
45                .get(3)
46                .or_else(|| cap.get(4))
47                .map(|m| m.as_str().to_string());
48            let duration = cap.get(5).map(|m| m.as_str().to_string());
49            Some(ClockEntry {
50                start,
51                end,
52                duration,
53            })
54        })
55        .collect();
56    if !clocks.is_empty() {
57        tracing::trace!(count = clocks.len(), "extracted clocks");
58    }
59    clocks
60}
61
62/// Calculate total time from clock entries (in minutes).
63///
64/// Returns `None` when no entry has a parseable `duration` (e.g. only open
65/// clocks were present, or the input slice is empty) — there is nothing to
66/// report. Returns `Some(total)` when at least one entry contributed a
67/// duration, even if the sum is zero: a legitimate `0:00` CLOCK must be
68/// distinguishable from "no duration recorded" in the output.
69///
70/// Returns `None` on arithmetic overflow; `checked_add` prevents wrap.
71pub fn calculate_total_minutes(clocks: &[ClockEntry]) -> Option<u32> {
72    let mut total = 0u32;
73    let mut saw_duration = false;
74    for clock in clocks {
75        if let Some(ref dur) = clock.duration {
76            if let Some(mins) = parse_duration(dur) {
77                total = total.checked_add(mins)?;
78                saw_duration = true;
79            }
80        }
81    }
82    if saw_duration {
83        Some(total)
84    } else {
85        None
86    }
87}
88
89/// Format minutes as HH:MM
90pub fn format_duration(minutes: u32) -> String {
91    format!("{}:{:02}", minutes / 60, minutes % 60)
92}
93
94/// Org-mode duration upper bound. CLOCK durations beyond this are treated as
95/// malformed: 10_000 hours = ~416 days. Anything larger almost certainly comes
96/// from a parser bug or hostile input.
97const MAX_DURATION_HOURS: u32 = 10_000;
98
99/// Parse duration string like "2:05" to minutes. Returns None for malformed
100/// strings, out-of-range values, or arithmetic overflow.
101fn parse_duration(s: &str) -> Option<u32> {
102    let (h_str, m_str) = s.split_once(':')?;
103    let hours: u32 = h_str.parse().ok()?;
104    let mins: u32 = m_str.parse().ok()?;
105    if hours > MAX_DURATION_HOURS || mins >= 60 {
106        return None;
107    }
108    hours.checked_mul(60)?.checked_add(mins)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_extract_closed_clock_square_brackets() {
117        let text = "CLOCK: [2023-02-19 Sun 21:30]--[2023-02-19 Sun 23:35] =>  2:05";
118        let clocks = extract_clocks(text);
119        assert_eq!(clocks.len(), 1);
120        assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
121        assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
122        assert_eq!(clocks[0].duration, Some("2:05".to_string()));
123    }
124
125    #[test]
126    fn test_extract_closed_clock_angle_brackets() {
127        let text = "CLOCK: <2023-02-19 Sun 21:30>--<2023-02-19 Sun 23:35> => 2:05";
128        let clocks = extract_clocks(text);
129        assert_eq!(clocks.len(), 1);
130        assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
131        assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
132        assert_eq!(clocks[0].duration, Some("2:05".to_string()));
133    }
134
135    #[test]
136    fn test_extract_open_clock_square_brackets() {
137        let text = "CLOCK: [2025-10-18 Sat 13:00]";
138        let clocks = extract_clocks(text);
139        assert_eq!(clocks.len(), 1);
140        assert_eq!(clocks[0].start, "2025-10-18 Sat 13:00");
141        assert_eq!(clocks[0].end, None);
142        assert_eq!(clocks[0].duration, None);
143    }
144
145    #[test]
146    fn test_extract_open_clock_angle_brackets() {
147        let text = "CLOCK: <2025-10-18 Sat 13:00>";
148        let clocks = extract_clocks(text);
149        assert_eq!(clocks.len(), 1);
150        assert_eq!(clocks[0].start, "2025-10-18 Sat 13:00");
151        assert_eq!(clocks[0].end, None);
152        assert_eq!(clocks[0].duration, None);
153    }
154
155    #[test]
156    fn test_rejects_mixed_brackets() {
157        // Opening `[` must close with `]`; opening `<` must close with `>`.
158        // Mixing them within a single timestamp is malformed and must not
159        // match (paired alternation in CLOCK_RE catches this by construction).
160        assert!(extract_clocks("CLOCK: [2023-02-19 21:30>").is_empty());
161        assert!(extract_clocks("CLOCK: <2023-02-19 21:30]").is_empty());
162        assert!(extract_clocks("CLOCK: [2023-02-19 21:30>--<2023-02-19 23:35]").is_empty());
163    }
164
165    #[test]
166    fn test_clock_range_endpoints_may_differ_in_bracket_form() {
167        // ADR-0003 keeps CLOCK forgiving: both `<...>` and `[...]` are
168        // valid bracket forms for the timer's start/end. ADR-0014 tightens
169        // the rule for other timestamp kinds (SCHEDULED/DEADLINE/CLOSED/
170        // CREATED) but explicitly leaves CLOCK unchanged. A real user
171        // might have closed an active timer started in `<...>` form and
172        // the editor wrote the end in `[...]`; the parser should accept
173        // this without erroring on the mismatch.
174        let mixed_range_first_square =
175            "CLOCK: [2023-02-19 Sun 21:30]--<2023-02-19 Sun 23:35> => 2:05";
176        let clocks = extract_clocks(mixed_range_first_square);
177        assert_eq!(clocks.len(), 1);
178        assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
179        assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
180
181        let mixed_range_first_angle =
182            "CLOCK: <2023-02-19 Sun 21:30>--[2023-02-19 Sun 23:35] => 2:05";
183        let clocks = extract_clocks(mixed_range_first_angle);
184        assert_eq!(clocks.len(), 1);
185        assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
186        assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
187    }
188
189    #[test]
190    fn test_calculate_total() {
191        let clocks = vec![
192            ClockEntry {
193                start: "2023-02-19 Sun 21:30".to_string(),
194                end: Some("2023-02-19 Sun 23:35".to_string()),
195                duration: Some("2:05".to_string()),
196            },
197            ClockEntry {
198                start: "2023-02-20 Mon 10:00".to_string(),
199                end: Some("2023-02-20 Mon 11:30".to_string()),
200                duration: Some("1:30".to_string()),
201            },
202        ];
203        let total = calculate_total_minutes(&clocks);
204        assert_eq!(total, Some(215)); // 125 + 90
205        assert_eq!(format_duration(215), "3:35");
206    }
207
208    #[test]
209    fn test_parse_duration() {
210        assert_eq!(parse_duration("2:05"), Some(125));
211        assert_eq!(parse_duration("0:30"), Some(30));
212        assert_eq!(parse_duration("10:00"), Some(600));
213    }
214
215    #[test]
216    fn test_parse_duration_rejects_invalid() {
217        assert_eq!(parse_duration(""), None);
218        assert_eq!(parse_duration("abc"), None);
219        assert_eq!(parse_duration("1:60"), None, "minutes must be < 60");
220        assert_eq!(parse_duration("1:99"), None);
221        assert_eq!(parse_duration("99999:00"), None, "hours capped");
222        assert_eq!(parse_duration("1:2:3"), None, "single colon only");
223    }
224
225    #[test]
226    fn clock_body_within_limit_is_accepted() {
227        use crate::regex_limits::CLOCK_BODY_MAX;
228        // Body chars must satisfy `[^\]<>]`. Use ASCII spaces.
229        let filler = " ".repeat(CLOCK_BODY_MAX);
230        let input = format!("CLOCK: [{filler}]");
231        let clocks = extract_clocks(&input);
232        assert_eq!(clocks.len(), 1, "body at exactly the cap must match");
233        assert_eq!(clocks[0].start.len(), CLOCK_BODY_MAX);
234    }
235
236    #[test]
237    fn clock_body_just_over_limit_is_rejected() {
238        use crate::regex_limits::CLOCK_BODY_MAX;
239        let filler = " ".repeat(CLOCK_BODY_MAX + 1);
240        let input = format!("CLOCK: [{filler}]");
241        // The regex requires {1,CLOCK_BODY_MAX}; with the closing bracket
242        // pushed past that window, no match should be produced.
243        assert!(
244            extract_clocks(&input).is_empty(),
245            "body of CLOCK_BODY_MAX+1 chars must not match"
246        );
247    }
248
249    #[test]
250    fn calculate_total_minutes_returns_some_zero_for_zero_duration() {
251        // A closed clock that legitimately recorded 0:00 should be reported as
252        // Some(0), not swallowed into None. Otherwise a CLOCK with an actual
253        // duration field is indistinguishable from a missing/open clock.
254        let clocks = vec![ClockEntry {
255            start: "2024-01-01 Mon 10:00".to_string(),
256            end: Some("2024-01-01 Mon 10:00".to_string()),
257            duration: Some("0:00".to_string()),
258        }];
259        assert_eq!(calculate_total_minutes(&clocks), Some(0));
260    }
261
262    #[test]
263    fn calculate_total_minutes_returns_none_for_only_open_clocks() {
264        // No duration fields anywhere -> nothing to sum -> None is correct.
265        let clocks = vec![ClockEntry {
266            start: "2024-01-01 Mon 10:00".to_string(),
267            end: None,
268            duration: None,
269        }];
270        assert_eq!(calculate_total_minutes(&clocks), None);
271    }
272
273    #[test]
274    fn calculate_total_minutes_mixes_open_and_zero_clocks() {
275        // One open clock (ignored) + one closed 0:00 -> Some(0), proving the
276        // 0:00 entry is what surfaces in the result, not the open one.
277        let clocks = vec![
278            ClockEntry {
279                start: "x".to_string(),
280                end: None,
281                duration: None,
282            },
283            ClockEntry {
284                start: "y".to_string(),
285                end: Some("z".to_string()),
286                duration: Some("0:00".to_string()),
287            },
288        ];
289        assert_eq!(calculate_total_minutes(&clocks), Some(0));
290    }
291
292    #[test]
293    fn test_calculate_total_overflow_protected() {
294        // Even a very large but in-range duration shouldn't wrap. Two valid maxima
295        // sum below u32::MAX, but if MAX_DURATION_HOURS were larger we'd want this
296        // to return Some(_) without panic.
297        let clocks = vec![
298            ClockEntry {
299                start: "x".to_string(),
300                end: Some("y".to_string()),
301                duration: Some("9999:59".to_string()),
302            },
303            ClockEntry {
304                start: "x".to_string(),
305                end: Some("y".to_string()),
306                duration: Some("9999:59".to_string()),
307            },
308        ];
309        let total = calculate_total_minutes(&clocks).unwrap();
310        assert_eq!(total, (9999 * 60 + 59) * 2);
311    }
312}