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