1use core::fmt;
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::Deserialize;
12
13use crate::values::UpDuration;
14
15#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
17#[serde(deny_unknown_fields, default)]
18pub struct DaemonSection {
19 pub log_json: bool,
21 pub log_level: LogLevel,
23 pub socket: Option<std::path::PathBuf>,
25 pub enabled_dogs: Vec<String>,
27 pub adopted_dogs: BTreeMap<String, PathBuf>,
36 pub max_cron_sleep: Option<UpDuration>,
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum LogLevel {
61 Off,
63 Error,
65 #[default]
67 Warn,
68 Info,
70 Debug,
72 Trace,
74}
75
76impl LogLevel {
77 #[must_use]
80 pub const fn as_str(self) -> &'static str {
81 match self {
82 Self::Off => "off",
83 Self::Error => "error",
84 Self::Warn => "warn",
85 Self::Info => "info",
86 Self::Debug => "debug",
87 Self::Trace => "trace",
88 }
89 }
90
91 #[must_use]
97 pub fn from_name(name: &str) -> Option<Self> {
98 match name {
99 "off" => Some(Self::Off),
100 "error" => Some(Self::Error),
101 "warn" => Some(Self::Warn),
102 "info" => Some(Self::Info),
103 "debug" => Some(Self::Debug),
104 "trace" => Some(Self::Trace),
105 _ => None,
106 }
107 }
108}
109
110const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
131#[serde(deny_unknown_fields, default)]
132pub struct WhistleSection {
133 pub allow_control: bool,
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
145#[serde(deny_unknown_fields, default)]
146pub struct StyleSection {
147 pub level: Option<String>,
149}
150
151#[non_exhaustive]
161#[derive(Clone, Default, PartialEq)]
162pub struct DaemonConfig {
163 pub daemon: DaemonSection,
165 pub whistle: WhistleSection,
167 pub style: StyleSection,
169 pub interpreters: BTreeMap<String, String>,
182 pub dog: BTreeMap<String, toml::Table>,
184}
185
186impl fmt::Debug for DaemonConfig {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 f.debug_struct("DaemonConfig")
190 .field("daemon", &self.daemon)
191 .field("whistle", &self.whistle)
192 .field("style", &self.style)
193 .field("interpreters", &self.interpreters)
194 .field("dog", &format_args!("<{} tables>", self.dog.len()))
195 .finish()
196 }
197}
198
199#[derive(Deserialize, Default)]
200#[serde(deny_unknown_fields, default)]
201struct RawDaemonConfig {
202 daemon: DaemonSection,
203 whistle: WhistleSection,
204 style: StyleSection,
205 interpreters: BTreeMap<String, String>,
206 dog: BTreeMap<String, toml::Table>,
207}
208
209impl DaemonConfig {
210 pub fn load(
220 file_source: Option<&str>,
221 env: &dyn Fn(&str) -> Option<String>,
222 ) -> Result<Self, DaemonConfigError> {
223 Self::load_layered(file_source, env, &DaemonOverrides::new())
224 }
225
226 pub fn load_layered(
237 file_source: Option<&str>,
238 env: &dyn Fn(&str) -> Option<String>,
239 overrides: &DaemonOverrides,
240 ) -> Result<Self, DaemonConfigError> {
241 let raw: RawDaemonConfig = match file_source {
242 Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
243 None => RawDaemonConfig::default(),
244 };
245 let mut cfg = Self {
246 daemon: raw.daemon,
247 whistle: raw.whistle,
248 style: raw.style,
249 interpreters: raw.interpreters,
250 dog: raw.dog,
251 };
252 if let Some(v) = env("SHEP_LOG_JSON") {
253 cfg.daemon.log_json = match parse_daemon_bool(&v) {
254 Some(value) => value,
255 None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
256 };
257 }
258 if let Some(v) = env("SHEP_LOG_LEVEL") {
259 let Some(level) = LogLevel::from_name(&v) else {
260 return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
261 };
262 cfg.daemon.log_level = level;
263 }
264 if let Some(v) = env("SHEP_SOCKET") {
265 cfg.daemon.socket = Some(std::path::PathBuf::from(v));
266 }
267 let mut max_cron_sleep_key = "max_cron_sleep";
272 if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
273 let parsed = v
274 .parse::<UpDuration>()
275 .map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
276 cfg.daemon.max_cron_sleep = Some(parsed);
277 max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
278 }
279 if let Some(value) = overrides.log_json {
280 cfg.daemon.log_json = value;
281 }
282 if let Some(value) = overrides.log_level {
283 cfg.daemon.log_level = value;
284 }
285 if let Some(value) = &overrides.socket {
286 cfg.daemon.socket = Some(value.clone());
287 }
288 if let Some(value) = overrides.max_cron_sleep {
289 cfg.daemon.max_cron_sleep = Some(value);
290 max_cron_sleep_key = "--max-cron-sleep";
291 }
292 cfg.validate(max_cron_sleep_key)?;
293 Ok(cfg)
294 }
295
296 fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
308 if let Some(value) = self.daemon.max_cron_sleep
309 && value < MIN_CRON_SLEEP
310 {
311 return Err(DaemonConfigError::BelowMinimum {
312 key,
313 value,
314 min: MIN_CRON_SLEEP,
315 });
316 }
317 Ok(())
318 }
319}
320
321#[non_exhaustive]
333#[derive(Debug, Clone, Default, PartialEq, Eq)]
334pub struct DaemonOverrides {
335 pub log_json: Option<bool>,
337 pub log_level: Option<LogLevel>,
339 pub socket: Option<PathBuf>,
341 pub max_cron_sleep: Option<UpDuration>,
343}
344
345impl DaemonOverrides {
346 #[must_use]
348 pub fn new() -> Self {
349 Self::default()
350 }
351
352 #[must_use]
354 pub fn log_json(mut self, value: Option<bool>) -> Self {
355 self.log_json = value;
356 self
357 }
358
359 #[must_use]
361 pub fn log_level(mut self, value: Option<LogLevel>) -> Self {
362 self.log_level = value;
363 self
364 }
365
366 #[must_use]
368 pub fn socket(mut self, value: Option<PathBuf>) -> Self {
369 self.socket = value;
370 self
371 }
372
373 #[must_use]
375 pub fn max_cron_sleep(mut self, value: Option<UpDuration>) -> Self {
376 self.max_cron_sleep = value;
377 self
378 }
379}
380
381#[must_use]
392pub fn parse_daemon_bool(value: &str) -> Option<bool> {
393 match value {
394 "1" | "true" => Some(true),
395 "0" | "false" => Some(false),
396 _ => None,
397 }
398}
399
400#[non_exhaustive]
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub enum DaemonConfigError {
408 Toml(String),
410 BadEnvValue(&'static str, String),
412 BelowMinimum {
416 key: &'static str,
418 value: UpDuration,
420 min: UpDuration,
422 },
423}
424
425impl fmt::Display for DaemonConfigError {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 match self {
428 Self::Toml(m) => write!(f, "invalid shep.toml: {m}"),
429 Self::BadEnvValue(var, v) => write!(f, "invalid value `{v}` for {var}"),
430 Self::BelowMinimum { key, value, min } => {
431 write!(
432 f,
433 "invalid value `{value}` for {key}: must be at least {min}"
434 )
435 }
436 }
437 }
438}
439
440impl core::error::Error for DaemonConfigError {}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::values::UpDuration;
446
447 fn no_env(_: &str) -> Option<String> {
448 None
449 }
450
451 #[test]
454 fn missing_max_cron_sleep_leaves_the_field_none() {
455 let cfg = DaemonConfig::load(None, &no_env).unwrap();
456 assert_eq!(cfg.daemon.max_cron_sleep, None);
457 }
458
459 #[test]
462 fn max_cron_sleep_file_value_parses_via_upduration() {
463 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
464 assert_eq!(
465 cfg.daemon.max_cron_sleep,
466 Some(UpDuration::from_millis(5 * 60_000))
467 );
468 }
469
470 #[test]
473 fn env_max_cron_sleep_beats_file_value() {
474 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
475 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
476 assert_eq!(
477 cfg.daemon.max_cron_sleep,
478 Some(UpDuration::from_millis(90_000))
479 );
480 }
481
482 #[test]
486 fn bad_env_max_cron_sleep_is_a_typed_error() {
487 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
488 assert_eq!(
489 DaemonConfig::load(None, &env),
490 Err(DaemonConfigError::BadEnvValue(
491 "SHEP_MAX_CRON_SLEEP",
492 "banana".to_string()
493 ))
494 );
495 }
496
497 #[test]
500 fn max_cron_sleep_floor_rejects_below_one_second() {
501 let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
502 assert_eq!(
503 cfg.daemon.max_cron_sleep,
504 Some(UpDuration::from_millis(1_000))
505 );
506
507 assert_eq!(
508 DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
509 Err(DaemonConfigError::BelowMinimum {
510 key: "max_cron_sleep",
511 value: UpDuration::from_millis(999),
512 min: UpDuration::from_millis(1_000),
513 })
514 );
515 }
516
517 #[test]
521 fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
522 let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
523 assert_eq!(
524 DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
525 Err(DaemonConfigError::BelowMinimum {
526 key: "SHEP_MAX_CRON_SLEEP",
527 value: UpDuration::from_millis(0),
528 min: UpDuration::from_millis(1_000),
529 })
530 );
531 }
532
533 #[test]
537 fn below_minimum_display_is_exact() {
538 let err = DaemonConfigError::BelowMinimum {
539 key: "max_cron_sleep",
540 value: UpDuration::from_millis(999),
541 min: UpDuration::from_millis(1_000),
542 };
543 assert_eq!(
544 err.to_string(),
545 "invalid value `999` for max_cron_sleep: must be at least 1s"
546 );
547 }
548
549 #[test]
550 fn missing_file_yields_defaults() {
551 let cfg = DaemonConfig::load(None, &no_env).unwrap();
552 assert!(!cfg.daemon.log_json);
553 assert!(cfg.daemon.enabled_dogs.is_empty());
554 assert!(cfg.dog.is_empty());
555 }
556
557 #[test]
558 fn file_sets_values_and_keeps_dog_sections_raw() {
559 let src = r#"
560[daemon]
561log_json = true
562enabled_dogs = ["metrics"]
563
564[dog.metrics]
565port = 9615
566"#;
567 let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
568 assert!(cfg.daemon.log_json);
569 assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
570 assert_eq!(cfg.dog["metrics"]["port"].as_integer(), Some(9615));
571 }
572
573 #[test]
577 fn adopted_dogs_default_empty_and_round_trip_by_name() {
578 let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
579 assert!(bare.daemon.adopted_dogs.is_empty());
580
581 let src = r#"
582[daemon]
583enabled_dogs = ["metrics", "otel"]
584
585[daemon.adopted_dogs]
586otel = "/usr/local/bin/shep-otel"
587"#;
588 let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
589 assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
590 assert_eq!(
591 cfg.daemon.adopted_dogs.get("otel"),
592 Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
593 );
594 assert!(
595 !cfg.daemon.adopted_dogs.contains_key("metrics"),
596 "a name with no entry here is a built-in, and that is the whole distinction"
597 );
598 }
599
600 #[test]
601 fn env_overrides_file() {
602 let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
603 let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
604 assert!(cfg.daemon.log_json);
605 }
606
607 #[test]
611 fn an_unset_log_level_is_warn() {
612 assert_eq!(
613 DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
614 LogLevel::Warn
615 );
616 }
617
618 #[test]
622 fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
623 let levels = [
624 LogLevel::Off,
625 LogLevel::Error,
626 LogLevel::Warn,
627 LogLevel::Info,
628 LogLevel::Debug,
629 LogLevel::Trace,
630 ];
631 for level in levels {
632 let name = level.as_str();
633 assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
634
635 let file = format!("[daemon]\nlog_level = \"{name}\"");
636 let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
637 assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
638
639 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
640 let cfg = DaemonConfig::load(None, &env).unwrap();
641 assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
642 }
643 }
644
645 #[test]
648 fn env_log_level_beats_file_value() {
649 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
650 let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
651 assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
652 }
653
654 #[test]
658 fn bad_env_log_level_is_a_typed_error() {
659 for value in ["verbose", "WARN", ""] {
660 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
661 assert_eq!(
662 DaemonConfig::load(None, &env),
663 Err(DaemonConfigError::BadEnvValue(
664 "SHEP_LOG_LEVEL",
665 value.to_string()
666 )),
667 "SHEP_LOG_LEVEL={value:?}"
668 );
669 }
670 }
671
672 #[test]
677 fn bad_file_log_level_is_a_toml_error() {
678 let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
679 .expect_err("a misspelled level must not parse");
680 let DaemonConfigError::Toml(message) = err else {
681 panic!("a misspelled level is a TOML error, not {err:?}");
682 };
683 assert!(
684 message.contains("unknown variant `verbose`"),
685 "the error must reject the level's own name, not some other key: {message:?}"
686 );
687 }
688
689 #[test]
690 fn socket_override_via_file_and_env() {
691 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
692 assert_eq!(
693 cfg.daemon.socket.as_deref(),
694 Some(std::path::Path::new("/tmp/a.sock"))
695 );
696 let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
697 let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
698 assert_eq!(
699 cfg.daemon.socket.as_deref(),
700 Some(std::path::Path::new("/tmp/b.sock"))
701 );
702 }
703
704 #[test]
705 fn bad_toml_is_a_typed_error() {
706 assert!(matches!(
707 DaemonConfig::load(Some("[daemon"), &no_env),
708 Err(DaemonConfigError::Toml(_))
709 ));
710 }
711
712 #[test]
716 fn a_whistle_section_parses_and_defaults_to_refusing_control() {
717 let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
718 assert!(cfg.whistle.allow_control);
719
720 let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
721 assert!(
722 !absent.whistle.allow_control,
723 "a file with no [whistle] section leaves control off"
724 );
725
726 let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
730 assert!(
731 !empty_table.whistle.allow_control,
732 "a [whistle] section with no keys leaves control off"
733 );
734 }
735
736 #[test]
741 fn a_misspelled_whistle_key_is_a_named_error() {
742 let err =
743 DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
744 let DaemonConfigError::Toml(message) = err else {
745 panic!("a misspelled key is a TOML error, got {err:?}")
746 };
747 assert!(
751 message.contains("unknown field `allow_contro`"),
752 "the message quotes the key that was not understood: {message}"
753 );
754 }
755
756 #[test]
759 fn a_flag_rescues_a_below_floor_file_value() {
760 let cfg = DaemonConfig::load_layered(
761 Some("[daemon]\nmax_cron_sleep = \"500\"\n"),
762 &no_env,
763 &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(300_000))),
764 )
765 .unwrap();
766 assert_eq!(
767 cfg.daemon.max_cron_sleep,
768 Some(UpDuration::from_millis(300_000))
769 );
770 }
771
772 #[test]
775 fn a_below_floor_flag_is_refused_naming_the_flag() {
776 let err = DaemonConfig::load_layered(
777 None,
778 &no_env,
779 &DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(500))),
780 )
781 .unwrap_err();
782 assert_eq!(
783 err,
784 DaemonConfigError::BelowMinimum {
785 key: "--max-cron-sleep",
786 value: UpDuration::from_millis(500),
787 min: MIN_CRON_SLEEP,
788 }
789 );
790 assert!(err.to_string().contains("--max-cron-sleep"), "got: {err}");
791 }
792
793 #[test]
795 fn a_flag_beats_the_environment() {
796 let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "trace".to_string());
797 let cfg = DaemonConfig::load_layered(
798 Some("[daemon]\nlog_level = \"error\"\n"),
799 &env,
800 &DaemonOverrides::new().log_level(Some(LogLevel::Info)),
801 )
802 .unwrap();
803 assert_eq!(cfg.daemon.log_level, LogLevel::Info);
804 }
805
806 #[test]
811 fn an_absent_flag_leaves_every_lower_layer_alone() {
812 let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
813 let layered =
814 DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
815 let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
816 assert_eq!(layered, plain);
817 }
818
819 #[test]
820 fn the_bool_grammar_is_exactly_four_spellings() {
821 assert_eq!(parse_daemon_bool("1"), Some(true));
822 assert_eq!(parse_daemon_bool("0"), Some(false));
823 assert_eq!(parse_daemon_bool("true"), Some(true));
824 assert_eq!(parse_daemon_bool("false"), Some(false));
825 for wider in ["yes", "no", "on", "off", "TRUE", "y"] {
826 assert_eq!(
827 parse_daemon_bool(wider),
828 None,
829 "{wider} must not be a boolean here"
830 );
831 }
832 }
833
834 #[test]
838 fn interpreters_parses_as_an_extension_map() {
839 let cfg = DaemonConfig::load(
840 Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
841 &no_env,
842 )
843 .unwrap();
844 assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
845 assert_eq!(
846 cfg.interpreters.get("py").map(String::as_str),
847 Some("python3")
848 );
849 assert_eq!(cfg.interpreters.len(), 2);
850 }
851
852 #[test]
856 fn interpreters_defaults_to_empty() {
857 assert!(
858 DaemonConfig::load(None, &no_env)
859 .unwrap()
860 .interpreters
861 .is_empty()
862 );
863 assert!(
864 DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
865 .unwrap()
866 .interpreters
867 .is_empty()
868 );
869 }
870
871 #[test]
875 fn an_unrecognised_extension_is_not_an_unknown_field() {
876 let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
877 assert_eq!(
878 cfg.interpreters.get("lua").map(String::as_str),
879 Some("lua5.4")
880 );
881 }
882
883 #[test]
886 fn a_non_string_interpreter_value_is_a_parse_error() {
887 assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
888 }
889
890 #[test]
891 fn debug_redacts_dog_values() {
892 let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
896 assert_eq!(
897 format!("{cfg:?}"),
898 "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> }"
899 );
900 }
901}