1#[allow(unused_imports)]
20use chrono::Timelike;
21use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, TimeZone, Weekday};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::path::PathBuf;
25use thiserror::Error;
26use tokio::process::Command;
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct DaemonConfig {
35 #[serde(default, rename = "job")]
36 pub jobs: Vec<Job>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Job {
41 pub name: String,
43 pub schedule: String,
45 #[serde(default)]
48 pub command: Option<String>,
49 #[serde(default)]
51 pub argv: Option<Vec<String>>,
52 #[serde(default)]
54 pub env: HashMap<String, String>,
55 #[serde(default)]
57 pub cwd: Option<PathBuf>,
58 #[serde(default = "default_enabled")]
60 pub enabled: bool,
61}
62fn default_enabled() -> bool {
63 true
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Schedule {
79 Daily {
80 hour: u8,
81 minute: u8,
82 },
83 Weekly {
84 weekday: Weekday,
85 hour: u8,
86 minute: u8,
87 },
88 Interval(Duration),
89}
90
91#[derive(Debug, Error)]
92pub enum ScheduleError {
93 #[error(
94 "schedule string must be 'daily HH:MM', 'weekly <day> HH:MM', or 'every Nm/h/d/s'; got: {0}"
95 )]
96 Format(String),
97 #[error("invalid time `{0}` — use HH:MM")]
98 BadTime(String),
99 #[error("invalid weekday `{0}` — use mon/tue/wed/thu/fri/sat/sun")]
100 BadWeekday(String),
101 #[error("invalid interval `{0}` — use Ns / Nm / Nh / Nd")]
102 BadInterval(String),
103}
104
105impl Schedule {
106 pub fn parse(s: &str) -> Result<Self, ScheduleError> {
107 let s = s.trim();
108 if let Some(rest) = s.strip_prefix("daily ") {
109 let (h, m) = parse_hhmm(rest)?;
110 return Ok(Schedule::Daily { hour: h, minute: m });
111 }
112 if let Some(rest) = s.strip_prefix("weekly ") {
113 let mut parts = rest.split_whitespace();
114 let wd_str = parts
115 .next()
116 .ok_or_else(|| ScheduleError::Format(s.to_string()))?;
117 let time_str = parts
118 .next()
119 .ok_or_else(|| ScheduleError::Format(s.to_string()))?;
120 let weekday = parse_weekday(wd_str)?;
121 let (h, m) = parse_hhmm(time_str)?;
122 return Ok(Schedule::Weekly {
123 weekday,
124 hour: h,
125 minute: m,
126 });
127 }
128 if let Some(rest) = s.strip_prefix("every ") {
129 return parse_interval(rest).map(Schedule::Interval);
130 }
131 Err(ScheduleError::Format(s.to_string()))
132 }
133
134 pub fn next_after(&self, now: DateTime<Local>) -> DateTime<Local> {
136 match *self {
137 Schedule::Daily { hour, minute } => {
138 let today = now.date_naive();
139 let t = NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap();
140 let candidate = Local
141 .from_local_datetime(&today.and_time(t))
142 .earliest()
143 .unwrap();
144 if candidate > now {
145 candidate
146 } else {
147 candidate + Duration::days(1)
148 }
149 }
150 Schedule::Weekly {
151 weekday,
152 hour,
153 minute,
154 } => {
155 let mut candidate = now;
157 for _ in 0..8 {
158 if candidate.weekday() == weekday {
159 let t = NaiveTime::from_hms_opt(hour.into(), minute.into(), 0).unwrap();
160 let attempt = Local
161 .from_local_datetime(&candidate.date_naive().and_time(t))
162 .earliest()
163 .unwrap();
164 if attempt > now {
165 return attempt;
166 }
167 }
168 candidate += Duration::days(1);
169 }
170 unreachable!("weekday must occur within 7 days")
171 }
172 Schedule::Interval(d) => now + d,
173 }
174 }
175}
176
177fn parse_hhmm(s: &str) -> Result<(u8, u8), ScheduleError> {
178 let (h, m) = s
179 .split_once(':')
180 .ok_or_else(|| ScheduleError::BadTime(s.into()))?;
181 let h: u8 = h.parse().map_err(|_| ScheduleError::BadTime(s.into()))?;
182 let m: u8 = m.parse().map_err(|_| ScheduleError::BadTime(s.into()))?;
183 if h > 23 || m > 59 {
184 return Err(ScheduleError::BadTime(s.into()));
185 }
186 Ok((h, m))
187}
188
189fn parse_weekday(s: &str) -> Result<Weekday, ScheduleError> {
190 match s.to_lowercase().as_str() {
191 "mon" | "monday" => Ok(Weekday::Mon),
192 "tue" | "tuesday" => Ok(Weekday::Tue),
193 "wed" | "wednesday" => Ok(Weekday::Wed),
194 "thu" | "thursday" => Ok(Weekday::Thu),
195 "fri" | "friday" => Ok(Weekday::Fri),
196 "sat" | "saturday" => Ok(Weekday::Sat),
197 "sun" | "sunday" => Ok(Weekday::Sun),
198 _ => Err(ScheduleError::BadWeekday(s.into())),
199 }
200}
201
202fn parse_interval(s: &str) -> Result<Duration, ScheduleError> {
203 let s = s.trim();
204 if s.len() < 2 {
205 return Err(ScheduleError::BadInterval(s.into()));
206 }
207 let (n_str, unit) = s.split_at(s.len() - 1);
208 let n: i64 = n_str
209 .trim()
210 .parse()
211 .map_err(|_| ScheduleError::BadInterval(s.into()))?;
212 match unit {
213 "s" => Ok(Duration::seconds(n)),
214 "m" => Ok(Duration::minutes(n)),
215 "h" => Ok(Duration::hours(n)),
216 "d" => Ok(Duration::days(n)),
217 _ => Err(ScheduleError::BadInterval(s.into())),
218 }
219}
220
221#[derive(Debug, Error)]
226pub enum DaemonError {
227 #[error("config: {0}")]
228 Config(String),
229 #[error("schedule: {0}")]
230 Schedule(#[from] ScheduleError),
231 #[error("job `{name}` has neither `command` nor `argv`")]
232 NoCommand { name: String },
233}
234
235pub struct Daemon {
236 pub jobs: Vec<ResolvedJob>,
237}
238
239#[derive(Debug, Clone)]
240pub struct ResolvedJob {
241 pub name: String,
242 pub schedule: Schedule,
243 pub argv: Vec<String>,
244 pub env: HashMap<String, String>,
245 pub cwd: Option<PathBuf>,
246}
247
248impl Daemon {
249 pub fn from_config(cfg: DaemonConfig) -> Result<Self, DaemonError> {
250 let mut resolved = Vec::new();
251 for j in cfg.jobs {
252 if !j.enabled {
253 continue;
254 }
255 let argv = match (j.argv, j.command) {
256 (Some(a), _) if !a.is_empty() => a,
257 (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
258 _ => {
259 return Err(DaemonError::NoCommand {
260 name: j.name.clone(),
261 });
262 }
263 };
264 resolved.push(ResolvedJob {
265 name: j.name,
266 schedule: Schedule::parse(&j.schedule)?,
267 argv,
268 env: j.env,
269 cwd: j.cwd,
270 });
271 }
272 Ok(Daemon { jobs: resolved })
273 }
274
275 pub fn dry_run(&self) {
278 let now = Local::now();
279 println!("now: {}", now.format("%Y-%m-%d %H:%M:%S %Z"));
280 if self.jobs.is_empty() {
281 println!("(no enabled jobs)");
282 return;
283 }
284 for j in &self.jobs {
285 let next = j.schedule.next_after(now);
286 let delta = next - now;
287 println!(
288 " {:30} next: {} (in {}) cmd: {}",
289 j.name,
290 next.format("%Y-%m-%d %H:%M:%S"),
291 fmt_delta(delta),
292 j.argv.join(" "),
293 );
294 }
295 }
296
297 pub async fn run(self) -> Result<(), DaemonError> {
300 if self.jobs.is_empty() {
301 tracing::warn!("no enabled jobs — daemon will idle until Ctrl-C");
302 }
303 let mut handles = Vec::new();
304 for job in self.jobs {
305 handles.push(tokio::spawn(run_job_loop(job)));
306 }
307 let _ = tokio::signal::ctrl_c().await;
309 tracing::info!("Ctrl-C received, shutting down");
310 for h in handles {
311 h.abort();
312 }
313 Ok(())
314 }
315}
316
317async fn run_job_loop(job: ResolvedJob) {
318 let started = Local::now();
319 tracing::info!(job = %job.name, "scheduled (next: {})",
320 job.schedule.next_after(started).format("%Y-%m-%d %H:%M:%S"));
321
322 loop {
323 let now = Local::now();
324 let next = job.schedule.next_after(now);
325 let wait = (next - now)
326 .to_std()
327 .unwrap_or(std::time::Duration::from_secs(1));
328 tokio::time::sleep(wait).await;
329
330 let started = std::time::Instant::now();
331 let result = run_once(&job).await;
332 let elapsed_ms = started.elapsed().as_millis();
333
334 match result {
335 Ok(status) if status.success() => {
336 tracing::info!(job = %job.name, ms = elapsed_ms, "✓ ok");
337 }
338 Ok(status) => {
339 tracing::warn!(job = %job.name, ms = elapsed_ms, code = ?status.code(), "✗ non-zero exit");
340 }
341 Err(e) => {
342 tracing::error!(job = %job.name, ms = elapsed_ms, error = %e, "✗ spawn failed");
343 }
344 }
345
346 }
349}
350
351async fn run_once(job: &ResolvedJob) -> std::io::Result<std::process::ExitStatus> {
352 let mut cmd = Command::new(&job.argv[0]);
353 if job.argv.len() > 1 {
354 cmd.args(&job.argv[1..]);
355 }
356 for (k, v) in &job.env {
357 cmd.env(k, v);
358 }
359 if let Some(cwd) = &job.cwd {
360 cmd.current_dir(cwd);
361 }
362 cmd.status().await
363}
364
365fn fmt_delta(d: Duration) -> String {
366 let total = d.num_seconds();
367 if total < 0 {
368 return "due".into();
369 }
370 let h = total / 3600;
371 let m = (total % 3600) / 60;
372 let s = total % 60;
373 if h > 0 {
374 format!("{h}h {m}m")
375 } else if m > 0 {
376 format!("{m}m {s}s")
377 } else {
378 format!("{s}s")
379 }
380}
381
382#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn parse_daily() {
392 assert_eq!(
393 Schedule::parse("daily 08:00").unwrap(),
394 Schedule::Daily { hour: 8, minute: 0 }
395 );
396 assert_eq!(
397 Schedule::parse("daily 23:59").unwrap(),
398 Schedule::Daily {
399 hour: 23,
400 minute: 59
401 }
402 );
403 assert!(Schedule::parse("daily 25:00").is_err());
404 assert!(Schedule::parse("daily noon").is_err());
405 }
406
407 #[test]
408 fn parse_weekly() {
409 let s = Schedule::parse("weekly mon 09:30").unwrap();
410 assert_eq!(
411 s,
412 Schedule::Weekly {
413 weekday: Weekday::Mon,
414 hour: 9,
415 minute: 30
416 }
417 );
418 assert!(Schedule::parse("weekly funday 09:30").is_err());
419 }
420
421 #[test]
422 fn parse_interval() {
423 assert_eq!(
424 Schedule::parse("every 30s").unwrap(),
425 Schedule::Interval(Duration::seconds(30))
426 );
427 assert_eq!(
428 Schedule::parse("every 5m").unwrap(),
429 Schedule::Interval(Duration::minutes(5))
430 );
431 assert_eq!(
432 Schedule::parse("every 2h").unwrap(),
433 Schedule::Interval(Duration::hours(2))
434 );
435 assert_eq!(
436 Schedule::parse("every 1d").unwrap(),
437 Schedule::Interval(Duration::days(1))
438 );
439 assert!(Schedule::parse("every 5min").is_err());
440 }
441
442 #[test]
443 fn daily_next_after() {
444 let now = Local.with_ymd_and_hms(2026, 5, 16, 9, 0, 0).unwrap();
446 let next = Schedule::Daily { hour: 8, minute: 0 }.next_after(now);
447 assert_eq!(next.date_naive(), now.date_naive() + Duration::days(1));
448 assert_eq!(next.hour(), 8);
449 let now = Local.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap();
451 let next = Schedule::Daily { hour: 8, minute: 0 }.next_after(now);
452 assert_eq!(next.date_naive(), now.date_naive());
453 assert_eq!(next.hour(), 8);
454 }
455
456 #[test]
457 fn weekly_next_after() {
458 let sat = Local.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap();
460 assert_eq!(sat.weekday(), Weekday::Sat);
461 let next = Schedule::Weekly {
462 weekday: Weekday::Mon,
463 hour: 9,
464 minute: 30,
465 }
466 .next_after(sat);
467 assert_eq!(next.weekday(), Weekday::Mon);
468 assert_eq!(next.hour(), 9);
469 assert_eq!(next.minute(), 30);
470 }
471
472 #[test]
473 fn config_roundtrip() {
474 let toml_src = r#"
475[[job]]
476name = "brief"
477schedule = "daily 08:00"
478argv = ["assistant", "--brief"]
479
480[[job]]
481name = "disabled-one"
482schedule = "every 5m"
483command = "echo hi"
484enabled = false
485"#;
486 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
487 assert_eq!(cfg.jobs.len(), 2);
488 let daemon = Daemon::from_config(cfg).unwrap();
489 assert_eq!(daemon.jobs.len(), 1);
491 assert_eq!(daemon.jobs[0].name, "brief");
492 assert_eq!(daemon.jobs[0].argv, vec!["assistant", "--brief"]);
493 }
494
495 #[test]
498 fn parse_zero_argv_falls_back_to_command_string() {
499 let toml_src = r#"
501[[job]]
502name = "echo"
503schedule = "every 1m"
504command = "echo hi there"
505"#;
506 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
507 let d = Daemon::from_config(cfg).unwrap();
508 assert_eq!(d.jobs.len(), 1);
509 assert_eq!(d.jobs[0].argv, vec!["echo", "hi", "there"]);
510 }
511
512 #[test]
513 fn parse_argv_wins_over_command_when_both_present() {
514 let toml_src = r#"
515[[job]]
516name = "either-or"
517schedule = "every 1m"
518argv = ["cmd-a", "arg-1"]
519command = "cmd-b arg-2"
520"#;
521 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
522 let d = Daemon::from_config(cfg).unwrap();
523 assert_eq!(d.jobs[0].argv, vec!["cmd-a", "arg-1"]);
524 }
525
526 #[test]
527 fn parse_empty_argv_then_command_is_used() {
528 let toml_src = r#"
529[[job]]
530name = "fallthrough"
531schedule = "every 1m"
532argv = []
533command = "echo from-command"
534"#;
535 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
536 let d = Daemon::from_config(cfg).unwrap();
537 assert_eq!(d.jobs[0].argv, vec!["echo", "from-command"]);
538 }
539
540 #[test]
541 fn parse_job_with_neither_argv_nor_command_errors() {
542 let toml_src = r#"
543[[job]]
544name = "broken"
545schedule = "every 1m"
546"#;
547 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
548 let r = Daemon::from_config(cfg);
549 match r {
550 Err(DaemonError::NoCommand { name }) => assert_eq!(name, "broken"),
551 Err(e) => panic!("expected NoCommand, got: {e}"),
552 Ok(_) => panic!("expected NoCommand, got Ok"),
553 }
554 }
555
556 #[test]
557 fn parse_bad_schedule_propagates() {
558 let toml_src = r#"
559[[job]]
560name = "x"
561schedule = "every 5min"
562command = "true"
563"#;
564 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
565 let r = Daemon::from_config(cfg);
566 assert!(matches!(r, Err(DaemonError::Schedule(_))));
567 }
568
569 #[test]
570 fn weekly_wraps_to_next_week_when_today_is_after_target_time() {
571 let mon = Local.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap();
574 assert_eq!(mon.weekday(), Weekday::Mon);
575 let next = Schedule::Weekly {
576 weekday: Weekday::Mon,
577 hour: 9,
578 minute: 30,
579 }
580 .next_after(mon);
581 assert_eq!(next.weekday(), Weekday::Mon);
582 assert_eq!(next.date_naive(), mon.date_naive() + Duration::days(7));
583 }
584
585 #[test]
586 fn daily_midnight_boundary() {
587 let late = Local.with_ymd_and_hms(2026, 5, 16, 23, 30, 0).unwrap();
589 let next = Schedule::Daily { hour: 0, minute: 5 }.next_after(late);
590 assert_eq!(next.date_naive(), late.date_naive() + Duration::days(1));
591 assert_eq!(next.hour(), 0);
592 assert_eq!(next.minute(), 5);
593 }
594
595 #[test]
596 fn interval_next_is_always_now_plus_d() {
597 let now = Local::now();
601 let s = Schedule::Interval(Duration::seconds(30));
602 let next = s.next_after(now);
603 let delta = next - now;
604 assert!(
605 delta >= Duration::seconds(29) && delta <= Duration::seconds(31),
606 "expected ~30s, got {delta:?}"
607 );
608 }
609
610 #[test]
611 fn fmt_delta_handles_hours_minutes_seconds() {
612 assert_eq!(fmt_delta(Duration::seconds(5)), "5s");
613 assert_eq!(fmt_delta(Duration::seconds(65)), "1m 5s");
614 assert_eq!(fmt_delta(Duration::seconds(3725)), "1h 2m");
615 assert_eq!(fmt_delta(Duration::seconds(-10)), "due");
616 }
617
618 #[test]
619 fn env_preserved_in_resolved_job() {
620 let toml_src = r#"
621[[job]]
622name = "with-env"
623schedule = "every 1m"
624command = "env"
625env = { FOO = "bar", BAZ = "qux" }
626"#;
627 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
628 let d = Daemon::from_config(cfg).unwrap();
629 assert_eq!(d.jobs[0].env.get("FOO").map(String::as_str), Some("bar"));
630 assert_eq!(d.jobs[0].env.get("BAZ").map(String::as_str), Some("qux"));
631 }
632
633 #[test]
634 fn cwd_preserved_in_resolved_job() {
635 let toml_src = r#"
636[[job]]
637name = "with-cwd"
638schedule = "every 1m"
639command = "pwd"
640cwd = "/tmp"
641"#;
642 let cfg: DaemonConfig = toml::from_str(toml_src).unwrap();
643 let d = Daemon::from_config(cfg).unwrap();
644 assert_eq!(d.jobs[0].cwd.as_deref(), Some(std::path::Path::new("/tmp")));
645 }
646
647 #[test]
648 fn weekday_aliases_all_accepted() {
649 for s in ["mon", "monday", "Mon", "MONDAY"] {
651 assert!(matches!(
652 Schedule::parse(&format!("weekly {s} 09:00")).unwrap(),
653 Schedule::Weekly {
654 weekday: Weekday::Mon,
655 ..
656 }
657 ));
658 }
659 for s in ["sun", "sunday"] {
660 assert!(matches!(
661 Schedule::parse(&format!("weekly {s} 09:00")).unwrap(),
662 Schedule::Weekly {
663 weekday: Weekday::Sun,
664 ..
665 }
666 ));
667 }
668 }
669
670 #[cfg(unix)]
675 #[tokio::test]
676 async fn run_once_executes_subprocess_with_env_and_cwd() {
677 let tmp = std::env::temp_dir().join(format!(
678 "harness-daemon-{}-{}",
679 std::process::id(),
680 std::time::SystemTime::now()
681 .duration_since(std::time::UNIX_EPOCH)
682 .unwrap()
683 .as_nanos()
684 ));
685 std::fs::create_dir_all(&tmp).unwrap();
686 let job = ResolvedJob {
689 name: "test".into(),
690 schedule: Schedule::Interval(Duration::seconds(60)),
691 argv: vec!["sh".into(), "-c".into(), "echo \"$FOO\" > marker".into()],
692 env: std::collections::HashMap::from([("FOO".into(), "hello-edge".into())]),
693 cwd: Some(tmp.clone()),
694 };
695 let status = run_once(&job).await.expect("spawn");
696 assert!(status.success(), "subprocess exit: {status:?}");
697 let marker = std::fs::read_to_string(tmp.join("marker")).unwrap();
698 assert_eq!(marker.trim(), "hello-edge");
699 let _ = std::fs::remove_dir_all(&tmp);
700 }
701}