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