1use regex::Regex;
8use std::sync::LazyLock;
9
10use crate::regex_limits::{compile_bounded, CLOCK_BODY_MAX};
11use crate::types::ClockEntry;
12
13static 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
26pub fn extract_clocks(text: &str) -> Vec<ClockEntry> {
38 let clocks: Vec<ClockEntry> = CLOCK_RE
39 .captures_iter(text)
40 .filter_map(|cap| {
41 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
62pub 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
89pub fn format_duration(minutes: u32) -> String {
91 format!("{}:{:02}", minutes / 60, minutes % 60)
92}
93
94const MAX_DURATION_HOURS: u32 = 10_000;
98
99fn 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 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 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)); 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 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 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 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 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 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 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}