1use chrono::TimeZone;
5use chrono_tz::Tz;
6use cron::Schedule;
7use serde_json::Value;
8use std::str::FromStr;
9
10pub struct NextRun {
12 pub at_ms: Option<u64>,
15 pub label: String,
17 pub desc: String,
19 pub paused: bool,
20}
21
22pub fn fmt_eta(at_ms: u64, now_ms: u64) -> String {
24 let secs = at_ms.saturating_sub(now_ms) / 1000;
25 match secs {
26 0..=59 => "in <1m".to_string(),
27 60..=3599 => format!("in {}m", secs / 60),
28 3600..=86_399 => format!("in {}h {}m", secs / 3600, (secs % 3600) / 60),
29 _ => format!("in {}d", secs / 86_400),
30 }
31}
32
33pub fn next_fire(cron: &str, tz: &str, now_ms: u64) -> Option<u64> {
36 let schedule = Schedule::from_str(cron.trim()).ok()?;
37 let tz: Tz = tz.parse().unwrap_or(chrono_tz::UTC);
38 let now = tz.timestamp_millis_opt(now_ms as i64).single()?;
39 schedule
40 .after(&now)
41 .next()
42 .map(|dt| dt.timestamp_millis() as u64)
43}
44
45pub fn next_run(settings: &Value, last_start_ms: Option<u64>, now_ms: u64) -> Option<NextRun> {
49 if let Some(cron) = settings["schedule"]["quartz_cron_expression"].as_str() {
50 let tz = settings["schedule"]["timezone_id"]
51 .as_str()
52 .unwrap_or("UTC");
53 let paused = settings["schedule"]["pause_status"].as_str() == Some("PAUSED");
54 let desc = format!("cron {cron} · {tz}");
55 if paused {
56 return Some(NextRun {
57 at_ms: None,
58 label: "⏸ paused".to_string(),
59 desc,
60 paused: true,
61 });
62 }
63 return Some(match next_fire(cron, tz, now_ms) {
64 Some(at) => NextRun {
65 at_ms: Some(at),
66 label: fmt_eta(at, now_ms),
67 desc,
68 paused: false,
69 },
70 None => NextRun {
71 at_ms: None,
72 label: "scheduled".to_string(),
73 desc,
74 paused: false,
75 },
76 });
77 }
78
79 let continuous = &settings["continuous"];
80 if continuous.is_object() {
81 let paused = continuous["pause_status"].as_str() == Some("PAUSED");
82 return Some(NextRun {
83 at_ms: None,
84 label: if paused { "⏸ paused" } else { "continuous" }.to_string(),
85 desc: "continuous".to_string(),
86 paused,
87 });
88 }
89
90 let trigger = &settings["trigger"];
91 if trigger.is_object() {
92 let paused = trigger["pause_status"].as_str() == Some("PAUSED");
93 if let (Some(n), Some(unit)) = (
94 trigger["periodic"]["interval"].as_u64(),
95 trigger["periodic"]["unit"].as_str(),
96 ) {
97 let unit_ms = match unit {
98 "HOURS" => 3_600_000,
99 "DAYS" => 86_400_000,
100 "WEEKS" => 604_800_000,
101 _ => 0,
102 };
103 let unit_word = unit.to_lowercase();
104 let unit_word = if n == 1 {
105 unit_word.trim_end_matches('s')
106 } else {
107 &unit_word
108 };
109 let desc = format!("every {n} {unit_word}");
110 if paused {
111 return Some(NextRun {
112 at_ms: None,
113 label: "⏸ paused".to_string(),
114 desc,
115 paused: true,
116 });
117 }
118 let at = last_start_ms
121 .filter(|_| unit_ms > 0)
122 .map(|s| s + n * unit_ms)
123 .filter(|at| *at > now_ms);
124 return Some(match at {
125 Some(at) => NextRun {
126 at_ms: Some(at),
127 label: format!("~{}", fmt_eta(at, now_ms)),
128 desc,
129 paused: false,
130 },
131 None => NextRun {
132 at_ms: None,
133 label: desc.clone(),
134 desc,
135 paused: false,
136 },
137 });
138 }
139 if trigger["file_arrival"].is_object() {
140 return Some(NextRun {
141 at_ms: None,
142 label: if paused {
143 "⏸ paused"
144 } else {
145 "on file arrival"
146 }
147 .to_string(),
148 desc: "file arrival trigger".to_string(),
149 paused,
150 });
151 }
152 }
153 None
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use serde_json::json;
160
161 const NOW: u64 = 1_784_376_000_000;
163
164 #[test]
165 fn quartz_daily_cron_fires_next_day() {
166 let at = next_fire("0 0 2 * * ?", "UTC", NOW).unwrap();
168 assert!(at > NOW);
169 assert_eq!((at - NOW) % 86_400_000 % 3_600_000, 0);
170 assert!(at - NOW < 86_400_000);
171 }
172
173 #[test]
174 fn timezone_shifts_the_fire_time() {
175 let utc = next_fire("0 0 2 * * ?", "UTC", NOW).unwrap();
176 let tokyo = next_fire("0 0 2 * * ?", "Asia/Tokyo", NOW).unwrap();
177 assert_ne!(utc, tokyo);
178 }
179
180 #[test]
181 fn bad_cron_is_none() {
182 assert!(next_fire("not a cron", "UTC", NOW).is_none());
183 }
184
185 #[test]
186 fn paused_schedule_has_no_time() {
187 let s = json!({"schedule": {
188 "quartz_cron_expression": "0 0 2 * * ?",
189 "timezone_id": "UTC",
190 "pause_status": "PAUSED"
191 }});
192 let n = next_run(&s, None, NOW).unwrap();
193 assert!(n.paused);
194 assert!(n.at_ms.is_none());
195 }
196
197 #[test]
198 fn periodic_trigger_estimates_from_last_start() {
199 let s = json!({"trigger": {"periodic": {"interval": 4, "unit": "HOURS"}}});
200 let n = next_run(&s, Some(NOW - 3_600_000), NOW).unwrap();
201 assert_eq!(n.at_ms, Some(NOW + 3 * 3_600_000));
202 assert_eq!(n.label, "~in 3h 0m");
203 }
204
205 #[test]
206 fn on_demand_job_is_none() {
207 assert!(next_run(&json!({"name": "adhoc"}), None, NOW).is_none());
208 }
209
210 #[test]
211 fn eta_formats() {
212 assert_eq!(fmt_eta(NOW + 30_000, NOW), "in <1m");
213 assert_eq!(fmt_eta(NOW + 27 * 60_000, NOW), "in 27m");
214 assert_eq!(fmt_eta(NOW + 3 * 86_400_000, NOW), "in 3d");
215 }
216}