use regex::Regex;
use std::sync::LazyLock;
use crate::regex_limits::compile_bounded;
use crate::types::ClockEntry;
static CLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(
r"CLOCK:\s*(?:\[([^\]<>]{1,128})\]|<([^\]<>]{1,128})>)(?:--(?:\[([^\]<>]{1,128})\]|<([^\]<>]{1,128})>))?(?:\s*=>\s*([0-9]{1,5}:[0-9]{1,2}))?",
)
});
pub fn extract_clocks(text: &str) -> Vec<ClockEntry> {
CLOCK_RE
.captures_iter(text)
.map(|cap| {
let start = cap
.get(1)
.or_else(|| cap.get(2))
.expect("CLOCK regex matched without a start timestamp")
.as_str()
.to_string();
let end = cap
.get(3)
.or_else(|| cap.get(4))
.map(|m| m.as_str().to_string());
let duration = cap.get(5).map(|m| m.as_str().to_string());
ClockEntry {
start,
end,
duration,
}
})
.collect()
}
pub fn calculate_total_minutes(clocks: &[ClockEntry]) -> Option<u32> {
let mut total = 0u32;
for clock in clocks {
if let Some(ref dur) = clock.duration {
if let Some(mins) = parse_duration(dur) {
total = total.checked_add(mins)?;
}
}
}
if total > 0 {
Some(total)
} else {
None
}
}
pub fn format_duration(minutes: u32) -> String {
format!("{}:{:02}", minutes / 60, minutes % 60)
}
const MAX_DURATION_HOURS: u32 = 10_000;
fn parse_duration(s: &str) -> Option<u32> {
let (h_str, m_str) = s.split_once(':')?;
let hours: u32 = h_str.parse().ok()?;
let mins: u32 = m_str.parse().ok()?;
if hours > MAX_DURATION_HOURS || mins >= 60 {
return None;
}
hours.checked_mul(60)?.checked_add(mins)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_closed_clock_square_brackets() {
let text = "CLOCK: [2023-02-19 Sun 21:30]--[2023-02-19 Sun 23:35] => 2:05";
let clocks = extract_clocks(text);
assert_eq!(clocks.len(), 1);
assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
assert_eq!(clocks[0].duration, Some("2:05".to_string()));
}
#[test]
fn test_extract_closed_clock_angle_brackets() {
let text = "CLOCK: <2023-02-19 Sun 21:30>--<2023-02-19 Sun 23:35> => 2:05";
let clocks = extract_clocks(text);
assert_eq!(clocks.len(), 1);
assert_eq!(clocks[0].start, "2023-02-19 Sun 21:30");
assert_eq!(clocks[0].end, Some("2023-02-19 Sun 23:35".to_string()));
assert_eq!(clocks[0].duration, Some("2:05".to_string()));
}
#[test]
fn test_extract_open_clock_square_brackets() {
let text = "CLOCK: [2025-10-18 Sat 13:00]";
let clocks = extract_clocks(text);
assert_eq!(clocks.len(), 1);
assert_eq!(clocks[0].start, "2025-10-18 Sat 13:00");
assert_eq!(clocks[0].end, None);
assert_eq!(clocks[0].duration, None);
}
#[test]
fn test_extract_open_clock_angle_brackets() {
let text = "CLOCK: <2025-10-18 Sat 13:00>";
let clocks = extract_clocks(text);
assert_eq!(clocks.len(), 1);
assert_eq!(clocks[0].start, "2025-10-18 Sat 13:00");
assert_eq!(clocks[0].end, None);
assert_eq!(clocks[0].duration, None);
}
#[test]
fn test_rejects_mixed_brackets() {
assert!(extract_clocks("CLOCK: [2023-02-19 21:30>").is_empty());
assert!(extract_clocks("CLOCK: <2023-02-19 21:30]").is_empty());
assert!(extract_clocks("CLOCK: [2023-02-19 21:30>--<2023-02-19 23:35]").is_empty());
}
#[test]
fn test_calculate_total() {
let clocks = vec![
ClockEntry {
start: "2023-02-19 Sun 21:30".to_string(),
end: Some("2023-02-19 Sun 23:35".to_string()),
duration: Some("2:05".to_string()),
},
ClockEntry {
start: "2023-02-20 Mon 10:00".to_string(),
end: Some("2023-02-20 Mon 11:30".to_string()),
duration: Some("1:30".to_string()),
},
];
let total = calculate_total_minutes(&clocks);
assert_eq!(total, Some(215)); assert_eq!(format_duration(215), "3:35");
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("2:05"), Some(125));
assert_eq!(parse_duration("0:30"), Some(30));
assert_eq!(parse_duration("10:00"), Some(600));
}
#[test]
fn test_parse_duration_rejects_invalid() {
assert_eq!(parse_duration(""), None);
assert_eq!(parse_duration("abc"), None);
assert_eq!(parse_duration("1:60"), None, "minutes must be < 60");
assert_eq!(parse_duration("1:99"), None);
assert_eq!(parse_duration("99999:00"), None, "hours capped");
assert_eq!(parse_duration("1:2:3"), None, "single colon only");
}
#[test]
fn test_calculate_total_overflow_protected() {
let clocks = vec![
ClockEntry {
start: "x".to_string(),
end: Some("y".to_string()),
duration: Some("9999:59".to_string()),
},
ClockEntry {
start: "x".to_string(),
end: Some("y".to_string()),
duration: Some("9999:59".to_string()),
},
];
let total = calculate_total_minutes(&clocks).unwrap();
assert_eq!(total, (9999 * 60 + 59) * 2);
}
}