use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use shep_core::config::daemon::{DaemonSection, WhistleSection};
use shep_core::config::{DaemonConfig, DaemonConfigError, parse_daemon_bool};
use shep_core::values::UpDuration;
use crate::commands::shep_toml::{ShepToml, ShepTomlError};
use crate::dog::BUILT_IN_DOGS;
use crate::style::{StyleLevel, StyleSource};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScalarView {
pub value: String,
pub source: StyleSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingField {
LogLevel,
LogJson,
Socket,
MaxCronSleep,
AllowControl,
StyleLevel,
}
impl SettingField {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::LogLevel => "log_level",
Self::LogJson => "log_json",
Self::Socket => "socket",
Self::MaxCronSleep => "max_cron_sleep",
Self::AllowControl => "allow_control",
Self::StyleLevel => "level",
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
Some(match key {
"log_level" => Self::LogLevel,
"log_json" => Self::LogJson,
"socket" => Self::Socket,
"max_cron_sleep" => Self::MaxCronSleep,
"allow_control" => Self::AllowControl,
"level" => Self::StyleLevel,
_ => return None,
})
}
}
#[must_use]
pub fn settings_field_set() -> crate::lookout::field::FieldSet {
use crate::lookout::app::{LOG_LEVEL_ORDER, STYLE_LEVEL_ORDER};
use crate::lookout::field::{Bounds, Field, FieldKind, FieldSet};
let f = |field: SettingField, group: &str, kind: FieldKind| Field {
key: field.key().to_owned(),
help: field.key().to_owned(),
group: Some(group.to_owned()),
kind,
value_kind: None,
default: None,
default_value: None,
secret: false,
editable: true,
example: None,
accepts: Vec::new(),
refuses: Vec::new(),
neighbours: Vec::new(),
bounds: Bounds::default(),
};
let log_levels = FieldKind::Choice(
LOG_LEVEL_ORDER
.iter()
.map(|l| l.as_str().to_owned())
.collect(),
);
let style_levels =
FieldKind::Choice(STYLE_LEVEL_ORDER.iter().map(ToString::to_string).collect());
FieldSet::from_fields(
vec![
f(SettingField::LogLevel, "[daemon]", log_levels),
f(SettingField::LogJson, "[daemon]", FieldKind::Bool),
f(SettingField::Socket, "[daemon]", FieldKind::Text),
f(SettingField::MaxCronSleep, "[daemon]", FieldKind::Text),
f(SettingField::AllowControl, "[whistle]", FieldKind::Bool),
f(SettingField::StyleLevel, "[style]", style_levels),
],
&["[daemon]", "[whistle]", "[style]"],
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingEdit {
Set {
field: SettingField,
value: String,
},
Unset {
field: SettingField,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsSnapshot {
pub log_level: ScalarView,
pub log_json: ScalarView,
pub socket: ScalarView,
pub max_cron_sleep: ScalarView,
pub allow_control: ScalarView,
pub style_level: ScalarView,
pub style_level_in_file: Option<String>,
pub dogs: Vec<DogView>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogView {
pub name: String,
pub enabled: bool,
pub adopted_path: Option<PathBuf>,
}
#[derive(Debug)]
pub enum SettingError {
Config(ShepTomlError),
Invalid(String),
}
impl std::fmt::Display for SettingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(err) => write!(f, "{err}"),
Self::Invalid(message) => write!(f, "{message}"),
}
}
}
impl core::error::Error for SettingError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Config(err) => Some(err),
Self::Invalid(_) => None,
}
}
}
impl From<ShepTomlError> for SettingError {
fn from(err: ShepTomlError) -> Self {
Self::Config(err)
}
}
pub fn load_settings(
path: &Path,
socket_default: &Path,
style: (StyleLevel, StyleSource),
) -> Result<SettingsSnapshot, ShepTomlError> {
let doc = ShepToml::read_only(path)?;
let daemon_default = DaemonSection::default();
let whistle_default = WhistleSection::default();
let log_level = match doc.daemon_log_level() {
Some(value) => ScalarView {
value,
source: StyleSource::Config,
},
None => ScalarView {
value: daemon_default.log_level.as_str().to_string(),
source: StyleSource::Default,
},
};
let log_json = match doc.daemon_log_json() {
Some(value) => ScalarView {
value: value.to_string(),
source: StyleSource::Config,
},
None => ScalarView {
value: daemon_default.log_json.to_string(),
source: StyleSource::Default,
},
};
let socket = match doc.daemon_socket() {
Some(value) => ScalarView {
value: value.display().to_string(),
source: StyleSource::Config,
},
None => ScalarView {
value: socket_default.display().to_string(),
source: StyleSource::Default,
},
};
let max_cron_sleep = match doc.daemon_max_cron_sleep() {
Some(value) => ScalarView {
value,
source: StyleSource::Config,
},
None => ScalarView {
value: render_compiled_max_cron_sleep(daemon_default.max_cron_sleep),
source: StyleSource::Default,
},
};
let allow_control = match doc.whistle_allow_control() {
Some(value) => ScalarView {
value: value.to_string(),
source: StyleSource::Config,
},
None => ScalarView {
value: whistle_default.allow_control.to_string(),
source: StyleSource::Default,
},
};
let (level, source) = style;
let style_level = ScalarView {
value: level.to_string(),
source,
};
let style_level_in_file = doc.style_level();
Ok(SettingsSnapshot {
log_level,
log_json,
socket,
max_cron_sleep,
allow_control,
style_level,
style_level_in_file,
dogs: dog_candidates(&doc),
})
}
fn render_compiled_max_cron_sleep(default: Option<UpDuration>) -> String {
default.map_or_else(|| "not set".to_string(), |value| value.to_string())
}
fn dog_candidates(doc: &ShepToml) -> Vec<DogView> {
let enabled = doc.enabled_dog_names();
let mut names: BTreeSet<String> = BUILT_IN_DOGS
.iter()
.map(|name| (*name).to_string())
.collect();
names.extend(doc.adopted_dog_names());
names
.into_iter()
.map(|name| {
let adopted_path = doc.adopted_dog_path(&name);
let is_enabled = enabled.contains(&name);
DogView {
name,
enabled: is_enabled,
adopted_path,
}
})
.collect()
}
pub fn apply_setting(path: &Path, edit: &SettingEdit) -> Result<(), SettingError> {
ShepToml::try_edit(path, |doc| -> Result<(), SettingError> {
match edit {
SettingEdit::Set { field, value } => set_field(doc, *field, value)?,
SettingEdit::Unset { field } => unset_field(doc, *field)?,
}
let rendered = doc.rendered();
DaemonConfig::load(Some(&rendered), &|_| None)
.map_err(|err: DaemonConfigError| SettingError::Invalid(err.to_string()))?;
Ok(())
})
}
fn set_field(doc: &mut ShepToml, field: SettingField, value: &str) -> Result<(), SettingError> {
match field {
SettingField::LogLevel => doc.set_daemon_log_level(value)?,
SettingField::LogJson => doc.set_daemon_log_json(parse_bool_field(value)?)?,
SettingField::Socket => doc.set_daemon_socket(Path::new(value))?,
SettingField::MaxCronSleep => doc.set_daemon_max_cron_sleep(value)?,
SettingField::AllowControl => doc.set_whistle_allow_control(parse_bool_field(value)?)?,
SettingField::StyleLevel => {
let level = StyleLevel::parse(value).ok_or_else(|| {
SettingError::Invalid(format!("{value} does not name a style level"))
})?;
doc.set_style_level(level)?;
}
}
Ok(())
}
fn unset_field(doc: &mut ShepToml, field: SettingField) -> Result<(), SettingError> {
match field {
SettingField::Socket => doc.unset_daemon_socket(),
SettingField::MaxCronSleep => doc.unset_daemon_max_cron_sleep(),
_ => {
return Err(SettingError::Invalid(
"this field has no unset form".to_string(),
));
}
}
Ok(())
}
fn parse_bool_field(value: &str) -> Result<bool, SettingError> {
parse_daemon_bool(value)
.ok_or_else(|| SettingError::Invalid(format!("{value} is not a valid boolean")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lookout::field::FieldKind;
fn style_fixture() -> (StyleLevel, StyleSource) {
(StyleLevel::Full, StyleSource::Config)
}
#[test]
fn every_setting_field_round_trips_through_its_key() {
for field in [
SettingField::LogLevel,
SettingField::LogJson,
SettingField::Socket,
SettingField::MaxCronSleep,
SettingField::AllowControl,
SettingField::StyleLevel,
] {
assert_eq!(SettingField::from_key(field.key()), Some(field));
}
assert_eq!(SettingField::from_key("no_such_key"), None);
}
#[test]
fn the_settings_field_set_lists_the_six_scalars_in_the_screens_fixed_order() {
let set = settings_field_set();
let keys: Vec<&str> = set.fields().iter().map(|f| f.key.as_str()).collect();
assert_eq!(
keys,
[
"log_level",
"log_json",
"socket",
"max_cron_sleep",
"allow_control",
"level"
]
);
let mut groups: Vec<&str> = Vec::new();
for field in set.fields() {
let group = field
.group
.as_deref()
.expect("every scalar names a section");
if groups.last() != Some(&group) {
groups.push(group);
}
}
assert_eq!(groups, ["[daemon]", "[whistle]", "[style]"]);
}
#[test]
fn the_cycled_scalars_are_choices_and_the_typed_ones_are_text() {
let set = settings_field_set();
assert!(matches!(
set.by_key("log_level").unwrap().kind,
FieldKind::Choice(_)
));
assert_eq!(set.by_key("log_json").unwrap().kind, FieldKind::Bool);
assert_eq!(set.by_key("socket").unwrap().kind, FieldKind::Text);
assert_eq!(set.by_key("max_cron_sleep").unwrap().kind, FieldKind::Text);
}
fn socket_default_fixture() -> PathBuf {
PathBuf::from("/var/run/shep-settings-fixture.sock")
}
#[test]
fn a_fresh_home_reads_every_scalar_as_the_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::edit(&path, ShepToml::write_starter_interpreters).unwrap();
let snap = load_settings(&path, &socket_default_fixture(), style_fixture()).unwrap();
assert_eq!(snap.log_level.source, StyleSource::Default);
assert_eq!(snap.log_level.value, "warn");
assert_eq!(snap.log_json.source, StyleSource::Default);
assert_eq!(snap.allow_control.source, StyleSource::Default);
assert_eq!(snap.max_cron_sleep.source, StyleSource::Default);
assert_eq!(
snap.style_level_in_file, None,
"a fresh home has never written [style], so the reader must say so"
);
}
#[test]
fn a_declared_scalar_reads_as_config_even_at_its_default_value() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nlog_level = \"warn\"\n\n[style]\nlevel = \"bare\"\n",
)
.unwrap();
let snap = load_settings(&path, &socket_default_fixture(), style_fixture()).unwrap();
assert_eq!(snap.log_level.value, "warn");
assert_eq!(
snap.log_level.source,
StyleSource::Config,
"a key written to its own default is still a key someone wrote"
);
assert_eq!(
snap.style_level_in_file,
Some("bare".to_string()),
"load_settings must read [style] level off the real document, \
not just carry the resolved level `style_fixture` supplies"
);
}
#[cfg(unix)]
#[test]
fn a_value_the_loader_refuses_leaves_the_file_byte_identical() {
use std::os::unix::fs::MetadataExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let before = "# mine\n[daemon]\nlog_level = \"debug\"\n";
std::fs::write(&path, before).unwrap();
let inode_before = std::fs::metadata(&path).unwrap().ino();
let refusal = apply_setting(
&path,
&SettingEdit::Set {
field: SettingField::MaxCronSleep,
value: "500ms".into(),
},
);
assert!(matches!(refusal, Err(SettingError::Invalid(_))));
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
assert_eq!(
std::fs::metadata(&path).unwrap().ino(),
inode_before,
"a refusal must not stage and rename, which is what try_edit buys"
);
}
#[test]
fn the_refusal_carries_the_loaders_own_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "").unwrap();
let Err(SettingError::Invalid(message)) = apply_setting(
&path,
&SettingEdit::Set {
field: SettingField::MaxCronSleep,
value: "500ms".into(),
},
) else {
panic!("a value under the floor must be refused");
};
assert!(
message.contains("max_cron_sleep"),
"the operator has to be told which key: {message}"
);
}
#[test]
fn unsetting_an_optional_field_returns_it_to_the_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nmax_cron_sleep = \"30s\"\n").unwrap();
apply_setting(
&path,
&SettingEdit::Unset {
field: SettingField::MaxCronSleep,
},
)
.unwrap();
let snap = load_settings(&path, &socket_default_fixture(), style_fixture()).unwrap();
assert_eq!(snap.max_cron_sleep.source, StyleSource::Default);
}
#[test]
fn every_built_in_dog_is_a_candidate_even_when_nothing_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "").unwrap();
let snap = load_settings(&path, &socket_default_fixture(), style_fixture()).unwrap();
let names: Vec<&str> = snap.dogs.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["bark", "metrics"]);
assert!(snap.dogs.iter().all(|d| !d.enabled));
}
#[test]
fn an_adopted_dog_joins_the_candidates_and_carries_its_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nenabled_dogs = [\"otel\"]\n\n[daemon.adopted_dogs]\notel = \"/usr/local/bin/shep-otel\"\n",
)
.unwrap();
let snap = load_settings(&path, &socket_default_fixture(), style_fixture()).unwrap();
let otel = snap.dogs.iter().find(|d| d.name == "otel").unwrap();
assert!(otel.enabled);
assert_eq!(
otel.adopted_path.as_deref(),
Some(Path::new("/usr/local/bin/shep-otel"))
);
let metrics = snap.dogs.iter().find(|d| d.name == "metrics").unwrap();
assert_eq!(metrics.adopted_path, None, "a built-in dog has no path");
}
}