Skip to main content

tl_stream/
schedule.rs

1// ThinkingLanguage — Schedule and duration parsing
2
3/// Parse a duration string like "5m", "30s", "100ms", "1h", "1d" into milliseconds.
4pub fn parse_duration(s: &str) -> Result<u64, String> {
5    let s = s.trim();
6    if s.is_empty() {
7        return Err("Empty duration string".to_string());
8    }
9
10    if let Some(n) = s.strip_suffix("ms") {
11        n.parse::<u64>()
12            .map_err(|_| format!("Invalid milliseconds: {n}"))
13    } else if let Some(n) = s.strip_suffix('s') {
14        n.parse::<u64>()
15            .map(|v| v * 1000)
16            .map_err(|_| format!("Invalid seconds: {n}"))
17    } else if let Some(n) = s.strip_suffix('m') {
18        n.parse::<u64>()
19            .map(|v| v * 60 * 1000)
20            .map_err(|_| format!("Invalid minutes: {n}"))
21    } else if let Some(n) = s.strip_suffix('h') {
22        n.parse::<u64>()
23            .map(|v| v * 3600 * 1000)
24            .map_err(|_| format!("Invalid hours: {n}"))
25    } else if let Some(n) = s.strip_suffix('d') {
26        n.parse::<u64>()
27            .map(|v| v * 86400 * 1000)
28            .map_err(|_| format!("Invalid days: {n}"))
29    } else {
30        // Try plain milliseconds
31        s.parse::<u64>()
32            .map_err(|_| format!("Invalid duration: {s}. Use suffixes: ms, s, m, h, d"))
33    }
34}
35
36/// Validate a cron expression (basic 5-field format: min hour dom month dow).
37pub fn validate_cron(expr: &str) -> Result<(), String> {
38    let fields: Vec<&str> = expr.split_whitespace().collect();
39    if fields.len() != 5 {
40        return Err(format!(
41            "Cron expression must have 5 fields (min hour dom month dow), got {}",
42            fields.len()
43        ));
44    }
45
46    let ranges = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 7)];
47    let names = ["minute", "hour", "day-of-month", "month", "day-of-week"];
48
49    for (i, field) in fields.iter().enumerate() {
50        validate_cron_field(field, ranges[i].0, ranges[i].1, names[i])?;
51    }
52
53    Ok(())
54}
55
56fn validate_cron_field(field: &str, min: u32, max: u32, name: &str) -> Result<(), String> {
57    if field == "*" {
58        return Ok(());
59    }
60
61    // Handle step syntax: */5, 1-30/2
62    if let Some((range_part, step_part)) = field.split_once('/') {
63        if range_part != "*" {
64            validate_cron_range(range_part, min, max, name)?;
65        }
66        step_part
67            .parse::<u32>()
68            .map_err(|_| format!("Invalid step in {name} field: {step_part}"))?;
69        return Ok(());
70    }
71
72    // Handle comma-separated values
73    for part in field.split(',') {
74        validate_cron_range(part, min, max, name)?;
75    }
76
77    Ok(())
78}
79
80fn validate_cron_range(part: &str, min: u32, max: u32, name: &str) -> Result<(), String> {
81    if let Some((start_s, end_s)) = part.split_once('-') {
82        let start: u32 = start_s
83            .parse()
84            .map_err(|_| format!("Invalid range start in {name}: {start_s}"))?;
85        let end: u32 = end_s
86            .parse()
87            .map_err(|_| format!("Invalid range end in {name}: {end_s}"))?;
88        if start < min || end > max || start > end {
89            return Err(format!(
90                "Range {start}-{end} out of bounds for {name} ({min}-{max})"
91            ));
92        }
93    } else {
94        let val: u32 = part
95            .parse()
96            .map_err(|_| format!("Invalid value in {name}: {part}"))?;
97        if val < min || val > max {
98            return Err(format!(
99                "Value {val} out of bounds for {name} ({min}-{max})"
100            ));
101        }
102    }
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_parse_duration_ms() {
112        assert_eq!(parse_duration("100ms").unwrap(), 100);
113    }
114
115    #[test]
116    fn test_parse_duration_seconds() {
117        assert_eq!(parse_duration("30s").unwrap(), 30_000);
118    }
119
120    #[test]
121    fn test_parse_duration_minutes() {
122        assert_eq!(parse_duration("5m").unwrap(), 300_000);
123    }
124
125    #[test]
126    fn test_parse_duration_hours() {
127        assert_eq!(parse_duration("2h").unwrap(), 7_200_000);
128    }
129
130    #[test]
131    fn test_parse_duration_days() {
132        assert_eq!(parse_duration("1d").unwrap(), 86_400_000);
133    }
134
135    #[test]
136    fn test_parse_duration_plain_ms() {
137        assert_eq!(parse_duration("5000").unwrap(), 5000);
138    }
139
140    #[test]
141    fn test_parse_duration_invalid() {
142        assert!(parse_duration("abc").is_err());
143        assert!(parse_duration("").is_err());
144    }
145
146    #[test]
147    fn test_validate_cron_basic() {
148        assert!(validate_cron("0 0 * * *").is_ok()); // daily at midnight
149        assert!(validate_cron("*/5 * * * *").is_ok()); // every 5 minutes
150        assert!(validate_cron("0 9 * * 1-5").is_ok()); // weekdays at 9am
151        assert!(validate_cron("30 14 1 * *").is_ok()); // 1st of month at 2:30pm
152    }
153
154    #[test]
155    fn test_validate_cron_invalid() {
156        assert!(validate_cron("0 0 *").is_err()); // too few fields
157        assert!(validate_cron("60 0 * * *").is_err()); // minute out of range
158        assert!(validate_cron("0 25 * * *").is_err()); // hour out of range
159    }
160}