Skip to main content

pitchfork_cli/
daemon.rs

1use crate::config_types::OneshotWait;
2use crate::daemon_id::DaemonId;
3use crate::daemon_status::DaemonStatus;
4use crate::pitchfork_toml::{
5    CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, PortConfig,
6    ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig, WatchMode,
7};
8use indexmap::IndexMap;
9use std::fmt::Display;
10use std::path::PathBuf;
11
12/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
13///
14/// A valid daemon ID:
15/// - Is not empty
16/// - Does not contain backslashes (`\`)
17/// - Does not contain parent directory references (`..`)
18/// - Does not contain spaces
19/// - Does not contain `--` (reserved for path encoding of `/`)
20/// - Is not `.` (current directory)
21/// - Contains only printable ASCII characters
22/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
23///
24/// Format: `[namespace/]short_id`
25/// - Qualified: `project/api`, `global/web`
26/// - Short: `api`, `web`
27///
28/// This validation prevents path traversal attacks when daemon IDs are used
29/// to construct log file paths or other filesystem operations.
30pub fn is_valid_daemon_id(id: &str) -> bool {
31    if id.contains('/') {
32        DaemonId::parse(id).is_ok()
33    } else {
34        DaemonId::try_new("global", id).is_ok()
35    }
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
39pub struct Daemon {
40    pub id: DaemonId,
41    pub title: Option<String>,
42    pub pid: Option<u32>,
43    /// High-resolution kernel start token recorded at spawn. Together with
44    /// `pid` this identifies the process across a supervisor crash: a recycled
45    /// PID has a different token, so orphan cleanup can tell a genuine orphan
46    /// from an unrelated process.
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    pub start_time: Option<u64>,
49    /// System boot time (seconds since epoch) recorded at spawn. Lets orphan
50    /// reconciliation tell a daemon that died under a crashed supervisor
51    /// during this boot from one whose process died with the machine, which
52    /// need different terminal states. Unlike `start_time` this is a
53    /// wall-clock value comparable across processes and platforms.
54    #[serde(skip_serializing_if = "Option::is_none", default)]
55    pub boot_time: Option<u64>,
56    pub shell_pid: Option<u32>,
57    pub status: DaemonStatus,
58    pub dir: Option<PathBuf>,
59    #[serde(skip_serializing_if = "Option::is_none", default)]
60    pub cmd: Option<Vec<String>>,
61    /// Original shell command string, persisted for retry/watch restarts.
62    #[serde(skip_serializing_if = "Option::is_none", default)]
63    pub run: Option<String>,
64    pub autostop: bool,
65    #[serde(skip_serializing_if = "Option::is_none", default)]
66    pub cron_schedule: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none", default)]
68    pub cron_retrigger: Option<CronRetrigger>,
69    #[serde(skip_serializing_if = "Option::is_none", default)]
70    pub cron_immediate: Option<bool>,
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    pub last_exit_success: Option<bool>,
75    #[serde(default)]
76    pub retry: Retry,
77    #[serde(default)]
78    pub retry_count: u32,
79    #[serde(skip_serializing_if = "Option::is_none", default)]
80    pub ready_delay: Option<u64>,
81    #[serde(skip_serializing_if = "Option::is_none", default)]
82    pub ready_output: Option<ReadyOutput>,
83    #[serde(skip_serializing_if = "Option::is_none", default)]
84    pub ready_http: Option<ReadyHttp>,
85    #[serde(skip_serializing_if = "Option::is_none", default)]
86    pub ready_port: Option<ReadyPort>,
87    #[serde(skip_serializing_if = "Option::is_none", default)]
88    pub ready_cmd: Option<ReadyCmd>,
89    #[serde(skip_serializing_if = "Option::is_none", default)]
90    pub health_cmd: Option<HealthCmd>,
91    #[serde(skip_serializing_if = "Option::is_none", default)]
92    pub health_http: Option<HealthHttp>,
93    #[serde(skip_serializing_if = "Option::is_none", default)]
94    pub health_port: Option<HealthPort>,
95    /// Port configuration (expected ports and auto-bump settings)
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub port: Option<PortConfig>,
98    /// Resolved ports actually used after auto-bump (may differ from expected)
99    #[serde(skip_serializing_if = "Vec::is_empty", default)]
100    pub resolved_port: Vec<u16>,
101    /// The first port the process is actually listening on (detected at runtime via listeners crate).
102    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub active_port: Option<u16>,
105    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub slug: Option<String>,
108    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub proxy: Option<bool>,
111    #[serde(skip_serializing_if = "Vec::is_empty", default)]
112    pub depends: Vec<DaemonId>,
113    #[serde(skip_serializing_if = "Option::is_none", default)]
114    pub env: Option<IndexMap<String, String>>,
115    #[serde(skip_serializing_if = "Vec::is_empty", default)]
116    pub watch: Vec<String>,
117    #[serde(default)]
118    pub watch_mode: WatchMode,
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub watch_base_dir: Option<PathBuf>,
121    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
122    ///
123    /// # Schema compatibility note
124    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
125    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
126    ///   correctly as `Some(true)` / `Some(false)`.
127    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
128    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
129    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
130    #[serde(skip_serializing_if = "Option::is_none", default)]
131    pub mise: Option<bool>,
132    /// Unix user to run this daemon as.
133    #[serde(skip_serializing_if = "Option::is_none", default)]
134    pub user: Option<String>,
135    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
136    #[serde(skip_serializing_if = "Option::is_none", default)]
137    pub memory_limit: Option<MemoryLimit>,
138    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
139    #[serde(skip_serializing_if = "Option::is_none", default)]
140    pub cpu_limit: Option<CpuLimit>,
141    /// Unix signal to send for graceful shutdown (default: SIGTERM)
142    #[serde(skip_serializing_if = "Option::is_none", default)]
143    pub stop_signal: Option<StopConfig>,
144    /// Archive hook command invoked before retention prunes this daemon's logs.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub archive_hook: Option<String>,
147    /// Log format for this daemon.
148    #[serde(skip_serializing_if = "Option::is_none", default)]
149    pub log_format: Option<String>,
150    /// Allocate a pseudo-terminal for the daemon process.
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub pty: Option<bool>,
153    /// True for daemons auto-registered from config by the cron watcher,
154    /// not yet started. Treated as "available" by list/status/stats.
155    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156    pub config_registered: bool,
157    /// Run-to-completion task rather than a long-running service. Readiness is
158    /// a zero exit code, and the terminal state is `completed` instead of
159    /// `stopped`. See `DaemonStatus::Completed`.
160    ///
161    /// Appended rather than grouped with `status`: IPC encodes this struct
162    /// positionally, so a field inserted in the middle shifts every field
163    /// after it for a peer that does not have it.
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub oneshot: bool,
166    /// Set when the proxy started this run and it may be stopped for
167    /// inactivity: how long, in milliseconds, it may go without proxy
168    /// activity. `None` for a daemon started any other way, or claimed since
169    /// by an explicit start.
170    ///
171    /// Appended last for the positional IPC encoding, like `oneshot`.
172    #[serde(default)]
173    pub proxy_idle_timeout_ms: Option<u64>,
174    /// When the cron watcher last actually started this daemon.
175    ///
176    /// Distinct from `last_cron_triggered`, which advances on every scheduled
177    /// tick the watcher observes -- including the anchoring tick that
178    /// `immediate = false` uses to skip the first window, and ticks where the
179    /// `retrigger` policy declines to run. Only this field means "it ran",
180    /// which is what `last_exit_success` describes the outcome of.
181    ///
182    /// Appended after `proxy_idle_timeout_ms` for the positional IPC encoding.
183    #[serde(skip_serializing_if = "Option::is_none", default)]
184    pub last_cron_run: Option<chrono::DateTime<chrono::Local>>,
185    /// Start `cmd` directly, without a shell. See `RunOptions::no_shell`.
186    ///
187    /// Appended after `last_cron_run` for the positional IPC encoding.
188    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
189    pub no_shell: bool,
190    /// Registered from config by the cron watcher and only ever started by
191    /// its schedule since, so each scheduled run is built from the current
192    /// config, templates rendered, rather than from what was stored at
193    /// registration. Cleared once a client starts the daemon: a run it asked
194    /// for carries its own options, which the schedule then keeps.
195    ///
196    /// Appended after `no_shell` for the positional IPC encoding.
197    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198    pub scheduled_from_config: bool,
199}
200
201#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
202pub struct RunOptions {
203    pub id: DaemonId,
204    pub cmd: Vec<String>,
205    /// Original shell command string (from config `run`), passed verbatim to the shell.
206    /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
207    #[serde(skip_serializing_if = "Option::is_none", default)]
208    pub run: Option<String>,
209    pub force: bool,
210    pub shell_pid: Option<u32>,
211    pub dir: Dir,
212    pub autostop: bool,
213    pub cron_schedule: Option<String>,
214    pub cron_retrigger: Option<CronRetrigger>,
215    pub cron_immediate: Option<bool>,
216    pub retry: Retry,
217    pub retry_count: u32,
218    pub ready_delay: Option<u64>,
219    pub ready_output: Option<ReadyOutput>,
220    pub ready_http: Option<ReadyHttp>,
221    pub ready_port: Option<ReadyPort>,
222    pub ready_cmd: Option<ReadyCmd>,
223    pub health_cmd: Option<HealthCmd>,
224    pub health_http: Option<HealthHttp>,
225    pub health_port: Option<HealthPort>,
226    pub port: Option<PortConfig>,
227    pub wait_ready: bool,
228    #[serde(skip_serializing_if = "Vec::is_empty", default)]
229    pub depends: Vec<DaemonId>,
230    #[serde(skip_serializing_if = "Option::is_none", default)]
231    pub env: Option<IndexMap<String, String>>,
232    #[serde(skip_serializing_if = "Vec::is_empty", default)]
233    pub watch: Vec<String>,
234    #[serde(default)]
235    pub watch_mode: WatchMode,
236    #[serde(skip_serializing_if = "Option::is_none", default)]
237    pub watch_base_dir: Option<PathBuf>,
238    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
239    ///
240    /// # Schema compatibility note
241    /// See `Daemon::mise` for downgrade implications when this field is `None`.
242    #[serde(skip_serializing_if = "Option::is_none", default)]
243    pub mise: Option<bool>,
244    /// Optional stable slug alias for this daemon.
245    #[serde(skip_serializing_if = "Option::is_none", default)]
246    pub slug: Option<String>,
247    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
248    #[serde(skip_serializing_if = "Option::is_none", default)]
249    pub proxy: Option<bool>,
250    /// Unix user to run this daemon as.
251    #[serde(skip_serializing_if = "Option::is_none", default)]
252    pub user: Option<String>,
253    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
254    #[serde(skip_serializing_if = "Option::is_none", default)]
255    pub memory_limit: Option<MemoryLimit>,
256    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
257    #[serde(skip_serializing_if = "Option::is_none", default)]
258    pub cpu_limit: Option<CpuLimit>,
259    /// Unix signal to send for graceful shutdown (default: SIGTERM)
260    #[serde(skip_serializing_if = "Option::is_none", default)]
261    pub stop_signal: Option<StopConfig>,
262    /// Archive hook command invoked before retention prunes this daemon's logs.
263    #[serde(skip_serializing_if = "Option::is_none", default)]
264    pub archive_hook: Option<String>,
265    /// Log format for this daemon: `json`, `logfmt`, `auto`, or `text`.
266    #[serde(skip_serializing_if = "Option::is_none", default)]
267    pub log_format: Option<String>,
268    /// Hook triggered when the daemon produces matching output
269    #[serde(skip_serializing_if = "Option::is_none", default)]
270    pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
271    /// Allocate a pseudo-terminal for the daemon process.
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub pty: Option<bool>,
274    /// Run-to-completion task rather than a long-running service.
275    ///
276    /// Appended rather than grouped with `autostop`: IPC encodes this struct
277    /// positionally, so a field inserted in the middle shifts every field
278    /// after it for a CLI or supervisor that does not have it, and a version
279    /// mismatch is only warned about, not refused.
280    #[serde(default)]
281    pub oneshot: bool,
282    /// How long to wait for a oneshot to finish, already resolved from the
283    /// project's `supervisor.oneshot_timeout`.
284    ///
285    /// Resolved by the client and carried on the request because the
286    /// supervisor is long-lived and may have started in another directory, so
287    /// its own `settings()` would not see the project's value. `None` leaves
288    /// the supervisor to fall back to whatever it can resolve.
289    #[serde(skip_serializing_if = "Option::is_none", default)]
290    pub oneshot_wait: Option<OneshotWait>,
291    /// This start came from entering a directory rather than from a person
292    /// asking for it, so a completed `oneshot` is left alone. Decided by the
293    /// supervisor because only it holds authoritative state: the state file
294    /// lags it by up to the flush interval, which is exactly the window a
295    /// second directory entry lands in.
296    #[serde(default)]
297    pub on_directory_enter: bool,
298    /// The proxy is starting this daemon, and it may be stopped after this
299    /// many milliseconds without proxy activity. `None` for every other start,
300    /// which is what makes such a start explicit. Carried over by restarts
301    /// (retry, file watch), which continue the same ownership.
302    ///
303    /// Appended last for the positional IPC encoding.
304    #[serde(default)]
305    pub proxy_idle_timeout_ms: Option<u64>,
306    /// The cron watcher is starting this run, so a successful spawn is what
307    /// `Daemon::last_cron_run` records.
308    ///
309    /// Set only by the watcher's own call. A retry, file-watch or manual
310    /// restart of a scheduled daemon carries the daemon's `cron_schedule` but
311    /// not this, because the schedule did not ask for it.
312    ///
313    /// Appended after `proxy_idle_timeout_ms` for the positional IPC
314    /// encoding.
315    #[serde(default)]
316    pub cron_started: bool,
317    /// Start `cmd` directly, without a shell: the config's `run` was an
318    /// array. `run` is `None` then, since there is no command line.
319    ///
320    /// Appended after `cron_started` for the positional IPC encoding.
321    #[serde(default)]
322    pub no_shell: bool,
323    /// Set by the supervisor on a start a client asked for over IPC; never
324    /// sent. See `Daemon::scheduled_from_config`.
325    #[serde(skip)]
326    pub requested_by_client: bool,
327}
328
329impl Daemon {
330    /// The next time the cron watcher will consider this daemon due, or
331    /// `None` when it has no schedule or the schedule does not parse.
332    ///
333    /// Anchored exactly the way `check_cron_schedules` anchors itself, so the
334    /// answer is what the watcher will actually do rather than an independent
335    /// reading of the schedule. That includes the case where the supervisor
336    /// was down across a window: the anchor is still the old tick, so the
337    /// result is a time in the past -- the overdue run the watcher takes on
338    /// its next check.
339    pub fn next_cron_run(
340        &self,
341        now: chrono::DateTime<chrono::Local>,
342    ) -> Option<chrono::DateTime<chrono::Local>> {
343        use std::str::FromStr;
344        let schedule = cron::Schedule::from_str(self.cron_schedule.as_ref()?).ok()?;
345        let anchor = match self.last_cron_triggered {
346            Some(t) => t,
347            // Mirrors the watcher's first-sighting branch: `immediate` looks
348            // back ten seconds, the default anchors to now.
349            None if self.cron_immediate.unwrap_or(false) => now - chrono::Duration::seconds(10),
350            None => now,
351        };
352        schedule.after(&anchor).next()
353    }
354
355    /// Build RunOptions from persisted daemon state.
356    ///
357    /// Carries over all configuration fields from the daemon state.
358    /// Callers can override specific fields on the returned value.
359    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
360        // Re-read on_output_hook from fresh config so restarts (retry, watch,
361        // cron) always pick up the current hook configuration.
362        // Use daemon.dir if available to handle daemons started via slugs
363        // whose project directory is not in the supervisor's cwd ancestry.
364        let on_output_hook = self
365            .dir
366            .as_deref()
367            .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
368            .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
369            .and_then(|pt| {
370                pt.daemons
371                    .get(&self.id)
372                    .and_then(|d| d.hooks.as_ref())
373                    .and_then(|h| h.on_output.clone())
374            });
375
376        RunOptions {
377            id: self.id.clone(),
378            cmd,
379            run: self.run.clone(),
380            force: false,
381            shell_pid: self.shell_pid,
382            dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
383            autostop: self.autostop,
384            oneshot: self.oneshot,
385            // Re-resolved by the client on the paths that have a project to
386            // resolve it from; a supervisor-internal restart keeps None and
387            // falls back.
388            oneshot_wait: None,
389            on_directory_enter: false,
390            // A restart continues whatever ownership the run it replaces had.
391            proxy_idle_timeout_ms: self.proxy_idle_timeout_ms,
392            // A restart of a scheduled daemon is not the schedule starting a
393            // run; only the cron watcher's own call sets this.
394            cron_started: false,
395            no_shell: self.no_shell,
396            // Set by the IPC handler for a client's own request.
397            requested_by_client: false,
398            cron_schedule: self.cron_schedule.clone(),
399            cron_retrigger: self.cron_retrigger,
400            cron_immediate: self.cron_immediate,
401            retry: self.retry,
402            retry_count: self.retry_count,
403            ready_delay: self.ready_delay,
404            ready_output: self.ready_output.clone(),
405            ready_http: self.ready_http.clone(),
406            ready_port: self.ready_port.clone(),
407            ready_cmd: self.ready_cmd.clone(),
408            health_cmd: self.health_cmd.clone(),
409            health_http: self.health_http.clone(),
410            health_port: self.health_port.clone(),
411            port: self.port.clone(),
412            wait_ready: false,
413            depends: self.depends.clone(),
414            env: self.env.clone(),
415            watch: self.watch.clone(),
416            watch_mode: self.watch_mode,
417            watch_base_dir: self.watch_base_dir.clone(),
418            mise: self.mise,
419            slug: self.slug.clone(),
420            proxy: self.proxy,
421            user: self.user.clone(),
422            memory_limit: self.memory_limit,
423            cpu_limit: self.cpu_limit,
424            stop_signal: self.stop_signal,
425            archive_hook: self.archive_hook.clone(),
426            log_format: self.log_format.clone(),
427            on_output_hook,
428            pty: self.pty,
429        }
430    }
431}
432
433impl Display for Daemon {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        write!(f, "{}", self.id.qualified())
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use chrono::TimeZone;
443
444    fn at(h: u32, m: u32, sec: u32) -> chrono::DateTime<chrono::Local> {
445        chrono::Local
446            .with_ymd_and_hms(2026, 9, 21, h, m, sec)
447            .unwrap()
448    }
449
450    /// Daily at 03:00, the schedule from the report that asked for this.
451    fn daily_3am(last_triggered: Option<chrono::DateTime<chrono::Local>>) -> Daemon {
452        Daemon {
453            cron_schedule: Some("0 0 3 * * *".to_string()),
454            last_cron_triggered: last_triggered,
455            ..Daemon::default()
456        }
457    }
458
459    #[test]
460    fn next_cron_run_is_none_without_a_schedule() {
461        assert!(Daemon::default().next_cron_run(at(7, 0, 0)).is_none());
462    }
463
464    /// The watcher warns and skips an expression it cannot parse; there is no
465    /// next run to report for one.
466    #[test]
467    fn next_cron_run_is_none_for_an_invalid_schedule() {
468        let d = Daemon {
469            cron_schedule: Some("not a cron expression".to_string()),
470            ..Daemon::default()
471        };
472        assert!(d.next_cron_run(at(7, 0, 0)).is_none());
473    }
474
475    #[test]
476    fn next_cron_run_follows_the_last_tick() {
477        let d = daily_3am(Some(at(3, 0, 4)));
478        assert_eq!(
479            d.next_cron_run(at(7, 0, 0)),
480            Some(
481                chrono::Local
482                    .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
483                    .unwrap()
484            )
485        );
486    }
487
488    /// A supervisor that was down across 03:00 has a stale anchor, so the next
489    /// run is in the past: the window it still owes, which is what the watcher
490    /// will take on its next check.
491    #[test]
492    fn next_cron_run_reports_a_missed_window_as_past() {
493        let d = daily_3am(Some(
494            chrono::Local
495                .with_ymd_and_hms(2026, 9, 20, 3, 0, 0)
496                .unwrap(),
497        ));
498        let next = d.next_cron_run(at(7, 0, 0)).unwrap();
499        assert_eq!(next, at(3, 0, 0));
500        assert!(next < at(7, 0, 0));
501    }
502
503    /// Never triggered, `immediate = false`: the watcher will anchor to now
504    /// and skip the current window, so the answer is the next one.
505    #[test]
506    fn next_cron_run_skips_the_current_window_without_immediate() {
507        let d = daily_3am(None);
508        assert_eq!(
509            d.next_cron_run(at(2, 59, 0)),
510            Some(at(3, 0, 0)),
511            "a window still ahead of now is reported as-is"
512        );
513        assert_eq!(
514            d.next_cron_run(at(3, 0, 30)),
515            Some(
516                chrono::Local
517                    .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
518                    .unwrap()
519            ),
520            "a window that just passed is not claimed: immediate=false skips it"
521        );
522    }
523
524    /// `immediate = true` keeps the watcher's ten-second look-back, so a
525    /// window that just passed is still due.
526    #[test]
527    fn next_cron_run_honors_the_immediate_lookback() {
528        let d = Daemon {
529            cron_immediate: Some(true),
530            ..daily_3am(None)
531        };
532        assert_eq!(d.next_cron_run(at(3, 0, 5)), Some(at(3, 0, 0)));
533    }
534
535    #[test]
536    fn test_valid_daemon_ids() {
537        // Short IDs
538        assert!(is_valid_daemon_id("myapp"));
539        assert!(is_valid_daemon_id("my-app"));
540        assert!(is_valid_daemon_id("my_app"));
541        assert!(is_valid_daemon_id("my.app"));
542        assert!(is_valid_daemon_id("MyApp123"));
543
544        // Qualified IDs (namespace/short_id)
545        assert!(is_valid_daemon_id("project/api"));
546        assert!(is_valid_daemon_id("global/web"));
547        assert!(is_valid_daemon_id("my-project/my-app"));
548    }
549
550    #[test]
551    fn test_invalid_daemon_ids() {
552        // Empty
553        assert!(!is_valid_daemon_id(""));
554
555        // Multiple slashes (invalid qualified format)
556        assert!(!is_valid_daemon_id("a/b/c"));
557        assert!(!is_valid_daemon_id("../etc/passwd"));
558
559        // Invalid qualified format (empty parts)
560        assert!(!is_valid_daemon_id("/api"));
561        assert!(!is_valid_daemon_id("project/"));
562
563        // Backslashes
564        assert!(!is_valid_daemon_id("foo\\bar"));
565
566        // Parent directory reference
567        assert!(!is_valid_daemon_id(".."));
568        assert!(!is_valid_daemon_id("foo..bar"));
569
570        // Double dash (reserved for path encoding)
571        assert!(!is_valid_daemon_id("my--app"));
572        assert!(!is_valid_daemon_id("project--api"));
573        assert!(!is_valid_daemon_id("--app"));
574        assert!(!is_valid_daemon_id("app--"));
575
576        // Spaces
577        assert!(!is_valid_daemon_id("my app"));
578        assert!(!is_valid_daemon_id(" myapp"));
579        assert!(!is_valid_daemon_id("myapp "));
580
581        // Current directory
582        assert!(!is_valid_daemon_id("."));
583
584        // Control characters
585        assert!(!is_valid_daemon_id("my\x00app"));
586        assert!(!is_valid_daemon_id("my\napp"));
587        assert!(!is_valid_daemon_id("my\tapp"));
588
589        // Non-ASCII
590        assert!(!is_valid_daemon_id("myäpp"));
591        assert!(!is_valid_daemon_id("приложение"));
592
593        // Unsupported punctuation under DaemonId rules
594        assert!(!is_valid_daemon_id("app@host"));
595        assert!(!is_valid_daemon_id("app:8080"));
596    }
597}