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