Skip to main content

robinpath_modules/modules/
cron_mod.rs

1use robinpath::{RobinPath, Value};
2
3pub fn register(rp: &mut RobinPath) {
4    // cron.isValid expression → bool
5    rp.register_builtin("cron.isValid", |args, _| {
6        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
7        Ok(Value::Bool(parse_cron(&expr).is_some()))
8    });
9
10    // cron.parse expression → {minute, hour, dayOfMonth, month, dayOfWeek}
11    rp.register_builtin("cron.parse", |args, _| {
12        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
13        match parse_cron(&expr) {
14            Some(fields) => {
15                let mut obj = indexmap::IndexMap::new();
16                obj.insert("minute".to_string(), vec_to_value(&fields.minutes));
17                obj.insert("hour".to_string(), vec_to_value(&fields.hours));
18                obj.insert("dayOfMonth".to_string(), vec_to_value(&fields.days));
19                obj.insert("month".to_string(), vec_to_value(&fields.months));
20                obj.insert("dayOfWeek".to_string(), vec_to_value(&fields.weekdays));
21                Ok(Value::Object(obj))
22            }
23            None => Err(format!("Invalid cron expression: {}", expr)),
24        }
25    });
26
27    // cron.next expression from? → ISO date string
28    rp.register_builtin("cron.next", |args, _| {
29        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
30        let from = args.get(1).map(|v| v.to_display_string());
31        let fields = parse_cron(&expr).ok_or_else(|| format!("Invalid cron: {}", expr))?;
32        let start = parse_datetime(&from);
33        match next_occurrence(&fields, start) {
34            Some(dt) => Ok(Value::String(format_datetime(dt))),
35            None => Ok(Value::Null),
36        }
37    });
38
39    // cron.nextN expression count from? → array of ISO date strings
40    rp.register_builtin("cron.nextN", |args, _| {
41        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
42        let count = args.get(1).map(|v| v.to_number() as usize).unwrap_or(5);
43        let from = args.get(2).map(|v| v.to_display_string());
44        let fields = parse_cron(&expr).ok_or_else(|| format!("Invalid cron: {}", expr))?;
45        let mut current = parse_datetime(&from);
46        let mut results = Vec::new();
47        for _ in 0..count {
48            match next_occurrence(&fields, current) {
49                Some(dt) => {
50                    results.push(Value::String(format_datetime(dt)));
51                    current = dt + 60; // advance by 1 minute
52                }
53                None => break,
54            }
55        }
56        Ok(Value::Array(results))
57    });
58
59    // cron.describe expression → human-readable string
60    rp.register_builtin("cron.describe", |args, _| {
61        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
62        let fields = parse_cron(&expr).ok_or_else(|| format!("Invalid cron: {}", expr))?;
63        Ok(Value::String(describe_cron(&fields)))
64    });
65
66    // cron.matches expression date? → bool
67    rp.register_builtin("cron.matches", |args, _| {
68        let expr = args.first().map(|v| v.to_display_string()).unwrap_or_default();
69        let date = args.get(1).map(|v| v.to_display_string());
70        let fields = parse_cron(&expr).ok_or_else(|| format!("Invalid cron: {}", expr))?;
71        let ts = parse_datetime(&date);
72        let (_, mon, day, hour, min, wday) = timestamp_to_parts(ts);
73        let matches = fields.minutes.contains(&min)
74            && fields.hours.contains(&hour)
75            && fields.days.contains(&day)
76            && fields.months.contains(&mon)
77            && fields.weekdays.contains(&wday);
78        Ok(Value::Bool(matches))
79    });
80}
81
82struct CronFields {
83    minutes: Vec<u32>,
84    hours: Vec<u32>,
85    days: Vec<u32>,
86    months: Vec<u32>,
87    weekdays: Vec<u32>,
88}
89
90fn parse_cron(expr: &str) -> Option<CronFields> {
91    let parts: Vec<&str> = expr.trim().split_whitespace().collect();
92    if parts.len() != 5 { return None; }
93    Some(CronFields {
94        minutes: parse_field(parts[0], 0, 59)?,
95        hours: parse_field(parts[1], 0, 23)?,
96        days: parse_field(parts[2], 1, 31)?,
97        months: parse_field(parts[3], 1, 12)?,
98        weekdays: parse_field(parts[4], 0, 6)?,
99    })
100}
101
102fn parse_field(field: &str, min: u32, max: u32) -> Option<Vec<u32>> {
103    let mut values = Vec::new();
104    for part in field.split(',') {
105        let part = part.trim();
106        if part == "*" {
107            return Some((min..=max).collect());
108        }
109        if let Some(slash) = part.find('/') {
110            let range_part = &part[..slash];
111            let step: u32 = part[slash + 1..].parse().ok()?;
112            let start = if range_part == "*" { min } else { range_part.parse().ok()? };
113            let mut v = start;
114            while v <= max {
115                values.push(v);
116                v += step;
117            }
118        } else if let Some(dash) = part.find('-') {
119            let start: u32 = part[..dash].parse().ok()?;
120            let end: u32 = part[dash + 1..].parse().ok()?;
121            for v in start..=end {
122                values.push(v);
123            }
124        } else {
125            values.push(part.parse().ok()?);
126        }
127    }
128    values.sort();
129    values.dedup();
130    Some(values)
131}
132
133fn vec_to_value(v: &[u32]) -> Value {
134    Value::Array(v.iter().map(|&n| Value::Number(n as f64)).collect())
135}
136
137// Simple timestamp-based date handling (seconds since epoch)
138fn parse_datetime(s: &Option<String>) -> i64 {
139    if let Some(date_str) = s {
140        // Try ISO 8601 parsing (simplified)
141        let trimmed = date_str.trim();
142        if trimmed.len() >= 10 {
143            let parts: Vec<&str> = trimmed[..10].split('-').collect();
144            if parts.len() == 3 {
145                if let (Ok(y), Ok(m), Ok(d)) = (parts[0].parse::<i64>(), parts[1].parse::<u32>(), parts[2].parse::<u32>()) {
146                    let (hour, min) = if trimmed.len() >= 16 {
147                        let time_part = &trimmed[11..];
148                        let time_parts: Vec<&str> = time_part.split(':').collect();
149                        (time_parts.first().and_then(|s| s.parse().ok()).unwrap_or(0u32),
150                         time_parts.get(1).and_then(|s| s[..2.min(s.len())].parse().ok()).unwrap_or(0u32))
151                    } else {
152                        (0, 0)
153                    };
154                    return date_to_timestamp(y, m, d, hour, min);
155                }
156            }
157        }
158    }
159    std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .unwrap_or_default()
162        .as_secs() as i64
163}
164
165fn date_to_timestamp(year: i64, month: u32, day: u32, hour: u32, min: u32) -> i64 {
166    // Simplified: days since epoch
167    let mut y = year;
168    let mut m = month as i64;
169    if m <= 2 { y -= 1; m += 12; }
170    let days = 365 * y + y / 4 - y / 100 + y / 400 + (153 * (m - 3) + 2) / 5 + day as i64 - 719469;
171    days * 86400 + hour as i64 * 3600 + min as i64 * 60
172}
173
174fn timestamp_to_parts(ts: i64) -> (i64, u32, u32, u32, u32, u32) {
175    let days = ts.div_euclid(86400);
176    let time_of_day = ts.rem_euclid(86400);
177    let hour = (time_of_day / 3600) as u32;
178    let min = ((time_of_day % 3600) / 60) as u32;
179    // Day of week: Jan 1 1970 was Thursday (4)
180    let wday = ((days + 4).rem_euclid(7)) as u32;
181    // Date from days since epoch
182    let z = days + 719468;
183    let era = z.div_euclid(146097);
184    let doe = z.rem_euclid(146097);
185    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
186    let y = yoe + era * 400;
187    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
188    let mp = (5 * doy + 2) / 153;
189    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
190    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
191    let y = if m <= 2 { y + 1 } else { y };
192    (y, m, d, hour, min, wday)
193}
194
195fn format_datetime(ts: i64) -> String {
196    let (y, m, d, h, min, _) = timestamp_to_parts(ts);
197    format!("{:04}-{:02}-{:02}T{:02}:{:02}:00Z", y, m, d, h, min)
198}
199
200fn next_occurrence(fields: &CronFields, after: i64) -> Option<i64> {
201    let mut ts = after + 60; // start from next minute
202    ts -= ts % 60; // round down to minute
203    let max_ts = ts + 366 * 86400; // search up to 1 year
204    while ts < max_ts {
205        let (_, mon, day, hour, min, wday) = timestamp_to_parts(ts);
206        if fields.minutes.contains(&min)
207            && fields.hours.contains(&hour)
208            && fields.days.contains(&day)
209            && fields.months.contains(&mon)
210            && fields.weekdays.contains(&wday)
211        {
212            return Some(ts);
213        }
214        ts += 60;
215    }
216    None
217}
218
219fn describe_cron(fields: &CronFields) -> String {
220    let min_str = if fields.minutes.len() == 60 { "every minute".to_string() }
221        else { format!("at minute {}", join_nums(&fields.minutes)) };
222    let hour_str = if fields.hours.len() == 24 { "every hour".to_string() }
223        else { format!("at hour {}", join_nums(&fields.hours)) };
224    let day_str = if fields.days.len() == 31 { "every day".to_string() }
225        else { format!("on day {}", join_nums(&fields.days)) };
226    let mon_str = if fields.months.len() == 12 { "every month".to_string() }
227        else { format!("in month {}", join_nums(&fields.months)) };
228    format!("{}, {}, {}, {}", min_str, hour_str, day_str, mon_str)
229}
230
231fn join_nums(v: &[u32]) -> String {
232    v.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",")
233}