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]
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 let err =
375 DaemonConfig::load(Some("[daemon]\nenvironment = \"all\"\n"), &|_| None).unwrap_err();
376 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 #[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 #[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 #[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 #[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 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 #[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 #[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 assert!(
533 message.contains("unknown field `allow_contro`"),
534 "the message quotes the key that was not understood: {message}"
535 );
536 }
537
538 #[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 #[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 #[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 #[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 #[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}