Skip to main content

shep_core/config/
daemon.rs

1//! Daemon-level configuration: `$SHEP_HOME/shep.toml`
2//!
3//! Layering (spec §5): file < `SHEP_*` env < CLI flags. This module applies
4//! the first two; the CLI applies its flags onto the returned struct.
5
6use core::fmt;
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::Deserialize;
12
13use crate::values::UpDuration;
14
15/// The `[daemon]` section
16#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
17#[serde(deny_unknown_fields, default)]
18pub struct DaemonSection {
19    /// Emit the daemon's own logs as JSON lines
20    pub log_json: bool,
21    /// Lowest severity of the daemon's own records that reaches its log
22    pub log_level: LogLevel,
23    /// Control-socket path override (default: `$SHEP_HOME/run/shep.sock`)
24    pub socket: Option<std::path::PathBuf>,
25    /// Dogs to autostart with the daemon (`shep enable` writes this)
26    pub enabled_dogs: Vec<String>,
27    /// Where an adopted dog's binary lives, keyed by dog name
28    /// (`shep adopt` writes this; `shep rehome` removes it).
29    ///
30    /// A name in [`Self::enabled_dogs`] with no entry here is a built-in
31    /// dog — an argv branch of the shep binary itself. That is the whole of
32    /// the distinction, and it is deliberately NOT recorded inside
33    /// `[dog.<name>]`: that table is the dog's own opaque configuration, and
34    /// a shep-owned key inside it would collide with a third-party dog's
35    /// schema.
36    pub adopted_dogs: BTreeMap<String, PathBuf>,
37    /// Longest a cron worker sleeps before re-deriving its next occurrence.
38    ///
39    /// Shorter recovers faster from a suspended laptop or an NTP step and
40    /// costs proportionally more wakeups per cron-configured sheep; longer
41    /// is cheaper and drifts further. Unset means the daemon's own default.
42    /// There is no upper bound: a very long value only degrades to sleeping
43    /// straight through to the occurrence, which still fires.
44    pub max_cron_sleep: Option<UpDuration>,
45}
46
47/// How much of the daemon's own diagnostics reaches its log.
48///
49/// Written as one of the names below in `[daemon] log_level` or in
50/// `SHEP_LOG_LEVEL`, lowercase and nothing else — the same closed grammar
51/// `log_json` accepts, so a typo is a startup error naming the value rather
52/// than a level silently reverting to the default.
53///
54/// The default is [`LogLevel::Warn`]. The daemon's records are dominated by
55/// warn-and-continue arms — an app that asked to be watched and could not be,
56/// a cron pattern that would not parse, a memory ceiling a process tree
57/// crossed — and each one is the *only* account of a decision the operator
58/// cannot otherwise see. [`LogLevel::Debug`] adds per-decision detail that
59/// fires per dropped restart and per child metric sample, which is a firehose
60/// on a busy flock rather than a slightly noisier log.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum LogLevel {
64    /// Nothing at all — the daemon writes no records of its own.
65    Off,
66    /// Only faults the daemon could not work around.
67    Error,
68    /// Faults the daemon worked around, and what working around them cost.
69    #[default]
70    Warn,
71    /// Lifecycle milestones: the daemon came up, the daemon is going down.
72    Info,
73    /// Per-decision detail — every restart weighed, every metric sampled.
74    Debug,
75    /// Everything the daemon can say about itself.
76    Trace,
77}
78
79impl LogLevel {
80    /// The one spelling this level is written as, in the file and in the
81    /// environment alike
82    #[must_use]
83    pub const fn as_str(self) -> &'static str {
84        match self {
85            Self::Off => "off",
86            Self::Error => "error",
87            Self::Warn => "warn",
88            Self::Info => "info",
89            Self::Debug => "debug",
90            Self::Trace => "trace",
91        }
92    }
93
94    /// The level `name` spells, or `None` when it spells no level.
95    ///
96    /// The inverse of [`LogLevel::as_str`], and exact: an uppercase or
97    /// mixed-case name is not a level here, because `SHEP_LOG_JSON` accepts
98    /// no `TRUE` either.
99    #[must_use]
100    pub fn from_name(name: &str) -> Option<Self> {
101        match name {
102            "off" => Some(Self::Off),
103            "error" => Some(Self::Error),
104            "warn" => Some(Self::Warn),
105            "info" => Some(Self::Info),
106            "debug" => Some(Self::Debug),
107            "trace" => Some(Self::Trace),
108            _ => None,
109        }
110    }
111}
112
113/// Floor on `[daemon] max_cron_sleep`.
114///
115/// Zero makes every sleep return immediately and turns the loop into a hot
116/// spin that re-derives a schedule as fast as the runtime allows — while
117/// still firing correctly, which is what makes it hard to attribute. Low
118/// milliseconds are the same fault with a smaller constant. One second is a
119/// floor no legitimate configuration wants to be under: a five-field cron
120/// pattern cannot name anything finer than a minute, so even this is sixty
121/// times more often than the tightest schedule can fire.
122const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
123
124/// The `[whistle]` section
125///
126/// One key, and it is a gate rather than a tuning knob: `shep whistle`'s four
127/// control tools (`start_sheep`, `stop_sheep`, `restart_sheep`,
128/// `reload_sheep`) exist only when this is `true`, and its five read-only
129/// tools exist regardless.
130///
131/// **This lives in the shepherd's config file and nowhere else.** There is no
132/// `--allow-control` flag and no `SHEP_*` variable, deliberately: spec §14.7
133/// rules that whistle's gate is daemon config because config is auditable and
134/// flags are per-invocation. That is a legibility argument, not a
135/// containment one — `--home`/`SHEP_HOME` already choose which `shep.toml`
136/// gets read, so a flag would open nothing those don't already; a boolean in
137/// a file just leaves a diff and an mtime an operator can audit.
138///
139/// The shepherd itself never reads this key — `shep whistle` reads the file
140/// directly, at startup, in its own process. It is here because this struct is
141/// the grammar of `shep.toml`: `RawDaemonConfig` denies unknown fields, so a
142/// `[whistle]` section the grammar does not know about would refuse the whole
143/// file to boot rather than being silently ignored.
144///
145/// `Debug` is derived rather than redacted (IR-41): one boolean, no secret,
146/// nothing a `{:?}` could leak.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
148#[serde(deny_unknown_fields, default)]
149pub struct WhistleSection {
150    /// Whether `shep whistle` offers its control tools. Default `false`.
151    pub allow_control: bool,
152}
153
154/// The `[style]` section: how much the CLI dresses up its output.
155///
156/// Read by the CLI only. The daemon has no opinion about how anyone likes
157/// their tables, and parses this solely so an unknown key is not an error.
158///
159/// `Debug` is derived rather than redacted (IR-41): one optional string, no
160/// secret, nothing a `{:?}` could leak.
161#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
162#[serde(deny_unknown_fields, default)]
163pub struct StyleSection {
164    /// `full`, `plain` or `bare`. Absent means the CLI decides.
165    pub level: Option<String>,
166}
167
168/// Parsed daemon configuration with raw per-dog sections
169///
170/// Dog sections stay untyped here: each dog deserializes its own
171/// `[dog.<name>]` table so dog config schemas live with the dog code.
172///
173/// `#[non_exhaustive]`: this struct grows a section over time, and each one
174/// would otherwise be a breaking change for an out-of-tree struct literal.
175/// That is IR-20's ordinary reasoning applied to a struct. **It is not a
176/// validation gate**, and this type is deliberately
177/// not the proof token [`crate::config::ResolvedApp`] is: the attribute blocks
178/// struct literals and functional-update syntax from outside this crate, but
179/// not field mutation, and [`Self::default`] followed by an assignment to
180/// `daemon.max_cron_sleep` reaches an unvalidated value without a literal.
181///
182/// The contract is therefore stated, not enforced. [`Self::load`] and
183/// [`Self::load_layered`] are the validating constructors; a caller that
184/// mutates a loaded config afterwards is out of contract, and shep-core does
185/// not detect it and, with public fields, cannot.
186///
187/// That is the right trade here because nothing ever *receives* one of these.
188/// `ResolvedApp` protects a property of travel — the supervisor is handed one
189/// and must trust normalization it cannot see. Every production site loads a
190/// `DaemonConfig` and consumes it within a few lines (`run_daemon` renders it
191/// straight into `BootOptions`; shep-cli's `whistle::gate` reads one
192/// boolean), and the daemon holds a `BootOptions`, not this. A dog's own
193/// table is no longer among them: it lives in `dogs.toml` now, and
194/// shep-daemon's `dog_section` reads it out of a
195/// [`DogsConfig`](crate::config::DogsConfig), so
196/// [`Self::dog`] is what an un-migrated file parses into and nothing in
197/// production reads. Guarding the one `max_cron_sleep` floor against a
198/// caller who is already out of contract would cost accessors for every
199/// field of every section, that `BTreeMap<String, toml::Table>` included. If an
200/// out-of-tree caller ever does need to mutate and re-check, the answer is to
201/// make `validate` public — one line, non-breaking — not to privatise the
202/// fields. `docs/specs/deferred.md` records this as resolved.
203///
204/// Nothing in the repository observes the attribute itself: it is invisible
205/// inside the defining crate, and seeing it needs a `trybuild` compile-fail
206/// tier this project declined once already for `ProcessInfo` (see
207/// `tests/process_info_builder_from_outside_the_crate.rs`, which admits the
208/// same gap and is required to stay shep-core's only `tests/` file).
209#[non_exhaustive]
210#[derive(Clone, Default, PartialEq)]
211pub struct DaemonConfig {
212    /// The `[daemon]` section
213    pub daemon: DaemonSection,
214    /// The `[whistle]` section
215    pub whistle: WhistleSection,
216    /// The `[style]` section
217    pub style: StyleSection,
218    /// The `[interpreters]` section: a script extension (no leading dot,
219    /// `"js"` not `".js"`) mapped to the interpreter that runs it.
220    ///
221    /// Read by the CLI only, and applied before a request ever reaches the
222    /// wire: `shep start`'s target resolution folds a match into an app's
223    /// own [`AppConfig::interpreter`](crate::config::AppConfig::interpreter)
224    /// exactly when that field is still unset, the same way `--cwd` and
225    /// `--fold` are folded in there. An app that already names an
226    /// interpreter -- explicitly in a Flockfile, including the literal
227    /// `"none"` -- is never touched by this map, and `--interpreter` on the
228    /// command line outranks both. The daemon itself never reads this
229    /// field; by the time an app reaches it, the CLI has already resolved
230    /// the question.
231    ///
232    /// Lives in this daemon-config grammar for the reason
233    /// [`StyleSection`]'s own doc gives for itself: `RawDaemonConfig`
234    /// denies unknown top-level keys, so a section this type does not know
235    /// about is a hard parse error for every `shep.toml` carrying one --
236    /// registering it here is what keeps `[interpreters]` from breaking
237    /// every daemon boot the moment an operator, or `shep`'s own first-run
238    /// scaffold, writes one.
239    pub interpreters: BTreeMap<String, String>,
240    /// Raw `[dog.<name>]` sections keyed by dog name
241    pub dog: BTreeMap<String, toml::Table>,
242}
243
244/// Debug implementation does not leak dog config values (IR-41)
245impl fmt::Debug for DaemonConfig {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("DaemonConfig")
248            .field("daemon", &self.daemon)
249            .field("whistle", &self.whistle)
250            .field("style", &self.style)
251            .field("interpreters", &self.interpreters)
252            .field("dog", &format_args!("<{} tables>", self.dog.len()))
253            .finish()
254    }
255}
256
257#[derive(Deserialize, Default)]
258#[serde(deny_unknown_fields, default)]
259struct RawDaemonConfig {
260    daemon: DaemonSection,
261    whistle: WhistleSection,
262    style: StyleSection,
263    interpreters: BTreeMap<String, String>,
264    dog: BTreeMap<String, toml::Table>,
265}
266
267impl DaemonConfig {
268    /// Builds config from optional file source + environment overrides
269    ///
270    /// `file < env`, validated. Equivalent to
271    /// [`Self::load_layered`] with an empty [`DaemonOverrides`]; unchanged
272    /// for every existing caller.
273    ///
274    /// # Errors
275    ///
276    /// - [`DaemonConfigError::Toml`] — the file source is invalid TOML.
277    /// - [`DaemonConfigError::BadEnvValue`] — a `SHEP_*` value is not
278    ///   parseable (`SHEP_LOG_JSON` accepts `1|0|true|false`;
279    ///   `SHEP_LOG_LEVEL` accepts a [`LogLevel`] name;
280    ///   `SHEP_MAX_CRON_SLEEP` parses as an [`UpDuration`]).
281    /// - [`DaemonConfigError::BelowMinimum`] — the effective
282    ///   `max_cron_sleep` (file, `SHEP_MAX_CRON_SLEEP`, whichever won) is
283    ///   below the floor.
284    pub fn load(
285        file_source: Option<&str>,
286        env: &dyn Fn(&str) -> Option<String>,
287    ) -> Result<Self, DaemonConfigError> {
288        Self::load_layered(file_source, env, &DaemonOverrides::new())
289    }
290
291    /// Builds config from optional file source + environment + CLI-flag
292    /// overrides
293    ///
294    /// `file < env < flags` (spec §5), validated exactly once, at the end —
295    /// see the private `validate` method below for why validating per layer
296    /// instead would be wrong.
297    ///
298    /// # Errors
299    ///
300    /// - [`DaemonConfigError::Toml`] — the file source is invalid TOML.
301    /// - [`DaemonConfigError::BadEnvValue`] — a `SHEP_*` value is not
302    ///   parseable (`SHEP_LOG_JSON` accepts `1|0|true|false`;
303    ///   `SHEP_LOG_LEVEL` accepts a [`LogLevel`] name;
304    ///   `SHEP_MAX_CRON_SLEEP` parses as an [`UpDuration`]).
305    /// - [`DaemonConfigError::BelowMinimum`] — the effective
306    ///   `max_cron_sleep` (file, `SHEP_MAX_CRON_SLEEP`, or
307    ///   `--max-cron-sleep`, whichever won) is below the floor.
308    pub fn load_layered(
309        file_source: Option<&str>,
310        env: &dyn Fn(&str) -> Option<String>,
311        overrides: &DaemonOverrides,
312    ) -> Result<Self, DaemonConfigError> {
313        let raw: RawDaemonConfig = match file_source {
314            Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
315            None => RawDaemonConfig::default(),
316        };
317        let mut cfg = Self {
318            daemon: raw.daemon,
319            whistle: raw.whistle,
320            style: raw.style,
321            interpreters: raw.interpreters,
322            dog: raw.dog,
323        };
324        if let Some(v) = env("SHEP_LOG_JSON") {
325            cfg.daemon.log_json = match parse_daemon_bool(&v) {
326                Some(value) => value,
327                None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
328            };
329        }
330        if let Some(v) = env("SHEP_LOG_LEVEL") {
331            let Some(level) = LogLevel::from_name(&v) else {
332                return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
333            };
334            cfg.daemon.log_level = level;
335        }
336        if let Some(v) = env("SHEP_SOCKET") {
337            cfg.daemon.socket = Some(std::path::PathBuf::from(v));
338        }
339        // Provenance needs no tracking beyond this one flag: whichever layer
340        // last wrote max_cron_sleep is the key the refusal names, so the
341        // operator is pointed at the thing they can actually edit.
342        // Validating each layer as it is read instead would make a good
343        // SHEP_MAX_CRON_SLEEP unable to rescue a broken shep.toml, or a good
344        // --max-cron-sleep unable to rescue either, which is not what
345        // "file < env < flags" means.
346        let mut max_cron_sleep_key = "max_cron_sleep";
347        if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
348            let parsed = v
349                .parse::<UpDuration>()
350                .map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
351            cfg.daemon.max_cron_sleep = Some(parsed);
352            max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
353        }
354        if let Some(value) = overrides.log_json {
355            cfg.daemon.log_json = value;
356        }
357        if let Some(value) = overrides.log_level {
358            cfg.daemon.log_level = value;
359        }
360        if let Some(value) = &overrides.socket {
361            cfg.daemon.socket = Some(value.clone());
362        }
363        if let Some(value) = overrides.max_cron_sleep {
364            cfg.daemon.max_cron_sleep = Some(value);
365            max_cron_sleep_key = "--max-cron-sleep";
366        }
367        cfg.validate(max_cron_sleep_key)?;
368        Ok(cfg)
369    }
370
371    /// Checks every invariant a `DaemonConfig` carries, whatever layers
372    /// produced it.
373    ///
374    /// One call site, at the bottom of [`Self::load_layered`], and that is
375    /// the point: validating per layer would stop a good `--max-cron-sleep`
376    /// from rescuing a broken `shep.toml`, which is not what
377    /// `file < env < flags` means. The same reasoning the env layer already
378    /// carries, extended one layer up.
379    ///
380    /// `key` is provenance — the spelling the operator actually set, so the
381    /// refusal names the thing they can edit.
382    ///
383    /// Private. It guards construction, not mutation: a caller outside this
384    /// crate can assign to a `pub` field afterwards and this never runs
385    /// again. See the type's own doc for why that is accepted rather than
386    /// closed.
387    ///
388    /// # Errors
389    ///
390    /// - [`DaemonConfigError::BelowMinimum`] — `max_cron_sleep` is under the
391    ///   floor that keeps the cron loop from spinning.
392    fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
393        if let Some(value) = self.daemon.max_cron_sleep
394            && value < MIN_CRON_SLEEP
395        {
396            return Err(DaemonConfigError::BelowMinimum {
397                key,
398                value,
399                min: MIN_CRON_SLEEP,
400            });
401        }
402        Ok(())
403    }
404}
405
406/// The CLI-flag layer of `file < env < flags` (spec §5).
407///
408/// Every field is `Option`: `None` means the flag was absent and the layer
409/// below wins. Nothing here validates — [`DaemonConfig::load_layered`] runs
410/// [`DaemonConfig`]'s single validation pass once, after all three layers,
411/// so a flag can rescue a file the layer below would have rejected.
412///
413/// `#[non_exhaustive]` because this type grows a field every time the hidden
414/// `daemon` subcommand grows a flag; that is anticipated by construction, not
415/// hypothetical — the same field-growth reasoning [`DaemonConfig`] carries,
416/// and like it, not a claim that the value was validated. Build one with
417/// [`Self::new`] and the chained setters — the consuming-self shape
418/// `ProcessInfo::builder` already uses in this workspace, and the shape
419/// `#[non_exhaustive]` requires, since it rules out struct literals and
420/// functional update from outside.
421///
422/// `Debug` is derived rather than redacted (IR-41): four values, none of
423/// them a secret — a socket path and a log level are already visible in
424/// `ps`.
425#[non_exhaustive]
426#[derive(Debug, Clone, Default, PartialEq, Eq)]
427pub struct DaemonOverrides {
428    /// `--log-json`
429    pub log_json: Option<bool>,
430    /// `--log-level`
431    pub log_level: Option<LogLevel>,
432    /// `--socket`
433    pub socket: Option<PathBuf>,
434    /// `--max-cron-sleep`
435    pub max_cron_sleep: Option<UpDuration>,
436}
437
438impl DaemonOverrides {
439    /// An empty layer — every flag absent.
440    #[must_use]
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Sets the `--log-json` override.
446    #[must_use]
447    pub fn log_json(mut self, value: Option<bool>) -> Self {
448        self.log_json = value;
449        self
450    }
451
452    /// Sets the `--log-level` override.
453    #[must_use]
454    pub fn log_level(mut self, value: Option<LogLevel>) -> Self {
455        self.log_level = value;
456        self
457    }
458
459    /// Sets the `--socket` override.
460    #[must_use]
461    pub fn socket(mut self, value: Option<PathBuf>) -> Self {
462        self.socket = value;
463        self
464    }
465
466    /// Sets the `--max-cron-sleep` override.
467    #[must_use]
468    pub fn max_cron_sleep(mut self, value: Option<UpDuration>) -> Self {
469        self.max_cron_sleep = value;
470        self
471    }
472}
473
474/// The boolean grammar of `shep.toml` and the `SHEP_*` environment: `1`, `0`,
475/// `true`, `false`, and nothing else.
476///
477/// One function so the file/env layer and the `--log-json` flag cannot drift.
478/// clap's own `BoolishValueParser` additionally accepts
479/// `yes`/`no`/`y`/`n`/`on`/`off`; using it would widen the grammar on the flag
480/// side only, and widening an input grammar beyond spec is a named drift risk
481/// on this project.
482///
483/// The name says whose grammar this is on purpose. It is not a general
484/// boolean parser and must not be widened into one: the whole value of
485/// exporting it is that there is exactly one answer to "what counts as true
486/// in shep's daemon config".
487#[must_use]
488pub fn parse_daemon_bool(value: &str) -> Option<bool> {
489    match value {
490        "1" | "true" => Some(true),
491        "0" | "false" => Some(false),
492        _ => None,
493    }
494}
495
496/// Error type returned from [`DaemonConfig::load`]
497///
498/// `#[non_exhaustive]`: every `[daemon]` key this crate learns to validate
499/// brings its own rejection reason, and `deferred.md`'s daemon-config flags
500/// layer is a whole set of them at once (IR-20 — the same reasoning
501/// [`NormalizeError`](crate::config::NormalizeError) states for the per-app
502/// side).
503#[non_exhaustive]
504#[derive(Debug, Clone, PartialEq, Eq)]
505pub enum DaemonConfigError {
506    /// `shep.toml` is invalid TOML (carries the parser message)
507    Toml(String),
508    /// A `SHEP_*` env var held an unparseable value (var name, value)
509    BadEnvValue(&'static str, String),
510    /// A `[daemon]` duration is below the floor that keeps the daemon from
511    /// spinning. Carries the key the user actually set — the TOML key or
512    /// the environment variable, whichever supplied the winning value.
513    BelowMinimum {
514        /// `max_cron_sleep` or `SHEP_MAX_CRON_SLEEP`.
515        key: &'static str,
516        /// The value as the user wrote it.
517        value: UpDuration,
518        /// The floor it failed.
519        min: UpDuration,
520    },
521}
522
523impl fmt::Display for DaemonConfigError {
524    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
525        match self {
526            Self::Toml(m) => write!(f, "invalid shep.toml: {m}"),
527            Self::BadEnvValue(var, v) => write!(f, "invalid value `{v}` for {var}"),
528            Self::BelowMinimum { key, value, min } => {
529                write!(
530                    f,
531                    "invalid value `{value}` for {key}: must be at least {min}"
532                )
533            }
534        }
535    }
536}
537
538impl core::error::Error for DaemonConfigError {}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::values::UpDuration;
544
545    fn no_env(_: &str) -> Option<String> {
546        None
547    }
548
549    // fails if a serde default invents 60s in shep-core and takes the
550    // "unset" state away from the layer below
551    #[test]
552    fn missing_max_cron_sleep_leaves_the_field_none() {
553        let cfg = DaemonConfig::load(None, &no_env).unwrap();
554        assert_eq!(cfg.daemon.max_cron_sleep, None);
555    }
556
557    // fails if the field is a bare integer, where "5m" is a TOML error and
558    // "5" is five milliseconds
559    #[test]
560    fn max_cron_sleep_file_value_parses_via_upduration() {
561        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
562        assert_eq!(
563            cfg.daemon.max_cron_sleep,
564            Some(UpDuration::from_millis(5 * 60_000))
565        );
566    }
567
568    // fails if the env read is placed before the file is folded in, or
569    // omitted entirely
570    #[test]
571    fn env_max_cron_sleep_beats_file_value() {
572        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
573        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
574        assert_eq!(
575            cfg.daemon.max_cron_sleep,
576            Some(UpDuration::from_millis(90_000))
577        );
578    }
579
580    // fails if the env read swallows its parse failure (`.ok()` and drop
581    // it, or an `Err` arm that only logs), leaving the file's value
582    // silently in force and the typo invisible
583    #[test]
584    fn bad_env_max_cron_sleep_is_a_typed_error() {
585        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
586        assert_eq!(
587            DaemonConfig::load(None, &env),
588            Err(DaemonConfigError::BadEnvValue(
589                "SHEP_MAX_CRON_SLEEP",
590                "banana".to_string()
591            ))
592        );
593    }
594
595    // fails if the floor is compared with `>` instead of `>=`, or the check
596    // silently clamps instead of rejecting
597    #[test]
598    fn max_cron_sleep_floor_rejects_below_one_second() {
599        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
600        assert_eq!(
601            cfg.daemon.max_cron_sleep,
602            Some(UpDuration::from_millis(1_000))
603        );
604
605        assert_eq!(
606            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
607            Err(DaemonConfigError::BelowMinimum {
608                key: "max_cron_sleep",
609                value: UpDuration::from_millis(999),
610                min: UpDuration::from_millis(1_000),
611            })
612        );
613    }
614
615    // fails if only the file value is validated and never the override, or
616    // if the reported key is the file's even though the environment
617    // introduced the fault
618    #[test]
619    fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
620        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
621        assert_eq!(
622            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
623            Err(DaemonConfigError::BelowMinimum {
624                key: "SHEP_MAX_CRON_SLEEP",
625                value: UpDuration::from_millis(0),
626                min: UpDuration::from_millis(1_000),
627            })
628        );
629    }
630
631    // fails if the message wording drifts (e.g. "invalid" alone, or the
632    // `key`/`min` operands swapped) without anyone noticing — this is the
633    // entire user-facing payload of the reject-don't-clamp decision: it is
634    // what actually reaches `shepd.err.log` on exit code 4.
635    #[test]
636    fn below_minimum_display_is_exact() {
637        let err = DaemonConfigError::BelowMinimum {
638            key: "max_cron_sleep",
639            value: UpDuration::from_millis(999),
640            min: UpDuration::from_millis(1_000),
641        };
642        assert_eq!(
643            err.to_string(),
644            "invalid value `999` for max_cron_sleep: must be at least 1s"
645        );
646    }
647
648    #[test]
649    fn missing_file_yields_defaults() {
650        let cfg = DaemonConfig::load(None, &no_env).unwrap();
651        assert!(!cfg.daemon.log_json);
652        assert!(cfg.daemon.enabled_dogs.is_empty());
653        assert!(cfg.dog.is_empty());
654    }
655
656    #[test]
657    fn file_sets_values_and_keeps_dog_sections_raw() {
658        let src = r#"
659[daemon]
660log_json = true
661enabled_dogs = ["metrics"]
662
663[dog.metrics]
664port = 9615
665"#;
666        let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
667        assert!(cfg.daemon.log_json);
668        assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
669        assert_eq!(cfg.dog["metrics"]["port"].as_integer(), Some(9615));
670    }
671
672    /// fails if `adopted_dogs` is not `default`ed, or is declared outside
673    /// `deny_unknown_fields`'s reach: a `shep.toml` written before it
674    /// existed must still load, and a typo'd key must still be refused.
675    /// Both halves matter — dropping `default` breaks every existing file,
676    /// and the table is the one place an operator names a binary shep is
677    /// about to run at the daemon's own trust level.
678    #[test]
679    fn adopted_dogs_default_empty_and_round_trip_by_name() {
680        let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
681        assert!(bare.daemon.adopted_dogs.is_empty());
682
683        let src = r#"
684[daemon]
685enabled_dogs = ["metrics", "otel"]
686
687[daemon.adopted_dogs]
688otel = "/usr/local/bin/shep-otel"
689"#;
690        let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
691        assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
692        assert_eq!(
693            cfg.daemon.adopted_dogs.get("otel"),
694            Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
695        );
696        assert!(
697            !cfg.daemon.adopted_dogs.contains_key("metrics"),
698            "a name with no entry here is a built-in, and that is the whole distinction"
699        );
700    }
701
702    #[test]
703    fn env_overrides_file() {
704        let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
705        let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
706        assert!(cfg.daemon.log_json);
707    }
708
709    // The default decides what an operator who configured nothing actually
710    // sees, and every warn-and-continue arm in the daemon rides on it. `Off`
711    // would hide all of them; `Info` and below would bury them.
712    //
713    // fails if the `#[default]` attribute moves to another variant, or if a
714    // serde default invents a level the enum's own `Default` does not agree
715    // with.
716    #[test]
717    fn an_unset_log_level_is_warn() {
718        assert_eq!(
719            DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
720            LogLevel::Warn
721        );
722    }
723
724    // One owner for the six names (Rule 9). `as_str`, `from_name` and serde's
725    // `rename_all` are three separate spellings of the same mapping, and
726    // nothing but this makes them agree.
727    //
728    // fails if any one of the three drifts from the other two — a `rename_all`
729    // dropped or changed to `snake_case`, an `as_str` arm returning the
730    // variant name verbatim, a `from_name` arm mapped to the wrong level.
731    #[test]
732    fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
733        let levels = [
734            LogLevel::Off,
735            LogLevel::Error,
736            LogLevel::Warn,
737            LogLevel::Info,
738            LogLevel::Debug,
739            LogLevel::Trace,
740        ];
741        for level in levels {
742            let name = level.as_str();
743            assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
744
745            let file = format!("[daemon]\nlog_level = \"{name}\"");
746            let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
747            assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
748
749            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
750            let cfg = DaemonConfig::load(None, &env).unwrap();
751            assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
752        }
753    }
754
755    // fails if the env read is placed before the file is folded in, or omitted
756    // entirely — the shape that leaves a knob parsed and never applied.
757    #[test]
758    fn env_log_level_beats_file_value() {
759        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
760        let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
761        assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
762    }
763
764    // fails if the env read swallows an unknown name and leaves the default
765    // standing — a daemon that silently logs at `warn` after being asked for
766    // `trace` is indistinguishable from one with nothing to say. Also fails if
767    // the grammar is widened to accept case-insensitive names, which
768    // `SHEP_LOG_JSON` does not accept either.
769    #[test]
770    fn bad_env_log_level_is_a_typed_error() {
771        for value in ["verbose", "WARN", ""] {
772            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
773            assert_eq!(
774                DaemonConfig::load(None, &env),
775                Err(DaemonConfigError::BadEnvValue(
776                    "SHEP_LOG_LEVEL",
777                    value.to_string()
778                )),
779                "SHEP_LOG_LEVEL={value:?}"
780            );
781        }
782    }
783
784    // fails if the enum grows a `#[serde(other)]` catch-all, which would turn
785    // a misspelled level in `shep.toml` into a silent fallback instead of the
786    // startup error `ExitCode::InvalidConfig` reports.
787    //
788    // Also fails if `log_level` is dropped from `DaemonSection` altogether,
789    // which the variant alone cannot tell apart: `deny_unknown_fields` answers
790    // an undefined key with the same `Toml` variant. Asserting the message
791    // merely mentions `verbose` would not separate them either — a
792    // `deny_unknown_fields` error echoes the offending source line, the value
793    // included, which was checked against a key this section really does not
794    // define. Only "unknown *variant*" is exclusive to the level's own name
795    // being rejected, so that is what is pinned; the wording is serde's, and
796    // it is also what an operator reads out of `ExitCode::InvalidConfig`.
797    #[test]
798    fn bad_file_log_level_is_a_toml_error() {
799        let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
800            .expect_err("a misspelled level must not parse");
801        let DaemonConfigError::Toml(message) = err else {
802            panic!("a misspelled level is a TOML error, not {err:?}");
803        };
804        assert!(
805            message.contains("unknown variant `verbose`"),
806            "the error must reject the level's own name, not some other key: {message:?}"
807        );
808    }
809
810    #[test]
811    fn socket_override_via_file_and_env() {
812        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
813        assert_eq!(
814            cfg.daemon.socket.as_deref(),
815            Some(std::path::Path::new("/tmp/a.sock"))
816        );
817        let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
818        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
819        assert_eq!(
820            cfg.daemon.socket.as_deref(),
821            Some(std::path::Path::new("/tmp/b.sock"))
822        );
823    }
824
825    #[test]
826    fn bad_toml_is_a_typed_error() {
827        assert!(matches!(
828            DaemonConfig::load(Some("[daemon"), &no_env),
829            Err(DaemonConfigError::Toml(_))
830        ));
831    }
832
833    // fails if `[whistle]` stops being a section the shepherd will start
834    // with: `RawDaemonConfig` denies unknown fields, so a `[whistle]`
835    // section this type did not know about would return
836    // `DaemonConfigError::Toml` and `shep daemon` would exit 4 — an
837    // operator who turned whistle's control tools on would lose their
838    // shepherd on the next boot.
839    #[test]
840    fn a_whistle_section_parses_and_defaults_to_refusing_control() {
841        let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
842        assert!(cfg.whistle.allow_control);
843
844        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
845        assert!(
846            !absent.whistle.allow_control,
847            "a file with no [whistle] section leaves control off"
848        );
849
850        // The third case, and it is a DIFFERENT code path from the second:
851        // an absent `[whistle]` table is filled by `RawDaemonConfig`'s own
852        // container-level `#[serde(default)]`, which never consults the
853        // field's serde default at all. A present-but-empty table is the
854        // only input that does. Without this line, a field-level
855        // `#[serde(default = "...")]` on `allow_control` could flip the gate
856        // open and no test in this file would notice — which is exactly
857        // what the first draft's mutation assumed it was proving.
858        let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
859        assert!(
860            !empty_table.whistle.allow_control,
861            "a [whistle] section with no keys leaves control off"
862        );
863    }
864
865    // fails if the section silently accepts a key it does not implement. A
866    // `[whistle] allow_contro = true` typo that parsed would leave an
867    // operator certain the gate was open and whistle certain it was shut,
868    // with nothing anywhere saying otherwise.
869    #[test]
870    fn a_misspelled_whistle_key_is_a_named_error() {
871        let err =
872            DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
873        let DaemonConfigError::Toml(message) = err else {
874            panic!("a misspelled key is a TOML error, got {err:?}")
875        };
876        // The full quoted form, not the bare stem: `"allow_control"` also
877        // contains `"allow_contro"`, so an assertion on the stem would pass
878        // on a message that named only what serde EXPECTED and never quoted
879        // what the operator actually wrote. serde's `deny_unknown_fields`
880        // message is "unknown field `allow_contro`, expected
881        // `allow_control`", and the closing backtick is what distinguishes
882        // the two.
883        assert!(
884            message.contains("unknown field `allow_contro`"),
885            "the message quotes the key that was not understood: {message}"
886        );
887    }
888
889    // fails if validation moves back into a per-layer position — the flags
890    // layer must be able to rescue a file the layer below would reject,
891    // which is what `file < env < flags` means. Same rule the env layer's
892    // own comment already states.
893    #[test]
894    fn a_flag_rescues_a_below_floor_file_value() {
895        let cfg = DaemonConfig::load_layered(
896            Some("[daemon]\nmax_cron_sleep = \"500\"\n"),
897            &no_env,
898            &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(300_000))),
899        )
900        .unwrap();
901        assert_eq!(
902            cfg.daemon.max_cron_sleep,
903            Some(UpDuration::from_millis(300_000))
904        );
905    }
906
907    // fails if a below-floor FLAG is accepted, or if the refusal names the
908    // TOML key the operator did not set.
909    #[test]
910    fn a_below_floor_flag_is_refused_naming_the_flag() {
911        let err = DaemonConfig::load_layered(
912            None,
913            &no_env,
914            &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(500))),
915        )
916        .unwrap_err();
917        assert_eq!(
918            err,
919            DaemonConfigError::BelowMinimum {
920                key: "--max-cron-sleep",
921                value: UpDuration::from_millis(500),
922                min: MIN_CRON_SLEEP,
923            }
924        );
925        assert!(err.to_string().contains("--max-cron-sleep"), "got: {err}");
926    }
927
928    // fails if a flag stops beating the env layer.
929    #[test]
930    fn a_flag_beats_the_environment() {
931        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "trace".to_string());
932        let cfg = DaemonConfig::load_layered(
933            Some("[daemon]\nlog_level = \"error\"\n"),
934            &env,
935            &DaemonOverrides::new().log_level(Some(LogLevel::Info)),
936        )
937        .unwrap();
938        assert_eq!(cfg.daemon.log_level, LogLevel::Info);
939    }
940
941    // Pins that `load` (the two-layer file+env path) and `load_layered`
942    // (the three-layer file+env+flags path `load` itself delegates to)
943    // agree when no flag is set. It does NOT catch a `bool` field standing
944    // in for `Option<bool>`: `load` routes through `load_layered` on both
945    // sides of the `assert_eq!`, so a mutation on that line lands on both
946    // sides alike and this test stays green. That guard is
947    // `file_sets_values_and_keeps_dog_sections_raw` and `env_overrides_file`
948    // in this file, and cli_e2e's
949    // `shep_log_json_makes_the_daemons_own_records_json` — each pins an
950    // actual value coming through a specific layer, which a flattened
951    // `Option<bool>` would get wrong.
952    #[test]
953    fn an_absent_flag_leaves_every_lower_layer_alone() {
954        let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
955        let layered =
956            DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
957        let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
958        assert_eq!(layered, plain);
959    }
960
961    #[test]
962    fn the_bool_grammar_is_exactly_four_spellings() {
963        assert_eq!(parse_daemon_bool("1"), Some(true));
964        assert_eq!(parse_daemon_bool("0"), Some(false));
965        assert_eq!(parse_daemon_bool("true"), Some(true));
966        assert_eq!(parse_daemon_bool("false"), Some(false));
967        for wider in ["yes", "no", "on", "off", "TRUE", "y"] {
968            assert_eq!(
969                parse_daemon_bool(wider),
970                None,
971                "{wider} must not be a boolean here"
972            );
973        }
974    }
975
976    // fails if `[interpreters]` stops parsing as a plain extension ->
977    // interpreter map, or if a value written as a bare word (no quotes
978    // needed, since these are ordinary TOML strings) fails to round-trip.
979    #[test]
980    fn interpreters_parses_as_an_extension_map() {
981        let cfg = DaemonConfig::load(
982            Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
983            &no_env,
984        )
985        .unwrap();
986        assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
987        assert_eq!(
988            cfg.interpreters.get("py").map(String::as_str),
989            Some("python3")
990        );
991        assert_eq!(cfg.interpreters.len(), 2);
992    }
993
994    // An empty/absent `[interpreters]` must not fail a `shep.toml` that
995    // never mentions the section at all -- most of them, until an operator
996    // (or the first-run scaffold) writes one.
997    #[test]
998    fn interpreters_defaults_to_empty() {
999        assert!(
1000            DaemonConfig::load(None, &no_env)
1001                .unwrap()
1002                .interpreters
1003                .is_empty()
1004        );
1005        assert!(
1006            DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
1007                .unwrap()
1008                .interpreters
1009                .is_empty()
1010        );
1011    }
1012
1013    // `[interpreters]` values are arbitrary extension keys, not a fixed
1014    // field set, so `deny_unknown_fields` (which governs *struct* fields)
1015    // must not reject an extension this build has never heard of -- that
1016    // would defeat the whole point of an operator-editable map.
1017    #[test]
1018    fn an_unrecognised_extension_is_not_an_unknown_field() {
1019        let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
1020        assert_eq!(
1021            cfg.interpreters.get("lua").map(String::as_str),
1022            Some("lua5.4")
1023        );
1024    }
1025
1026    // A value that is not a string (an operator's `js = 5`, say) is still a
1027    // parse error -- shep-core's usual "fail loudly at parse time" rule for
1028    // a bad shape, same as every other typed field in this file.
1029    #[test]
1030    fn a_non_string_interpreter_value_is_a_parse_error() {
1031        assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
1032    }
1033
1034    #[test]
1035    fn debug_redacts_dog_values() {
1036        // Dog tables carry things like webhook URLs; a lazy derive(Debug)
1037        // would land them in daemon logs. Exact string pinned so that
1038        // regression fails here instead of leaking a secret.
1039        let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
1040        assert_eq!(
1041            format!("{cfg:?}"),
1042            "DaemonConfig { daemon: DaemonSection { log_json: false, log_level: Warn, socket: None, enabled_dogs: [], adopted_dogs: {}, max_cron_sleep: None }, whistle: WhistleSection { allow_control: false }, style: StyleSection { level: None }, interpreters: {}, dog: <1 tables> }"
1043        );
1044    }
1045}