1use std::collections::{BTreeMap, BTreeSet};
16
17use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
18use chrono_tz::Tz;
19use serde_json::{Map as JsonMap, Value as Json, json};
20use sha2::{Digest, Sha256};
21
22use crate::project::{Project, ScheduleEntry};
23use crate::schedule;
24
25pub const FORMAT_VERSION: u64 = 1;
27pub const MAX_WINDOW_DAYS: i64 = 366;
29pub const DEFAULT_WINDOW_DAYS: i64 = 35;
31const MAX_FIRINGS: usize = u16::MAX as usize;
33
34#[derive(Debug, Clone, Copy)]
36pub struct Window {
37 pub from: DateTime<Utc>,
38 pub to: DateTime<Utc>,
39}
40
41impl Window {
42 pub fn new(from: DateTime<Utc>, to: DateTime<Utc>) -> Result<Self, String> {
44 if to <= from {
45 return Err(format!(
46 "--to ({}) must be after --from ({})",
47 rfc3339(to),
48 rfc3339(from)
49 ));
50 }
51 if to - from > Duration::days(MAX_WINDOW_DAYS) {
52 return Err(format!(
53 "the window from {} to {} is longer than {MAX_WINDOW_DAYS} days; ask for less, in several calls if needed",
54 rfc3339(from),
55 rfc3339(to)
56 ));
57 }
58 Ok(Window { from, to })
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct Request {
65 pub window: Window,
66 pub schedules: Vec<String>,
68 pub select: Option<String>,
70 pub limit: Option<usize>,
72 pub split: bool,
74}
75
76pub use crate::run::rfc3339;
77
78pub fn firing_tz(project: &Project, e: &ScheduleEntry) -> Tz {
81 e.timezone
82 .as_ref()
83 .or(project.timezone.as_ref())
84 .and_then(|t| crate::dates::parse_tz(t).ok())
85 .unwrap_or(Tz::UTC)
86}
87
88pub fn bindings<'a>(project: &'a Project, name: &str) -> Vec<(&'a str, Option<&'a str>)> {
90 project
91 .reports
92 .iter()
93 .flat_map(|r| {
94 r.bindings
95 .iter()
96 .filter(|b| b.schedules.iter().any(|s| s == name))
97 .map(|b| (r.name.as_str(), b.set.as_deref()))
98 })
99 .collect()
100}
101
102pub fn expand(timing: &JsonMap<String, Json>, tz: Tz, window: &Window) -> Result<Vec<DateTime<Utc>>, String> {
105 let lo = window.from.naive_utc() - Duration::days(2);
107 let hi = window.to.naive_utc() + Duration::days(2);
108 let s = |k: &str| timing.get(k).and_then(Json::as_str);
109 let starting = s("starting").and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok());
110 let at = s("at").and_then(|t| NaiveTime::parse_from_str(t, "%H:%M").ok());
111 let mut times = if let Some(expr) = s("cron") {
112 cron_times(expr, lo, hi)?
113 } else if let Some(rule) = s("rrule") {
114 rule_times(rule, starting, at, lo, hi)?
115 } else if let Some(every) = timing.get("every").and_then(Json::as_object) {
116 let (unit, n) = every.iter().next().ok_or("`every` has no unit")?;
117 let freq = match unit.as_str() {
118 "days" => "DAILY",
119 "weeks" => "WEEKLY",
120 "months" => "MONTHLY",
121 u => return Err(format!("unknown `every` unit `{u}`")),
122 };
123 if starting.is_none() {
124 return Err("`every` needs `starting`".into());
125 }
126 let rule = format!("FREQ={freq};INTERVAL={}", n.as_u64().unwrap_or(1));
127 rule_times(&rule, starting, at, lo, hi)?
128 } else {
129 return Err("a schedule needs one of `cron`, `every` or `rrule`".into());
130 };
131 let dates = |k: &str| -> BTreeSet<NaiveDate> {
132 timing
133 .get(k)
134 .and_then(Json::as_array)
135 .into_iter()
136 .flatten()
137 .filter_map(|d| {
138 d.as_str()
139 .and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
140 })
141 .collect()
142 };
143 let except = dates("except");
144 times.retain(|t| !except.contains(&t.date()));
145 let also = dates("also");
146 if !also.is_empty() {
147 let time =
148 schedule::time_of_day(timing).ok_or("`also` needs a schedule that fires at one time of day")?;
149 times.extend(also.iter().map(|d| d.and_time(time)));
150 }
151 let fired: BTreeSet<DateTime<Utc>> = times
152 .into_iter()
153 .map(|t| place(tz, t))
154 .filter(|t| *t >= window.from && *t < window.to)
155 .collect();
156 Ok(fired.into_iter().collect())
157}
158
159pub fn place(tz: Tz, t: NaiveDateTime) -> DateTime<Utc> {
162 let mut m = t;
163 for _ in 0..=24 * 60 {
165 if let Some(x) = tz.from_local_datetime(&m).earliest() {
166 return x.with_timezone(&Utc);
167 }
168 m += Duration::minutes(1);
169 }
170 Utc.from_utc_datetime(&t)
171}
172
173const CRON_MACROS: &[(&str, &str)] = &[
174 ("@yearly", "0 0 1 1 *"),
175 ("@annually", "0 0 1 1 *"),
176 ("@monthly", "0 0 1 * *"),
177 ("@weekly", "0 0 * * 0"),
178 ("@daily", "0 0 * * *"),
179 ("@midnight", "0 0 * * *"),
180 ("@hourly", "0 * * * *"),
181];
182
183fn cron_times(expr: &str, lo: NaiveDateTime, hi: NaiveDateTime) -> Result<Vec<NaiveDateTime>, String> {
186 schedule::validate_cron(expr)?;
187 let expr = expr.trim();
188 let expr = CRON_MACROS
189 .iter()
190 .find(|(m, _)| *m == expr)
191 .map(|(_, e)| *e)
192 .unwrap_or(expr)
193 .to_ascii_uppercase();
194 let fields: Vec<&str> = expr.split_whitespace().collect();
195 let both = fields[2].starts_with('*') || fields[4].starts_with('*');
196 let cron = croner::parser::CronParser::builder()
197 .seconds(croner::parser::Seconds::Disallowed)
198 .year(croner::parser::Year::Disallowed)
199 .dom_and_dow(both)
200 .build()
201 .parse(&expr)
202 .map_err(|e| format!("can't expand cron `{expr}`: {e}"))?;
203 let mut out = Vec::new();
204 for t in cron.iter_from(lo, croner::Direction::Forward) {
205 if t > hi {
206 break;
207 }
208 out.push(t);
209 if out.len() > MAX_FIRINGS * 8 {
212 return Err(format!("cron `{expr}` fires too often to list"));
213 }
214 }
215 Ok(out)
216}
217
218fn rule_times(
221 rule: &str,
222 starting: Option<NaiveDate>,
223 at: Option<NaiveTime>,
224 lo: NaiveDateTime,
225 hi: NaiveDateTime,
226) -> Result<Vec<NaiveDateTime>, String> {
227 let parts = schedule::rule_parts(rule).map_err(|e| format!("invalid rrule `{rule}`: {e}"))?;
228 let sets_time = parts.iter().any(|(k, _)| k == "BYHOUR" || k == "BYMINUTE");
229 let time = if sets_time {
230 NaiveTime::MIN
231 } else {
232 at.unwrap_or(NaiveTime::MIN)
233 };
234 let start = starting.unwrap_or(lo.date()).and_time(time);
235 let body: Vec<String> = parts
237 .iter()
238 .map(|(k, v)| match k.as_str() {
239 "UNTIL" if v.len() == 8 => format!("UNTIL={v}T235959Z"),
240 "UNTIL" => format!("UNTIL={}Z", v.trim_end_matches('Z')),
241 _ => format!("{k}={v}"),
242 })
243 .collect();
244 let text = format!(
245 "DTSTART:{}Z\nRRULE:{}",
246 start.format("%Y%m%dT%H%M%S"),
247 body.join(";")
248 );
249 let set: rrule::RRuleSet = text
250 .parse()
251 .map_err(|e| format!("can't expand rrule `{rule}`: {e}"))?;
252 let utc = |n: NaiveDateTime| rrule::Tz::UTC.from_utc_datetime(&n);
253 let found = set.after(utc(lo)).before(utc(hi)).all(u16::MAX);
254 if found.limited && found.dates.len() >= MAX_FIRINGS {
255 return Err(format!(
256 "rrule `{rule}` fires more than {MAX_FIRINGS} times in the window; ask for a shorter one"
257 ));
258 }
259 Ok(found.dates.iter().map(|d| d.naive_utc()).collect())
260}
261
262fn hash(v: &Json) -> String {
264 let text = format!(
265 "dre_schedule_v{FORMAT_VERSION}\n{}",
266 crate::manifest::canonical(v)
267 );
268 crate::manifest::hex(&Sha256::digest(text.as_bytes()))
269}
270
271fn definition(project: &Project, e: &ScheduleEntry) -> Json {
274 json!({
275 "timing": e.timing,
276 "schedule": e.schedule,
277 "timezone": firing_tz(project, e).name(),
278 "vars": e.vars,
279 "enabled": e.enabled,
280 "bindings": bindings(project, &e.name)
281 .iter()
282 .map(|(r, s)| json!({"report": r, "set": s}))
283 .collect::<Vec<_>>(),
284 })
285}
286
287pub fn definition_hash(project: &Project, e: &ScheduleEntry) -> String {
289 hash(&definition(project, e))
290}
291
292pub fn project_hash(project: &Project) -> String {
295 let all: BTreeMap<&str, String> = project
296 .schedules
297 .iter()
298 .map(|e| (e.name.as_str(), definition_hash(project, e)))
299 .collect();
300 hash(&json!(all))
301}
302
303fn chosen<'a>(project: &'a Project, req: &Request) -> Result<Vec<&'a ScheduleEntry>, String> {
305 for n in &req.schedules {
306 if !project.schedules.iter().any(|e| &e.name == n) {
307 return Err(crate::run::unknown_schedule(project, n));
308 }
309 }
310 let reports: Option<BTreeSet<&str>> = match &req.select {
311 None => None,
312 Some(s) => match crate::selector::resolve(project, s) {
313 Ok(r) if r.is_empty() => return Err(format!("selector `{s}` matches no report")),
314 Ok(r) => Some(r.into_iter().map(|r| r.name.as_str()).collect()),
315 Err(e) => return Err(e.to_string()),
316 },
317 };
318 Ok(project
319 .schedules
320 .iter()
321 .filter(|e| req.schedules.is_empty() || req.schedules.contains(&e.name))
322 .filter(|e| match &reports {
323 None => true,
324 Some(rs) => bindings(project, &e.name).iter().any(|(r, _)| rs.contains(r)),
325 })
326 .collect())
327}
328
329fn invocation(
332 name: &str,
333 binding: Option<(&str, Option<&str>)>,
334 at: DateTime<Utc>,
335 run_date: NaiveDate,
336) -> Json {
337 let mut argv = vec![
338 "dre".to_string(),
339 "run".into(),
340 "--schedule".into(),
341 name.to_string(),
342 ];
343 if let Some((report, set)) = binding {
344 argv.extend(["-s".into(), report.to_string()]);
345 if let Some(set) = set {
346 argv.extend(["--set".into(), set.to_string()]);
347 }
348 }
349 json!({
350 "argv": argv,
351 "env": {"DRE_RUN_AT": rfc3339(at), "DRE_RUN_DATE": run_date.to_string()},
352 })
353}
354
355fn binding_json(report: &str, set: Option<&str>) -> Json {
356 json!({"report": report, "set": set, "binding": set.unwrap_or("default")})
357}
358
359pub fn document(project: &Project, req: &Request) -> Result<Json, String> {
362 let entries = chosen(project, req)?;
363 let mut schedules = JsonMap::new();
364 let mut occurrences: Vec<(DateTime<Utc>, String, Json)> = Vec::new();
365 let mut problems = Vec::new();
366 for e in entries {
367 let tz = firing_tz(project, e);
368 let bs = bindings(project, &e.name);
369 schedules.insert(
370 e.name.clone(),
371 json!({
372 "definition_hash": definition_hash(project, e),
373 "enabled": e.enabled,
374 "timing": e.timing,
375 "timezone": tz.name(),
376 "vars": e.vars,
377 "bindings": bs.iter().map(|(r, s)| binding_json(r, *s)).collect::<Vec<_>>(),
378 }),
379 );
380 if !e.enabled {
381 continue;
382 }
383 let strict = schedule::strictness(&e.schedule);
384 if !strict.is_empty() {
385 for (code, message) in strict {
386 problems.push(json!({"schedule": e.name, "code": code, "message": message}));
387 }
388 continue;
389 }
390 let fired = match expand(&e.schedule, tz, &req.window) {
391 Ok(f) => f,
392 Err(message) => {
393 problems
394 .push(json!({"schedule": e.name, "code": "schedule-not-expandable", "message": message}));
395 continue;
396 }
397 };
398 for at in fired.into_iter().take(req.limit.unwrap_or(usize::MAX)) {
399 let local = at.with_timezone(&tz);
400 let run_date = local.date_naive();
401 let occurrence = |key: &str, bindings: Json, invocation: Json| {
402 json!({
403 "key": key,
404 "schedule": e.name,
405 "timing": e.timing,
406 "fires_at": rfc3339(at),
407 "fires_at_local": local.to_rfc3339_opts(chrono::SecondsFormat::Secs, false),
408 "timezone": tz.name(),
409 "run_date": run_date.to_string(),
410 "bindings": bindings,
411 "vars": e.vars,
412 "invocation": invocation,
413 })
414 };
415 let key = format!("{}/{}", e.name, rfc3339(at));
416 if req.split {
417 for (r, s) in &bs {
418 let key = format!("{key}/{r}/{}", s.unwrap_or("default"));
419 let o = occurrence(
420 &key,
421 json!([binding_json(r, *s)]),
422 invocation(&e.name, Some((r, *s)), at, run_date),
423 );
424 occurrences.push((at, key, o));
425 }
426 } else {
427 let all = bs.iter().map(|(r, s)| binding_json(r, *s)).collect::<Vec<_>>();
428 let o = occurrence(&key, json!(all), invocation(&e.name, None, at, run_date));
429 occurrences.push((at, key, o));
430 }
431 }
432 }
433 occurrences.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
434 Ok(json!({
435 "dre_schedule_version": FORMAT_VERSION,
436 "dre_version": crate::version(),
437 "project": project.name,
438 "window": {"from": rfc3339(req.window.from), "to": rfc3339(req.window.to)},
439 "split": req.split,
440 "complete": req.schedules.is_empty() && req.select.is_none(),
441 "project_hash": project_hash(project),
442 "schedules": schedules,
443 "occurrences": occurrences.into_iter().map(|(_, _, o)| o).collect::<Vec<_>>(),
444 "problems": problems,
445 }))
446}