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