Skip to main content

shep_core/config/
normalize.rs

1//! Validation and normalization: `AppConfig` -> `ResolvedApp`
2//!
3//! `ResolvedApp` is a proof token: constructing one is only possible through
4//! [`normalize`], so daemon code can require it and skip re-validation.
5
6use core::fmt;
7
8use std::path::Path;
9
10use std::collections::BTreeSet;
11
12use globset::Glob;
13
14use crate::config::{
15    AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
16};
17use crate::values::UpDuration;
18
19/// Shortest `interval` a `liveness_probe` may name.
20///
21/// The daemon's liveness loop floors whatever it is handed at this same value
22/// (its own `MIN_PROBE_INTERVAL`), so a smaller number would be *honoured* as
23/// this one with nothing to say so — in a detached daemon, not even a log
24/// line. That is the reasoning `max_cron_sleep` was settled on (`MIN_CRON_SLEEP`
25/// rejects rather than clamps), and it applies here for the same reason: the
26/// user's file is the only place the discrepancy could ever be noticed.
27///
28/// One second is a floor no legitimate configuration wants to be under. A
29/// liveness check asked for more often than that is polling, and for
30/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) it is that many process
31/// spawns per second, per sheep, for as long as the sheep runs.
32const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
33
34/// Shortest `interval` a `readiness_probe` may name.
35///
36/// A whole second lower than [`MIN_LIVENESS_INTERVAL`], and deliberately so:
37/// the readiness wait honours its `interval` exactly as written and is bounded
38/// by the app's `listen_timeout`, so there is no clamp for a rejection to keep
39/// honest here — only the zero, which would spin that wait for the whole
40/// `listen_timeout`. A fast app that polls every 20ms to leave `starting`
41/// sooner is asking for something the daemon really does, so this floor must
42/// not take it away.
43const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
44
45/// Longest `action_timeout` an app may name.
46///
47/// Not a floor this time but a ceiling, and for a different reason than
48/// [`MIN_LIVENESS_INTERVAL`]'s: there, a smaller number was silently
49/// honoured as the floor; here, a larger one could never be honoured by
50/// ANY caller at all. The daemon clamps every RPC deadline a client can
51/// possibly ask for — its own `MAX_DEADLINE_MS`, 60s, in `shep-daemon`'s
52/// `rpc` module — so an `action_timeout` at or above that line describes a
53/// wait the daemon could never finish inside any request budget, no matter
54/// how generous a caller's own `Client::request_with_deadline` call is.
55/// That is not "the caller forgot to widen its deadline" (this crate has no
56/// way to see a caller's choice, and does not try to); it is "no choice
57/// exists", which is what makes it a config error rather than a caller's to
58/// fix. Set 2s under the hard clamp — the same margin `action_timeout`'s own
59/// default keeps under the *default* RPC budget — so the daemon still has
60/// room to build the `TimedOut` row and get it back down the wire after the
61/// wait itself gives up.
62const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
63
64/// A validated app config — only obtainable via [`normalize`]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ResolvedApp {
67    config: AppConfig,
68}
69
70impl ResolvedApp {
71    /// Borrow the validated configuration
72    #[must_use]
73    pub fn config(&self) -> &AppConfig {
74        &self.config
75    }
76
77    /// Unwrap the validated configuration (consumes the proof token)
78    #[must_use]
79    pub fn into_config(self) -> AppConfig {
80        self.config
81    }
82}
83
84/// Expands a leading `~/` against `home`, and refuses `~user/`.
85///
86/// `~` is a shell feature. A shell expands it before a program ever sees the
87/// argument, so a value read out of a Flockfile has nothing between it and
88/// the parser and arrives literally: shep would look for a directory named
89/// `~`. A process manager standing in for the shell that would otherwise
90/// have started the process inherits this narrow piece of its job.
91///
92/// Deliberately narrow. `~/` only:
93///
94/// - `~user/...` is refused. Resolving it means a passwd lookup, and under a
95///   systemd unit the answer depends on who the daemon runs as rather than
96///   on who wrote the file.
97/// - `$VAR` is NOT expanded, here or anywhere. Once a config file expands
98///   variables it has to answer WHICH environment it means -- the operator's,
99///   the daemon's, or the app's own `env` table -- and there is no good
100///   answer.
101///
102/// A path that does not start with `~` is returned untouched, so this is a
103/// no-op for every absolute and relative path anyone already has.
104///
105/// Thin wrapper over [`expand_home_tilde`], the one piece of tilde-expansion
106/// logic in the workspace: this attaches the sheep name and field
107/// [`NormalizeError`] carries, so a Flockfile refusal names the line to
108/// edit. A caller with no such context — `shep-cli`'s own `shep adopt`,
109/// resolving a bare CLI argument — calls [`expand_home_tilde`] directly.
110///
111/// # Errors
112/// - [`NormalizeError::TildeUser`] if the path names another user's home.
113/// - [`NormalizeError::NoHomeForTilde`] if `~/` is used and `home` is `None`.
114fn expand_tilde(
115    value: &str,
116    home: Option<&Path>,
117    name: &str,
118    field: &'static str,
119) -> Result<String, NormalizeError> {
120    expand_home_tilde(value, home).map_err(|err| match err {
121        TildeError::OtherUser => NormalizeError::TildeUser {
122            name: name.to_string(),
123            field,
124            value: value.to_string(),
125        },
126        TildeError::NoHome => NormalizeError::NoHomeForTilde {
127            name: name.to_string(),
128            field,
129        },
130    })
131}
132
133/// Why [`expand_home_tilde`] refused a value — the two failure modes with
134/// no per-field context attached, for a caller that has none to give.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum TildeError {
137    /// The value names another user's home (`~user/...`). Refused rather
138    /// than resolved: answering it means a passwd lookup, and under a
139    /// systemd unit the answer depends on who the process runs as rather
140    /// than on who wrote the value.
141    OtherUser,
142    /// The value begins `~/` and no home directory could be determined.
143    NoHome,
144}
145
146impl fmt::Display for TildeError {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        match self {
149            Self::OtherUser => write!(
150                f,
151                "shep expands only `~/` (your own home); another user's home needs a \
152                 passwd lookup whose answer depends on who the process runs as"
153            ),
154            Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
155        }
156    }
157}
158
159impl core::error::Error for TildeError {}
160
161/// Expands a leading `~/` in `value` against `home` — the one piece of
162/// tilde-expansion logic in the workspace, shared by `expand_tilde` above
163/// (Flockfile path fields, private to this module) and `shep-cli`'s own
164/// `shep adopt` (its `<path>` argument).
165///
166/// Deliberately narrow, matching `expand_tilde`'s own doc: `~/` only.
167/// `~user/...` is refused (a passwd lookup, whose answer depends on who
168/// the process runs as) and `$VAR` is never expanded, here or anywhere.
169///
170/// A value that does not start with `~` is returned unchanged, so this is
171/// a no-op for every absolute and relative path anyone already has.
172///
173/// # Errors
174/// - [`TildeError::OtherUser`] if the value names another user's home.
175/// - [`TildeError::NoHome`] if `~/` is used and `home` is `None`.
176pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
177    let Some(rest) = value.strip_prefix('~') else {
178        return Ok(value.to_string());
179    };
180    // `~` alone, or `~/...`. Anything else after the tilde names a user.
181    if !(rest.is_empty() || rest.starts_with('/')) {
182        return Err(TildeError::OtherUser);
183    }
184    let Some(home) = home else {
185        return Err(TildeError::NoHome);
186    };
187    // `join` would discard `home` for a rest that still looks absolute, so
188    // the separator is trimmed and the two halves are concatenated instead.
189    let joined = home.join(rest.trim_start_matches('/'));
190    Ok(joined.to_string_lossy().into_owned())
191}
192
193/// Every field of an [`AppConfig`] that carries a filesystem path.
194///
195/// Named once, and walked by [`expand_paths`] and by its own test, so a
196/// fifth path field added later fails that test until it is handled.
197/// Expanding `~/` in some path fields and not others would be worse than
198/// expanding in none: it teaches that tildes work and then fails somewhere
199/// the operator has no reason to suspect.
200#[cfg(test)]
201const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
202
203/// Expands `~/` in every path field of `app`, in place.
204///
205/// # Errors
206/// Whatever [`expand_tilde`] refuses, named with the field that carried it.
207fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
208    let name = app.name.clone();
209    app.script = expand_tilde(&app.script, home, &name, "script")?;
210    for (field, slot) in [
211        ("cwd", &mut app.cwd),
212        ("out_file", &mut app.out_file),
213        ("err_file", &mut app.err_file),
214    ] {
215        if let Some(value) = slot {
216            *slot = Some(expand_tilde(value, home, &name, field)?);
217        }
218    }
219    Ok(())
220}
221
222/// Validates one app config
223///
224/// # Errors
225///
226/// - [`NormalizeError::MissingName`] — `name` is empty.
227/// - [`NormalizeError::InvalidName`] — `name` contains a path separator or is `.`/`..`.
228/// - [`NormalizeError::MissingScript`] — `script` is empty.
229/// - [`NormalizeError::ZeroInstances`] — `instances == 0`.
230/// - [`NormalizeError::InvalidCron`] — `cron_restart` is not valid in
231///   croner's dialect (carries the pattern and the rejection reason).
232/// - [`NormalizeError::InvalidTimezone`] — `cron_timezone` is not a name in
233///   the IANA time-zone database.
234/// - [`NormalizeError::InvalidProbe`] — `readiness_probe` or `liveness_probe`
235///   has a target [`ProbeTarget::parse`] rejects (carries which probe and
236///   the rendered reason).
237/// - [`NormalizeError::ZeroFailureThreshold`] — a probe's `failure_threshold`
238///   is explicitly `0`.
239/// - [`NormalizeError::IntervalBelowMinimum`] — a probe's `interval` is under
240///   the floor its own loop honours: a full second for `liveness_probe`, and
241///   only "greater than zero" for `readiness_probe` (carries which probe, the
242///   value and the floor).
243/// - [`NormalizeError::ZeroMaxMemory`] — `max_memory` is `0`.
244/// - [`NormalizeError::ActionTimeoutTooLong`] — `action_timeout` is at or
245///   above the ceiling no RPC caller could ever be given room to wait past
246///   (carries the app name, the value and the ceiling).
247/// - [`NormalizeError::InvalidKillSignal`] — `kill_signal` names a signal the
248///   daemon's stop ladder cannot send (carries the app name and the value).
249/// - [`NormalizeError::WatchWithoutCwd`] — `watch` is `true` with no `cwd`
250///   set.
251/// - [`NormalizeError::ZeroWatchDelay`] — `watch_delay` is `0`.
252/// - [`NormalizeError::InvalidWatchGlob`] — a `watch_options` or
253///   `ignore_watch` pattern globset will not compile (carries the app name,
254///   which of the two lists, the pattern and the reason).
255pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
256    normalize_with_home(app, std::env::home_dir().as_deref())
257}
258
259/// [`normalize`], with the home directory supplied rather than read.
260///
261/// A parameter so the `~/` expansion above is testable without mutating the
262/// process environment, which is racy under a parallel `cargo test`. This is
263/// also the seam that matters for correctness rather than only for tests:
264/// the daemon may run as a different user than the CLI, so `~` has to be
265/// resolved where the config is normalised, not where it is executed.
266///
267/// # Errors
268/// The same set [`normalize`] documents.
269pub fn normalize_with_home(
270    mut app: AppConfig,
271    home: Option<&Path>,
272) -> Result<ResolvedApp, NormalizeError> {
273    if app.name.is_empty() {
274        return Err(NormalizeError::MissingName);
275    }
276    if app.name.contains(['/', '\\']) || app.name == "." || app.name == ".." {
277        return Err(NormalizeError::InvalidName(app.name));
278    }
279    if app.script.is_empty() {
280        return Err(NormalizeError::MissingScript);
281    }
282    // After the emptiness checks, so a missing script is reported as missing
283    // rather than as a path problem, and before every check below that reads
284    // a path.
285    expand_paths(&mut app, home)?;
286    if app.instances == 0 {
287        return Err(NormalizeError::ZeroInstances);
288    }
289    if let Some(pattern) = &app.cron_restart {
290        CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
291            CronParseError::Pattern { pattern, reason } => {
292                NormalizeError::InvalidCron { pattern, reason }
293            }
294            CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
295        })?;
296    } else if let Some(tz_name) = &app.cron_timezone {
297        // A Flockfile can carry `cron_timezone` with no `cron_restart` to
298        // pair it with — still a typo the user wants to hear about (spec §5).
299        crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
300            NormalizeError::InvalidTimezone {
301                name: tz_name.clone(),
302            }
303        })?;
304    }
305    validate_probe(
306        app.readiness_probe.as_ref(),
307        "readiness_probe",
308        MIN_READINESS_INTERVAL,
309    )?;
310    validate_probe(
311        app.liveness_probe.as_ref(),
312        "liveness_probe",
313        MIN_LIVENESS_INTERVAL,
314    )?;
315    if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
316        // A ceiling every live process is over, armed against every poll: the
317        // enforcer would report a breach on its first reading and on every
318        // reading after it, and the restart that follows is automatic, which
319        // RESETS the restart budget rather than spending it. `max_restarts`
320        // cannot end that loop, so it has to be refused here.
321        return Err(NormalizeError::ZeroMaxMemory { name: app.name });
322    }
323    if let Some(name) = &app.kill_signal
324        && KillSignal::parse(name).is_none()
325    {
326        // Rejected rather than clamped, and this one is the sharpest case of
327        // that trade in the file: a typo silently falling back to SIGTERM
328        // would cost the operator every stop and every reload for the life
329        // of the process, with the only evidence in a detached daemon's log
330        // at the moment of a stop. `max_cron_sleep` and `MIN_LIVENESS_INTERVAL`
331        // reject for the same reason at lower stakes: the user's file is the
332        // only place a silently-substituted value could ever be noticed.
333        return Err(NormalizeError::InvalidKillSignal {
334            name: app.name,
335            value: name.clone(),
336        });
337    }
338    if app.action_timeout > MAX_ACTION_TIMEOUT {
339        // Rejected rather than clamped, the same trade `MIN_LIVENESS_INTERVAL`
340        // and `max_cron_sleep` already made: a daemon running detached has no
341        // reader for a log line saying the value was silently lowered, so the
342        // Flockfile would be the only place the discrepancy ever showed up —
343        // and here there is no honest lowered value to fall back to anyway,
344        // since every value above the ceiling is equally unreachable by any
345        // caller.
346        return Err(NormalizeError::ActionTimeoutTooLong {
347            name: app.name,
348            value: app.action_timeout,
349            max: MAX_ACTION_TIMEOUT,
350        });
351    }
352    if app.watch && app.cwd.is_none() {
353        // `watch` asked for a feature the daemon has no directory to arm:
354        // there is no cwd in the Flockfile, and defaulting to the daemon's
355        // own cwd risks watching the whole filesystem under a systemd unit
356        // with no `WorkingDirectory=`.
357        return Err(NormalizeError::WatchWithoutCwd { name: app.name });
358    }
359    if app.watch_delay == Some(UpDuration::from_millis(0)) {
360        // notify's debouncer derives its own poll tick as `watch_delay / 4`
361        // and runs it on a dedicated OS thread, so a zero turns that thread
362        // into `loop { sleep(0); lock(); }`, a CPU-spinning busy loop.
363        // shep-daemon's watch arming floors this independently too (its
364        // `MIN_WATCH_DELAY`), the same belt-and-suspenders shape
365        // `validate_probe`'s interval check has opposite the liveness loop's
366        // own floor.
367        return Err(NormalizeError::ZeroWatchDelay { name: app.name });
368    }
369    // Both lists are checked whether or not `watch` is on. A pattern globset
370    // will not compile is a typo, and the user wants it named now rather than
371    // the day they flip `watch = true` and wonder why saving a file changes
372    // nothing — the same reasoning that makes `watch` without `cwd` a config
373    // error above.
374    validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
375    validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
376    Ok(ResolvedApp { config: app })
377}
378
379/// Validates one of an app's two watch glob lists, rejecting any pattern
380/// globset will not compile. `field` is the Flockfile field name
381/// (`"watch_options"` or `"ignore_watch"`), carried into any error so the
382/// user knows which list to edit. The compiled globs are discarded — this
383/// function's job is rejection; the daemon builds its own watch filter when
384/// it arms the watch.
385fn validate_watch_globs(
386    name: &str,
387    field: &'static str,
388    patterns: &[String],
389) -> Result<(), NormalizeError> {
390    for pattern in patterns {
391        Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
392            name: name.to_string(),
393            field,
394            pattern: pattern.clone(),
395            reason: err.to_string(),
396        })?;
397    }
398    Ok(())
399}
400
401/// Validates one probe's target, `failure_threshold` and `interval`, if the
402/// probe is configured. `probe` is the Flockfile field name
403/// (`"readiness_probe"` or `"liveness_probe"`), carried into any error so the
404/// user knows which field to edit; `min_interval` is the floor that probe's
405/// own loop in the daemon honours, which is why the two call sites pass
406/// different ones. Its own parsed [`ProbeTarget`] is discarded — this
407/// function's job is rejection; the daemon re-parses when it arms the probe.
408fn validate_probe(
409    probe: Option<&ProbeConfig>,
410    name: &'static str,
411    min_interval: UpDuration,
412) -> Result<(), NormalizeError> {
413    let Some(probe) = probe else {
414        return Ok(());
415    };
416    ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
417        probe: name,
418        reason: reason.to_string(),
419    })?;
420    if probe.failure_threshold == 0 {
421        // Unhealthy before the first probe ever runs — not a configuration
422        // anybody wants, and it would make the liveness loop restart the
423        // sheep immediately and forever.
424        return Err(NormalizeError::ZeroFailureThreshold { probe: name });
425    }
426    if probe.interval < min_interval {
427        // Not a configuration anybody wants either. Both probe loops sleep
428        // `interval` between attempts, so a zero turns either into a hot
429        // spin — for `ProbeKind::Exec`, hundreds of process spawns per
430        // second, per sheep. A liveness interval that is merely *small*
431        // is refused for a second reason: `spawn_liveness_task` rounds it UP
432        // to its own `MIN_PROBE_INTERVAL`, which would leave the user's file
433        // the only place the discrepancy exists and nothing anywhere to
434        // report it. Rejecting rather than clamping is what `max_cron_sleep`
435        // settled on for that same trade; the daemon-side floor stays too,
436        // because this crate does not own the boot wiring that guarantees
437        // every `ProbeConfig` reaching the loop came through here.
438        return Err(NormalizeError::IntervalBelowMinimum {
439            probe: name,
440            value: probe.interval,
441            min: min_interval,
442        });
443    }
444    Ok(())
445}
446
447/// Validates a whole flock, rejecting duplicate sheep names
448///
449/// # Errors
450///
451/// Everything [`normalize`] returns, plus
452/// [`NormalizeError::DuplicateName`] — two apps share a `name`.
453pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
454    let mut seen = BTreeSet::new();
455    apps.into_iter()
456        .map(|app| {
457            if !seen.insert(app.name.clone()) {
458                return Err(NormalizeError::DuplicateName(app.name));
459            }
460            normalize(app)
461        })
462        .collect()
463}
464
465/// Error type returned from [`normalize`] and [`normalize_all`]
466///
467/// Growth is expected: every config surface this crate learns to validate
468/// brings its own rejection reasons with it (IR-20).
469#[non_exhaustive]
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub enum NormalizeError {
472    /// `name` is empty
473    MissingName,
474    /// `name` contains `/` or `\` or is `.`/`..` — it becomes a filesystem
475    /// path stem, so these would escape the shep home (carries the name)
476    InvalidName(String),
477    /// `script` is empty
478    MissingScript,
479    /// `instances` is zero
480    ZeroInstances,
481    /// `cron_restart` is not valid in croner's dialect. Carries the pattern
482    /// and the rejection reason — croner's own sentence where croner did the
483    /// rejecting, ours where shep's pre-parse pass did.
484    InvalidCron {
485        /// The pattern as the user wrote it
486        pattern: String,
487        /// Why it was rejected
488        reason: String,
489    },
490    /// `cron_timezone` is not a name in the IANA time-zone database
491    InvalidTimezone {
492        /// The value as the user wrote it
493        name: String,
494    },
495    /// Two apps in one flock share this name
496    DuplicateName(String),
497    /// A `readiness_probe` or `liveness_probe` target is malformed. Carries
498    /// which probe and the rendered reason.
499    InvalidProbe {
500        /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
501        /// name, so the error names the line the user has to edit.
502        probe: &'static str,
503        /// [`ProbeTarget::parse`]'s rendered rejection reason.
504        reason: String,
505    },
506    /// A `readiness_probe` or `liveness_probe` has `failure_threshold == 0`.
507    ZeroFailureThreshold {
508        /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
509        /// name, so the error names the line the user has to edit.
510        probe: &'static str,
511    },
512    /// A `readiness_probe` or `liveness_probe` has an `interval` under the
513    /// floor its own loop in the daemon honours. At `0` that would spin the
514    /// loop as fast as the runtime allows; a `liveness_probe` under a full
515    /// second would instead be silently polled at that second.
516    IntervalBelowMinimum {
517        /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
518        /// name, so the error names the line the user has to edit.
519        probe: &'static str,
520        /// The value as the user wrote it.
521        value: UpDuration,
522        /// The floor it failed.
523        min: UpDuration,
524    },
525    /// `max_memory` is `0` — a ceiling every live process is already over, so
526    /// the enforcer would restart the sheep on every poll forever. Carries
527    /// the app name.
528    ZeroMaxMemory {
529        /// The sheep name, so the error names which Flockfile entry to edit.
530        name: String,
531    },
532    /// `action_timeout` is at or above `normalize`'s own ceiling — a wait no
533    /// RPC caller could ever be given enough deadline to outlast, since the
534    /// daemon clamps every deadline a caller can ask for. Carries the app
535    /// name, the value as written, and the ceiling it failed.
536    ActionTimeoutTooLong {
537        /// The sheep name, so the error names which Flockfile entry to edit.
538        name: String,
539        /// The value as the user wrote it.
540        value: UpDuration,
541        /// The ceiling it failed.
542        max: UpDuration,
543    },
544    /// `kill_signal` names a signal the daemon's stop ladder cannot send.
545    /// Carries the app name and the value as written.
546    InvalidKillSignal {
547        /// The sheep name, so the error names which Flockfile entry to edit.
548        name: String,
549        /// The value as the user wrote it.
550        value: String,
551    },
552    /// `watch` is enabled but the app sets no `cwd`, so there is no
553    /// directory to watch. Carries the app name.
554    WatchWithoutCwd {
555        /// The sheep name, so the error names which Flockfile entry to edit.
556        name: String,
557    },
558    /// A path begins `~user/`, naming another user's home.
559    ///
560    /// Refused rather than resolved: answering it means a passwd lookup, and
561    /// under a systemd unit the answer is not obviously the one anyone meant.
562    /// `~/` is supported; this is not.
563    TildeUser {
564        /// The sheep name, so the error names which Flockfile entry to edit.
565        name: String,
566        /// Which field carried it.
567        field: &'static str,
568        /// The path as written.
569        value: String,
570    },
571    /// A path begins `~/` and no home directory could be determined.
572    NoHomeForTilde {
573        /// The sheep name, so the error names which Flockfile entry to edit.
574        name: String,
575        /// Which field carried it.
576        field: &'static str,
577    },
578    /// `watch_delay` is `0`, which would spin the debouncer's own OS thread.
579    /// Carries the app name.
580    ZeroWatchDelay {
581        /// The sheep name, so the error names which Flockfile entry to edit.
582        name: String,
583    },
584    /// A `watch_options` or `ignore_watch` pattern is one globset will not
585    /// compile, so the watch it describes could never be armed.
586    InvalidWatchGlob {
587        /// The sheep name, so the error names which Flockfile entry to edit.
588        name: String,
589        /// `"watch_options"` or `"ignore_watch"` — the Flockfile field name,
590        /// so the error names which of the two lists to edit.
591        field: &'static str,
592        /// The pattern as the user wrote it.
593        pattern: String,
594        /// globset's own rendered reason.
595        reason: String,
596    },
597}
598
599impl fmt::Display for NormalizeError {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        match self {
602            Self::MissingName => f.write_str("app config is missing a name"),
603            Self::InvalidName(n) => {
604                write!(
605                    f,
606                    "sheep name `{n}` may not contain a path separator or be `.` or `..`"
607                )
608            }
609            Self::MissingScript => f.write_str("app config is missing a script"),
610            Self::ZeroInstances => f.write_str("instances must be at least 1"),
611            Self::InvalidCron { pattern, reason } => {
612                write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
613            }
614            Self::InvalidTimezone { name } => {
615                write!(f, "`{name}` is not a recognized IANA timezone")
616            }
617            Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
618            Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
619            Self::ZeroFailureThreshold { probe } => {
620                write!(f, "{probe}.failure_threshold must be at least 1")
621            }
622            Self::IntervalBelowMinimum { probe, value, min } => {
623                write!(f, "{probe}.interval is `{value}`: must be at least {min}")
624            }
625            Self::ZeroMaxMemory { name } => {
626                write!(
627                    f,
628                    "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
629                )
630            }
631            Self::ActionTimeoutTooLong { name, value, max } => {
632                write!(
633                    f,
634                    "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
635                     the longest wait any caller's deadline could ever cover"
636                )
637            }
638            Self::InvalidKillSignal { name, value } => {
639                write!(
640                    f,
641                    "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
642                    KillSignal::ACCEPTED.join(", ")
643                )
644            }
645            Self::TildeUser { name, field, value } => write!(
646                f,
647                "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
648                 Another user's home needs a passwd lookup whose answer depends on who the \
649                 daemon runs as, so write the path out in full instead."
650            ),
651            Self::NoHomeForTilde { name, field } => write!(
652                f,
653                "`{name}`: {field} begins with `~/` but no home directory could be found. \
654                 Set $HOME, or write the path out in full."
655            ),
656            Self::WatchWithoutCwd { name } => {
657                write!(f, "sheep `{name}` has watch = true but no cwd to watch")
658            }
659            Self::ZeroWatchDelay { name } => {
660                write!(
661                    f,
662                    "sheep `{name}` has watch_delay = 0: must be greater than 0"
663                )
664            }
665            Self::InvalidWatchGlob {
666                name,
667                field,
668                pattern,
669                reason,
670            } => write!(
671                f,
672                "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
673            ),
674        }
675    }
676}
677
678impl core::error::Error for NormalizeError {}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    /// All four path fields expand `~/`, and expanding some but not others
685    /// would be worse than expanding none: it teaches that tildes work and
686    /// then fails where the operator has no reason to suspect it.
687    #[test]
688    fn every_path_field_expands_a_leading_tilde() {
689        let home = Path::new("/home/ada");
690        let mut app = AppConfig::minimal("web", "~/app/server.js");
691        app.cwd = Some("~/app".to_string());
692        app.out_file = Some("~/logs/out.log".to_string());
693        app.err_file = Some("~/logs/err.log".to_string());
694
695        let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
696        let c = resolved.config();
697        // Expectations are built with `join` rather than written as literals:
698        // the separator is `/` here and `\` on Windows, and hardcoding one
699        // turned CI's three Windows legs red when this test first landed.
700        let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
701        assert_eq!(c.script, expect("app/server.js"));
702        assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
703        assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
704        assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
705    }
706
707    /// The anti-drift half. A fifth path field added to `AppConfig` fails
708    /// here until `expand_paths` handles it, which is the only thing keeping
709    /// the "all four or none" rule true over time.
710    #[test]
711    fn the_path_field_list_matches_what_expand_paths_walks() {
712        let home = Path::new("/home/ada");
713        let mut app = AppConfig::minimal("web", "~/s");
714        app.cwd = Some("~/c".to_string());
715        app.out_file = Some("~/o".to_string());
716        app.err_file = Some("~/e".to_string());
717
718        let resolved = normalize_with_home(app, Some(home)).expect("expands");
719        let c = resolved.config();
720        let expanded = [
721            ("script", Some(c.script.as_str())),
722            ("cwd", c.cwd.as_deref()),
723            ("out_file", c.out_file.as_deref()),
724            ("err_file", c.err_file.as_deref()),
725        ];
726        assert_eq!(
727            expanded.len(),
728            PATH_FIELDS.len(),
729            "PATH_FIELDS and this test must name the same set"
730        );
731        for (field, value) in expanded {
732            assert!(
733                PATH_FIELDS.contains(&field),
734                "`{field}` is not in PATH_FIELDS"
735            );
736            assert!(
737                value.is_some_and(|v| v.starts_with("/home/ada")),
738                "`{field}` was not expanded: {value:?}"
739            );
740        }
741    }
742
743    /// A path with no tilde is untouched, so this is a no-op for every
744    /// absolute and relative path anyone already has.
745    #[test]
746    fn a_path_without_a_tilde_is_left_exactly_as_written() {
747        let app = AppConfig::minimal("web", "./server.js");
748        let resolved =
749            normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
750        assert_eq!(resolved.config().script, "./server.js");
751    }
752
753    /// `~user/` needs a passwd lookup whose answer depends on who the daemon
754    /// runs as, so it is refused rather than guessed at.
755    #[test]
756    fn another_users_home_is_refused_rather_than_resolved() {
757        let app = AppConfig::minimal("web", "~deploy/app/server.js");
758        let err = normalize_with_home(app, Some(Path::new("/home/ada")))
759            .expect_err("~user/ must not resolve");
760        assert!(
761            matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
762            "the refusal names the field: {err:?}"
763        );
764        let rendered = err.to_string();
765        assert!(
766            rendered.contains("~/"),
767            "and says what IS supported: {rendered}"
768        );
769        assert!(
770            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
771            "no em or en dash in copy a user reads: {rendered}"
772        );
773    }
774
775    /// `$VAR` is not expanded, here or anywhere. A config file that expands
776    /// variables has to answer whose environment it means.
777    #[test]
778    fn a_dollar_variable_is_not_expanded() {
779        let app = AppConfig::minimal("web", "$HOME/server.js");
780        let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
781        assert_eq!(resolved.config().script, "$HOME/server.js");
782    }
783
784    /// `~/` with no home to expand against is an error naming the field
785    /// rather than a path containing a literal tilde.
786    #[test]
787    fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
788        let app = AppConfig::minimal("web", "~/server.js");
789        let err = normalize_with_home(app, None).expect_err("nothing to expand against");
790        assert!(
791            matches!(err, NormalizeError::NoHomeForTilde { .. }),
792            "{err:?}"
793        );
794    }
795
796    /// Pins [`expand_home_tilde`]'s own public contract directly, apart
797    /// from [`expand_tilde`]'s wrapping of it into a [`NormalizeError`]:
798    /// `shep-cli`'s own `shep adopt` calls this function directly (it has
799    /// no app name or field to attach), so its behavior needs its own
800    /// coverage independent of whatever `AppConfig`-shaped test drives it
801    /// above.
802    #[test]
803    fn expand_home_tilde_covers_its_four_documented_cases() {
804        let home = Path::new("/home/ada");
805        assert_eq!(
806            expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
807            home.join("bin/dog").to_string_lossy()
808        );
809        assert_eq!(
810            expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
811            "/opt/bin/dog",
812            "a value with no leading ~ is returned unchanged"
813        );
814        assert_eq!(
815            expand_home_tilde("~/bin/dog", None).unwrap_err(),
816            TildeError::NoHome
817        );
818        assert_eq!(
819            expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
820            TildeError::OtherUser
821        );
822    }
823
824    /// `reuse_port` loads because reload's overlap mode is chosen from it —
825    /// refusing it would deny an operator the only way to ask for an
826    /// overlapping reload.
827    #[test]
828    fn reuse_port_loads_now_that_reload_reads_it() {
829        let mut app = AppConfig::minimal("web", "./server");
830        app.reuse_port = true;
831
832        let resolved = normalize(app).expect("reuse_port is implemented");
833        assert!(resolved.config().reuse_port);
834    }
835
836    /// The default is off, so every Flockfile that does not mention it keeps
837    /// loading.
838    #[test]
839    fn an_app_that_never_mentions_reuse_port_still_normalizes() {
840        let resolved = normalize(AppConfig::minimal("web", "./server"))
841            .expect("the common case must be untouched");
842        assert!(!resolved.config().reuse_port);
843    }
844    use crate::config::AppConfig;
845
846    #[test]
847    fn valid_minimal_config_normalizes() {
848        let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
849        assert_eq!(resolved.config().name, "web");
850    }
851
852    #[test]
853    fn names_that_reach_the_filesystem_are_rejected() {
854        // A name becomes a log/pid file stem via Path::join; a slash-prefixed
855        // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
856        for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
857            assert_eq!(
858                normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
859                NormalizeError::InvalidName(bad.to_string())
860            );
861        }
862        assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
863    }
864
865    #[test]
866    fn missing_name_and_script_are_distinct_errors() {
867        assert_eq!(
868            normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
869            NormalizeError::MissingName
870        );
871        assert_eq!(
872            normalize(AppConfig::minimal("web", "")).unwrap_err(),
873            NormalizeError::MissingScript
874        );
875    }
876
877    #[test]
878    fn zero_instances_rejected() {
879        let mut app = AppConfig::minimal("web", "./srv");
880        app.instances = 0;
881        assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
882    }
883
884    #[test]
885    fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
886        // fails if the reason is not carried through from croner.
887        let mut app = AppConfig::minimal("web", "./srv");
888        app.cron_restart = Some("not a cron".to_string());
889        match normalize(app).unwrap_err() {
890            NormalizeError::InvalidCron { pattern, reason } => {
891                assert_eq!(pattern, "not a cron");
892                assert!(!reason.is_empty());
893            }
894            other => panic!("expected InvalidCron, got {other:?}"),
895        }
896    }
897
898    #[test]
899    fn five_tokens_of_garbage_cron_pattern_rejected() {
900        // fails if the validator only counts whitespace-separated tokens
901        // instead of checking each field's range: five numeric-looking
902        // tokens, all out of range.
903        let mut app = AppConfig::minimal("web", "./srv");
904        app.cron_restart = Some("99 99 99 99 99".to_string());
905        match normalize(app).unwrap_err() {
906            NormalizeError::InvalidCron { pattern, .. } => {
907                assert_eq!(pattern, "99 99 99 99 99");
908            }
909            other => panic!("expected InvalidCron, got {other:?}"),
910        }
911    }
912
913    #[test]
914    fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
915        // fails if the `cron_restart` branch maps CronParseError::Timezone to
916        // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
917        // resolves the zone before it looks at the pattern, so a valid pattern
918        // paired with a bad zone is the only input that reaches that arm — the
919        // zone-with-no-pattern test below takes the separate `else if` branch.
920        let mut app = AppConfig::minimal("web", "./srv");
921        app.cron_restart = Some("0 3 * * *".to_string());
922        app.cron_timezone = Some("Mars/Olympus".to_string());
923        match normalize(app).unwrap_err() {
924            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
925            other => panic!("expected InvalidTimezone, got {other:?}"),
926        }
927    }
928
929    #[test]
930    fn cron_timezone_validated_even_without_cron_restart() {
931        // fails if timezone validation is skipped when there's no pattern to
932        // pair it with — a Flockfile with only a bad `cron_timezone` is a
933        // typo the user wants to hear about (spec §5).
934        let mut app = AppConfig::minimal("web", "./srv");
935        app.cron_timezone = Some("Mars/Olympus".to_string());
936        match normalize(app).unwrap_err() {
937            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
938            other => panic!("expected InvalidTimezone, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn duplicate_names_rejected_across_a_flock() {
944        let apps = vec![
945            AppConfig::minimal("web", "./a"),
946            AppConfig::minimal("web", "./b"),
947        ];
948        assert_eq!(
949            normalize_all(apps).unwrap_err(),
950            NormalizeError::DuplicateName("web".to_string())
951        );
952    }
953
954    fn probe_config(target: &str) -> crate::config::ProbeConfig {
955        crate::config::ProbeConfig {
956            kind: crate::config::ProbeKind::Http,
957            target: target.to_string(),
958            interval: crate::values::UpDuration::from_millis(10_000),
959            timeout: crate::values::UpDuration::from_millis(5_000),
960            failure_threshold: 3,
961        }
962    }
963
964    #[test]
965    fn malformed_readiness_probe_target_rejected_naming_the_field() {
966        // fails if validate_probe is never called for readiness_probe, or if
967        // it drops which of the two probe fields the rejection came from
968        let mut app = AppConfig::minimal("web", "./srv");
969        app.readiness_probe = Some(probe_config("not-a-url"));
970        match normalize(app).unwrap_err() {
971            NormalizeError::InvalidProbe { probe, reason } => {
972                assert_eq!(probe, "readiness_probe");
973                assert!(!reason.is_empty());
974            }
975            other => panic!("expected InvalidProbe, got {other:?}"),
976        }
977    }
978
979    #[test]
980    fn malformed_liveness_probe_target_rejected_naming_the_field() {
981        // fails if only readiness_probe is ever validated, leaving a bad
982        // liveness_probe target to surface later at the daemon's first poll
983        let mut app = AppConfig::minimal("web", "./srv");
984        app.liveness_probe = Some(probe_config("not-a-url"));
985        match normalize(app).unwrap_err() {
986            NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
987            other => panic!("expected InvalidProbe, got {other:?}"),
988        }
989    }
990
991    #[test]
992    fn valid_probe_targets_accepted() {
993        // fails if validate_probe rejects a well-formed target outright
994        let mut app = AppConfig::minimal("web", "./srv");
995        app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
996        assert!(normalize(app).is_ok());
997    }
998
999    #[test]
1000    fn zero_failure_threshold_rejected() {
1001        // fails if failure_threshold is never inspected — a threshold of 0
1002        // means "unhealthy before the first probe ever runs"
1003        let mut app = AppConfig::minimal("web", "./srv");
1004        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1005        probe.failure_threshold = 0;
1006        app.readiness_probe = Some(probe);
1007        let err = normalize(app).unwrap_err();
1008        assert_eq!(
1009            err,
1010            NormalizeError::ZeroFailureThreshold {
1011                probe: "readiness_probe"
1012            }
1013        );
1014        // fails if the message regresses to a bare variant name with no
1015        // explanation — following the sibling precedent at app.rs:261.
1016        assert!(err.to_string().contains("at least 1"), "{err}");
1017    }
1018
1019    #[test]
1020    fn zero_interval_rejected() {
1021        // fails if interval is never inspected — a zero interval would spin
1022        // the readiness wait as fast as the runtime allows for the whole
1023        // `listen_timeout` (`await_ready` deliberately does not floor it)
1024        let mut app = AppConfig::minimal("web", "./srv");
1025        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1026        probe.interval = UpDuration::from_millis(0);
1027        app.readiness_probe = Some(probe);
1028        let err = normalize(app).unwrap_err();
1029        assert_eq!(
1030            err,
1031            NormalizeError::IntervalBelowMinimum {
1032                probe: "readiness_probe",
1033                value: UpDuration::from_millis(0),
1034                min: MIN_READINESS_INTERVAL,
1035            }
1036        );
1037        // fails if the message regresses to a bare variant name with no
1038        // explanation — following the sibling precedent at app.rs:261.
1039        assert!(err.to_string().contains("must be at least"), "{err}");
1040    }
1041
1042    #[test]
1043    fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1044        // fails if the liveness check is `interval == 0` rather than a
1045        // floor. A 500ms interval survives an equality check and is then
1046        // rounded UP to a full second by `spawn_liveness_task`'s own
1047        // `MIN_PROBE_INTERVAL` — an app polled at half the rate its
1048        // Flockfile asks for, with nothing anywhere to say so: that clamp
1049        // writes no record at all, so not even the daemon's own log names
1050        // it. Also fails if the rejection drops the value
1051        // the user wrote, which is the one number that tells them what to
1052        // edit.
1053        let mut app = AppConfig::minimal("web", "./srv");
1054        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1055        probe.interval = UpDuration::from_millis(500);
1056        app.liveness_probe = Some(probe);
1057        let err = normalize(app).unwrap_err();
1058        assert_eq!(
1059            err,
1060            NormalizeError::IntervalBelowMinimum {
1061                probe: "liveness_probe",
1062                value: UpDuration::from_millis(500),
1063                min: MIN_LIVENESS_INTERVAL,
1064            }
1065        );
1066        assert!(err.to_string().contains("500"), "{err}");
1067    }
1068
1069    #[test]
1070    fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1071        // fails if the comparison is `<=` rather than `<` — the floor is a
1072        // value the liveness loop honours exactly, so naming it must not be
1073        // an error (IR-40: sweep the boundary, not just past it).
1074        let mut app = AppConfig::minimal("web", "./srv");
1075        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1076        probe.interval = MIN_LIVENESS_INTERVAL;
1077        app.liveness_probe = Some(probe);
1078        assert!(normalize(app).is_ok());
1079    }
1080
1081    #[test]
1082    fn a_sub_second_readiness_interval_is_accepted() {
1083        // fails if both probes are validated against the liveness floor. A
1084        // readiness wait is bounded by `listen_timeout` and honours its
1085        // `interval` exactly as written (`await_ready` argues the case
1086        // itself), so a fast app polling every 50ms to leave `starting`
1087        // sooner is asking for something the daemon really does — refusing
1088        // it would take a working feature away to fix a clamp that only the
1089        // liveness loop has.
1090        let mut app = AppConfig::minimal("web", "./srv");
1091        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1092        probe.interval = UpDuration::from_millis(50);
1093        app.readiness_probe = Some(probe);
1094        assert!(normalize(app).is_ok());
1095    }
1096
1097    #[test]
1098    fn zero_max_memory_rejected() {
1099        // fails if `max_memory` is never inspected. Zero is a ceiling every
1100        // live process is already over, so the enforcer breaches on its
1101        // first reading and every reading after it — and the restart that
1102        // follows is automatic, which RESETS the restart budget, so
1103        // `max_restarts` never ends the loop.
1104        let mut app = AppConfig::minimal("web", "./srv");
1105        app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1106        let err = normalize(app).unwrap_err();
1107        assert_eq!(
1108            err,
1109            NormalizeError::ZeroMaxMemory {
1110                name: "web".to_string()
1111            }
1112        );
1113        // fails if the message regresses to a bare variant name with no
1114        // explanation — following the sibling precedent at app.rs:261.
1115        assert!(err.to_string().contains("max_memory"), "{err}");
1116    }
1117
1118    #[test]
1119    fn a_nonzero_max_memory_is_accepted() {
1120        // fails if the check fires on `max_memory` being set at all rather
1121        // than on its being zero — that would refuse every app that
1122        // configures a limit, which is the whole feature
1123        let mut app = AppConfig::minimal("web", "./srv");
1124        app.max_memory = Some("512M".parse().unwrap());
1125        assert!(normalize(app).is_ok());
1126    }
1127
1128    /// fails if a `kill_signal` shep cannot send is accepted here. Accepting it
1129    /// is what put SIGTERM on the wire for the life of the process with nothing
1130    /// but one daemon log line to say so — the clamp this rejection replaces.
1131    #[test]
1132    fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1133        let mut app = AppConfig::minimal("web", "./srv");
1134        app.kill_signal = Some("SIGUSR1".to_string());
1135
1136        let err = normalize(app).unwrap_err();
1137
1138        assert_eq!(
1139            err,
1140            NormalizeError::InvalidKillSignal {
1141                name: "web".to_string(),
1142                value: "SIGUSR1".to_string(),
1143            }
1144        );
1145        // The message has to name the accepted set, because the operator's next
1146        // move is picking a different word and there is nowhere else to look.
1147        let rendered = err.to_string();
1148        assert!(rendered.contains("SIGUSR1"), "{rendered}");
1149        assert!(rendered.contains("SIGTERM"), "{rendered}");
1150        assert!(rendered.contains("SIGUSR2"), "{rendered}");
1151    }
1152
1153    /// fails if the four supported names, their bare forms, or a lowercase
1154    /// spelling stop being accepted. This is the compatibility half: every
1155    /// spelling `stop_signal` accepted before this task must still normalize.
1156    #[test]
1157    fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1158        for name in [
1159            "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1160            "USR2", "sigusr2",
1161        ] {
1162            let mut app = AppConfig::minimal("web", "./srv");
1163            app.kill_signal = Some(name.to_string());
1164            assert!(
1165                normalize(app).is_ok(),
1166                "`{name}` was accepted before this task and must still be"
1167            );
1168        }
1169    }
1170
1171    /// fails if an unset `kill_signal` is refused — the overwhelmingly common
1172    /// case, and the one a validation pass is most likely to break by treating
1173    /// `None` as an empty string.
1174    #[test]
1175    fn an_unset_kill_signal_is_not_a_config_error() {
1176        let app = AppConfig::minimal("web", "./srv");
1177        assert!(app.kill_signal.is_none());
1178        assert!(normalize(app).is_ok());
1179    }
1180
1181    #[test]
1182    fn action_timeout_past_the_ceiling_is_rejected() {
1183        // fails if `action_timeout` is never inspected. One millisecond over
1184        // the ceiling is deliberate: a test at a round number like 60s could
1185        // pass by coincidence if the check used the wrong constant entirely
1186        // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
1187        let mut app = AppConfig::minimal("web", "./srv");
1188        app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1189        let err = normalize(app).unwrap_err();
1190        assert_eq!(
1191            err,
1192            NormalizeError::ActionTimeoutTooLong {
1193                name: "web".to_string(),
1194                value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1195                max: MAX_ACTION_TIMEOUT,
1196            }
1197        );
1198        // fails if the message regresses to a bare variant name with no
1199        // explanation — following the sibling precedent at app.rs:261.
1200        assert!(err.to_string().contains("action_timeout"), "{err}");
1201    }
1202
1203    #[test]
1204    fn action_timeout_at_the_ceiling_is_accepted() {
1205        // fails if the comparison is `>=` rather than `>` — the ceiling
1206        // itself still leaves the daemon its full margin under the hard
1207        // clamp, so it is not one of the values nothing could ever satisfy.
1208        let mut app = AppConfig::minimal("web", "./srv");
1209        app.action_timeout = MAX_ACTION_TIMEOUT;
1210        assert!(normalize(app).is_ok());
1211    }
1212
1213    #[test]
1214    fn the_default_action_timeout_is_accepted() {
1215        // fails if `AppConfig::default()`'s own value ever drifts past the
1216        // ceiling normalize enforces — the one combination that must never
1217        // reject the config nobody customized.
1218        assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1219    }
1220
1221    #[test]
1222    fn zero_watch_delay_rejected() {
1223        // fails if `watch_delay` is never inspected. notify's debouncer
1224        // derives its poll tick as `watch_delay / 4` and sleeps it on its own
1225        // OS thread, so zero is `loop { sleep(0); lock(); }`, a CPU-spinning
1226        // busy loop.
1227        let mut app = AppConfig::minimal("web", "./srv");
1228        app.watch = true;
1229        app.cwd = Some("/srv/web".to_string());
1230        app.watch_delay = Some(UpDuration::from_millis(0));
1231        let err = normalize(app).unwrap_err();
1232        assert_eq!(
1233            err,
1234            NormalizeError::ZeroWatchDelay {
1235                name: "web".to_string()
1236            }
1237        );
1238        // fails if the message regresses to a bare variant name with no
1239        // explanation — following the sibling precedent at app.rs:261.
1240        assert!(err.to_string().contains("watch_delay"), "{err}");
1241    }
1242
1243    #[test]
1244    fn a_zero_watch_delay_is_rejected_with_watch_off() {
1245        // fails if the check is nested inside the `watch` block: an app
1246        // carrying `watch_delay = "0"` with `watch = false` would normalize
1247        // clean, and the spin would arrive the day someone flips `watch =
1248        // true` — the same reasoning that puts the glob checks outside it
1249        let mut app = AppConfig::minimal("web", "./srv");
1250        app.watch_delay = Some(UpDuration::from_millis(0));
1251        assert!(matches!(
1252            normalize(app).unwrap_err(),
1253            NormalizeError::ZeroWatchDelay { .. }
1254        ));
1255    }
1256
1257    #[test]
1258    fn a_nonzero_watch_delay_is_accepted() {
1259        // fails if the check fires on `watch_delay` being set at all rather
1260        // than on its being zero — that would refuse every app that tunes
1261        // its own debounce
1262        let mut app = AppConfig::minimal("web", "./srv");
1263        app.watch = true;
1264        app.cwd = Some("/srv/web".to_string());
1265        app.watch_delay = Some(UpDuration::from_millis(1));
1266        assert!(normalize(app).is_ok());
1267    }
1268
1269    #[test]
1270    fn default_failure_threshold_from_toml_accepted() {
1271        // fails if the check fires on the ordinary default instead of only
1272        // an explicit 0. Deserializes a Flockfile snippet that omits
1273        // `failure_threshold` entirely, so this exercises the real
1274        // `#[serde(default = "default_failure_threshold")]` path
1275        // (config/app.rs) rather than duplicating `probe_config`'s
1276        // hardcoded `3` — a literal that wouldn't notice if the wired
1277        // default ever changed.
1278        let src = r#"
1279name = "web"
1280script = "./srv"
1281
1282[readiness_probe]
1283kind = "http"
1284target = "http://127.0.0.1:8080/healthz"
1285"#;
1286        let app: AppConfig = toml::from_str(src).unwrap();
1287        assert!(normalize(app).is_ok());
1288    }
1289
1290    #[test]
1291    fn watch_true_without_cwd_rejected_naming_the_app() {
1292        // fails if a validator never looks at `watch`, or looks at it but
1293        // carries no app name, leaving the user unable to tell which
1294        // Flockfile entry to edit
1295        let mut app = AppConfig::minimal("web", "./srv");
1296        app.watch = true;
1297        let err = normalize(app).unwrap_err();
1298        assert_eq!(
1299            err,
1300            NormalizeError::WatchWithoutCwd {
1301                name: "web".to_string()
1302            }
1303        );
1304        // fails if the message regresses to a bare variant name with no
1305        // explanation — following the sibling precedent at app.rs:261.
1306        assert!(err.to_string().contains("no cwd to watch"), "{err}");
1307    }
1308
1309    #[test]
1310    fn watch_true_with_cwd_accepted() {
1311        // fails if the check fires on `watch` alone, ignoring that a cwd was
1312        // actually provided
1313        let mut app = AppConfig::minimal("web", "./srv");
1314        app.watch = true;
1315        app.cwd = Some("/srv/web".to_string());
1316        assert!(normalize(app).is_ok());
1317    }
1318
1319    #[test]
1320    fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1321        // fails if `watch_options` patterns are never compiled at config
1322        // time — the sheep would then report `online` with no watch armed
1323        // and nothing but a log line to say so. Also fails if the rejection
1324        // blames the whole list instead of the one bad pattern: the valid
1325        // `src/**` comes first, so an error carrying it, or carrying the
1326        // patterns joined together, is not the pattern the user must fix.
1327        let mut app = AppConfig::minimal("web", "./srv");
1328        app.watch = true;
1329        app.cwd = Some("/srv/web".to_string());
1330        app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1331        let err = normalize(app).unwrap_err();
1332        assert_eq!(
1333            err,
1334            NormalizeError::InvalidWatchGlob {
1335                name: "web".to_string(),
1336                field: "watch_options",
1337                pattern: "[".to_string(),
1338                reason: Glob::new("[").unwrap_err().to_string(),
1339            }
1340        );
1341        // fails if the message drops the app name, the list or the pattern —
1342        // the three things that name the Flockfile line to edit.
1343        let rendered = err.to_string();
1344        for expected in ["web", "watch_options", "`[`"] {
1345            assert!(
1346                rendered.contains(expected),
1347                "{expected} missing: {rendered}"
1348            );
1349        }
1350    }
1351
1352    #[test]
1353    fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1354        // fails if only `watch_options` is ever compiled, leaving a mistyped
1355        // `ignore_watch` to cost the app its watch at arm time instead
1356        let mut app = AppConfig::minimal("web", "./srv");
1357        app.watch = true;
1358        app.cwd = Some("/srv/web".to_string());
1359        app.ignore_watch = vec!["[".to_string()];
1360        match normalize(app).unwrap_err() {
1361            NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1362                assert_eq!(field, "ignore_watch");
1363                assert_eq!(pattern, "[");
1364            }
1365            other => panic!("expected InvalidWatchGlob, got {other:?}"),
1366        }
1367    }
1368
1369    #[test]
1370    fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1371        // fails if glob validation is nested inside the `watch` check: an app
1372        // carrying a mistyped glob with `watch = false` would then normalize
1373        // clean, and the typo would surface only the day someone flips
1374        // `watch = true`
1375        let mut app = AppConfig::minimal("web", "./srv");
1376        app.watch_options = vec!["[".to_string()];
1377        assert!(matches!(
1378            normalize(app).unwrap_err(),
1379            NormalizeError::InvalidWatchGlob { .. }
1380        ));
1381    }
1382
1383    #[test]
1384    fn well_formed_watch_globs_are_accepted() {
1385        // fails if the new check rejects patterns globset compiles happily —
1386        // recursive `**`, a character class, a negated character class and a
1387        // brace alternation are all ordinary globset syntax a Flockfile is
1388        // entitled to use. Also fails if the check is wired to a parser that
1389        // is not globset's: every one of these is valid to globset and a
1390        // syntax error to a regex engine.
1391        let mut app = AppConfig::minimal("web", "./srv");
1392        app.watch = true;
1393        app.cwd = Some("/srv/web".to_string());
1394        app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1395        app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1396        assert!(normalize(app).is_ok());
1397    }
1398
1399    #[test]
1400    fn watch_options_without_watch_or_cwd_accepted() {
1401        // fails if the check is keyed on `watch_options` being non-empty
1402        // rather than on `watch` being true — that would reject a Flockfile
1403        // that never asked to be watched
1404        let mut app = AppConfig::minimal("web", "./srv");
1405        app.watch_options = vec!["src/**".to_string()];
1406        assert!(normalize(app).is_ok());
1407    }
1408}