1use chrono::{DateTime, Utc};
4
5use crate::{CatchUpPolicy, NodeRegistry, ScheduleCadence, SchedulePolicy, Spec};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct ScheduleDecision {
11 pub tick_at: Option<DateTime<Utc>>,
13 pub next_at: DateTime<Utc>,
15 pub catch_up_count: u32,
17}
18
19pub fn next_cron_at(expression: &str, after: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
21 expression
22 .parse::<cron::Schedule>()
23 .map_err(|error| format!("invalid cron '{expression}': {error}"))?
24 .after(&after)
25 .next()
26 .ok_or_else(|| format!("cron '{expression}' has no future occurrence"))
27}
28
29pub fn next_scheduled_at(
31 policy: &SchedulePolicy,
32 after: DateTime<Utc>,
33 last_completed_at: Option<DateTime<Utc>>,
34) -> Result<DateTime<Utc>, String> {
35 policy.validate().map_err(|error| error.to_string())?;
36 match &policy.cadence {
37 ScheduleCadence::Cron { expression } => {
38 let timezone = policy
39 .timezone
40 .parse::<chrono_tz::Tz>()
41 .map_err(|_| format!("unknown IANA timezone '{}'", policy.timezone))?;
42 expression
43 .parse::<cron::Schedule>()
44 .map_err(|error| format!("invalid cron '{expression}': {error}"))?
45 .after(&after.with_timezone(&timezone))
46 .next()
47 .map(|next| next.with_timezone(&Utc))
48 .ok_or_else(|| format!("cron '{expression}' has no future occurrence"))
49 }
50 ScheduleCadence::FixedRate { milliseconds } => after
51 .checked_add_signed(chrono::Duration::milliseconds(
52 i64::try_from(*milliseconds).map_err(|_| "fixed rate exceeds i64")?,
53 ))
54 .ok_or_else(|| "fixed rate overflows time".into()),
55 ScheduleCadence::FixedDelay { milliseconds } => last_completed_at
56 .unwrap_or(after)
57 .checked_add_signed(chrono::Duration::milliseconds(
58 i64::try_from(*milliseconds).map_err(|_| "fixed delay exceeds i64")?,
59 ))
60 .ok_or_else(|| "fixed delay overflows time".into()),
61 }
62}
63
64fn first_after_now(
65 policy: &SchedulePolicy,
66 scheduled_at: DateTime<Utc>,
67 now: DateTime<Utc>,
68) -> Result<DateTime<Utc>, String> {
69 match &policy.cadence {
70 ScheduleCadence::FixedRate { milliseconds } => {
71 let period = i64::try_from(*milliseconds).map_err(|_| "fixed rate exceeds i64")?;
72 let overdue = now
73 .signed_duration_since(scheduled_at)
74 .num_milliseconds()
75 .max(0);
76 let periods = overdue / period + 1;
77 scheduled_at
78 .checked_add_signed(chrono::Duration::milliseconds(
79 period.saturating_mul(periods),
80 ))
81 .ok_or_else(|| "fixed rate overflows time".into())
82 }
83 ScheduleCadence::FixedDelay { .. } => next_scheduled_at(policy, now, Some(now)),
84 ScheduleCadence::Cron { .. } => next_scheduled_at(policy, now, None),
85 }
86}
87
88pub fn schedule_decision(
92 policy: &SchedulePolicy,
93 scheduled_at: DateTime<Utc>,
94 now: DateTime<Utc>,
95 catch_up_count: u32,
96) -> Result<ScheduleDecision, String> {
97 let next_due = next_scheduled_at(policy, scheduled_at, Some(now))?;
98 let has_missed_window =
99 !matches!(&policy.cadence, ScheduleCadence::FixedDelay { .. }) && next_due <= now;
100 if !has_missed_window {
101 return Ok(ScheduleDecision {
102 tick_at: Some(scheduled_at),
103 next_at: next_due,
104 catch_up_count: 0,
105 });
106 }
107 match policy.catch_up {
108 CatchUpPolicy::Skip => Ok(ScheduleDecision {
109 tick_at: None,
110 next_at: first_after_now(policy, scheduled_at, now)?,
111 catch_up_count: 0,
112 }),
113 CatchUpPolicy::CatchUpOnce => Ok(ScheduleDecision {
114 tick_at: Some(scheduled_at),
115 next_at: first_after_now(policy, scheduled_at, now)?,
116 catch_up_count: 0,
117 }),
118 CatchUpPolicy::CatchUpAll { limit } => {
119 let completed = catch_up_count.saturating_add(1);
120 let keep_catching_up = completed < limit && next_due <= now;
121 Ok(ScheduleDecision {
122 tick_at: Some(scheduled_at),
123 next_at: if keep_catching_up {
124 next_due
125 } else {
126 first_after_now(policy, scheduled_at, now)?
127 },
128 catch_up_count: u32::from(keep_catching_up) * completed,
129 })
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq)]
136pub struct BranchIngressPlan {
137 pub branch_id: String,
139 pub ingress_type: String,
141 pub ingress_config: serde_json::Value,
143}
144
145pub fn ingress_plans(spec: &Spec, registry: &NodeRegistry) -> Vec<BranchIngressPlan> {
147 spec.branches
148 .iter()
149 .filter_map(|branch| {
150 let ingress = branch
151 .nodes
152 .iter()
153 .find(|node| registry.is_ingress(&node.node_type))?;
154 Some(BranchIngressPlan {
155 branch_id: branch.branch_id.clone(),
156 ingress_type: ingress.node_type.clone(),
157 ingress_config: ingress.config.clone(),
158 })
159 })
160 .collect()
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use chrono::TimeZone;
167
168 #[test]
169 fn timezone_and_fixed_delay_are_explicit() {
170 let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
171 let cron = SchedulePolicy {
172 cadence: ScheduleCadence::Cron {
173 expression: "0 30 9 * * * *".into(),
174 },
175 timezone: "Asia/Shanghai".into(),
176 catch_up: CatchUpPolicy::CatchUpOnce,
177 };
178 assert_eq!(
179 next_scheduled_at(&cron, after, None).unwrap(),
180 Utc.with_ymd_and_hms(2026, 1, 1, 1, 30, 0).unwrap()
181 );
182
183 let delay = SchedulePolicy {
184 cadence: ScheduleCadence::FixedDelay {
185 milliseconds: 1_000,
186 },
187 timezone: "UTC".into(),
188 catch_up: CatchUpPolicy::CatchUpOnce,
189 };
190 assert_eq!(
191 next_scheduled_at(&delay, after, Some(after + chrono::Duration::seconds(10))).unwrap(),
192 after + chrono::Duration::seconds(11)
193 );
194 assert!(SchedulePolicy {
195 timezone: "not/a-zone".into(),
196 ..delay
197 }
198 .validate()
199 .is_err());
200 }
201
202 #[test]
203 fn catch_up_policies_advance_the_persisted_cursor_without_a_burst() {
204 let scheduled = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
205 let now = scheduled + chrono::Duration::seconds(35);
206 let policy = |catch_up| SchedulePolicy {
207 cadence: ScheduleCadence::FixedRate {
208 milliseconds: 10_000,
209 },
210 timezone: "UTC".into(),
211 catch_up,
212 };
213 let skipped = schedule_decision(&policy(CatchUpPolicy::Skip), scheduled, now, 0).unwrap();
214 assert_eq!(skipped.tick_at, None);
215 assert_eq!(skipped.next_at, scheduled + chrono::Duration::seconds(40));
216
217 let once =
218 schedule_decision(&policy(CatchUpPolicy::CatchUpOnce), scheduled, now, 0).unwrap();
219 assert_eq!(once.tick_at, Some(scheduled));
220 assert_eq!(once.next_at, scheduled + chrono::Duration::seconds(40));
221
222 let all = policy(CatchUpPolicy::CatchUpAll { limit: 2 });
223 let first = schedule_decision(&all, scheduled, now, 0).unwrap();
224 assert_eq!(first.next_at, scheduled + chrono::Duration::seconds(10));
225 assert_eq!(first.catch_up_count, 1);
226 let second = schedule_decision(&all, first.next_at, now, first.catch_up_count).unwrap();
227 assert_eq!(second.next_at, scheduled + chrono::Duration::seconds(40));
228 assert_eq!(second.catch_up_count, 0);
229 }
230
231 #[test]
232 fn cron_uses_timezone_rules_across_dst() {
233 let policy = SchedulePolicy {
234 cadence: ScheduleCadence::Cron {
235 expression: "0 30 2 * * * *".into(),
236 },
237 timezone: "America/New_York".into(),
238 catch_up: CatchUpPolicy::CatchUpOnce,
239 };
240 let before_spring_forward = Utc.with_ymd_and_hms(2026, 3, 8, 6, 59, 0).unwrap();
241 assert_eq!(
242 next_scheduled_at(&policy, before_spring_forward, None).unwrap(),
243 Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap(),
244 "nonexistent local times are skipped by chrono-tz/cron"
245 );
246 }
247
248 #[test]
249 fn next_cron_at_reports_invalid_expressions() {
250 let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
251 assert_eq!(
252 next_cron_at("0 0 12 * * * *", after).unwrap(),
253 Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 0).unwrap()
254 );
255 assert!(next_cron_at("not a cron", after)
256 .unwrap_err()
257 .contains("invalid cron"));
258 }
259
260 #[test]
261 fn next_scheduled_at_rejects_unparseable_cron_and_fixed_rate_measures_from_after() {
262 let after = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
263 let broken = SchedulePolicy {
264 cadence: ScheduleCadence::Cron {
265 expression: "every day".into(),
266 },
267 timezone: "UTC".into(),
268 catch_up: CatchUpPolicy::Skip,
269 };
270 assert!(next_scheduled_at(&broken, after, None).is_err());
271 let rate = SchedulePolicy {
272 cadence: ScheduleCadence::FixedRate {
273 milliseconds: 1_500,
274 },
275 timezone: "UTC".into(),
276 catch_up: CatchUpPolicy::Skip,
277 };
278 assert_eq!(
279 next_scheduled_at(&rate, after, Some(after + chrono::Duration::hours(1))).unwrap(),
280 after + chrono::Duration::milliseconds(1_500)
281 );
282 }
283}