1use chrono::{DateTime, Duration, Utc};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum NextRunDecision {
31 RunAt(String),
33 Finished(String),
35}
36
37const MIN_RANDOM_INTERVAL: i64 = 60; const MAX_RANDOM_INTERVAL: i64 = 366 * 24 * 3600;
40
41pub fn parse_duration(s: &str) -> Result<Duration, String> {
43 let s = s.trim();
44 let split = s
45 .find(|c: char| !c.is_ascii_digit())
46 .ok_or_else(|| format!("duration `{s}` is missing a unit (s/m/h/d/w/mo)"))?;
47 let (num, unit) = s.split_at(split);
48 let n: i64 = num
49 .parse()
50 .map_err(|_| format!("duration `{s}` has an invalid number"))?;
51 if n <= 0 {
52 return Err(format!("duration `{s}` must be positive"));
53 }
54 let seconds = match unit {
55 "s" => n,
56 "m" => n * 60,
57 "h" => n * 3600,
58 "d" => n * 86_400,
59 "w" => n * 7 * 86_400,
60 "mo" => n * 30 * 86_400,
61 other => {
62 return Err(format!(
63 "duration `{s}` has unknown unit `{other}` (use s/m/h/d/w/mo)"
64 ))
65 }
66 };
67 Ok(Duration::seconds(seconds))
68}
69
70pub fn parse_duration_range(spec: &str) -> Result<(Duration, Duration), String> {
73 let spec = spec.trim();
74 let (lo, hi) = spec
75 .split_once("..")
76 .or_else(|| spec.split_once('-'))
77 .ok_or_else(|| {
78 format!("random schedule `{spec}` must be a range like `2h..3d` or `30m..4h`")
79 })?;
80 let min = parse_duration(lo)?;
81 let max = parse_duration(hi)?;
82 if min.num_seconds() < MIN_RANDOM_INTERVAL {
83 return Err(format!(
84 "random window minimum `{lo}` is below 1 minute — that would hot-loop the scheduler"
85 ));
86 }
87 if max.num_seconds() > MAX_RANDOM_INTERVAL {
88 return Err(format!(
89 "random window maximum `{hi}` exceeds 366 days — pick a shorter window"
90 ));
91 }
92 if min > max {
93 return Err(format!("random window `{spec}` has min > max"));
94 }
95 Ok((min, max))
96}
97
98pub fn sample_random_next(
101 now: DateTime<Utc>,
102 min: Duration,
103 max: Duration,
104 unit: f64,
105) -> DateTime<Utc> {
106 let span = (max - min).num_seconds().max(0);
107 let offset = min.num_seconds() + ((span as f64) * unit.clamp(0.0, 0.999_999)) as i64;
108 now + Duration::seconds(offset)
109}
110
111pub fn random_unit() -> f64 {
114 let bytes = *uuid::Uuid::new_v4().as_bytes();
115 let mut v: u64 = 0;
116 for b in &bytes[..8] {
117 v = (v << 8) | u64::from(*b);
118 }
119 (v >> 11) as f64 / (1u64 << 53) as f64
121}
122
123#[allow(clippy::too_many_arguments)]
126pub fn decide_next_run(
127 schedule_type: &str,
128 schedule_value: &str,
129 tz: chrono_tz::Tz,
130 now: DateTime<Utc>,
131 run_count_after: i64,
132 max_runs: Option<i64>,
133 not_after: Option<DateTime<Utc>>,
134 unit: f64,
135) -> NextRunDecision {
136 if let Some(max) = max_runs {
137 if run_count_after >= max {
138 return NextRunDecision::Finished(format!(
139 "reached max_runs ({run_count_after}/{max})"
140 ));
141 }
142 }
143
144 let candidate: Option<DateTime<Utc>> = match schedule_type {
145 "cron" => {
146 use std::str::FromStr;
147 match cron::Schedule::from_str(schedule_value) {
148 Ok(schedule) => schedule.upcoming(tz).next().map(|t| t.with_timezone(&Utc)),
149 Err(_) => None,
150 }
151 }
152 "random" => match parse_duration_range(schedule_value) {
153 Ok((min, max)) => Some(sample_random_next(now, min, max, unit)),
154 Err(_) => None,
155 },
156 _ => None,
157 };
158
159 let Some(candidate) = candidate else {
160 return NextRunDecision::Finished(format!(
161 "schedule `{schedule_value}` ({schedule_type}) is no longer computable"
162 ));
163 };
164
165 if let Some(deadline) = not_after {
166 if candidate > deadline {
167 return NextRunDecision::Finished(format!(
168 "next firing {} would pass not_after {}",
169 candidate.to_rfc3339(),
170 deadline.to_rfc3339()
171 ));
172 }
173 }
174
175 NextRunDecision::RunAt(candidate.to_rfc3339())
176}
177
178pub fn deadline_passed(not_after: Option<&str>, now: DateTime<Utc>) -> bool {
181 not_after
182 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
183 .map(|d| d.with_timezone(&Utc) < now)
184 .unwrap_or(false)
185}
186
187pub fn describe_schedule(schedule_type: &str, schedule_value: &str) -> String {
189 match schedule_type {
190 "once" => format!("once at {schedule_value}"),
191 "cron" => format!("cron `{schedule_value}`"),
192 "random" => format!("randomly every {schedule_value}"),
193 other => format!("{other} `{schedule_value}`"),
194 }
195}
196
197pub fn humanize_until(now: DateTime<Utc>, ts: &str) -> Option<String> {
199 let when = DateTime::parse_from_rfc3339(ts).ok()?.with_timezone(&Utc);
200 let secs = (when - now).num_seconds();
201 if secs <= 0 {
202 return Some("due now".to_string());
203 }
204 let (d, rem) = (secs / 86_400, secs % 86_400);
205 let (h, rem) = (rem / 3600, rem % 3600);
206 let m = rem / 60;
207 Some(match (d, h, m) {
208 (0, 0, m) => format!("in {}m", m.max(1)),
209 (0, h, m) => format!("in {h}h {m}m"),
210 (d, h, _) => format!("in {d}d {h}h"),
211 })
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn t(s: &str) -> DateTime<Utc> {
219 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
220 }
221
222 #[test]
223 fn duration_parsing_units_and_errors() {
224 assert_eq!(parse_duration("90s").unwrap().num_seconds(), 90);
225 assert_eq!(parse_duration("30m").unwrap().num_minutes(), 30);
226 assert_eq!(parse_duration("2h").unwrap().num_hours(), 2);
227 assert_eq!(parse_duration("3d").unwrap().num_days(), 3);
228 assert_eq!(parse_duration("1w").unwrap().num_days(), 7);
229 assert_eq!(parse_duration("2mo").unwrap().num_days(), 60);
230 assert!(parse_duration("5x").is_err());
231 assert!(parse_duration("h").is_err());
232 assert!(parse_duration("0m").is_err());
233 }
234
235 #[test]
236 fn range_parsing_accepts_both_separators_and_clamps() {
237 let (min, max) = parse_duration_range("2h..3d").unwrap();
238 assert_eq!(min.num_hours(), 2);
239 assert_eq!(max.num_days(), 3);
240 let (min, _) = parse_duration_range("30m-4h").unwrap();
241 assert_eq!(min.num_minutes(), 30);
242 assert!(parse_duration_range("5s..1h").is_err());
244 assert!(parse_duration_range("1d..24mo").is_err());
245 assert!(parse_duration_range("3d..2h").is_err());
246 assert!(parse_duration_range("2h").is_err());
247 }
248
249 #[test]
250 fn random_sampling_stays_inside_window() {
251 let now = t("2026-07-11T00:00:00Z");
252 let (min, max) = parse_duration_range("2h..3d").unwrap();
253 for unit in [0.0, 0.25, 0.5, 0.999] {
254 let next = sample_random_next(now, min, max, unit);
255 assert!(next >= now + Duration::hours(2), "unit={unit}");
256 assert!(next <= now + Duration::days(3), "unit={unit}");
257 }
258 let next = sample_random_next(now, min, min, 0.7);
260 assert_eq!(next, now + Duration::hours(2));
261 }
262
263 #[test]
264 fn random_unit_is_in_range() {
265 for _ in 0..64 {
266 let u = random_unit();
267 assert!((0.0..1.0).contains(&u));
268 }
269 }
270
271 #[test]
272 fn max_runs_retires_task() {
273 let d = decide_next_run(
274 "random",
275 "2h..3d",
276 chrono_tz::UTC,
277 t("2026-07-11T00:00:00Z"),
278 5,
279 Some(5),
280 None,
281 0.5,
282 );
283 assert!(matches!(d, NextRunDecision::Finished(ref r) if r.contains("max_runs")));
284 let d = decide_next_run(
286 "random",
287 "2h..3d",
288 chrono_tz::UTC,
289 t("2026-07-11T00:00:00Z"),
290 4,
291 Some(5),
292 None,
293 0.5,
294 );
295 assert!(matches!(d, NextRunDecision::RunAt(_)));
296 }
297
298 #[test]
299 fn deadline_retires_task_when_next_firing_passes_it() {
300 let now = t("2026-07-11T00:00:00Z");
301 let d = decide_next_run(
303 "random",
304 "2h..3d",
305 chrono_tz::UTC,
306 now,
307 1,
308 None,
309 Some(t("2026-07-11T01:00:00Z")),
310 0.0,
311 );
312 assert!(matches!(d, NextRunDecision::Finished(ref r) if r.contains("not_after")));
313 let d = decide_next_run(
315 "random",
316 "2h..3d",
317 chrono_tz::UTC,
318 now,
319 1,
320 None,
321 Some(t("2026-12-31T00:00:00Z")),
322 0.0,
323 );
324 assert!(matches!(d, NextRunDecision::RunAt(_)));
325 }
326
327 #[test]
328 fn cron_still_schedules_and_respects_deadline() {
329 let now = t("2026-07-11T00:00:00Z");
330 let d = decide_next_run(
331 "cron",
332 "0 0 * * * *", chrono_tz::UTC,
334 now,
335 10,
336 None,
337 None,
338 0.5,
339 );
340 assert!(matches!(d, NextRunDecision::RunAt(_)));
341 let d = decide_next_run(
342 "cron",
343 "0 0 12 1 1 *", chrono_tz::UTC,
345 now,
346 1,
347 None,
348 Some(t("2026-08-01T00:00:00Z")),
349 0.5,
350 );
351 assert!(matches!(d, NextRunDecision::Finished(_)));
352 }
353
354 #[test]
355 fn invalid_schedule_finishes_instead_of_looping() {
356 let d = decide_next_run(
357 "cron",
358 "not a cron",
359 chrono_tz::UTC,
360 t("2026-07-11T00:00:00Z"),
361 1,
362 None,
363 None,
364 0.5,
365 );
366 assert!(matches!(d, NextRunDecision::Finished(_)));
367 }
368
369 #[test]
370 fn deadline_passed_checks_claim_time() {
371 let now = t("2026-07-11T12:00:00Z");
372 assert!(deadline_passed(Some("2026-07-11T11:00:00Z"), now));
373 assert!(!deadline_passed(Some("2026-07-11T13:00:00Z"), now));
374 assert!(!deadline_passed(None, now));
375 assert!(!deadline_passed(Some("garbage"), now));
378 }
379
380 #[test]
381 fn humanize_until_renders_compact() {
382 let now = t("2026-07-11T00:00:00Z");
383 assert_eq!(
384 humanize_until(now, "2026-07-11T00:05:00Z").unwrap(),
385 "in 5m"
386 );
387 assert_eq!(
388 humanize_until(now, "2026-07-11T03:30:00Z").unwrap(),
389 "in 3h 30m"
390 );
391 assert_eq!(
392 humanize_until(now, "2026-07-13T06:00:00Z").unwrap(),
393 "in 2d 6h"
394 );
395 assert_eq!(
396 humanize_until(now, "2026-07-10T00:00:00Z").unwrap(),
397 "due now"
398 );
399 }
400}