use core::fmt;
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
use crate::values::UpDuration;
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct DaemonSection {
pub log_json: bool,
pub log_level: LogLevel,
pub socket: Option<std::path::PathBuf>,
pub enabled_dogs: Vec<String>,
pub adopted_dogs: BTreeMap<String, PathBuf>,
pub max_cron_sleep: Option<UpDuration>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Off,
Error,
#[default]
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
Self::Trace => "trace",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"off" => Some(Self::Off),
"error" => Some(Self::Error),
"warn" => Some(Self::Warn),
"info" => Some(Self::Info),
"debug" => Some(Self::Debug),
"trace" => Some(Self::Trace),
_ => None,
}
}
}
const MIN_CRON_SLEEP: UpDuration = UpDuration::from_millis(1_000);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct WhistleSection {
pub allow_control: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct StyleSection {
pub level: Option<String>,
}
#[non_exhaustive]
#[derive(Clone, Default, PartialEq)]
pub struct DaemonConfig {
pub daemon: DaemonSection,
pub whistle: WhistleSection,
pub style: StyleSection,
pub interpreters: BTreeMap<String, String>,
pub dog: BTreeMap<String, toml::Table>,
}
impl fmt::Debug for DaemonConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DaemonConfig")
.field("daemon", &self.daemon)
.field("whistle", &self.whistle)
.field("style", &self.style)
.field("interpreters", &self.interpreters)
.field("dog", &format_args!("<{} tables>", self.dog.len()))
.finish()
}
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
struct RawDaemonConfig {
daemon: DaemonSection,
whistle: WhistleSection,
style: StyleSection,
interpreters: BTreeMap<String, String>,
dog: BTreeMap<String, toml::Table>,
}
impl DaemonConfig {
pub fn load(
file_source: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<Self, DaemonConfigError> {
Self::load_layered(file_source, env, &DaemonOverrides::new())
}
pub fn load_layered(
file_source: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
overrides: &DaemonOverrides,
) -> Result<Self, DaemonConfigError> {
let raw: RawDaemonConfig = match file_source {
Some(src) => toml::from_str(src).map_err(|e| DaemonConfigError::Toml(e.to_string()))?,
None => RawDaemonConfig::default(),
};
let mut cfg = Self {
daemon: raw.daemon,
whistle: raw.whistle,
style: raw.style,
interpreters: raw.interpreters,
dog: raw.dog,
};
if let Some(v) = env("SHEP_LOG_JSON") {
cfg.daemon.log_json = match parse_daemon_bool(&v) {
Some(value) => value,
None => return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_JSON", v)),
};
}
if let Some(v) = env("SHEP_LOG_LEVEL") {
let Some(level) = LogLevel::from_name(&v) else {
return Err(DaemonConfigError::BadEnvValue("SHEP_LOG_LEVEL", v));
};
cfg.daemon.log_level = level;
}
if let Some(v) = env("SHEP_SOCKET") {
cfg.daemon.socket = Some(std::path::PathBuf::from(v));
}
let mut max_cron_sleep_key = "max_cron_sleep";
if let Some(v) = env("SHEP_MAX_CRON_SLEEP") {
let parsed = v
.parse::<UpDuration>()
.map_err(|_| DaemonConfigError::BadEnvValue("SHEP_MAX_CRON_SLEEP", v))?;
cfg.daemon.max_cron_sleep = Some(parsed);
max_cron_sleep_key = "SHEP_MAX_CRON_SLEEP";
}
if let Some(value) = overrides.log_json {
cfg.daemon.log_json = value;
}
if let Some(value) = overrides.log_level {
cfg.daemon.log_level = value;
}
if let Some(value) = &overrides.socket {
cfg.daemon.socket = Some(value.clone());
}
if let Some(value) = overrides.max_cron_sleep {
cfg.daemon.max_cron_sleep = Some(value);
max_cron_sleep_key = "--max-cron-sleep";
}
cfg.validate(max_cron_sleep_key)?;
Ok(cfg)
}
fn validate(&self, key: &'static str) -> Result<(), DaemonConfigError> {
if let Some(value) = self.daemon.max_cron_sleep
&& value < MIN_CRON_SLEEP
{
return Err(DaemonConfigError::BelowMinimum {
key,
value,
min: MIN_CRON_SLEEP,
});
}
Ok(())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DaemonOverrides {
pub log_json: Option<bool>,
pub log_level: Option<LogLevel>,
pub socket: Option<PathBuf>,
pub max_cron_sleep: Option<UpDuration>,
}
impl DaemonOverrides {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn log_json(mut self, value: Option<bool>) -> Self {
self.log_json = value;
self
}
#[must_use]
pub fn log_level(mut self, value: Option<LogLevel>) -> Self {
self.log_level = value;
self
}
#[must_use]
pub fn socket(mut self, value: Option<PathBuf>) -> Self {
self.socket = value;
self
}
#[must_use]
pub fn max_cron_sleep(mut self, value: Option<UpDuration>) -> Self {
self.max_cron_sleep = value;
self
}
}
#[must_use]
pub fn parse_daemon_bool(value: &str) -> Option<bool> {
match value {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonConfigError {
Toml(String),
BadEnvValue(&'static str, String),
BelowMinimum {
key: &'static str,
value: UpDuration,
min: UpDuration,
},
}
impl fmt::Display for DaemonConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(m) => write!(f, "invalid shep.toml: {m}"),
Self::BadEnvValue(var, v) => write!(f, "invalid value `{v}` for {var}"),
Self::BelowMinimum { key, value, min } => {
write!(
f,
"invalid value `{value}` for {key}: must be at least {min}"
)
}
}
}
}
impl core::error::Error for DaemonConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::values::UpDuration;
fn no_env(_: &str) -> Option<String> {
None
}
#[test]
fn missing_max_cron_sleep_leaves_the_field_none() {
let cfg = DaemonConfig::load(None, &no_env).unwrap();
assert_eq!(cfg.daemon.max_cron_sleep, None);
}
#[test]
fn max_cron_sleep_file_value_parses_via_upduration() {
let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &no_env).unwrap();
assert_eq!(
cfg.daemon.max_cron_sleep,
Some(UpDuration::from_millis(5 * 60_000))
);
}
#[test]
fn env_max_cron_sleep_beats_file_value() {
let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "90s".to_string());
let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env).unwrap();
assert_eq!(
cfg.daemon.max_cron_sleep,
Some(UpDuration::from_millis(90_000))
);
}
#[test]
fn bad_env_max_cron_sleep_is_a_typed_error() {
let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "banana".to_string());
assert_eq!(
DaemonConfig::load(None, &env),
Err(DaemonConfigError::BadEnvValue(
"SHEP_MAX_CRON_SLEEP",
"banana".to_string()
))
);
}
#[test]
fn max_cron_sleep_floor_rejects_below_one_second() {
let cfg = DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"1s\""), &no_env).unwrap();
assert_eq!(
cfg.daemon.max_cron_sleep,
Some(UpDuration::from_millis(1_000))
);
assert_eq!(
DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"999\""), &no_env),
Err(DaemonConfigError::BelowMinimum {
key: "max_cron_sleep",
value: UpDuration::from_millis(999),
min: UpDuration::from_millis(1_000),
})
);
}
#[test]
fn env_max_cron_sleep_floor_check_runs_on_the_winner() {
let env = |k: &str| (k == "SHEP_MAX_CRON_SLEEP").then(|| "0".to_string());
assert_eq!(
DaemonConfig::load(Some("[daemon]\nmax_cron_sleep = \"5m\""), &env),
Err(DaemonConfigError::BelowMinimum {
key: "SHEP_MAX_CRON_SLEEP",
value: UpDuration::from_millis(0),
min: UpDuration::from_millis(1_000),
})
);
}
#[test]
fn below_minimum_display_is_exact() {
let err = DaemonConfigError::BelowMinimum {
key: "max_cron_sleep",
value: UpDuration::from_millis(999),
min: UpDuration::from_millis(1_000),
};
assert_eq!(
err.to_string(),
"invalid value `999` for max_cron_sleep: must be at least 1s"
);
}
#[test]
fn missing_file_yields_defaults() {
let cfg = DaemonConfig::load(None, &no_env).unwrap();
assert!(!cfg.daemon.log_json);
assert!(cfg.daemon.enabled_dogs.is_empty());
assert!(cfg.dog.is_empty());
}
#[test]
fn file_sets_values_and_keeps_dog_sections_raw() {
let src = r#"
[daemon]
log_json = true
enabled_dogs = ["metrics"]
[dog.metrics]
port = 9615
"#;
let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
assert!(cfg.daemon.log_json);
assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
assert_eq!(cfg.dog["metrics"]["port"].as_integer(), Some(9615));
}
#[test]
fn adopted_dogs_default_empty_and_round_trip_by_name() {
let bare = DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env).unwrap();
assert!(bare.daemon.adopted_dogs.is_empty());
let src = r#"
[daemon]
enabled_dogs = ["metrics", "otel"]
[daemon.adopted_dogs]
otel = "/usr/local/bin/shep-otel"
"#;
let cfg = DaemonConfig::load(Some(src), &no_env).unwrap();
assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics", "otel"]);
assert_eq!(
cfg.daemon.adopted_dogs.get("otel"),
Some(&std::path::PathBuf::from("/usr/local/bin/shep-otel"))
);
assert!(
!cfg.daemon.adopted_dogs.contains_key("metrics"),
"a name with no entry here is a built-in, and that is the whole distinction"
);
}
#[test]
fn env_overrides_file() {
let env = |k: &str| (k == "SHEP_LOG_JSON").then(|| "true".to_string());
let cfg = DaemonConfig::load(Some("[daemon]\nlog_json = false"), &env).unwrap();
assert!(cfg.daemon.log_json);
}
#[test]
fn an_unset_log_level_is_warn() {
assert_eq!(
DaemonConfig::load(None, &no_env).unwrap().daemon.log_level,
LogLevel::Warn
);
}
#[test]
fn every_log_level_name_means_the_same_thing_in_the_file_and_the_environment() {
let levels = [
LogLevel::Off,
LogLevel::Error,
LogLevel::Warn,
LogLevel::Info,
LogLevel::Debug,
LogLevel::Trace,
];
for level in levels {
let name = level.as_str();
assert_eq!(LogLevel::from_name(name), Some(level), "from_name({name})");
let file = format!("[daemon]\nlog_level = \"{name}\"");
let cfg = DaemonConfig::load(Some(&file), &no_env).unwrap();
assert_eq!(cfg.daemon.log_level, level, "[daemon] log_level = {name:?}");
let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| name.to_string());
let cfg = DaemonConfig::load(None, &env).unwrap();
assert_eq!(cfg.daemon.log_level, level, "SHEP_LOG_LEVEL={name}");
}
}
#[test]
fn env_log_level_beats_file_value() {
let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "debug".to_string());
let cfg = DaemonConfig::load(Some("[daemon]\nlog_level = \"error\""), &env).unwrap();
assert_eq!(cfg.daemon.log_level, LogLevel::Debug);
}
#[test]
fn bad_env_log_level_is_a_typed_error() {
for value in ["verbose", "WARN", ""] {
let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| value.to_string());
assert_eq!(
DaemonConfig::load(None, &env),
Err(DaemonConfigError::BadEnvValue(
"SHEP_LOG_LEVEL",
value.to_string()
)),
"SHEP_LOG_LEVEL={value:?}"
);
}
}
#[test]
fn bad_file_log_level_is_a_toml_error() {
let err = DaemonConfig::load(Some("[daemon]\nlog_level = \"verbose\""), &no_env)
.expect_err("a misspelled level must not parse");
let DaemonConfigError::Toml(message) = err else {
panic!("a misspelled level is a TOML error, not {err:?}");
};
assert!(
message.contains("unknown variant `verbose`"),
"the error must reject the level's own name, not some other key: {message:?}"
);
}
#[test]
fn socket_override_via_file_and_env() {
let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &no_env).unwrap();
assert_eq!(
cfg.daemon.socket.as_deref(),
Some(std::path::Path::new("/tmp/a.sock"))
);
let env = |k: &str| (k == "SHEP_SOCKET").then(|| "/tmp/b.sock".to_string());
let cfg = DaemonConfig::load(Some("[daemon]\nsocket = \"/tmp/a.sock\""), &env).unwrap();
assert_eq!(
cfg.daemon.socket.as_deref(),
Some(std::path::Path::new("/tmp/b.sock"))
);
}
#[test]
fn bad_toml_is_a_typed_error() {
assert!(matches!(
DaemonConfig::load(Some("[daemon"), &no_env),
Err(DaemonConfigError::Toml(_))
));
}
#[test]
fn a_whistle_section_parses_and_defaults_to_refusing_control() {
let cfg = DaemonConfig::load(Some("[whistle]\nallow_control = true\n"), &no_env).unwrap();
assert!(cfg.whistle.allow_control);
let absent = DaemonConfig::load(Some("[daemon]\nlog_level = \"info\"\n"), &no_env).unwrap();
assert!(
!absent.whistle.allow_control,
"a file with no [whistle] section leaves control off"
);
let empty_table = DaemonConfig::load(Some("[whistle]\n"), &no_env).unwrap();
assert!(
!empty_table.whistle.allow_control,
"a [whistle] section with no keys leaves control off"
);
}
#[test]
fn a_misspelled_whistle_key_is_a_named_error() {
let err =
DaemonConfig::load(Some("[whistle]\nallow_contro = true\n"), &no_env).unwrap_err();
let DaemonConfigError::Toml(message) = err else {
panic!("a misspelled key is a TOML error, got {err:?}")
};
assert!(
message.contains("unknown field `allow_contro`"),
"the message quotes the key that was not understood: {message}"
);
}
#[test]
fn a_flag_rescues_a_below_floor_file_value() {
let cfg = DaemonConfig::load_layered(
Some("[daemon]\nmax_cron_sleep = \"500\"\n"),
&no_env,
&DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(300_000))),
)
.unwrap();
assert_eq!(
cfg.daemon.max_cron_sleep,
Some(UpDuration::from_millis(300_000))
);
}
#[test]
fn a_below_floor_flag_is_refused_naming_the_flag() {
let err = DaemonConfig::load_layered(
None,
&no_env,
&DaemonOverrides::new().max_cron_sleep(Some(UpDuration::from_millis(500))),
)
.unwrap_err();
assert_eq!(
err,
DaemonConfigError::BelowMinimum {
key: "--max-cron-sleep",
value: UpDuration::from_millis(500),
min: MIN_CRON_SLEEP,
}
);
assert!(err.to_string().contains("--max-cron-sleep"), "got: {err}");
}
#[test]
fn a_flag_beats_the_environment() {
let env = |k: &str| (k == "SHEP_LOG_LEVEL").then(|| "trace".to_string());
let cfg = DaemonConfig::load_layered(
Some("[daemon]\nlog_level = \"error\"\n"),
&env,
&DaemonOverrides::new().log_level(Some(LogLevel::Info)),
)
.unwrap();
assert_eq!(cfg.daemon.log_level, LogLevel::Info);
}
#[test]
fn an_absent_flag_leaves_every_lower_layer_alone() {
let src = "[daemon]\nlog_json = true\nlog_level = \"debug\"\nsocket = \"/tmp/s.sock\"\n";
let layered =
DaemonConfig::load_layered(Some(src), &no_env, &DaemonOverrides::new()).unwrap();
let plain = DaemonConfig::load(Some(src), &no_env).unwrap();
assert_eq!(layered, plain);
}
#[test]
fn the_bool_grammar_is_exactly_four_spellings() {
assert_eq!(parse_daemon_bool("1"), Some(true));
assert_eq!(parse_daemon_bool("0"), Some(false));
assert_eq!(parse_daemon_bool("true"), Some(true));
assert_eq!(parse_daemon_bool("false"), Some(false));
for wider in ["yes", "no", "on", "off", "TRUE", "y"] {
assert_eq!(
parse_daemon_bool(wider),
None,
"{wider} must not be a boolean here"
);
}
}
#[test]
fn interpreters_parses_as_an_extension_map() {
let cfg = DaemonConfig::load(
Some("[interpreters]\njs = \"node\"\npy = \"python3\"\n"),
&no_env,
)
.unwrap();
assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
assert_eq!(
cfg.interpreters.get("py").map(String::as_str),
Some("python3")
);
assert_eq!(cfg.interpreters.len(), 2);
}
#[test]
fn interpreters_defaults_to_empty() {
assert!(
DaemonConfig::load(None, &no_env)
.unwrap()
.interpreters
.is_empty()
);
assert!(
DaemonConfig::load(Some("[daemon]\nlog_json = true\n"), &no_env)
.unwrap()
.interpreters
.is_empty()
);
}
#[test]
fn an_unrecognised_extension_is_not_an_unknown_field() {
let cfg = DaemonConfig::load(Some("[interpreters]\nlua = \"lua5.4\"\n"), &no_env).unwrap();
assert_eq!(
cfg.interpreters.get("lua").map(String::as_str),
Some("lua5.4")
);
}
#[test]
fn a_non_string_interpreter_value_is_a_parse_error() {
assert!(DaemonConfig::load(Some("[interpreters]\njs = 5\n"), &no_env).is_err());
}
#[test]
fn debug_redacts_dog_values() {
let cfg = DaemonConfig::load(Some("[dog.metrics]\nport = 9615"), &no_env).unwrap();
assert_eq!(
format!("{cfg:?}"),
"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> }"
);
}
}