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