use regex::Regex;
use std::sync::LazyLock;
use crate::regex_limits::{compile_bounded, CLOCK_BODY_MAX};
use crate::types::ClockEntry;
static CLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_bounded(&format!(
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}}))?"
))
});
pub fn extract_clocks(text: &str) -> Vec<ClockEntry> {
let clocks: Vec<ClockEntry> = CLOCK_RE
.captures_iter(text)
.filter_map(|cap| {
let start = cap.get(1).or_else(|| cap.get(2))?.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());
Some(ClockEntry {
start,
end,
duration,
})
})
.collect();
if !clocks.is_empty() {
tracing::trace!(count = clocks.len(), "extracted clocks");
}
clocks
}
pub fn calculate_total_minutes(clocks: &[ClockEntry]) -> Option<u32> {
let mut total = 0u32;
let mut saw_duration = false;
for clock in clocks {
if let Some(ref dur) = clock.duration {
if let Some(mins) = parse_duration(dur) {
total = total.checked_add(mins)?;
saw_duration = true;
}
}
}
if saw_duration {
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 clock_body_within_limit_is_accepted() {
use crate::regex_limits::CLOCK_BODY_MAX;
let filler = " ".repeat(CLOCK_BODY_MAX);
let input = format!("CLOCK: [{filler}]");
let clocks = extract_clocks(&input);
assert_eq!(clocks.len(), 1, "body at exactly the cap must match");
assert_eq!(clocks[0].start.len(), CLOCK_BODY_MAX);
}
#[test]
fn clock_body_just_over_limit_is_rejected() {
use crate::regex_limits::CLOCK_BODY_MAX;
let filler = " ".repeat(CLOCK_BODY_MAX + 1);
let input = format!("CLOCK: [{filler}]");
assert!(
extract_clocks(&input).is_empty(),
"body of CLOCK_BODY_MAX+1 chars must not match"
);
}
#[test]
fn calculate_total_minutes_returns_some_zero_for_zero_duration() {
let clocks = vec![ClockEntry {
start: "2024-01-01 Mon 10:00".to_string(),
end: Some("2024-01-01 Mon 10:00".to_string()),
duration: Some("0:00".to_string()),
}];
assert_eq!(calculate_total_minutes(&clocks), Some(0));
}
#[test]
fn calculate_total_minutes_returns_none_for_only_open_clocks() {
let clocks = vec![ClockEntry {
start: "2024-01-01 Mon 10:00".to_string(),
end: None,
duration: None,
}];
assert_eq!(calculate_total_minutes(&clocks), None);
}
#[test]
fn calculate_total_minutes_mixes_open_and_zero_clocks() {
let clocks = vec![
ClockEntry {
start: "x".to_string(),
end: None,
duration: None,
},
ClockEntry {
start: "y".to_string(),
end: Some("z".to_string()),
duration: Some("0:00".to_string()),
},
];
assert_eq!(calculate_total_minutes(&clocks), Some(0));
}
#[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);
}
}