1use crate::workflow::humanize_age_ms;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ScheduleRow {
8 pub namespace: String,
9 pub schedule_id: String,
10 pub workflow_type: String,
11 pub paused: bool,
12 pub notes: String,
13 pub spec: String,
15 pub next_run: Option<i64>,
17 pub recent_runs: usize,
18}
19
20impl ScheduleRow {
21 pub fn key(&self) -> (&str, &str) {
24 (self.namespace.as_str(), self.schedule_id.as_str())
25 }
26
27 pub fn glyph(&self) -> char {
30 if self.paused { '‖' } else { '●' }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub struct Range {
37 pub start: i32,
38 pub end: i32,
39 pub step: i32,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct Calendar {
49 pub second: Vec<Range>,
50 pub minute: Vec<Range>,
51 pub hour: Vec<Range>,
52 pub day_of_month: Vec<Range>,
53 pub month: Vec<Range>,
54 pub day_of_week: Vec<Range>,
55}
56
57pub fn describe_calendar(c: &Calendar) -> String {
62 if [
66 &c.second,
67 &c.minute,
68 &c.hour,
69 &c.day_of_month,
70 &c.month,
71 &c.day_of_week,
72 ]
73 .iter()
74 .any(|f| f.is_empty())
75 {
76 return "never".to_string();
77 }
78
79 let minute = field(&c.minute, 0, 59);
80 let hour = field(&c.hour, 0, 23);
81 let dom = field(&c.day_of_month, 1, 31);
82 let month = field(&c.month, 1, 12);
83 let dow = field(&c.day_of_week, 0, 6);
84 let five = format!("{minute} {hour} {dom} {month} {dow}");
85
86 let second = field(&c.second, 0, 59);
87 if second == "0" {
88 five
89 } else {
90 format!("{second} {five}")
91 }
92}
93
94fn field(ranges: &[Range], min: i32, max: i32) -> String {
96 let parts: Vec<String> = ranges
97 .iter()
98 .map(|r| {
99 let step = r.step.max(1);
100 let covers_all = r.start <= min && r.end >= max;
101 match (covers_all, step) {
102 (true, 1) => "*".to_string(),
103 (true, s) => format!("*/{s}"),
104 (false, 1) if r.start == r.end => r.start.to_string(),
105 (false, 1) => format!("{}-{}", r.start, r.end),
106 (false, s) => format!("{}-{}/{s}", r.start, r.end),
107 }
108 })
109 .collect();
110 parts.join(",")
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Interval {
116 pub every_secs: i64,
117 pub offset_secs: i64,
118}
119
120pub fn describe_spec(cron: &[String], calendars: &[Calendar], intervals: &[Interval]) -> String {
129 let mut parts: Vec<String> = cron.iter().filter(|c| !c.is_empty()).cloned().collect();
130 parts.extend(calendars.iter().map(describe_calendar));
131
132 for i in intervals {
133 let every = humanize_duration(i.every_secs);
134 parts.push(if i.offset_secs == 0 {
135 format!("every {every}")
136 } else {
137 format!("every {every} at +{}", humanize_duration(i.offset_secs))
138 });
139 }
140
141 if parts.is_empty() {
142 "manual".to_string()
144 } else {
145 parts.join(", ")
146 }
147}
148
149fn humanize_duration(secs: i64) -> String {
151 match secs {
152 s if s <= 0 => "0s".into(),
153 s if s % 86_400 == 0 => format!("{}d", s / 86_400),
154 s if s % 3_600 == 0 => format!("{}h", s / 3_600),
155 s if s % 60 == 0 => format!("{}m", s / 60),
156 s => format!("{s}s"),
157 }
158}
159
160pub fn time_until(next_run: Option<i64>, now: i64) -> Option<String> {
166 let at = next_run?;
167 Some(if at <= now {
168 "due".to_string()
169 } else {
170 humanize_age_ms(at - now)
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn every(secs: i64) -> Interval {
179 Interval {
180 every_secs: secs,
181 offset_secs: 0,
182 }
183 }
184
185 #[test]
186 fn a_cron_string_is_shown_verbatim() {
187 assert_eq!(
190 describe_spec(&["0 9 * * 1-5".into()], &[], &[]),
191 "0 9 * * 1-5"
192 );
193 }
194
195 #[test]
196 fn an_interval_reads_as_a_period() {
197 assert_eq!(describe_spec(&[], &[], &[every(3_600)]), "every 1h");
198 assert_eq!(describe_spec(&[], &[], &[every(86_400)]), "every 1d");
199 assert_eq!(describe_spec(&[], &[], &[every(300)]), "every 5m");
200 assert_eq!(describe_spec(&[], &[], &[every(45)]), "every 45s");
201 }
202
203 #[test]
204 fn an_offset_interval_says_where_it_lands() {
205 let i = Interval {
206 every_secs: 86_400,
207 offset_secs: 32_400,
208 };
209 assert_eq!(describe_spec(&[], &[], &[i]), "every 1d at +9h");
210 }
211
212 #[test]
213 fn several_rules_are_joined_rather_than_one_being_picked() {
214 let out = describe_spec(&["0 9 * * 1-5".into()], &[], &[every(3_600)]);
217 assert_eq!(out, "0 9 * * 1-5, every 1h");
218 }
219
220 fn at(field: &[(i32, i32)]) -> Vec<Range> {
221 field
222 .iter()
223 .map(|(a, b)| Range {
224 start: *a,
225 end: *b,
226 step: 1,
227 })
228 .collect()
229 }
230
231 #[test]
232 fn a_cron_schedule_is_stored_as_a_calendar_and_reads_back_as_cron() {
233 let c = Calendar {
237 second: at(&[(0, 0)]),
238 minute: at(&[(0, 0)]),
239 hour: at(&[(2, 2)]),
240 day_of_month: at(&[(1, 31)]),
241 month: at(&[(1, 12)]),
242 day_of_week: at(&[(0, 6)]),
243 };
244 assert_eq!(describe_calendar(&c), "0 2 * * *");
245 assert_eq!(describe_spec(&[], &[c], &[]), "0 2 * * *");
246 }
247
248 fn full() -> Calendar {
250 Calendar {
251 second: at(&[(0, 0)]),
252 minute: at(&[(0, 0)]),
253 hour: at(&[(0, 23)]),
254 day_of_month: at(&[(1, 31)]),
255 month: at(&[(1, 12)]),
256 day_of_week: at(&[(0, 6)]),
257 }
258 }
259
260 #[test]
261 fn a_full_range_is_a_star_and_a_step_keeps_its_slash() {
262 let mut c = full();
263 c.minute = vec![Range {
264 start: 0,
265 end: 59,
266 step: 15,
267 }];
268 c.hour = at(&[(9, 17)]);
269 assert_eq!(describe_calendar(&c), "*/15 9-17 * * *");
270 }
271
272 #[test]
273 fn a_seconds_field_shows_only_when_it_is_not_zero() {
274 let mut c = full();
275 c.second = at(&[(30, 30)]);
276 assert_eq!(describe_calendar(&c), "30 0 * * * *", "six fields");
277 assert_eq!(describe_calendar(&full()), "0 * * * *", "five fields");
278 }
279
280 #[test]
281 fn a_calendar_missing_a_field_never_fires() {
282 let mut c = full();
285 c.hour = Vec::new();
286 assert_eq!(describe_calendar(&c), "never");
287 }
288
289 #[test]
290 fn a_spec_with_no_rules_is_manual() {
291 assert_eq!(describe_spec(&[], &[], &[]), "manual");
293 assert_eq!(describe_spec(&[String::new()], &[], &[]), "manual");
294 }
295
296 #[test]
297 fn the_next_run_reads_as_a_countdown() {
298 let now = 1_000_000;
299 assert_eq!(
300 time_until(Some(now + 3_600_000), now).as_deref(),
301 Some("1h")
302 );
303 assert_eq!(time_until(Some(now + 45_000), now).as_deref(), Some("45s"));
304 assert_eq!(time_until(None, now), None);
305 }
306
307 #[test]
308 fn a_run_that_is_already_due_says_so_rather_than_showing_zero() {
309 let now = 1_000_000;
310 assert_eq!(time_until(Some(now), now).as_deref(), Some("due"));
311 assert_eq!(time_until(Some(now - 5_000), now).as_deref(), Some("due"));
312 }
313
314 #[test]
315 fn paused_shows_in_the_glyph() {
316 let row = |paused| ScheduleRow {
317 namespace: "d".into(),
318 schedule_id: "s".into(),
319 workflow_type: "W".into(),
320 paused,
321 notes: String::new(),
322 spec: "every 1h".into(),
323 next_run: None,
324 recent_runs: 0,
325 };
326 assert_ne!(row(true).glyph(), row(false).glyph());
327 assert_eq!(row(false).key(), ("d", "s"));
328 }
329}