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#[non_exhaustive]
22#[derive(Clone, Default, PartialEq)]
23pub struct DaemonConfig {
24 pub daemon: DaemonSection,
26 pub whistle: WhistleSection,
28 pub secrets: SecretsSection,
30 pub style: StyleSection,
32 pub interpreters: BTreeMap<String, String>,
45 pub dog: BTreeMap<String, toml::Table>,
47}
48
49impl 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
325 fn channel_dogs_parses_and_defaults_empty() {
326 let src = r#"
327 [daemon]
328 enabled_dogs = ["otel"]
329 channel_dogs = ["otel"]
330
331 [daemon.adopted_dogs]
332 otel = "/usr/local/bin/shep-otel"
333 "#;
334 let cfg = DaemonConfig::load(Some(src), &no_env).expect("channel_dogs is a known key");
335 assert_eq!(cfg.daemon.channel_dogs, vec!["otel".to_string()]);
336
337 let older = DaemonConfig::load(Some("[daemon]\nenabled_dogs = [\"otel\"]\n"), &no_env)
338 .expect("a file without the key loads");
339 assert!(older.daemon.channel_dogs.is_empty());
340 }
341
342 #[test]
345 fn boot_first_dogs_parses_and_defaults_empty() {
346 let config = DaemonConfig::load(
347 Some(
348 r#"
349 [daemon]
350 enabled_dogs = ["metrics"]
351 boot_first_dogs = ["log-rotate"]
352 "#,
353 ),
354 &no_env,
355 )
356 .expect("boot_first_dogs is a known key");
357 assert_eq!(
358 config.daemon.boot_first_dogs,
359 vec!["log-rotate".to_string()]
360 );
361
362 let bare =
363 DaemonConfig::load(Some("[daemon]\n"), &no_env).expect("an empty section parses");
364 assert!(bare.daemon.boot_first_dogs.is_empty());
365 }
366
367 #[test]
368 fn env_overrides_file() {
369 let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
370 let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
371 assert!(cfg.daemon.log_json);
372 }
373
374 #[test]
375 fn the_host_environment_defaults_to_production() {
376 let cfg = DaemonConfig::load(None, &|_| None).unwrap();
377 assert_eq!(cfg.daemon.environment, "production");
378 }
379
380 #[test]
381 fn the_host_environment_reads_from_the_file() {
382 let cfg =
383 DaemonConfig::load(Some("[daemon]\nenvironment = \"staging\"\n"), &|_| None).unwrap();
384 assert_eq!(cfg.daemon.environment, "staging");
385 }
386
387 #[test]
388 fn the_host_environment_cannot_be_all() {
389 let err =
394 DaemonConfig::load(Some("[daemon]\nenvironment = \"all\"\n"), &|_| None).unwrap_err();
395 assert_eq!(
399 err,
400 DaemonConfigError::InvalidEnvironment(secrets::ALL_ENVIRONMENTS.to_string())
401 );
402 }
403
404 #[test]
405 fn a_host_environment_outside_the_grammar_is_refused() {
406 for bad in ["", "has space", "has/slash"] {
407 let source = format!("[daemon]\nenvironment = \"{bad}\"\n");
408 assert!(
409 DaemonConfig::load(Some(&source), &|_| None).is_err(),
410 "{bad:?} must be refused"
411 );
412 }
413 }
414
415 #[test]
418 fn env_log_level_beats_file_value() {
419 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
420 let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
421 assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
422 }
423
424 #[test]
428 fn bad_env_log_level_is_a_typed_error() {
429 for value in ["verbose", "WARN", ""] {
430 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
431 assert_eq!(
432 DaemonConfig::load(None, &env),
433 Err(DaemonConfigError::BadEnvValue(
434 "SHEP_LOG_LEVEL",
435 value.to_string()
436 )),
437 "SHEP_LOG_LEVEL={value:?}"
438 );
439 }
440 }
441
442 #[test]
447 fn bad_file_log_level_is_a_toml_error() {
448 let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
449 .expect_err("a misspelled level must not parse");
450 let DaemonConfigError::Toml(message) = err else {
451 panic!("a misspelled level is a TOML error, not {err:?}");
452 };
453 assert!(
454 message.contains("unknown variant `verbose`"),
455 "the error must reject the level's own name, not some other key: {message:?}"
456 );
457 }
458
459 #[test]
460 fn socket_override_via_file_and_env() {
461 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
462 assert_eq!(
463 cfg.daemon.socket.as_deref(),
464 Some(std::path::Path::new("/tmp/a.sock"))
465 );
466 let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
467 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
468 assert_eq!(
469 cfg.daemon.socket.as_deref(),
470 Some(std::path::Path::new("/tmp/b.sock"))
471 );
472 }
473
474 #[test]
475 fn bad_toml_is_a_typed_error() {
476 assert!(matches!(
477 DaemonConfig::load(Some("[daemon"), &no_env),
478 Err(DaemonConfigError::Toml(_))
479 ));
480 }
481
482 #[test]
486 fn a_whistle_section_parses_and_defaults_to_refusing_control() {
487 let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
488 assert!(cfg.whistle.allow_control);
489
490 let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
491 assert!(
492 !absent.whistle.allow_control,
493 "a file with no [whistle] section leaves control off"
494 );
495
496 let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
500 assert!(
501 !empty_table.whistle.allow_control,
502 "a [whistle] section with no keys leaves control off"
503 );
504 }
505
506 #[test]
511 fn a_secrets_section_parses_and_defaults_to_refusing_reads() {
512 let cfg = DaemonConfig::load(Some("[secrets]\nallow_read = true\n"), &no_env).unwrap();
513 assert!(cfg.secrets.allow_read);
514
515 let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
516 assert!(
517 !absent.secrets.allow_read,
518 "a file with no [secrets] section leaves reads off"
519 );
520
521 let empty_table = DaemonConfig::load(Some("[secrets]\n"), &no_env).unwrap();
522 assert!(
523 !empty_table.secrets.allow_read,
524 "a [secrets] section with no keys leaves reads off"
525 );
526
527 let err = DaemonConfig::load(Some("[secrets]\nallow_reads = true\n"), &no_env).unwrap_err();
528 let DaemonConfigError::Toml(message) = err else {
529 panic!("a misspelled key is a TOML error, got {err:?}")
530 };
531 assert!(
532 message.contains("unknown field `allow_reads`"),
533 "the message quotes the key that was not understood: {message}"
534 );
535 }
536
537 #[test]
542 fn a_misspelled_whistle_key_is_a_named_error() {
543 let err =
544 DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
545 let DaemonConfigError::Toml(message) = err else {
546 panic!("a misspelled key is a TOML error, got {err:?}")
547 };
548 assert!(
552 message.contains("unknown field `allow_contro`"),
553 "the message quotes the key that was not understood: {message}"
554 );
555 }
556
557 #[test]
562 fn an_absent_flag_leaves_every_lower_layer_alone() {
563 let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
564 let layered =
565 DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
566 let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
567 assert_eq!(layered, plain);
568 }
569
570 #[test]
574 fn interpreters_parses_as_an_extension_map() {
575 let cfg = DaemonConfig::load(
576 Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
577 &no_env,
578 )
579 .unwrap();
580 assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
581 assert_eq!(
582 cfg.interpreters.get("py").map(String::as_str),
583 Some("python3")
584 );
585 assert_eq!(cfg.interpreters.len(), 2);
586 }
587
588 #[test]
592 fn interpreters_defaults_to_empty() {
593 assert!(
594 DaemonConfig::load(None, &no_env)
595 .unwrap()
596 .interpreters
597 .is_empty()
598 );
599 assert!(
600 DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
601 .unwrap()
602 .interpreters
603 .is_empty()
604 );
605 }
606
607 #[test]
611 fn an_unrecognised_extension_is_not_an_unknown_field() {
612 let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
613 assert_eq!(
614 cfg.interpreters.get("lua").map(String::as_str),
615 Some("lua5.4")
616 );
617 }
618
619 #[test]
622 fn a_non_string_interpreter_value_is_a_parse_error() {
623 assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
624 }
625}