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    // fails if the key is unknown, which deny_unknown_fields turns into a
324    // startup error, or if it is not defaulted
325    #[test]
326    fn boot_first_dogs_parses_and_defaults_empty() {
327        let config = DaemonConfig::load(
328            Some(
329                r#"
330    [daemon]
331    enabled_dogs = ["metrics"]
332    boot_first_dogs = ["log-rotate"]
333    "#,
334            ),
335            &no_env,
336        )
337        .expect("boot_first_dogs is a known key");
338        assert_eq!(
339            config.daemon.boot_first_dogs,
340            vec!["log-rotate".to_string()]
341        );
342
343        let bare =
344            DaemonConfig::load(Some("[daemon]\n"), &no_env).expect("an empty section parses");
345        assert!(bare.daemon.boot_first_dogs.is_empty());
346    }
347
348    #[test]
349    fn env_overrides_file() {
350        let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
351        let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
352        assert!(cfg.daemon.log_json);
353    }
354
355    #[test]
356    fn the_host_environment_defaults_to_production() {
357        let cfg = DaemonConfig::load(None, &|_| None).unwrap();
358        assert_eq!(cfg.daemon.environment, "production");
359    }
360
361    #[test]
362    fn the_host_environment_reads_from_the_file() {
363        let cfg =
364            DaemonConfig::load(Some("[daemon]\nenvironment = \"staging\"\n"), &|_| None).unwrap();
365        assert_eq!(cfg.daemon.environment, "staging");
366    }
367
368    #[test]
369    fn the_host_environment_cannot_be_all() {
370        // `all` is the secrets store's every-environment slot. A host
371        // default of `all` would put every sheep with no environment of
372        // its own there, bypassing the same refusal `normalize.rs` gives a
373        // sheep that names `all` directly.
374        let err =
375            DaemonConfig::load(Some("[daemon]\nenvironment = \"all\"\n"), &|_| None).unwrap_err();
376        // The variant and the value it carries, not the rendered text: the
377        // message interpolates `ALL_ENVIRONMENTS` whatever it refused, so its
378        // words cannot say which check fired.
379        assert_eq!(
380            err,
381            DaemonConfigError::InvalidEnvironment(secrets::ALL_ENVIRONMENTS.to_string())
382        );
383    }
384
385    #[test]
386    fn a_host_environment_outside_the_grammar_is_refused() {
387        for bad in ["", "has space", "has/slash"] {
388            let source = format!("[daemon]\nenvironment = \"{bad}\"\n");
389            assert!(
390                DaemonConfig::load(Some(&source), &|_| None).is_err(),
391                "{bad:?} must be refused"
392            );
393        }
394    }
395
396    // fails if the env read is placed before the file is folded in, or
397    // omitted entirely.
398    #[test]
399    fn env_log_level_beats_file_value() {
400        let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
401        let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
402        assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
403    }
404
405    // fails if the env read swallows an unknown name and leaves the
406    // default standing, or if the grammar is widened to accept
407    // case-insensitive names.
408    #[test]
409    fn bad_env_log_level_is_a_typed_error() {
410        for value in ["verbose", "WARN", ""] {
411            let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
412            assert_eq!(
413                DaemonConfig::load(None, &env),
414                Err(DaemonConfigError::BadEnvValue(
415                    "SHEP_LOG_LEVEL",
416                    value.to_string()
417                )),
418                "SHEP_LOG_LEVEL={value:?}"
419            );
420        }
421    }
422
423    // fails if a `#[serde(other)]` catch-all swallows a misspelled level
424    // into a silent fallback. Pins "unknown variant", not just the
425    // misspelled name, since that phrase is the only one exclusive to the
426    // level being rejected rather than to some other unknown key.
427    #[test]
428    fn bad_file_log_level_is_a_toml_error() {
429        let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
430            .expect_err("a misspelled level must not parse");
431        let DaemonConfigError::Toml(message) = err else {
432            panic!("a misspelled level is a TOML error, not {err:?}");
433        };
434        assert!(
435            message.contains("unknown variant `verbose`"),
436            "the error must reject the level's own name, not some other key: {message:?}"
437        );
438    }
439
440    #[test]
441    fn socket_override_via_file_and_env() {
442        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
443        assert_eq!(
444            cfg.daemon.socket.as_deref(),
445            Some(std::path::Path::new("/tmp/a.sock"))
446        );
447        let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
448        let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
449        assert_eq!(
450            cfg.daemon.socket.as_deref(),
451            Some(std::path::Path::new("/tmp/b.sock"))
452        );
453    }
454
455    #[test]
456    fn bad_toml_is_a_typed_error() {
457        assert!(matches!(
458            DaemonConfig::load(Some("[daemon"), &no_env),
459            Err(DaemonConfigError::Toml(_))
460        ));
461    }
462
463    // fails if `[whistle]` becomes an unrecognized section: `shep daemon`
464    // would exit 4, and an operator who turned control tools on would
465    // lose their shepherd on the next boot.
466    #[test]
467    fn a_whistle_section_parses_and_defaults_to_refusing_control() {
468        let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
469        assert!(cfg.whistle.allow_control);
470
471        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
472        assert!(
473            !absent.whistle.allow_control,
474            "a file with no [whistle] section leaves control off"
475        );
476
477        // A present-but-empty table is the only input that reaches
478        // `allow_control`'s own field-level default; an absent `[whistle]`
479        // table is filled by the container-level default instead.
480        let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
481        assert!(
482            !empty_table.whistle.allow_control,
483            "a [whistle] section with no keys leaves control off"
484        );
485    }
486
487    // fails if `[secrets]` becomes an unrecognized section, or if the gate
488    // stops defaulting shut. A misspelled key is a named error for
489    // `[whistle]`'s reason: an operator certain a value was readable and a
490    // CLI certain it was not.
491    #[test]
492    fn a_secrets_section_parses_and_defaults_to_refusing_reads() {
493        let cfg = DaemonConfig::load(Some("[secrets]\nallow_read = true\n"), &no_env).unwrap();
494        assert!(cfg.secrets.allow_read);
495
496        let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
497        assert!(
498            !absent.secrets.allow_read,
499            "a file with no [secrets] section leaves reads off"
500        );
501
502        let empty_table = DaemonConfig::load(Some("[secrets]\n"), &no_env).unwrap();
503        assert!(
504            !empty_table.secrets.allow_read,
505            "a [secrets] section with no keys leaves reads off"
506        );
507
508        let err = DaemonConfig::load(Some("[secrets]\nallow_reads = true\n"), &no_env).unwrap_err();
509        let DaemonConfigError::Toml(message) = err else {
510            panic!("a misspelled key is a TOML error, got {err:?}")
511        };
512        assert!(
513            message.contains("unknown field `allow_reads`"),
514            "the message quotes the key that was not understood: {message}"
515        );
516    }
517
518    // fails if the section silently accepts a key it does not implement. A
519    // `[whistle] allow_contro = true` typo that parsed would leave an
520    // operator certain the gate was open and whistle certain it was shut,
521    // with nothing anywhere saying otherwise.
522    #[test]
523    fn a_misspelled_whistle_key_is_a_named_error() {
524        let err =
525            DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
526        let DaemonConfigError::Toml(message) = err else {
527            panic!("a misspelled key is a TOML error, got {err:?}")
528        };
529        // The full quoted form, not the bare stem: `"allow_control"` also
530        // contains `"allow_contro"`, so a stem-only assertion could pass
531        // on a message naming only what serde expected.
532        assert!(
533            message.contains("unknown field `allow_contro`"),
534            "the message quotes the key that was not understood: {message}"
535        );
536    }
537
538    // Pins that `load` and `load_layered` agree when no flag is set. Does
539    // not catch a `bool` standing in for `Option<bool>`, since both sides
540    // route through the same code; other tests in this file and cli_e2e
541    // pin that instead.
542    #[test]
543    fn an_absent_flag_leaves_every_lower_layer_alone() {
544        let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
545        let layered =
546            DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
547        let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
548        assert_eq!(layered, plain);
549    }
550
551    // fails if `[interpreters]` stops parsing as a plain extension ->
552    // interpreter map, or if a value written as a bare word (no quotes
553    // needed, since these are ordinary TOML strings) fails to round-trip.
554    #[test]
555    fn interpreters_parses_as_an_extension_map() {
556        let cfg = DaemonConfig::load(
557            Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
558            &no_env,
559        )
560        .unwrap();
561        assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
562        assert_eq!(
563            cfg.interpreters.get("py").map(String::as_str),
564            Some("python3")
565        );
566        assert_eq!(cfg.interpreters.len(), 2);
567    }
568
569    // An empty/absent `[interpreters]` must not fail a `shep.toml` that
570    // never mentions the section, which is most of them until an operator
571    // (or the first-run scaffold) writes one.
572    #[test]
573    fn interpreters_defaults_to_empty() {
574        assert!(
575            DaemonConfig::load(None, &no_env)
576                .unwrap()
577                .interpreters
578                .is_empty()
579        );
580        assert!(
581            DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
582                .unwrap()
583                .interpreters
584                .is_empty()
585        );
586    }
587
588    // `[interpreters]` values are arbitrary extension keys, not a fixed
589    // field set, so `deny_unknown_fields` (which governs struct fields)
590    // must not reject an extension this build has never heard of.
591    #[test]
592    fn an_unrecognised_extension_is_not_an_unknown_field() {
593        let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
594        assert_eq!(
595            cfg.interpreters.get("lua").map(String::as_str),
596            Some("lua5.4")
597        );
598    }
599
600    // A value that is not a string (an operator's `js = 5`, say) is still
601    // a parse error, shep-core's usual fail-loudly-at-parse-time rule.
602    #[test]
603    fn a_non_string_interpreter_value_is_a_parse_error() {
604        assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
605    }
606}