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