Skip to main content

shep_core/config/daemon/
config.rs

1use super::error::DaemonConfigError;
2use super::overrides::{DaemonOverrides, parse_daemon_bool};
3use super::sections::{
4    DaemonSection, LogLevel, MIN_CRON_SLEEP, SecretsSection, StyleSection, WhistleSection,
5};
6use crate::secrets;
7use crate::values::UpDuration;
8use core::fmt;
9use serde::Deserialize;
10use std::collections::BTreeMap;
11
12/// Parsed daemon configuration with raw per-dog sections.
13///
14/// Dog sections stay untyped here: each dog deserializes its own
15/// `[dog.<name>]` table, so dog config schemas live with the dog code.
16///
17/// `#[non_exhaustive]` guards against a breaking struct literal as this
18/// type grows sections, but is not a validation gate: its `pub` fields
19/// can still be mutated after [`Self::load`]/[`Self::load_layered`]
20/// validate, and shep-core cannot detect that.
21#[non_exhaustive]
22#[derive(Clone, Default, PartialEq)]
23pub struct DaemonConfig {
24    /// The `[daemon]` section
25    pub daemon: DaemonSection,
26    /// The `[whistle]` section
27    pub whistle: WhistleSection,
28    /// The `[secrets]` section
29    pub secrets: SecretsSection,
30    /// The `[style]` section
31    pub style: StyleSection,
32    /// The `[interpreters]` section: a script extension (no leading dot,
33    /// `"js"` not `".js"`) mapped to the interpreter that runs it.
34    ///
35    /// Read by the CLI only, before a request reaches the wire: target
36    /// resolution folds a match into an app's own
37    /// [`AppConfig::interpreter`](crate::config::AppConfig::interpreter)
38    /// only when that field is unset, and `--interpreter` on the command
39    /// line outranks both. The daemon itself never reads this field.
40    ///
41    /// Declared here, like [`StyleSection`], so `RawDaemonConfig`'s
42    /// `deny_unknown_fields` does not turn an unrecognized `[interpreters]`
43    /// section into a hard parse error on every boot.
44    pub interpreters: BTreeMap<String, String>,
45    /// Raw `[dog.<name>]` sections keyed by dog name
46    pub dog: BTreeMap<String, toml::Table>,
47}
48
49/// Redacts `dog`: only the table count is printed.
50impl fmt::Debug for DaemonConfig {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.debug_struct("DaemonConfig")
53            .field("daemon", &self.daemon)
54            .field("whistle", &self.whistle)
55            .field("secrets", &self.secrets)
56            .field("style", &self.style)
57            .field("interpreters", &self.interpreters)
58            .field("dog", &format_args!("<{} tables>", self.dog.len()))
59            .finish()
60    }
61}
62
63#[derive(Deserialize, Default)]
64#[serde(deny_unknown_fields, default)]
65pub(super) struct RawDaemonConfig {
66    daemon: DaemonSection,
67    whistle: WhistleSection,
68    secrets: SecretsSection,
69    style: StyleSection,
70    interpreters: BTreeMap<String, String>,
71    dog: BTreeMap<String, toml::Table>,
72}
73
74impl DaemonConfig {
75    /// Builds config from optional file source + environment overrides.
76    ///
77    /// `file < env`, validated. Equivalent to [`Self::load_layered`] with
78    /// an empty [`DaemonOverrides`].
79    ///
80    /// # Errors
81    /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
82    /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
83    /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
84    /// - [`DaemonConfigError::InvalidEnvironment`]: the effective `environment` is `all` or falls outside the secrets store's name grammar.
85    pub fn load(
86        file_source: Option<&str>,
87        env: &dyn Fn(&str) -> Option<String>,
88    ) -> Result<Self, DaemonConfigError> {
89        Self::load_layered(file_source, env, &DaemonOverrides::new())
90    }
91
92    /// Builds config from optional file source + environment + CLI-flag
93    /// overrides.
94    ///
95    /// `file < env < flags` (spec ยง5), validated exactly once, at the end,
96    /// so a later layer can rescue a value an earlier one would reject.
97    ///
98    /// # Errors
99    /// - [`DaemonConfigError::Toml`]: the file source is invalid TOML.
100    /// - [`DaemonConfigError::BadEnvValue`]: a `SHEP_*` value is not parseable.
101    /// - [`DaemonConfigError::BelowMinimum`]: the effective `max_cron_sleep` is below the floor.
102    /// - [`DaemonConfigError::InvalidEnvironment`]: the effective `environment` is `all` or falls outside the secrets store's name grammar.
103    pub fn load_layered(
104        file_source: Option<&str>,
105        env: &dyn Fn(&str) -> Option<String>,
106        overrides: &DaemonOverrides,
107    ) -> Result<Self, DaemonConfigError> {
108        let raw: RawDaemonConfig = match file_source {
109            Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
110            None => RawDaemonConfig::default(),
111        };
112        let mut cfg = Self {
113            daemon: raw.daemon,
114            whistle: raw.whistle,
115            secrets: raw.secrets,
116            style: raw.style,
117            interpreters: raw.interpreters,
118            dog: raw.dog,
119        };
120        if let Some(v) = env("SHEP_LOG_JSON") {
121            cfg.daemon.log_json = match parse_daemon_bool(&v) {
122                Some(value) => value,
123                None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
124            };
125        }
126        if let Some(v) = env("SHEP_LOG_LEVEL") {
127            let Some(level) = LogLevel::from_name(&v) else {
128                return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
129            };
130            cfg.daemon.log_level = level;
131        }
132        if let Some(v) = env("SHEP_SOCKET") {
133            cfg.daemon.socket = Some(std::path::PathBuf::from(v));
134        }
135        // Whichever layer last wrote max_cron_sleep is the key the refusal
136        // names, so the operator is pointed at the thing they can edit.
137        // Validating per layer instead would stop a good override from
138        // rescuing a bad one below it.
139        let mut max_cron_sleep_key = "max_cron_sleep";
140        if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
141            let parsed = v
142                .parse::<UpDuration>()
143                .map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
144            cfg.daemon.max_cron_sleep = Some(parsed);
145            max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
146        }
147        if let Some(value) = overrides.log_json {
148            cfg.daemon.log_json = value;
149        }
150        if let Some(value) = overrides.log_level {
151            cfg.daemon.log_level = value;
152        }
153        if let Some(value) = &overrides.socket {
154            cfg.daemon.socket = Some(value.clone());
155        }
156        if let Some(value) = overrides.max_cron_sleep {
157            cfg.daemon.max_cron_sleep = Some(value);
158            max_cron_sleep_key = "--max-cron-sleep";
159        }
160        cfg.validate(max_cron_sleep_key)?;
161        Ok(cfg)
162    }
163
164    /// Checks every invariant a `DaemonConfig` carries, whatever layers
165    /// produced it. One call site, at the bottom of [`Self::load_layered`]: validating
166    /// per layer would stop a good `--max-cron-sleep` from rescuing a
167    /// broken `shep.toml`.
168    ///
169    /// `key` is provenance: the spelling the operator actually set, so the
170    /// refusal names the thing they can edit. Private; guards construction,
171    /// not a later mutation of a `pub` field.
172    ///
173    /// # Errors
174    /// - [`DaemonConfigError::BelowMinimum`]: `max_cron_sleep` is under the floor.
175    /// - [`DaemonConfigError::InvalidEnvironment`]: `environment` is `all` or falls outside the secrets store's name grammar.
176    fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
177        if self.daemon.environment == secrets::ALL_ENVIRONMENTS
178            || !secrets::is_name(&self.daemon.environment)
179        {
180            return Err(DaemonConfigError::InvalidEnvironment(
181                self.daemon.environment.clone(),
182            ));
183        }
184        if let Some(value) = self.daemon.max_cron_sleep
185            && value < MIN_CRON_SLEEP
186        {
187            return Err(DaemonConfigError::BelowMinimum {
188                key,
189                value,
190                min: MIN_CRON_SLEEP,
191            });
192        }
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::super::error::DaemonConfigError;
200    use super::super::overrides::DaemonOverrides;
201    use super::super::sections::LogLevel;
202
203    use super::super::testing::*;
204    use super::*;
205    use crate::secrets;
206    use crate::values::UpDuration;
207
208    #[test]
209    fn missing_max_cron_sleep_leaves_the_field_none() {
210        let cfg = DaemonConfig::load(None, &no_env).unwrap();
211        assert_eq!(cfg.daemon.max_cron_sleep, None);
212    }
213
214    // fails if the field is a bare integer, where "5m" is a TOML error and
215    // "5" is five milliseconds
216    #[test]
217    fn max_cron_sleep_file_value_parses_via_upduration() {
218        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
219        assert_eq!(
220            cfg.daemon.max_cron_sleep,
221            Some(UpDuration::from_millis(5 * 60_000))
222        );
223    }
224
225    // fails if the env read is placed before the file is folded in, or
226    // omitted entirely
227    #[test]
228    fn env_max_cron_sleep_beats_file_value() {
229        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
230        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
231        assert_eq!(
232            cfg.daemon.max_cron_sleep,
233            Some(UpDuration::from_millis(90_000))
234        );
235    }
236
237    // fails if the env read swallows its parse failure (`.ok()` and drop
238    // it, or an `Err` arm that only logs), leaving the file's value
239    // silently in force and the typo invisible
240    #[test]
241    fn bad_env_max_cron_sleep_is_a_typed_error() {
242        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
243        assert_eq!(
244            DaemonConfig::load(None, &env),
245            Err(DaemonConfigError::BadEnvValue(
246                "SHEP_MAX_CRON_SLEEP",
247                "banana".to_string()
248            ))
249        );
250    }
251
252    // fails if the floor is compared with `>` instead of `>=`, or the check
253    // silently clamps instead of rejecting
254    #[test]
255    fn max_cron_sleep_floor_rejects_below_one_second() {
256        let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
257        assert_eq!(
258            cfg.daemon.max_cron_sleep,
259            Some(UpDuration::from_millis(1_000))
260        );
261
262        assert_eq!(
263            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
264            Err(DaemonConfigError::BelowMinimum {
265                key: "max_cron_sleep",
266                value: UpDuration::from_millis(999),
267                min: UpDuration::from_millis(1_000),
268            })
269        );
270    }
271
272    // fails if only the file value is validated and never the override, or
273    // if the reported key is the file's even though the environment
274    // introduced the fault
275    #[test]
276    fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
277        let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
278        assert_eq!(
279            DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
280            Err(DaemonConfigError::BelowMinimum {
281                key: "SHEP_MAX_CRON_SLEEP",
282                value: UpDuration::from_millis(0),
283                min: UpDuration::from_millis(1_000),
284            })
285        );
286    }
287
288    #[test]
289    fn missing_file_yields_defaults() {
290        let cfg = DaemonConfig::load(None, &no_env).unwrap();
291        assert!(!cfg.daemon.log_json);
292        assert!(cfg.daemon.enabled_dogs.is_empty());
293        assert!(cfg.dog.is_empty());
294    }
295
296    /// `adopted_dogs` needs `default` (existing files predate it) and
297    /// `deny_unknown_fields` (a typo names a binary shep would otherwise
298    /// run at the daemon's own trust level).
299    #[test]
300    fn adopted_dogs_default_empty_and_round_trip_by_name() {
301        let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
302        assert!(bare.daemon.adopted_dogs.is_empty());
303
304        let src = r#"
305    [daemon]
306    enabled_dogs = ["metrics", "otel"]
307
308    [daemon.adopted_dogs]
309    otel = "/usr/local/bin/shep-otel"
310    "#;
311        let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
312        assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
313        assert_eq!(
314            cfg.daemon.adopted_dogs.get("otel"),
315            Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
316        );
317        assert!(
318            !cfg.daemon.adopted_dogs.contains_key("metrics"),
319            "a name with no entry here is a built-in, and that is the whole distinction"
320        );
321    }
322
323    /// A `shep.toml` written before the key existed has to keep loading.
324    #[test]
325    fn channel_dogs_parses_and_defaults_empty() {
326        let src = r#"
327    [daemon]
328    enabled_dogs = ["otel"]
329    channel_dogs = ["otel"]
330
331    [daemon.adopted_dogs]
332    otel = "/usr/local/bin/shep-otel"
333    "#;
334        let cfg = DaemonConfig::load(Some(src), &no_env).expect("channel_dogs is a known key");
335        assert_eq!(cfg.daemon.channel_dogs, vec!["otel".to_string()]);
336
337        let older = DaemonConfig::load(Some("[daemon]\nenabled_dogs = [\"otel\"]\n"), &no_env)
338            .expect("a file without the key loads");
339        assert!(older.daemon.channel_dogs.is_empty());
340    }
341
342    // fails if the key is unknown, which deny_unknown_fields turns into a
343    // startup error, or if it is not defaulted
344    #[test]
345    fn boot_first_dogs_parses_and_defaults_empty() {
346        let config = DaemonConfig::load(
347            Some(
348                r#"
349    [daemon]
350    enabled_dogs = ["metrics"]
351    boot_first_dogs = ["log-rotate"]
352    "#,
353            ),
354            &no_env,
355        )
356        .expect("boot_first_dogs is a known key");
357        assert_eq!(
358            config.daemon.boot_first_dogs,
359            vec!["log-rotate".to_string()]
360        );
361
362        let bare =
363            DaemonConfig::load(Some("[daemon]\n"), &no_env).expect("an empty section parses");
364        assert!(bare.daemon.boot_first_dogs.is_empty());
365    }
366
367    #[test]
368    fn env_overrides_file() {
369        let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
370        let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
371        assert!(cfg.daemon.log_json);
372    }
373
374    #[test]
375    fn the_host_environment_defaults_to_production() {
376        let cfg = DaemonConfig::load(None, &|_| None).unwrap();
377        assert_eq!(cfg.daemon.environment, "production");
378    }
379
380    #[test]
381    fn the_host_environment_reads_from_the_file() {
382        let cfg =
383            DaemonConfig::load(Some("[daemon]\nenvironment = \"staging\"\n"), &|_| None).unwrap();
384        assert_eq!(cfg.daemon.environment, "staging");
385    }
386
387    #[test]
388    fn the_host_environment_cannot_be_all() {
389        // `all` is the secrets store's every-environment slot. A host
390        // default of `all` would put every sheep with no environment of
391        // its own there, bypassing the same refusal `normalize.rs` gives a
392        // sheep that names `all` directly.
393        let err =
394            DaemonConfig::load(Some("[daemon]\nenvironment = \"all\"\n"), &|_| None).unwrap_err();
395        // The variant and the value it carries, not the rendered text: the
396        // message interpolates `ALL_ENVIRONMENTS` whatever it refused, so its
397        // words cannot say which check fired.
398        assert_eq!(
399            err,
400            DaemonConfigError::InvalidEnvironment(secrets::ALL_ENVIRONMENTS.to_string())
401        );
402    }
403
404    #[test]
405    fn a_host_environment_outside_the_grammar_is_refused() {
406        for bad in ["", "has space", "has/slash"] {
407            let source = format!("[daemon]\nenvironment = \"{bad}\"\n");
408            assert!(
409                DaemonConfig::load(Some(&source), &|_| None).is_err(),
410                "{bad:?} must be refused"
411            );
412        }
413    }
414
415    // fails if the env read is placed before the file is folded in, or
416    // omitted entirely.
417    #[test]
418    fn env_log_level_beats_file_value() {
419        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
420        let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
421        assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
422    }
423
424    // fails if the env read swallows an unknown name and leaves the
425    // default standing, or if the grammar is widened to accept
426    // case-insensitive names.
427    #[test]
428    fn bad_env_log_level_is_a_typed_error() {
429        for value in ["verbose", "WARN", ""] {
430            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
431            assert_eq!(
432                DaemonConfig::load(None, &env),
433                Err(DaemonConfigError::BadEnvValue(
434                    "SHEP_LOG_LEVEL",
435                    value.to_string()
436                )),
437                "SHEP_LOG_LEVEL={value:?}"
438            );
439        }
440    }
441
442    // fails if a `#[serde(other)]` catch-all swallows a misspelled level
443    // into a silent fallback. Pins "unknown variant", not just the
444    // misspelled name, since that phrase is the only one exclusive to the
445    // level being rejected rather than to some other unknown key.
446    #[test]
447    fn bad_file_log_level_is_a_toml_error() {
448        let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
449            .expect_err("a misspelled level must not parse");
450        let DaemonConfigError::Toml(message) = err else {
451            panic!("a misspelled level is a TOML error, not {err:?}");
452        };
453        assert!(
454            message.contains("unknown variant `verbose`"),
455            "the error must reject the level's own name, not some other key: {message:?}"
456        );
457    }
458
459    #[test]
460    fn socket_override_via_file_and_env() {
461        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
462        assert_eq!(
463            cfg.daemon.socket.as_deref(),
464            Some(std::path::Path::new("/tmp/a.sock"))
465        );
466        let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
467        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
468        assert_eq!(
469            cfg.daemon.socket.as_deref(),
470            Some(std::path::Path::new("/tmp/b.sock"))
471        );
472    }
473
474    #[test]
475    fn bad_toml_is_a_typed_error() {
476        assert!(matches!(
477            DaemonConfig::load(Some("[daemon"), &no_env),
478            Err(DaemonConfigError::Toml(_))
479        ));
480    }
481
482    // fails if `[whistle]` becomes an unrecognized section: `shep daemon`
483    // would exit 4, and an operator who turned control tools on would
484    // lose their shepherd on the next boot.
485    #[test]
486    fn a_whistle_section_parses_and_defaults_to_refusing_control() {
487        let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
488        assert!(cfg.whistle.allow_control);
489
490        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
491        assert!(
492            !absent.whistle.allow_control,
493            "a file with no [whistle] section leaves control off"
494        );
495
496        // A present-but-empty table is the only input that reaches
497        // `allow_control`'s own field-level default; an absent `[whistle]`
498        // table is filled by the container-level default instead.
499        let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
500        assert!(
501            !empty_table.whistle.allow_control,
502            "a [whistle] section with no keys leaves control off"
503        );
504    }
505
506    // fails if `[secrets]` becomes an unrecognized section, or if the gate
507    // stops defaulting shut. A misspelled key is a named error for
508    // `[whistle]`'s reason: an operator certain a value was readable and a
509    // CLI certain it was not.
510    #[test]
511    fn a_secrets_section_parses_and_defaults_to_refusing_reads() {
512        let cfg = DaemonConfig::load(Some("[secrets]\nallow_read = true\n"), &no_env).unwrap();
513        assert!(cfg.secrets.allow_read);
514
515        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
516        assert!(
517            !absent.secrets.allow_read,
518            "a file with no [secrets] section leaves reads off"
519        );
520
521        let empty_table = DaemonConfig::load(Some("[secrets]\n"), &no_env).unwrap();
522        assert!(
523            !empty_table.secrets.allow_read,
524            "a [secrets] section with no keys leaves reads off"
525        );
526
527        let err = DaemonConfig::load(Some("[secrets]\nallow_reads = true\n"), &no_env).unwrap_err();
528        let DaemonConfigError::Toml(message) = err else {
529            panic!("a misspelled key is a TOML error, got {err:?}")
530        };
531        assert!(
532            message.contains("unknown field `allow_reads`"),
533            "the message quotes the key that was not understood: {message}"
534        );
535    }
536
537    // fails if the section silently accepts a key it does not implement. A
538    // `[whistle] allow_contro = true` typo that parsed would leave an
539    // operator certain the gate was open and whistle certain it was shut,
540    // with nothing anywhere saying otherwise.
541    #[test]
542    fn a_misspelled_whistle_key_is_a_named_error() {
543        let err =
544            DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
545        let DaemonConfigError::Toml(message) = err else {
546            panic!("a misspelled key is a TOML error, got {err:?}")
547        };
548        // The full quoted form, not the bare stem: `"allow_control"` also
549        // contains `"allow_contro"`, so a stem-only assertion could pass
550        // on a message naming only what serde expected.
551        assert!(
552            message.contains("unknown field `allow_contro`"),
553            "the message quotes the key that was not understood: {message}"
554        );
555    }
556
557    // Pins that `load` and `load_layered` agree when no flag is set. Does
558    // not catch a `bool` standing in for `Option<bool>`, since both sides
559    // route through the same code; other tests in this file and cli_e2e
560    // pin that instead.
561    #[test]
562    fn an_absent_flag_leaves_every_lower_layer_alone() {
563        let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
564        let layered =
565            DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
566        let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
567        assert_eq!(layered, plain);
568    }
569
570    // fails if `[interpreters]` stops parsing as a plain extension ->
571    // interpreter map, or if a value written as a bare word (no quotes
572    // needed, since these are ordinary TOML strings) fails to round-trip.
573    #[test]
574    fn interpreters_parses_as_an_extension_map() {
575        let cfg = DaemonConfig::load(
576            Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
577            &no_env,
578        )
579        .unwrap();
580        assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
581        assert_eq!(
582            cfg.interpreters.get("py").map(String::as_str),
583            Some("python3")
584        );
585        assert_eq!(cfg.interpreters.len(), 2);
586    }
587
588    // An empty/absent `[interpreters]` must not fail a `shep.toml` that
589    // never mentions the section, which is most of them until an operator
590    // (or the first-run scaffold) writes one.
591    #[test]
592    fn interpreters_defaults_to_empty() {
593        assert!(
594            DaemonConfig::load(None, &no_env)
595                .unwrap()
596                .interpreters
597                .is_empty()
598        );
599        assert!(
600            DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
601                .unwrap()
602                .interpreters
603                .is_empty()
604        );
605    }
606
607    // `[interpreters]` values are arbitrary extension keys, not a fixed
608    // field set, so `deny_unknown_fields` (which governs struct fields)
609    // must not reject an extension this build has never heard of.
610    #[test]
611    fn an_unrecognised_extension_is_not_an_unknown_field() {
612        let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
613        assert_eq!(
614            cfg.interpreters.get("lua").map(String::as_str),
615            Some("lua5.4")
616        );
617    }
618
619    // A value that is not a string (an operator's `js = 5`, say) is still
620    // a parse error, shep-core's usual fail-loudly-at-parse-time rule.
621    #[test]
622    fn a_non_string_interpreter_value_is_a_parse_error() {
623        assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
624    }
625}