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. Not recorded inside
32    /// `[dog.<name>]`: that table is the dog's own opaque configuration,
33    /// and a shep-owned key inside it would collide with a third-party
34    /// dog's schema.
35    pub adopted_dogs: BTreeMap<String, PathBuf>,
36    /// Dogs that run before every sheep, rather than after the flock.
37    ///
38    /// The default position for a dog is a final stage, for the reason
39    /// `boot.rs` gives: a metrics dog must not answer for a flock that is not
40    /// up yet. A log-rotation dog is the opposite case, since it has to be
41    /// running before a sheep starts writing. shep cannot tell which is
42    /// which, because an adopted dog is a third-party binary, so the
43    /// operator says.
44    ///
45    /// Here rather than in `dogs.toml` for the reason [`Self::adopted_dogs`]
46    /// gives: that file's `[<name>]` table is the dog's own opaque
47    /// configuration and a shep-owned key inside it would collide with a
48    /// third-party dog's schema.
49    ///
50    /// A name absent from [`Self::enabled_dogs`] is inert here.
51    pub boot_first_dogs: Vec<String>,
52    /// Longest a cron worker sleeps before re-deriving its next occurrence.
53    ///
54    /// Shorter recovers faster from a suspended laptop or an NTP step and
55    /// costs proportionally more wakeups per cron-configured sheep; longer
56    /// is cheaper and drifts further. Unset means the daemon's own default.
57    /// There is no upper bound: a very long value only degrades to sleeping
58    /// straight through to the occurrence, which still fires.
59    pub max_cron_sleep: Option<UpDuration>,
60}
61
62/// How much of the daemon's own diagnostics reaches its log.
63///
64/// Written as one of the names below in `[daemon] log_level` or in
65/// `SHEP_LOG_LEVEL`, lowercase and nothing else, the same closed grammar
66/// `log_json` accepts, so a typo is a startup error naming the value
67/// rather than a level silently reverting to the default.
68///
69/// The default is [`LogLevel::Warn`]. The daemon's records are dominated
70/// by warn-and-continue arms, each the only account of a decision the
71/// operator cannot otherwise see. [`LogLevel::Debug`] adds per-decision
72/// detail firing per dropped restart and per child metric sample, a
73/// firehose on a busy flock.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum LogLevel {
77    /// Nothing at all: the daemon writes no records of its own.
78    Off,
79    /// Only faults the daemon could not work around.
80    Error,
81    /// Faults the daemon worked around, and what working around them cost.
82    #[default]
83    Warn,
84    /// Lifecycle milestones: the daemon came up, the daemon is going down.
85    Info,
86    /// Per-decision detail: every restart weighed, every metric sampled.
87    Debug,
88    /// Everything the daemon can say about itself.
89    Trace,
90}
91
92impl LogLevel {
93    /// The one spelling this level is written as, in the file and in the
94    /// environment alike
95    #[must_use]
96    pub const fn as_str(self) -> &'static str {
97        match self {
98            Self::Off => "off",
99            Self::Error => "error",
100            Self::Warn => "warn",
101            Self::Info => "info",
102            Self::Debug => "debug",
103            Self::Trace => "trace",
104        }
105    }
106
107    /// The level `name` spells, or `None` when it spells no level.
108    ///
109    /// The inverse of [`LogLevel::as_str`], and exact: an uppercase or
110    /// mixed-case name is not a level here, because `SHEP_LOG_JSON` accepts
111    /// no `TRUE` either.
112    #[must_use]
113    pub fn from_name(name: &str) -> Option<Self> {
114        match name {
115            "off" => Some(Self::Off),
116            "error" => Some(Self::Error),
117            "warn" => Some(Self::Warn),
118            "info" => Some(Self::Info),
119            "debug" => Some(Self::Debug),
120            "trace" => Some(Self::Trace),
121            _ => None,
122        }
123    }
124}
125
126/// Floor on `[daemon] max_cron_sleep`.
127///
128/// Zero makes every sleep return immediately, spinning the loop while
129/// still firing correctly, which is what makes it hard to attribute. One
130/// second is a floor no legitimate configuration wants to be under: a
131/// five-field cron pattern cannot name anything finer than a minute.
132const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
133
134/// The `[whistle]` section.
135///
136/// One key, a gate rather than a tuning knob: `shep whistle`'s four
137/// control tools exist only when this is `true`; its five read-only
138/// tools exist regardless.
139///
140/// Lives only in `shep.toml`, no flag or env var, since config is
141/// auditable where a flag is not. The shepherd itself never reads this
142/// key; `shep whistle` reads the file directly. Declared here anyway
143/// because `RawDaemonConfig` denies unknown fields, so an undeclared
144/// `[whistle]` section would refuse the whole file to boot. `Debug` is
145/// derived, not redacted: one boolean, nothing to leak.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
147#[serde(deny_unknown_fields, default)]
148pub struct WhistleSection {
149    /// Whether `shep whistle` offers its control tools. Default `false`.
150    pub allow_control: bool,
151}
152
153/// The `[style]` section: how much the CLI dresses up its output.
154///
155/// Read by the CLI only. The daemon has no opinion about how anyone likes
156/// their tables, and parses this solely so an unknown key is not an error.
157///
158/// `Debug` is derived rather than redacted: one optional string, no
159/// secret, nothing a `{:?}` could leak.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
161#[serde(deny_unknown_fields, default)]
162pub struct StyleSection {
163    /// `full`, `plain` or `bare`. Absent means the CLI decides.
164    pub level: Option<String>,
165}
166
167/// Parsed daemon configuration with raw per-dog sections.
168///
169/// Dog sections stay untyped here: each dog deserializes its own
170/// `[dog.<name>]` table, so dog config schemas live with the dog code.
171///
172/// `#[non_exhaustive]` guards against a breaking struct literal as this
173/// type grows sections, but is not a validation gate: its `pub` fields
174/// can still be mutated after [`Self::load`]/[`Self::load_layered`]
175/// validate, and shep-core cannot detect that.
176#[non_exhaustive]
177#[derive(Clone, Default, PartialEq)]
178pub struct DaemonConfig {
179    /// The `[daemon]` section
180    pub daemon: DaemonSection,
181    /// The `[whistle]` section
182    pub whistle: WhistleSection,
183    /// The `[style]` section
184    pub style: StyleSection,
185    /// The `[interpreters]` section: a script extension (no leading dot,
186    /// `"js"` not `".js"`) mapped to the interpreter that runs it.
187    ///
188    /// Read by the CLI only, before a request reaches the wire: target
189    /// resolution folds a match into an app's own
190    /// [`AppConfig::interpreter`](crate::config::AppConfig::interpreter)
191    /// only when that field is unset, and `--interpreter` on the command
192    /// line outranks both. The daemon itself never reads this field.
193    ///
194    /// Declared here, like [`StyleSection`], so `RawDaemonConfig`'s
195    /// `deny_unknown_fields` does not turn an unrecognized `[interpreters]`
196    /// section into a hard parse error on every boot.
197    pub interpreters: BTreeMap<String, String>,
198    /// Raw `[dog.<name>]` sections keyed by dog name
199    pub dog: BTreeMap<String, toml::Table>,
200}
201
202/// Redacts `dog`: only the table count is printed.
203impl fmt::Debug for DaemonConfig {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.debug_struct("DaemonConfig")
206            .field("daemon", &self.daemon)
207            .field("whistle", &self.whistle)
208            .field("style", &self.style)
209            .field("interpreters", &self.interpreters)
210            .field("dog", &format_args!("<{} tables>", self.dog.len()))
211            .finish()
212    }
213}
214
215#[derive(Deserialize, Default)]
216#[serde(deny_unknown_fields, default)]
217struct RawDaemonConfig {
218    daemon: DaemonSection,
219    whistle: WhistleSection,
220    style: StyleSection,
221    interpreters: BTreeMap<String, String>,
222    dog: BTreeMap<String, toml::Table>,
223}
224
225impl DaemonConfig {
226    /// Builds config from optional file source + environment overrides.
227    ///
228    /// `file < env`, validated. Equivalent to [`Self::load_layered`] with
229    /// an empty [`DaemonOverrides`].
230    ///
231    /// # Errors
232    /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
233    /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
234    /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
235    pub fn load(
236        file_source: Option<&str>,
237        env: &dyn Fn(&str) -> Option<String>,
238    ) -> Result<Self, DaemonConfigError> {
239        Self::load_layered(file_source, env, &DaemonOverrides::new())
240    }
241
242    /// Builds config from optional file source + environment + CLI-flag
243    /// overrides.
244    ///
245    /// `file < env < flags` (spec §5), validated exactly once, at the end,
246    /// so a later layer can rescue a value an earlier one would reject.
247    ///
248    /// # Errors
249    /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
250    /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
251    /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
252    pub fn load_layered(
253        file_source: Option<&str>,
254        env: &dyn Fn(&str) -> Option<String>,
255        overrides: &DaemonOverrides,
256    ) -> Result<Self, DaemonConfigError> {
257        let raw: RawDaemonConfig = match file_source {
258            Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
259            None => RawDaemonConfig::default(),
260        };
261        let mut cfg = Self {
262            daemon: raw.daemon,
263            whistle: raw.whistle,
264            style: raw.style,
265            interpreters: raw.interpreters,
266            dog: raw.dog,
267        };
268        if let Some(v) = env("SHEP_LOG_JSON") {
269            cfg.daemon.log_json = match parse_daemon_bool(&v) {
270                Some(value) => value,
271                None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
272            };
273        }
274        if let Some(v) = env("SHEP_LOG_LEVEL") {
275            let Some(level) = LogLevel::from_name(&v) else {
276                return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
277            };
278            cfg.daemon.log_level = level;
279        }
280        if let Some(v) = env("SHEP_SOCKET") {
281            cfg.daemon.socket = Some(std::path::PathBuf::from(v));
282        }
283        // Whichever layer last wrote max_cron_sleep is the key the refusal
284        // names, so the operator is pointed at the thing they can edit.
285        // Validating per layer instead would stop a good override from
286        // rescuing a bad one below it.
287        let mut max_cron_sleep_key = "max_cron_sleep";
288        if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
289            let parsed = v
290                .parse::<UpDuration>()
291                .map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
292            cfg.daemon.max_cron_sleep = Some(parsed);
293            max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
294        }
295        if let Some(value) = overrides.log_json {
296            cfg.daemon.log_json = value;
297        }
298        if let Some(value) = overrides.log_level {
299            cfg.daemon.log_level = value;
300        }
301        if let Some(value) = &overrides.socket {
302            cfg.daemon.socket = Some(value.clone());
303        }
304        if let Some(value) = overrides.max_cron_sleep {
305            cfg.daemon.max_cron_sleep = Some(value);
306            max_cron_sleep_key = "--max-cron-sleep";
307        }
308        cfg.validate(max_cron_sleep_key)?;
309        Ok(cfg)
310    }
311
312    /// Checks every invariant a `DaemonConfig` carries, whatever layers
313    /// produced it. One call site, at the bottom of [`Self::load_layered`]: validating
314    /// per layer would stop a good `--max-cron-sleep` from rescuing a
315    /// broken `shep.toml`.
316    ///
317    /// `key` is provenance: the spelling the operator actually set, so the
318    /// refusal names the thing they can edit. Private; guards construction,
319    /// not a later mutation of a `pub` field.
320    ///
321    /// # Errors
322    /// - [`DaemonConfigError::BelowMinimum`]: `max_cron_sleep` is under the floor.
323    fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
324        if let Some(value) = self.daemon.max_cron_sleep
325            && value < MIN_CRON_SLEEP
326        {
327            return Err(DaemonConfigError::BelowMinimum {
328                key,
329                value,
330                min: MIN_CRON_SLEEP,
331            });
332        }
333        Ok(())
334    }
335}
336
337/// The CLI-flag layer of `file < env < flags` (spec §5).
338///
339/// Every field is `Option`: `None` means the flag was absent and the
340/// layer below wins. Nothing here validates; [`DaemonConfig::load_layered`]
341/// runs the single validation pass once, after all three layers.
342///
343/// `#[non_exhaustive]`: this type grows a field whenever the hidden
344/// `daemon` subcommand grows a flag. Build one with [`Self::new`] and the
345/// chained setters.
346///
347/// `Debug` is derived, not redacted: four values, none a secret.
348#[non_exhaustive]
349#[derive(Debug, Clone, Default, PartialEq, Eq)]
350pub struct DaemonOverrides {
351    /// `--log-json`
352    pub log_json: Option<bool>,
353    /// `--log-level`
354    pub log_level: Option<LogLevel>,
355    /// `--socket`
356    pub socket: Option<PathBuf>,
357    /// `--max-cron-sleep`
358    pub max_cron_sleep: Option<UpDuration>,
359}
360
361impl DaemonOverrides {
362    /// An empty layer: every flag absent.
363    #[must_use]
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    /// Sets the `--log-json` override.
369    #[must_use]
370    pub fn log_json(mut self, value: Option<bool>) -> Self {
371        self.log_json = value;
372        self
373    }
374
375    /// Sets the `--log-level` override.
376    #[must_use]
377    pub fn log_level(mut self, value: Option<LogLevel>) -> Self {
378        self.log_level = value;
379        self
380    }
381
382    /// Sets the `--socket` override.
383    #[must_use]
384    pub fn socket(mut self, value: Option<PathBuf>) -> Self {
385        self.socket = value;
386        self
387    }
388
389    /// Sets the `--max-cron-sleep` override.
390    #[must_use]
391    pub fn max_cron_sleep(mut self, value: Option<UpDuration>) -> Self {
392        self.max_cron_sleep = value;
393        self
394    }
395}
396
397/// The boolean grammar of `shep.toml` and the `SHEP_*` environment: `1`,
398/// `0`, `true`, `false`, and nothing else.
399///
400/// One function so the file/env layer and the `--log-json` flag cannot
401/// drift. clap's own `BoolishValueParser` additionally accepts
402/// `yes`/`no`/`y`/`n`/`on`/`off`; using it would widen the grammar on the
403/// flag side only.
404///
405/// Not a general boolean parser: exporting it only under this name keeps
406/// exactly one answer to what counts as true in shep's daemon config.
407#[must_use]
408pub fn parse_daemon_bool(value: &str) -> Option<bool> {
409    match value {
410        "1" | "true" => Some(true),
411        "0" | "false" => Some(false),
412        _ => None,
413    }
414}
415
416/// Error type returned from [`DaemonConfig::load`].
417///
418/// `#[non_exhaustive]`: every `[daemon]` key this crate learns to validate
419/// brings its own rejection reason, and `deferred.md`'s daemon-config
420/// flags layer is a whole set of them at once.
421#[non_exhaustive]
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum DaemonConfigError {
424    /// `shep.toml` is invalid TOML (carries the parser message)
425    Toml(String),
426    /// A `SHEP_*` env var held an unparseable value (var name, value)
427    BadEnvValue(&'static str, String),
428    /// A `[daemon]` duration is below the floor that keeps the daemon from
429    /// spinning. Carries the key the user actually set: the TOML key or
430    /// the environment variable, whichever supplied the winning value.
431    BelowMinimum {
432        /// `max_cron_sleep` or `SHEP_MAX_CRON_SLEEP`.
433        key: &'static str,
434        /// The value as the user wrote it.
435        value: UpDuration,
436        /// The floor it failed.
437        min: UpDuration,
438    },
439}
440
441impl fmt::Display for DaemonConfigError {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        match self {
444            Self::Toml(m) => write!(f, "invalid shep.toml: {m}"),
445            Self::BadEnvValue(var, v) => write!(f, "invalid value `{v}` for {var}"),
446            Self::BelowMinimum { key, value, min } => {
447                write!(
448                    f,
449                    "invalid value `{value}` for {key}: must be at least {min}"
450                )
451            }
452        }
453    }
454}
455
456impl core::error::Error for DaemonConfigError {}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::values::UpDuration;
462
463    fn no_env(_: &str) -> Option<String> {
464        None
465    }
466
467    // fails if a serde default invents 60s in shep-core and takes the
468    // "unset" state away from the layer below
469    #[test]
470    fn missing_max_cron_sleep_leaves_the_field_none() {
471        let cfg = DaemonConfig::load(None, &no_env).unwrap();
472        assert_eq!(cfg.daemon.max_cron_sleep, None);
473    }
474
475    // fails if the field is a bare integer, where "5m" is a TOML error and
476    // "5" is five milliseconds
477    #[test]
478    fn max_cron_sleep_file_value_parses_via_upduration() {
479        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
480        assert_eq!(
481            cfg.daemon.max_cron_sleep,
482            Some(UpDuration::from_millis(5 * 60_000))
483        );
484    }
485
486    // fails if the env read is placed before the file is folded in, or
487    // omitted entirely
488    #[test]
489    fn env_max_cron_sleep_beats_file_value() {
490        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
491        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
492        assert_eq!(
493            cfg.daemon.max_cron_sleep,
494            Some(UpDuration::from_millis(90_000))
495        );
496    }
497
498    // fails if the env read swallows its parse failure (`.ok()` and drop
499    // it, or an `Err` arm that only logs), leaving the file's value
500    // silently in force and the typo invisible
501    #[test]
502    fn bad_env_max_cron_sleep_is_a_typed_error() {
503        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
504        assert_eq!(
505            DaemonConfig::load(None, &env),
506            Err(DaemonConfigError::BadEnvValue(
507                "SHEP_MAX_CRON_SLEEP",
508                "banana".to_string()
509            ))
510        );
511    }
512
513    // fails if the floor is compared with `>` instead of `>=`, or the check
514    // silently clamps instead of rejecting
515    #[test]
516    fn max_cron_sleep_floor_rejects_below_one_second() {
517        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
518        assert_eq!(
519            cfg.daemon.max_cron_sleep,
520            Some(UpDuration::from_millis(1_000))
521        );
522
523        assert_eq!(
524            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
525            Err(DaemonConfigError::BelowMinimum {
526                key: "max_cron_sleep",
527                value: UpDuration::from_millis(999),
528                min: UpDuration::from_millis(1_000),
529            })
530        );
531    }
532
533    // fails if only the file value is validated and never the override, or
534    // if the reported key is the file's even though the environment
535    // introduced the fault
536    #[test]
537    fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
538        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
539        assert_eq!(
540            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
541            Err(DaemonConfigError::BelowMinimum {
542                key: "SHEP_MAX_CRON_SLEEP",
543                value: UpDuration::from_millis(0),
544                min: UpDuration::from_millis(1_000),
545            })
546        );
547    }
548
549    // fails if the message wording drifts (e.g. "invalid" alone, or the
550    // `key`/`min` operands swapped): this is what actually reaches
551    // `shepd.err.log` on exit code 4.
552    #[test]
553    fn below_minimum_display_is_exact() {
554        let err = DaemonConfigError::BelowMinimum {
555            key: "max_cron_sleep",
556            value: UpDuration::from_millis(999),
557            min: UpDuration::from_millis(1_000),
558        };
559        assert_eq!(
560            err.to_string(),
561            "invalid value `999` for max_cron_sleep: must be at least 1s"
562        );
563    }
564
565    #[test]
566    fn missing_file_yields_defaults() {
567        let cfg = DaemonConfig::load(None, &no_env).unwrap();
568        assert!(!cfg.daemon.log_json);
569        assert!(cfg.daemon.enabled_dogs.is_empty());
570        assert!(cfg.dog.is_empty());
571    }
572
573    #[test]
574    fn file_sets_values_and_keeps_dog_sections_raw() {
575        let src = r#"
576[daemon]
577log_json = true
578enabled_dogs = ["metrics"]
579
580[dog.metrics]
581port = 9615
582"#;
583        let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
584        assert!(cfg.daemon.log_json);
585        assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
586        assert_eq!(cfg.dog["metrics"]["port"].as_integer(), Some(9615));
587    }
588
589    /// `adopted_dogs` needs `default` (existing files predate it) and
590    /// `deny_unknown_fields` (a typo names a binary shep would otherwise
591    /// run at the daemon's own trust level).
592    #[test]
593    fn adopted_dogs_default_empty_and_round_trip_by_name() {
594        let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
595        assert!(bare.daemon.adopted_dogs.is_empty());
596
597        let src = r#"
598[daemon]
599enabled_dogs = ["metrics", "otel"]
600
601[daemon.adopted_dogs]
602otel = "/usr/local/bin/shep-otel"
603"#;
604        let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
605        assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
606        assert_eq!(
607            cfg.daemon.adopted_dogs.get("otel"),
608            Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
609        );
610        assert!(
611            !cfg.daemon.adopted_dogs.contains_key("metrics"),
612            "a name with no entry here is a built-in, and that is the whole distinction"
613        );
614    }
615
616    // fails if the key is unknown, which deny_unknown_fields turns into a
617    // startup error, or if it is not defaulted
618    #[test]
619    fn boot_first_dogs_parses_and_defaults_empty() {
620        let config = DaemonConfig::load(
621            Some(
622                r#"
623[daemon]
624enabled_dogs = ["metrics"]
625boot_first_dogs = ["log-rotate"]
626"#,
627            ),
628            &no_env,
629        )
630        .expect("boot_first_dogs is a known key");
631        assert_eq!(
632            config.daemon.boot_first_dogs,
633            vec!["log-rotate".to_string()]
634        );
635
636        let bare =
637            DaemonConfig::load(Some("[daemon]\n"), &no_env).expect("an empty section parses");
638        assert!(bare.daemon.boot_first_dogs.is_empty());
639    }
640
641    #[test]
642    fn env_overrides_file() {
643        let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
644        let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
645        assert!(cfg.daemon.log_json);
646    }
647
648    // The default decides what an unconfigured operator actually sees:
649    // `Off` hides every warn-and-continue arm, `Info` and below bury them.
650    // fails if `#[default]` moves, or a serde default disagrees with it.
651    #[test]
652    fn an_unset_log_level_is_warn() {
653        assert_eq!(
654            DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
655            LogLevel::Warn
656        );
657    }
658
659    // `as_str`, `from_name` and serde's `rename_all` are three separate
660    // spellings of the same mapping; nothing else keeps them in agreement.
661    // fails if any one drifts from the other two.
662    #[test]
663    fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
664        let levels = [
665            LogLevel::Off,
666            LogLevel::Error,
667            LogLevel::Warn,
668            LogLevel::Info,
669            LogLevel::Debug,
670            LogLevel::Trace,
671        ];
672        for level in levels {
673            let name = level.as_str();
674            assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
675
676            let file = format!("[daemon]\nlog_level = \"{name}\"");
677            let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
678            assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
679
680            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
681            let cfg = DaemonConfig::load(None, &env).unwrap();
682            assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
683        }
684    }
685
686    // fails if the env read is placed before the file is folded in, or
687    // omitted entirely.
688    #[test]
689    fn env_log_level_beats_file_value() {
690        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
691        let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
692        assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
693    }
694
695    // fails if the env read swallows an unknown name and leaves the
696    // default standing, or if the grammar is widened to accept
697    // case-insensitive names.
698    #[test]
699    fn bad_env_log_level_is_a_typed_error() {
700        for value in ["verbose", "WARN", ""] {
701            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
702            assert_eq!(
703                DaemonConfig::load(None, &env),
704                Err(DaemonConfigError::BadEnvValue(
705                    "SHEP_LOG_LEVEL",
706                    value.to_string()
707                )),
708                "SHEP_LOG_LEVEL={value:?}"
709            );
710        }
711    }
712
713    // fails if a `#[serde(other)]` catch-all swallows a misspelled level
714    // into a silent fallback. Pins "unknown variant", not just the
715    // misspelled name, since that phrase is the only one exclusive to the
716    // level being rejected rather than to some other unknown key.
717    #[test]
718    fn bad_file_log_level_is_a_toml_error() {
719        let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
720            .expect_err("a misspelled level must not parse");
721        let DaemonConfigError::Toml(message) = err else {
722            panic!("a misspelled level is a TOML error, not {err:?}");
723        };
724        assert!(
725            message.contains("unknown variant `verbose`"),
726            "the error must reject the level's own name, not some other key: {message:?}"
727        );
728    }
729
730    #[test]
731    fn socket_override_via_file_and_env() {
732        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
733        assert_eq!(
734            cfg.daemon.socket.as_deref(),
735            Some(std::path::Path::new("/tmp/a.sock"))
736        );
737        let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
738        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
739        assert_eq!(
740            cfg.daemon.socket.as_deref(),
741            Some(std::path::Path::new("/tmp/b.sock"))
742        );
743    }
744
745    #[test]
746    fn bad_toml_is_a_typed_error() {
747        assert!(matches!(
748            DaemonConfig::load(Some("[daemon"), &no_env),
749            Err(DaemonConfigError::Toml(_))
750        ));
751    }
752
753    // fails if `[whistle]` becomes an unrecognized section: `shep daemon`
754    // would exit 4, and an operator who turned control tools on would
755    // lose their shepherd on the next boot.
756    #[test]
757    fn a_whistle_section_parses_and_defaults_to_refusing_control() {
758        let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
759        assert!(cfg.whistle.allow_control);
760
761        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
762        assert!(
763            !absent.whistle.allow_control,
764            "a file with no [whistle] section leaves control off"
765        );
766
767        // A present-but-empty table is the only input that reaches
768        // `allow_control`'s own field-level default; an absent `[whistle]`
769        // table is filled by the container-level default instead.
770        let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
771        assert!(
772            !empty_table.whistle.allow_control,
773            "a [whistle] section with no keys leaves control off"
774        );
775    }
776
777    // fails if the section silently accepts a key it does not implement. A
778    // `[whistle] allow_contro = true` typo that parsed would leave an
779    // operator certain the gate was open and whistle certain it was shut,
780    // with nothing anywhere saying otherwise.
781    #[test]
782    fn a_misspelled_whistle_key_is_a_named_error() {
783        let err =
784            DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
785        let DaemonConfigError::Toml(message) = err else {
786            panic!("a misspelled key is a TOML error, got {err:?}")
787        };
788        // The full quoted form, not the bare stem: `"allow_control"` also
789        // contains `"allow_contro"`, so a stem-only assertion could pass
790        // on a message naming only what serde expected.
791        assert!(
792            message.contains("unknown field `allow_contro`"),
793            "the message quotes the key that was not understood: {message}"
794        );
795    }
796
797    // fails if validation moves back into a per-layer position: a later
798    // layer must be able to rescue a value an earlier one would reject.
799    #[test]
800    fn a_flag_rescues_a_below_floor_file_value() {
801        let cfg = DaemonConfig::load_layered(
802            Some("[daemon]\nmax_cron_sleep = \"500\"\n"),
803            &no_env,
804            &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(300_000))),
805        )
806        .unwrap();
807        assert_eq!(
808            cfg.daemon.max_cron_sleep,
809            Some(UpDuration::from_millis(300_000))
810        );
811    }
812
813    // fails if a below-floor FLAG is accepted, or if the refusal names the
814    // TOML key the operator did not set.
815    #[test]
816    fn a_below_floor_flag_is_refused_naming_the_flag() {
817        let err = DaemonConfig::load_layered(
818            None,
819            &no_env,
820            &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(500))),
821        )
822        .unwrap_err();
823        assert_eq!(
824            err,
825            DaemonConfigError::BelowMinimum {
826                key: "--max-cron-sleep",
827                value: UpDuration::from_millis(500),
828                min: MIN_CRON_SLEEP,
829            }
830        );
831        assert!(err.to_string().contains("--max-cron-sleep"), "got: {err}");
832    }
833
834    // fails if a flag stops beating the env layer.
835    #[test]
836    fn a_flag_beats_the_environment() {
837        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "trace".to_string());
838        let cfg = DaemonConfig::load_layered(
839            Some("[daemon]\nlog_level = \"error\"\n"),
840            &env,
841            &DaemonOverrides::new().log_level(Some(LogLevel::Info)),
842        )
843        .unwrap();
844        assert_eq!(cfg.daemon.log_level, LogLevel::Info);
845    }
846
847    // Pins that `load` and `load_layered` agree when no flag is set. Does
848    // not catch a `bool` standing in for `Option<bool>`, since both sides
849    // route through the same code; other tests in this file and cli_e2e
850    // pin that instead.
851    #[test]
852    fn an_absent_flag_leaves_every_lower_layer_alone() {
853        let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
854        let layered =
855            DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
856        let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
857        assert_eq!(layered, plain);
858    }
859
860    #[test]
861    fn the_bool_grammar_is_exactly_four_spellings() {
862        assert_eq!(parse_daemon_bool("1"), Some(true));
863        assert_eq!(parse_daemon_bool("0"), Some(false));
864        assert_eq!(parse_daemon_bool("true"), Some(true));
865        assert_eq!(parse_daemon_bool("false"), Some(false));
866        for wider in ["yes", "no", "on", "off", "TRUE", "y"] {
867            assert_eq!(
868                parse_daemon_bool(wider),
869                None,
870                "{wider} must not be a boolean here"
871            );
872        }
873    }
874
875    // fails if `[interpreters]` stops parsing as a plain extension ->
876    // interpreter map, or if a value written as a bare word (no quotes
877    // needed, since these are ordinary TOML strings) fails to round-trip.
878    #[test]
879    fn interpreters_parses_as_an_extension_map() {
880        let cfg = DaemonConfig::load(
881            Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
882            &no_env,
883        )
884        .unwrap();
885        assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
886        assert_eq!(
887            cfg.interpreters.get("py").map(String::as_str),
888            Some("python3")
889        );
890        assert_eq!(cfg.interpreters.len(), 2);
891    }
892
893    // An empty/absent `[interpreters]` must not fail a `shep.toml` that
894    // never mentions the section, which is most of them until an operator
895    // (or the first-run scaffold) writes one.
896    #[test]
897    fn interpreters_defaults_to_empty() {
898        assert!(
899            DaemonConfig::load(None, &no_env)
900                .unwrap()
901                .interpreters
902                .is_empty()
903        );
904        assert!(
905            DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
906                .unwrap()
907                .interpreters
908                .is_empty()
909        );
910    }
911
912    // `[interpreters]` values are arbitrary extension keys, not a fixed
913    // field set, so `deny_unknown_fields` (which governs struct fields)
914    // must not reject an extension this build has never heard of.
915    #[test]
916    fn an_unrecognised_extension_is_not_an_unknown_field() {
917        let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
918        assert_eq!(
919            cfg.interpreters.get("lua").map(String::as_str),
920            Some("lua5.4")
921        );
922    }
923
924    // A value that is not a string (an operator's `js = 5`, say) is still
925    // a parse error, shep-core's usual fail-loudly-at-parse-time rule.
926    #[test]
927    fn a_non_string_interpreter_value_is_a_parse_error() {
928        assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
929    }
930
931    #[test]
932    fn debug_redacts_dog_values() {
933        // Dog tables carry things like webhook URLs; a lazy derive(Debug)
934        // would land them in daemon logs. Exact string pinned so that
935        // regression fails here instead of leaking a secret.
936        let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
937        assert_eq!(
938            format!("{cfg:?}"),
939            "DaemonConfig { daemon: DaemonSection { log_json: false, log_level: Warn, socket: None, enabled_dogs: [], adopted_dogs: {}, boot_first_dogs: [], max_cron_sleep: None }, whistle: WhistleSection { allow_control: false }, style: StyleSection { level: None }, interpreters: {}, dog: <1 tables> }"
940        );
941    }
942}