1use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::claude_runtime_state::{
14 ClaudeCronJob, ClaudeRuntimeExecutionState, ClaudeRuntimeManifest, ClaudeWakeup,
15};
16use crate::{Error, Result};
17
18fn default_timezone() -> String {
19 "UTC".to_string()
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ClaudeRuntimeSchedulerState {
26 #[serde(default)]
28 pub activated_at_unix: Option<i64>,
29 #[serde(default = "default_timezone")]
32 pub timezone: String,
33 #[serde(default)]
35 pub cron_jobs: Vec<ClaudeCronScheduleState>,
36 #[serde(default)]
38 pub wakeups: Vec<ClaudeWakeupScheduleState>,
39 #[serde(default)]
43 pub deliveries: Vec<ClaudeRuntimeDeliveryState>,
44}
45
46impl Default for ClaudeRuntimeSchedulerState {
47 fn default() -> Self {
48 Self {
49 activated_at_unix: None,
50 timezone: default_timezone(),
51 cron_jobs: Vec::new(),
52 wakeups: Vec::new(),
53 deliveries: Vec::new(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ClaudeCronScheduleState {
61 pub id: String,
63 pub next_due_unix: i64,
65 #[serde(default)]
67 pub expires_at_unix: Option<i64>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ClaudeWakeupScheduleState {
73 pub tool_use_id: String,
75 pub due_unix: i64,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ClaudeRuntimeTriggerKind {
83 Queue,
85 Cron,
87 Wakeup,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ClaudeRuntimeTrigger {
94 pub due_unix: i64,
96 pub kind: ClaudeRuntimeTriggerKind,
98 pub id: String,
100 pub prompt: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ClaudeRuntimeDeliveryState {
107 pub trigger: ClaudeRuntimeTrigger,
109 pub retry_at_unix: i64,
111}
112
113impl ClaudeRuntimeTrigger {
114 fn order_key(&self) -> (i64, ClaudeRuntimeTriggerKind, &str) {
115 (self.due_unix, self.kind, self.id.as_str())
116 }
117}
118
119impl ClaudeRuntimeManifest {
120 pub fn activate_scheduler(&mut self, now_unix: i64) -> Result<()> {
126 if self.execution_state == ClaudeRuntimeExecutionState::Active {
127 return Err(Error::Other(
128 "Claude runtime scheduler is already active".into(),
129 ));
130 }
131 let (scheduler, retained_crons) = build_scheduler(self, now_unix, false)?;
132 self.active_crons = retained_crons;
133 self.scheduler = scheduler;
134 self.execution_state = ClaudeRuntimeExecutionState::Active;
135 Ok(())
136 }
137
138 pub fn reconcile_scheduler(&mut self, now_unix: i64) -> Result<()> {
143 self.require_active()?;
144 let (scheduler, retained_crons) = build_scheduler(self, now_unix, true)?;
145 self.active_crons = retained_crons;
146 self.scheduler = scheduler;
147 Ok(())
148 }
149
150 pub fn next_due(&self, now_unix: i64) -> Result<Option<ClaudeRuntimeTrigger>> {
152 self.require_active()?;
153 let mut due = scheduler_triggers(self, now_unix, false)?;
154 due.extend(self.scheduler.deliveries.iter().map(|delivery| {
155 let mut trigger = delivery.trigger.clone();
156 trigger.due_unix = delivery.retry_at_unix;
157 trigger
158 }));
159 due.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
160 Ok(due.into_iter().next())
161 }
162
163 pub fn claim_due(&mut self, now_unix: i64) -> Result<Vec<ClaudeRuntimeTrigger>> {
168 self.require_active()?;
169 let mut claimed: Vec<_> = self
170 .scheduler
171 .deliveries
172 .iter()
173 .filter(|delivery| delivery.retry_at_unix <= now_unix)
174 .map(|delivery| delivery.trigger.clone())
175 .collect();
176 let mut newly_claimed = scheduler_triggers(self, now_unix, true)?;
177 newly_claimed.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
178
179 let claimed_crons: BTreeSet<String> = newly_claimed
180 .iter()
181 .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Cron)
182 .map(|item| item.id.clone())
183 .collect();
184 let claimed_queue: BTreeSet<String> = newly_claimed
185 .iter()
186 .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Queue)
187 .map(|item| item.id.clone())
188 .collect();
189 let claimed_wakeups: BTreeSet<String> = newly_claimed
190 .iter()
191 .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Wakeup)
192 .map(|item| item.id.clone())
193 .collect();
194
195 let mut next_crons = Vec::new();
196 let mut retained_jobs = Vec::new();
197 for job in std::mem::take(&mut self.active_crons) {
198 let Some(cursor) = self.scheduler.cron_jobs.iter().find(|c| c.id == job.id) else {
199 return Err(Error::Other(format!(
200 "active Claude cron `{}` has no scheduler cursor",
201 job.id
202 )));
203 };
204 if cursor
205 .expires_at_unix
206 .is_some_and(|expiry| now_unix >= expiry)
207 {
208 continue;
209 }
210 if !claimed_crons.contains(&job.id) {
211 next_crons.push(cursor.clone());
212 retained_jobs.push(job);
213 continue;
214 }
215 if !job.recurring {
216 continue;
217 }
218 let parsed = CronSchedule::parse(&job.schedule)?;
219 let next = parsed.next_after(now_unix)?;
220 if cursor.expires_at_unix.is_some_and(|expiry| next >= expiry) {
221 continue;
222 }
223 next_crons.push(ClaudeCronScheduleState {
224 id: job.id.clone(),
225 next_due_unix: next,
226 expires_at_unix: cursor.expires_at_unix,
227 });
228 retained_jobs.push(job);
229 }
230 self.active_crons = retained_jobs;
231 self.scheduler.cron_jobs = next_crons;
232 self.pending_wakeups
233 .retain(|wakeup| !claimed_wakeups.contains(&wakeup.tool_use_id));
234 self.scheduler
235 .wakeups
236 .retain(|wakeup| !claimed_wakeups.contains(&wakeup.tool_use_id));
237 if !claimed_queue.is_empty() {
238 let expected: BTreeSet<String> = (0..self.queue.pending.len())
239 .map(|index| queue_trigger_id(&self.queue, index))
240 .collect::<Result<_>>()?;
241 if claimed_queue != expected {
242 return Err(Error::Other(
243 "Claude queue claim did not cover the complete pending FIFO".into(),
244 ));
245 }
246 self.queue.pending.clear();
247 }
248 for trigger in &newly_claimed {
249 self.scheduler.deliveries.push(ClaudeRuntimeDeliveryState {
250 trigger: trigger.clone(),
251 retry_at_unix: now_unix,
252 });
253 }
254 self.scheduler
255 .deliveries
256 .sort_by(|a, b| a.trigger.order_key().cmp(&b.trigger.order_key()));
257 claimed.extend(newly_claimed);
258 claimed.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
259 Ok(claimed)
260 }
261
262 pub fn complete_delivery(&mut self, kind: ClaudeRuntimeTriggerKind, id: &str) -> Result<()> {
265 self.require_active()?;
266 let before = self.scheduler.deliveries.len();
267 self.scheduler
268 .deliveries
269 .retain(|delivery| delivery.trigger.kind != kind || delivery.trigger.id != id);
270 if self.scheduler.deliveries.len() == before {
271 return Err(Error::Other(format!(
272 "unknown Claude runtime delivery `{kind:?}` `{id}`"
273 )));
274 }
275 Ok(())
276 }
277
278 pub fn defer_delivery(
280 &mut self,
281 kind: ClaudeRuntimeTriggerKind,
282 id: &str,
283 retry_at_unix: i64,
284 ) -> Result<()> {
285 self.require_active()?;
286 let Some(delivery) = self
287 .scheduler
288 .deliveries
289 .iter_mut()
290 .find(|delivery| delivery.trigger.kind == kind && delivery.trigger.id == id)
291 else {
292 return Err(Error::Other(format!(
293 "unknown Claude runtime delivery `{kind:?}` `{id}`"
294 )));
295 };
296 delivery.retry_at_unix = retry_at_unix;
297 Ok(())
298 }
299
300 fn require_active(&self) -> Result<()> {
301 if self.execution_state != ClaudeRuntimeExecutionState::Active {
302 return Err(Error::Other(
303 "Claude runtime scheduler is paused; activate it explicitly first".into(),
304 ));
305 }
306 if self.scheduler.activated_at_unix.is_none() {
307 return Err(Error::Other(
308 "active Claude runtime manifest has no scheduler activation metadata".into(),
309 ));
310 }
311 Ok(())
312 }
313}
314
315fn build_scheduler(
316 manifest: &ClaudeRuntimeManifest,
317 now_unix: i64,
318 preserve_existing: bool,
319) -> Result<(ClaudeRuntimeSchedulerState, Vec<ClaudeCronJob>)> {
320 for index in 0..manifest.queue.pending.len() {
321 queue_trigger_id(&manifest.queue, index)?;
322 }
323 let old_crons: BTreeMap<&str, &ClaudeCronScheduleState> = manifest
324 .scheduler
325 .cron_jobs
326 .iter()
327 .map(|cursor| (cursor.id.as_str(), cursor))
328 .collect();
329 let old_wakeups: BTreeMap<&str, &ClaudeWakeupScheduleState> = manifest
330 .scheduler
331 .wakeups
332 .iter()
333 .map(|cursor| (cursor.tool_use_id.as_str(), cursor))
334 .collect();
335
336 let mut seen = BTreeSet::new();
337 let mut cron_jobs = Vec::new();
338 let mut retained_crons = Vec::new();
339 for job in &manifest.active_crons {
340 if !seen.insert(job.id.as_str()) {
341 return Err(Error::Other(format!(
342 "duplicate active Claude cron id `{}`",
343 job.id
344 )));
345 }
346 let parsed = CronSchedule::parse(&job.schedule)?;
347 let expires_at_unix = expiry_for(job)?;
348 if expires_at_unix.is_some_and(|expiry| now_unix >= expiry) {
349 continue;
350 }
351 let next_due_unix = if preserve_existing {
352 old_crons
353 .get(job.id.as_str())
354 .map(|cursor| cursor.next_due_unix)
355 .unwrap_or(parsed.next_after(now_unix)?)
356 } else {
357 parsed.next_after(now_unix)?
358 };
359 if expires_at_unix.is_some_and(|expiry| next_due_unix >= expiry) {
360 continue;
361 }
362 cron_jobs.push(ClaudeCronScheduleState {
363 id: job.id.clone(),
364 next_due_unix,
365 expires_at_unix,
366 });
367 retained_crons.push(job.clone());
368 }
369
370 let mut wakeups = Vec::new();
371 let mut seen_wakeups = BTreeSet::new();
372 for wakeup in &manifest.pending_wakeups {
373 if !seen_wakeups.insert(wakeup.tool_use_id.as_str()) {
374 return Err(Error::Other(format!(
375 "duplicate pending Claude wakeup id `{}`",
376 wakeup.tool_use_id
377 )));
378 }
379 let due_unix = if preserve_existing {
380 old_wakeups
381 .get(wakeup.tool_use_id.as_str())
382 .map(|cursor| cursor.due_unix)
383 .unwrap_or(wakeup_due(wakeup, now_unix)?)
384 } else {
385 wakeup_due(wakeup, now_unix)?
386 };
387 wakeups.push(ClaudeWakeupScheduleState {
388 tool_use_id: wakeup.tool_use_id.clone(),
389 due_unix,
390 });
391 }
392 cron_jobs.sort_by(|a, b| a.id.cmp(&b.id));
393 wakeups.sort_by(|a, b| a.tool_use_id.cmp(&b.tool_use_id));
394 Ok((
395 ClaudeRuntimeSchedulerState {
396 activated_at_unix: Some(
397 manifest
398 .scheduler
399 .activated_at_unix
400 .filter(|_| preserve_existing)
401 .unwrap_or(now_unix),
402 ),
403 timezone: default_timezone(),
404 cron_jobs,
405 wakeups,
406 deliveries: if preserve_existing {
407 manifest.scheduler.deliveries.clone()
408 } else {
409 Vec::new()
410 },
411 },
412 retained_crons,
413 ))
414}
415
416fn expiry_for(job: &ClaudeCronJob) -> Result<Option<i64>> {
417 let Some(seconds) = job.expires_after_seconds else {
418 return Ok(None);
419 };
420 let created = parse_created(job.created_at.as_deref(), "cron", &job.id)?;
421 let seconds = i64::try_from(seconds).map_err(|_| {
422 Error::Other(format!(
423 "Claude cron `{}` expiry exceeds supported Unix time",
424 job.id
425 ))
426 })?;
427 created.checked_add(seconds).map(Some).ok_or_else(|| {
428 Error::Other(format!(
429 "Claude cron `{}` expiry overflows Unix time",
430 job.id
431 ))
432 })
433}
434
435fn wakeup_due(wakeup: &ClaudeWakeup, now_unix: i64) -> Result<i64> {
436 let created = parse_created(wakeup.created_at.as_deref(), "wakeup", &wakeup.tool_use_id)?;
437 let delay = i64::try_from(wakeup.delay_seconds).map_err(|_| {
438 Error::Other(format!(
439 "Claude wakeup `{}` delay exceeds supported Unix time",
440 wakeup.tool_use_id
441 ))
442 })?;
443 let due = created.checked_add(delay).ok_or_else(|| {
444 Error::Other(format!(
445 "Claude wakeup `{}` due time overflows Unix time",
446 wakeup.tool_use_id
447 ))
448 })?;
449 Ok(due.max(now_unix))
450}
451
452fn parse_created(value: Option<&str>, kind: &str, id: &str) -> Result<i64> {
453 let value = value.ok_or_else(|| {
454 Error::Other(format!(
455 "Claude {kind} `{id}` has no creation timestamp for deterministic activation"
456 ))
457 })?;
458 crate::sidecar::rfc3339_to_ms(value)
459 .map(|ms| ms.div_euclid(1000))
460 .ok_or_else(|| {
461 Error::Other(format!(
462 "Claude {kind} `{id}` has invalid RFC3339 timestamp `{value}`"
463 ))
464 })
465}
466
467fn scheduler_triggers(
468 manifest: &ClaudeRuntimeManifest,
469 now_unix: i64,
470 only_due: bool,
471) -> Result<Vec<ClaudeRuntimeTrigger>> {
472 let jobs: BTreeMap<&str, &ClaudeCronJob> = manifest
473 .active_crons
474 .iter()
475 .map(|job| (job.id.as_str(), job))
476 .collect();
477 let wakeups: BTreeMap<&str, &ClaudeWakeup> = manifest
478 .pending_wakeups
479 .iter()
480 .map(|wakeup| (wakeup.tool_use_id.as_str(), wakeup))
481 .collect();
482 let mut out = Vec::new();
483 let queue_due = manifest.scheduler.activated_at_unix.ok_or_else(|| {
484 Error::Other("active Claude runtime manifest has no scheduler activation metadata".into())
485 })?;
486 if !only_due || queue_due <= now_unix {
487 for (index, prompt) in manifest.queue.pending.iter().enumerate() {
488 out.push(ClaudeRuntimeTrigger {
489 due_unix: queue_due,
490 kind: ClaudeRuntimeTriggerKind::Queue,
491 id: queue_trigger_id(&manifest.queue, index)?,
492 prompt: Some(prompt.clone()),
493 });
494 }
495 }
496 for cursor in &manifest.scheduler.cron_jobs {
497 let job = jobs.get(cursor.id.as_str()).ok_or_else(|| {
498 Error::Other(format!(
499 "scheduler cursor references missing Claude cron `{}`",
500 cursor.id
501 ))
502 })?;
503 if cursor
504 .expires_at_unix
505 .is_some_and(|expiry| now_unix >= expiry)
506 {
507 continue;
508 }
509 if !only_due || cursor.next_due_unix <= now_unix {
510 out.push(ClaudeRuntimeTrigger {
511 due_unix: cursor.next_due_unix,
512 kind: ClaudeRuntimeTriggerKind::Cron,
513 id: cursor.id.clone(),
514 prompt: Some(job.prompt.clone()),
515 });
516 }
517 }
518 for cursor in &manifest.scheduler.wakeups {
519 let wakeup = wakeups.get(cursor.tool_use_id.as_str()).ok_or_else(|| {
520 Error::Other(format!(
521 "scheduler cursor references missing Claude wakeup `{}`",
522 cursor.tool_use_id
523 ))
524 })?;
525 if !only_due || cursor.due_unix <= now_unix {
526 out.push(ClaudeRuntimeTrigger {
527 due_unix: cursor.due_unix,
528 kind: ClaudeRuntimeTriggerKind::Wakeup,
529 id: cursor.tool_use_id.clone(),
530 prompt: wakeup.prompt.clone().or_else(|| wakeup.reason.clone()),
531 });
532 }
533 }
534 Ok(out)
535}
536
537fn queue_trigger_id(
538 queue: &crate::claude_runtime_state::ClaudeQueueState,
539 index: usize,
540) -> Result<String> {
541 let pending = u64::try_from(queue.pending.len())
542 .map_err(|_| Error::Other("Claude pending queue length exceeds u64".into()))?;
543 let index = u64::try_from(index)
544 .map_err(|_| Error::Other("Claude pending queue index exceeds u64".into()))?;
545 let first_ordinal = queue.enqueued.checked_sub(pending).ok_or_else(|| {
546 Error::Other(format!(
547 "Claude queue has {} pending prompts but only {} enqueue records",
548 queue.pending.len(),
549 queue.enqueued
550 ))
551 })?;
552 let ordinal = first_ordinal
553 .checked_add(index)
554 .ok_or_else(|| Error::Other("Claude queue ordinal overflows u64".into()))?;
555 Ok(format!("queue-{ordinal:020}"))
556}
557
558#[derive(Debug, Clone)]
559struct CronField {
560 allowed: Vec<bool>,
561}
562
563impl CronField {
564 fn parse(text: &str, min: u32, max: u32, dow: bool) -> Result<Self> {
565 if text.is_empty() {
566 return Err(Error::Other("empty cron field".into()));
567 }
568 let mut allowed = vec![false; (max - min + 1) as usize];
569 for item in text.split(',') {
570 if item.is_empty() {
571 return Err(Error::Other(format!(
572 "invalid empty item in cron field `{text}`"
573 )));
574 }
575 let mut parts = item.split('/');
576 let base = parts.next().unwrap_or_default();
577 let step = parts
578 .next()
579 .map(|value| value.parse::<u32>())
580 .transpose()
581 .map_err(|_| Error::Other(format!("invalid cron step in `{item}`")))?
582 .unwrap_or(1);
583 if parts.next().is_some() || step == 0 {
584 return Err(Error::Other(format!("invalid cron step in `{item}`")));
585 }
586 let (start, end) = if base == "*" {
587 (min, max)
588 } else if let Some((start, end)) = base.split_once('-') {
589 (
590 parse_cron_num(start, min, max, dow)?,
591 parse_cron_num(end, min, max, dow)?,
592 )
593 } else {
594 let start = parse_cron_num(base, min, max, dow)?;
595 (start, if item.contains('/') { max } else { start })
596 };
597 if start > end {
598 return Err(Error::Other(format!(
599 "descending cron range `{base}` is unsupported"
600 )));
601 }
602 let mut value = start;
603 while value <= end {
604 let normalized = if dow && value == 7 { 0 } else { value };
605 allowed[(normalized - min) as usize] = true;
606 let Some(next) = value.checked_add(step) else {
607 break;
608 };
609 value = next;
610 }
611 }
612 if !allowed.iter().any(|allowed| *allowed) {
613 return Err(Error::Other(format!(
614 "cron field `{text}` matches no values"
615 )));
616 }
617 Ok(Self { allowed })
618 }
619
620 fn contains(&self, value: u32, min: u32) -> bool {
621 self.allowed
622 .get((value - min) as usize)
623 .copied()
624 .unwrap_or(false)
625 }
626
627 fn unrestricted(&self) -> bool {
628 self.allowed.iter().all(|allowed| *allowed)
629 }
630}
631
632fn parse_cron_num(text: &str, min: u32, max: u32, dow: bool) -> Result<u32> {
633 let value = text
634 .parse::<u32>()
635 .map_err(|_| Error::Other(format!("invalid cron number `{text}`")))?;
636 let upper = if dow { 7 } else { max };
637 if value < min || value > upper {
638 return Err(Error::Other(format!(
639 "cron number `{value}` is outside {min}..={upper}"
640 )));
641 }
642 Ok(value)
643}
644
645#[derive(Debug, Clone)]
646struct CronSchedule {
647 minute: CronField,
648 hour: CronField,
649 day_of_month: CronField,
650 month: CronField,
651 day_of_week: CronField,
652}
653
654impl CronSchedule {
655 fn parse(schedule: &str) -> Result<Self> {
656 let fields: Vec<&str> = schedule.split_whitespace().collect();
657 if fields.len() != 5 {
658 return Err(Error::Other(format!(
659 "invalid Claude cron `{schedule}`: expected exactly 5 fields"
660 )));
661 }
662 Ok(Self {
663 minute: CronField::parse(fields[0], 0, 59, false)?,
664 hour: CronField::parse(fields[1], 0, 23, false)?,
665 day_of_month: CronField::parse(fields[2], 1, 31, false)?,
666 month: CronField::parse(fields[3], 1, 12, false)?,
667 day_of_week: CronField::parse(fields[4], 0, 6, true)?,
668 })
669 }
670
671 fn next_after(&self, after_unix: i64) -> Result<i64> {
672 let start_minute = after_unix
673 .div_euclid(60)
674 .checked_add(1)
675 .ok_or_else(|| Error::Other("cron search overflows Unix time".into()))?;
676 const SEARCH_MINUTES: i64 = 8 * 366 * 24 * 60;
679 for delta in 0..SEARCH_MINUTES {
680 let unix = start_minute
681 .checked_add(delta)
682 .and_then(|minute| minute.checked_mul(60))
683 .ok_or_else(|| Error::Other("cron search overflows Unix time".into()))?;
684 if self.matches(unix) {
685 return Ok(unix);
686 }
687 }
688 Err(Error::Other(
689 "cron expression has no matching UTC minute within eight years".into(),
690 ))
691 }
692
693 fn matches(&self, unix: i64) -> bool {
694 let days = unix.div_euclid(86_400);
695 let seconds = unix.rem_euclid(86_400);
696 let (year, month, day) = civil_from_days(days);
697 let _ = year;
698 let hour = (seconds / 3600) as u32;
699 let minute = ((seconds % 3600) / 60) as u32;
700 let dow = (days + 4).rem_euclid(7) as u32;
701 let dom_match = self.day_of_month.contains(day, 1);
702 let dow_match = self.day_of_week.contains(dow, 0);
703 let day_match = match (
704 self.day_of_month.unrestricted(),
705 self.day_of_week.unrestricted(),
706 ) {
707 (true, true) => true,
708 (true, false) => dow_match,
709 (false, true) => dom_match,
710 (false, false) => dom_match || dow_match,
711 };
712 self.minute.contains(minute, 0)
713 && self.hour.contains(hour, 0)
714 && self.month.contains(month, 1)
715 && day_match
716 }
717}
718
719fn civil_from_days(days: i64) -> (i64, u32, u32) {
721 let z = days + 719_468;
722 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
723 let doe = z - era * 146_097;
724 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
725 let mut year = yoe + era * 400;
726 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
727 let mp = (5 * doy + 2) / 153;
728 let day = doy - (153 * mp + 2) / 5 + 1;
729 let month = if mp < 10 { mp + 3 } else { mp - 9 };
730 year += (month <= 2) as i64;
731 (year, month as u32, day as u32)
732}