#![allow(clippy::result_large_err)]
use std::collections::BTreeMap;
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::DirBuilderExt as _;
use std::path::{Path, PathBuf};
use toml_edit::{Array, DocumentMut, Item, Table, Value};
use crate::style::StyleLevel;
const STARTER_INTERPRETERS: &[(&str, &str)] = &[
("js", "node"),
("mjs", "node"),
("cjs", "node"),
("py", "python3"),
("rb", "ruby"),
("sh", "sh"),
("pl", "perl"),
("php", "php"),
];
const INTERPRETERS_STARTER_COMMENT: &str = "\
# Extension -> interpreter mapping. shep applies one of these to a script
# when nothing more specific already named an interpreter: not this app's
# own Flockfile entry, and not --interpreter on the command line, both of
# which win over anything here. shep never guesses beyond what is written
# below, so edit freely: change an interpreter, add an extension, or
# delete an entry (or this whole table) to turn the mapping off for it.
";
pub struct ShepToml {
path: PathBuf,
doc: DocumentMut,
}
impl std::fmt::Debug for ShepToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShepToml")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl ShepToml {
pub fn edit<T>(path: &Path, f: impl FnOnce(&mut Self) -> T) -> Result<T, ShepTomlError> {
let (mut doc, _lock) = Self::open_locked(path)?;
let value = f(&mut doc);
doc.save()?;
Ok(value)
}
pub fn try_edit<T, E: From<ShepTomlError>>(
path: &Path,
f: impl FnOnce(&mut Self) -> Result<T, E>,
) -> Result<T, E> {
let (mut doc, _lock) = Self::open_locked(path)?;
let value = f(&mut doc)?;
doc.save()?;
Ok(value)
}
fn open_locked(path: &Path) -> Result<(Self, ConfigLock), ShepTomlError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
create_home_dir(parent).map_err(|source| ShepTomlError::Io {
path: parent.to_path_buf(),
source,
})?;
let lock = ConfigLock::acquire(path).map_err(|source| ShepTomlError::Io {
path: path.to_path_buf(),
source,
})?;
let doc = Self::open(path)?;
Ok((doc, lock))
}
fn open(path: &Path) -> Result<Self, ShepTomlError> {
let doc = match std::fs::read_to_string(path) {
Ok(text) => text
.parse::<DocumentMut>()
.map_err(|source| ShepTomlError::Parse {
path: path.to_path_buf(),
source,
})?,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(source) => {
return Err(ShepTomlError::Io {
path: path.to_path_buf(),
source,
});
}
};
Ok(Self {
path: path.to_path_buf(),
doc,
})
}
pub fn read_only(path: &Path) -> Result<Self, ShepTomlError> {
Self::open(path)
}
#[must_use]
pub(crate) fn rendered(&self) -> String {
self.doc.to_string()
}
pub fn enable_dog(&mut self, name: &str) {
let daemon = self.daemon_table_mut();
let enabled_dogs = daemon
.entry("enabled_dogs")
.or_insert_with(|| Item::Value(Value::Array(Array::new())))
.as_array_mut()
.expect("enabled_dogs is only ever written as an array");
if !enabled_dogs.iter().any(|v| v.as_str() == Some(name)) {
enabled_dogs.push(name);
}
}
pub fn disable_dog(&mut self, name: &str) {
if let Some(enabled_dogs) = self
.doc
.get_mut("daemon")
.and_then(Item::as_table_mut)
.and_then(|daemon| daemon.get_mut("enabled_dogs"))
.and_then(Item::as_array_mut)
{
enabled_dogs.retain(|v| v.as_str() != Some(name));
}
}
pub fn adopt_dog(&mut self, name: &str, exec: &Path) {
let daemon = self.daemon_table_mut();
let adopted_dogs = daemon
.entry("adopted_dogs")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("adopted_dogs is only ever written as a table");
adopted_dogs.insert(
name,
Item::Value(exec.to_string_lossy().into_owned().into()),
);
self.enable_dog(name);
}
pub fn take_dog_sections(&mut self) -> BTreeMap<String, Item> {
let Some(item) = self.doc.remove("dog") else {
return BTreeMap::new();
};
let Some(dog) = item.as_table_like() else {
return BTreeMap::new();
};
dog.iter()
.filter(|(_, value)| value.as_table_like().is_some())
.map(|(name, value)| (name.to_owned(), value.clone()))
.collect()
}
#[must_use]
pub fn adopted_dog_path(&self, name: &str) -> Option<PathBuf> {
self.doc
.get("daemon")?
.as_table()?
.get("adopted_dogs")?
.as_table()?
.get(name)?
.as_str()
.map(PathBuf::from)
}
#[must_use]
pub fn adopted_dog_names(&self) -> Vec<String> {
self.doc
.get("daemon")
.and_then(Item::as_table)
.and_then(|daemon| daemon.get("adopted_dogs"))
.and_then(Item::as_table)
.map(|adopted| adopted.iter().map(|(name, _)| name.to_string()).collect())
.unwrap_or_default()
}
#[must_use]
pub fn enabled_dog_names(&self) -> Vec<String> {
self.table("daemon")
.and_then(|daemon| daemon.get("enabled_dogs"))
.and_then(Item::as_array)
.map(|names| {
names
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
pub fn adopted_dog_path_readonly(
path: &Path,
name: &str,
) -> Result<Option<PathBuf>, ShepTomlError> {
Ok(Self::open(path)?.adopted_dog_path(name))
}
pub fn rehome_dog(&mut self, name: &str) {
self.disable_dog(name);
if let Some(adopted_dogs) = self
.doc
.get_mut("daemon")
.and_then(Item::as_table_mut)
.and_then(|daemon| daemon.get_mut("adopted_dogs"))
.and_then(Item::as_table_mut)
{
adopted_dogs.remove(name);
}
if let Some(dog) = self.doc.get_mut("dog").and_then(Item::as_table_mut) {
dog.remove(name);
}
}
pub fn set_style_level(&mut self, level: StyleLevel) -> Result<(), ShepTomlError> {
let item = self
.doc
.entry("style")
.or_insert_with(|| Item::Table(Table::new()));
let Some(style) = item.as_table_mut() else {
return Err(ShepTomlError::WrongShape {
path: self.path.clone(),
key: "style",
found: item.type_name(),
});
};
style.insert("level", Item::Value(level.to_string().into()));
Ok(())
}
#[must_use]
pub fn style_level(&self) -> Option<String> {
self.table("style")?
.get("level")?
.as_str()
.map(String::from)
}
#[must_use]
pub fn daemon_log_json(&self) -> Option<bool> {
self.table("daemon")?.get("log_json")?.as_bool()
}
#[must_use]
pub fn daemon_log_level(&self) -> Option<String> {
self.table("daemon")?
.get("log_level")?
.as_str()
.map(String::from)
}
#[must_use]
pub fn daemon_socket(&self) -> Option<PathBuf> {
self.table("daemon")?
.get("socket")?
.as_str()
.map(PathBuf::from)
}
#[must_use]
pub fn daemon_max_cron_sleep(&self) -> Option<String> {
self.table("daemon")?
.get("max_cron_sleep")?
.as_str()
.map(String::from)
}
#[must_use]
pub fn whistle_allow_control(&self) -> Option<bool> {
self.table("whistle")?.get("allow_control")?.as_bool()
}
pub fn set_daemon_log_json(&mut self, value: bool) -> Result<(), ShepTomlError> {
self.section_table_mut("daemon")?
.insert("log_json", Item::Value(value.into()));
Ok(())
}
pub fn set_daemon_log_level(&mut self, value: &str) -> Result<(), ShepTomlError> {
self.section_table_mut("daemon")?
.insert("log_level", Item::Value(value.into()));
Ok(())
}
pub fn set_daemon_socket(&mut self, value: &Path) -> Result<(), ShepTomlError> {
self.section_table_mut("daemon")?.insert(
"socket",
Item::Value(value.to_string_lossy().into_owned().into()),
);
Ok(())
}
pub fn set_daemon_max_cron_sleep(&mut self, value: &str) -> Result<(), ShepTomlError> {
self.section_table_mut("daemon")?
.insert("max_cron_sleep", Item::Value(value.into()));
Ok(())
}
pub fn set_whistle_allow_control(&mut self, value: bool) -> Result<(), ShepTomlError> {
self.section_table_mut("whistle")?
.insert("allow_control", Item::Value(value.into()));
Ok(())
}
pub fn unset_daemon_socket(&mut self) {
if let Some(daemon) = self.doc.get_mut("daemon").and_then(Item::as_table_mut) {
daemon.remove("socket");
}
}
pub fn unset_daemon_max_cron_sleep(&mut self) {
if let Some(daemon) = self.doc.get_mut("daemon").and_then(Item::as_table_mut) {
daemon.remove("max_cron_sleep");
}
}
pub fn write_starter_interpreters(&mut self) {
if self.doc.contains_key("interpreters") {
return;
}
let mut table = Table::new();
for (extension, interpreter) in STARTER_INTERPRETERS {
table.insert(extension, Item::Value((*interpreter).into()));
}
table.decor_mut().set_prefix(INTERPRETERS_STARTER_COMMENT);
self.doc.insert("interpreters", Item::Table(table));
}
fn save(&self) -> Result<(), ShepTomlError> {
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = create_config_file(parent).map_err(|source| self.io_error(source))?;
tmp.write_all(self.doc.to_string().as_bytes())
.map_err(|source| self.io_error(source))?;
shep_core::atomic_file::publish(tmp, &self.path).map_err(|source| self.io_error(source))
}
fn io_error(&self, source: std::io::Error) -> ShepTomlError {
ShepTomlError::Io {
path: self.path.clone(),
source,
}
}
fn table(&self, section: &str) -> Option<&Table> {
self.doc.get(section).and_then(Item::as_table)
}
fn section_table_mut(&mut self, section: &'static str) -> Result<&mut Table, ShepTomlError> {
let item = self
.doc
.entry(section)
.or_insert_with(|| Item::Table(Table::new()));
let found = item.type_name();
item.as_table_mut().ok_or(ShepTomlError::WrongShape {
path: self.path.clone(),
key: section,
found,
})
}
fn daemon_table_mut(&mut self) -> &mut Table {
self.doc
.entry("daemon")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("daemon is only ever written as a table")
}
}
fn create_home_dir(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
builder.mode(shep_daemon::boot::DIR_MODE);
builder.create(dir)
}
pub(super) use shep_core::config_lock::{ConfigLock, create_config_file};
pub enum ShepTomlError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
source: toml_edit::TomlError,
},
WrongShape {
path: PathBuf,
key: &'static str,
found: &'static str,
},
}
impl std::fmt::Debug for ShepTomlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => f
.debug_struct("Io")
.field("path", path)
.field("source", source)
.finish(),
Self::Parse { path, source } => f
.debug_struct("Parse")
.field("path", path)
.field("message", &source.message())
.finish(),
Self::WrongShape { path, key, found } => f
.debug_struct("WrongShape")
.field("path", path)
.field("key", key)
.field("found", found)
.finish(),
}
}
}
impl std::fmt::Display for ShepTomlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::Parse { path, source } => write!(f, "{}: {source}", path.display()),
Self::WrongShape { path, key, found } => write!(
f,
"{}: [{key}] must be a table, found a {found}",
path.display()
),
}
}
}
impl core::error::Error for ShepTomlError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::WrongShape { .. } => None,
}
}
}
#[cfg(all(test, unix))]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use shep_core::config::DaemonConfig;
use super::*;
fn mode_of(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn enabling_a_dog_leaves_the_rest_of_the_file_exactly_as_it_was() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let original = "# the shepherd's own knobs\n[daemon]\nlog_level = \"info\" # chatty\nlog_json = false\n";
std::fs::write(&path, original).unwrap();
ShepToml::edit(&path, |doc| doc.enable_dog("metrics")).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("# the shepherd's own knobs"));
assert!(written.contains("# chatty"));
assert!(
written.find("log_level").unwrap() < written.find("log_json").unwrap(),
"key order survives"
);
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(cfg.daemon.enabled_dogs, vec!["metrics"]);
assert!(
cfg.dog.is_empty(),
"enable writes no dog section at all; the next boot refuses a \
name held in both files: {written}"
);
}
#[test]
fn enable_is_idempotent_and_disable_keeps_the_config_it_did_not_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[dog.bark]\ndebounce = \"30s\"\n").unwrap();
ShepToml::edit(&path, |doc| {
doc.enable_dog("bark");
doc.enable_dog("bark");
})
.unwrap();
let cfg =
DaemonConfig::load(Some(&std::fs::read_to_string(&path).unwrap()), &|_| None).unwrap();
assert_eq!(cfg.daemon.enabled_dogs, vec!["bark"]);
ShepToml::edit(&path, |doc| doc.disable_dog("bark")).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert!(cfg.daemon.enabled_dogs.is_empty());
assert!(
written.contains("30s"),
"disable stops a dog; rehome is what forgets it"
);
}
#[test]
fn a_file_that_will_not_parse_is_refused_rather_than_replaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon\nlog_json = true\n").unwrap();
assert!(matches!(
ShepToml::edit(&path, |doc| doc.enable_dog("metrics")),
Err(ShepTomlError::Parse { .. })
));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"[daemon\nlog_json = true\n"
);
}
#[test]
fn rehoming_a_dog_forgets_it_entirely() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[dog.otel]\ndebounce = \"30s\"\n").unwrap();
ShepToml::edit(&path, |doc| {
doc.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(cfg.daemon.enabled_dogs, vec!["otel"]);
assert_eq!(
cfg.daemon
.adopted_dogs
.get("otel")
.map(std::path::PathBuf::as_path),
Some(Path::new("/usr/local/bin/shep-otel"))
);
assert!(cfg.dog.contains_key("otel"));
ShepToml::edit(&path, |doc| doc.rehome_dog("otel")).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert!(cfg.daemon.enabled_dogs.is_empty());
assert!(!cfg.daemon.adopted_dogs.contains_key("otel"));
assert!(!cfg.dog.contains_key("otel"));
}
#[test]
fn adopted_dog_path_reads_what_adopt_dog_wrote_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::edit(&path, |doc| {
doc.enable_dog("metrics"); doc.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
assert_eq!(
doc.adopted_dog_path("otel"),
Some(PathBuf::from("/usr/local/bin/shep-otel"))
);
assert_eq!(doc.adopted_dog_path("metrics"), None);
assert_eq!(doc.adopted_dog_path("ghost"), None);
})
.unwrap();
}
#[test]
fn a_missing_file_opens_empty_and_edit_creates_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("shep.toml");
ShepToml::edit(&path, |doc| doc.enable_dog("metrics")).unwrap();
assert!(path.exists());
}
#[test]
fn setting_a_style_level_round_trips_through_daemon_config() {
for level in [StyleLevel::Full, StyleLevel::Plain, StyleLevel::Bare] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::try_edit(&path, |doc| doc.set_style_level(level)).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(cfg.style.level.as_deref(), Some(level.to_string().as_str()));
}
}
#[test]
fn setting_a_style_level_leaves_the_rest_of_the_file_exactly_as_it_was() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let original = "# the shepherd's own knobs\n[daemon]\nlog_level = \"info\" # chatty\nlog_json = false\n";
std::fs::write(&path, original).unwrap();
ShepToml::try_edit(&path, |doc| doc.set_style_level(StyleLevel::Plain)).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("# the shepherd's own knobs"));
assert!(written.contains("# chatty"));
assert!(
written.find("log_level").unwrap() < written.find("log_json").unwrap(),
"key order survives"
);
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(cfg.style.level.as_deref(), Some("plain"));
}
#[test]
fn setting_a_style_level_twice_replaces_rather_than_appends() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::try_edit(&path, |doc| doc.set_style_level(StyleLevel::Full)).unwrap();
ShepToml::try_edit(&path, |doc| doc.set_style_level(StyleLevel::Bare)).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written.matches("level").count(), 1, "one key, not appended");
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(cfg.style.level.as_deref(), Some("bare"));
}
#[test]
fn setting_a_style_level_into_a_home_with_no_shep_toml_creates_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
assert!(!path.exists());
ShepToml::try_edit(&path, |doc| doc.set_style_level(StyleLevel::Bare)).unwrap();
assert!(path.exists());
let cfg =
DaemonConfig::load(Some(&std::fs::read_to_string(&path).unwrap()), &|_| None).unwrap();
assert_eq!(cfg.style.level.as_deref(), Some("bare"));
}
#[test]
fn the_starter_interpreters_are_written_active() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::edit(&path, |doc| doc.write_starter_interpreters()).unwrap();
let cfg =
DaemonConfig::load(Some(&std::fs::read_to_string(&path).unwrap()), &|_| None).unwrap();
assert_eq!(cfg.interpreters.get("js").map(String::as_str), Some("node"));
assert_eq!(
cfg.interpreters.get("mjs").map(String::as_str),
Some("node")
);
assert_eq!(
cfg.interpreters.get("cjs").map(String::as_str),
Some("node")
);
assert_eq!(
cfg.interpreters.get("py").map(String::as_str),
Some("python3")
);
assert_eq!(cfg.interpreters.get("rb").map(String::as_str), Some("ruby"));
assert_eq!(cfg.interpreters.get("sh").map(String::as_str), Some("sh"));
}
#[test]
fn the_starter_interpreters_carry_an_explanatory_comment() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::edit(&path, |doc| doc.write_starter_interpreters()).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.contains("# Extension -> interpreter mapping"),
"no explanatory comment above [interpreters]:\n{written}"
);
assert!(
written.find("# Extension -> interpreter mapping").unwrap()
< written.find("[interpreters]").unwrap(),
"the comment must precede the table it explains:\n{written}"
);
assert!(
!written.contains('\u{2014}') && !written.contains('\u{2013}'),
"no em or en dashes in copy an operator reads:\n{written}"
);
}
#[test]
fn writing_the_starter_interpreters_twice_does_not_duplicate_or_clobber() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
ShepToml::edit(&path, |doc| doc.write_starter_interpreters()).unwrap();
let edited = std::fs::read_to_string(&path)
.unwrap()
.replace("js = \"node\"", "js = \"bun\"");
std::fs::write(&path, &edited).unwrap();
ShepToml::edit(&path, |doc| doc.write_starter_interpreters()).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(
written.matches("[interpreters]").count(),
1,
"one table, not appended:\n{written}"
);
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(
cfg.interpreters.get("js").map(String::as_str),
Some("bun"),
"the operator's own edit must survive a second scaffold call"
);
}
#[test]
fn a_style_key_that_is_not_a_table_is_reported_and_the_file_is_never_rewritten() {
use std::os::unix::fs::MetadataExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let original = "style = \"full\"\n";
std::fs::write(&path, original).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let before = std::fs::metadata(&path).unwrap();
let err = ShepToml::try_edit(&path, |doc| doc.set_style_level(StyleLevel::Bare))
.expect_err("style is a string here, not a table");
assert!(
matches!(
&err,
ShepTomlError::WrongShape { key, found, .. }
if *key == "style" && *found == "string"
),
"{err:?}"
);
assert_eq!(
err.to_string(),
format!(
"{}: [style] must be a table, found a string",
path.display()
)
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
original,
"a refused write must leave the operator's file exactly as it was"
);
let after = std::fs::metadata(&path).unwrap();
assert_eq!(
before.ino(),
after.ino(),
"a refused write must not replace the file -- same inode, not just same bytes"
);
assert_eq!(
before.mode() & 0o777,
after.mode() & 0o777,
"a refused write must not touch the file's mode"
);
}
#[test]
fn a_document_with_no_daemon_section_reads_every_scalar_as_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[interpreters]\njs = \"node\"\n").unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_log_json(), None);
assert_eq!(cfg.daemon_log_level(), None);
assert_eq!(cfg.daemon_socket(), None);
assert_eq!(cfg.daemon_max_cron_sleep(), None);
assert_eq!(cfg.whistle_allow_control(), None);
}
#[test]
fn a_scalar_written_to_its_default_still_reads_as_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nlog_level = \"warn\"\nlog_json = false\n").unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_log_level().as_deref(), Some("warn"));
assert_eq!(cfg.daemon_log_json(), Some(false));
}
#[test]
fn a_missing_file_reads_as_an_empty_document_and_creates_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_log_level(), None);
assert!(!path.exists(), "a read must never create the file");
}
#[test]
fn setting_a_scalar_keeps_the_comments_and_the_keys_around_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"# keep me\n[daemon]\nenabled_dogs = [\"metrics\"]\n\n[style]\nlevel = \"full\"\n",
)
.unwrap();
ShepToml::try_edit(&path, |cfg| cfg.set_daemon_log_level("debug")).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("# keep me\n"), "got: {text}");
assert!(text.contains("enabled_dogs = [\"metrics\"]"), "got: {text}");
assert!(text.contains("level = \"full\""), "got: {text}");
assert!(text.contains("log_level = \"debug\""), "got: {text}");
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn unsetting_removes_the_key_and_leaves_its_neighbours() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nlog_level = \"debug\"\nmax_cron_sleep = \"30s\"\n",
)
.unwrap();
ShepToml::edit(&path, ShepToml::unset_daemon_max_cron_sleep).unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_max_cron_sleep(), None);
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn a_daemon_key_of_the_wrong_shape_is_refused_rather_than_clobbered() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "daemon = \"loud\"\n").unwrap();
let refusal: Result<(), ShepTomlError> =
ShepToml::try_edit(&path, |cfg| cfg.set_daemon_log_json(true));
assert!(matches!(
refusal,
Err(ShepTomlError::WrongShape { key: "daemon", .. })
));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"daemon = \"loud\"\n"
);
}
#[test]
fn enabled_dog_names_reads_the_array_in_file_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nenabled_dogs = [\"metrics\", \"bark\"]\n").unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.enabled_dog_names(), vec!["metrics", "bark"]);
}
#[test]
fn enabled_dog_names_is_empty_when_the_document_never_wrote_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nlog_level = \"debug\"\n").unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.enabled_dog_names(), Vec::<String>::new());
}
#[test]
fn setting_the_socket_reads_back_through_daemon_socket() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nlog_level = \"debug\"\n").unwrap();
ShepToml::try_edit(&path, |cfg| {
cfg.set_daemon_socket(Path::new("/tmp/shep.sock"))
})
.unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_socket(), Some(PathBuf::from("/tmp/shep.sock")));
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn unsetting_the_socket_removes_the_key_and_leaves_its_neighbours() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nlog_level = \"debug\"\nsocket = \"/tmp/shep.sock\"\n",
)
.unwrap();
ShepToml::edit(&path, ShepToml::unset_daemon_socket).unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_socket(), None);
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn setting_max_cron_sleep_reads_back_through_daemon_max_cron_sleep() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nlog_level = \"debug\"\n").unwrap();
ShepToml::try_edit(&path, |cfg| cfg.set_daemon_max_cron_sleep("45s")).unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.daemon_max_cron_sleep().as_deref(), Some("45s"));
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn setting_whistle_allow_control_reads_back_through_whistle_allow_control() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\nlog_level = \"debug\"\n").unwrap();
ShepToml::try_edit(&path, |cfg| cfg.set_whistle_allow_control(true)).unwrap();
let cfg = ShepToml::read_only(&path).unwrap();
assert_eq!(cfg.whistle_allow_control(), Some(true));
assert_eq!(cfg.daemon_log_level().as_deref(), Some("debug"));
}
#[test]
fn a_first_edit_creates_the_home_and_the_file_owner_only() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("cold");
let path = home.join("shep.toml");
ShepToml::edit(&path, |doc| doc.enable_dog("bark")).unwrap();
assert_eq!(
mode_of(&home),
0o700,
"$SHEP_HOME is readable by other local users until the first boot"
);
assert_eq!(
mode_of(&path),
0o600,
"the file a webhook token goes in, and the mode a `tar` of it keeps"
);
}
#[test]
fn editing_a_world_readable_config_leaves_it_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[daemon]\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
ShepToml::edit(&path, |doc| doc.enable_dog("bark")).unwrap();
assert_eq!(mode_of(&path), 0o600);
}
#[test]
fn parse_error_debug_never_prints_the_document() {
let path = PathBuf::from("/home/ada/.shep/shep.toml");
let secret = "https://hooks.example.com/services/T00/B00/super-secret-token";
let broken = format!("[dog.bark]\nwebhook = \"{secret}\"\n[daemon\n");
let source = broken.parse::<DocumentMut>().unwrap_err();
let err = ShepTomlError::Parse { path, source };
let debug = format!("{err:?}");
assert!(
!debug.contains(secret),
"the document must never reach Debug: {debug}"
);
assert!(!debug.contains("webhook"), "{debug}");
assert!(!debug.contains("hooks.example.com"), "{debug}");
assert_eq!(
debug,
"Parse { path: \"/home/ada/.shep/shep.toml\", message: \"invalid table header\\n\
expected `.`, `]`\" }"
);
let display = err.to_string();
assert!(display.contains("invalid table header"));
}
const CHILD_PATH_VAR: &str = "SHEP_CONFIG_RACE_PATH";
const CHILD_TAG_VAR: &str = "SHEP_CONFIG_RACE_TAG";
const EDITS_PER_WRITER: usize = 100;
const ADOPTING_TAG: &str = "alpha";
#[test]
#[ignore = "child process of two_writer_processes_do_not_lose_each_other_s_edits"]
fn config_race_child() {
let Ok(path) = std::env::var(CHILD_PATH_VAR) else {
panic!("{CHILD_PATH_VAR} unset — this test is only run as a child process");
};
let tag = std::env::var(CHILD_TAG_VAR).expect("child needs a tag");
let path = PathBuf::from(path);
for i in 0..EDITS_PER_WRITER {
let name = format!("{tag}-{i}");
ShepToml::edit(&path, |doc| {
if tag == ADOPTING_TAG {
doc.adopt_dog(&name, Path::new("/usr/local/bin/shep-otel"));
} else {
doc.enable_dog(&name);
}
})
.expect("child edit");
}
}
#[test]
fn two_writer_processes_do_not_lose_each_other_s_edits() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let exe = std::env::current_exe().expect("test binary path");
let children: Vec<_> = [ADOPTING_TAG, "beta"]
.iter()
.map(|tag| {
std::process::Command::new(&exe)
.args([
"--exact",
"--ignored",
"commands::shep_toml::tests::config_race_child",
])
.env(CHILD_PATH_VAR, &path)
.env(CHILD_TAG_VAR, tag)
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn writer")
})
.collect();
for child in children {
let out = child.wait_with_output().expect("wait for writer");
assert!(
out.status.success(),
"a writer process failed: {}\n{}",
out.status,
String::from_utf8_lossy(&out.stdout)
);
}
let written = std::fs::read_to_string(&path).unwrap();
let cfg = DaemonConfig::load(Some(&written), &|_| None).unwrap();
for i in 0..EDITS_PER_WRITER {
let adopted = format!("{ADOPTING_TAG}-{i}");
let enabled = format!("beta-{i}");
assert!(
cfg.daemon.adopted_dogs.contains_key(&adopted),
"{adopted}: an adopt was overwritten by the other writer"
);
assert!(
cfg.daemon.enabled_dogs.contains(&adopted),
"{adopted}: the adopt's own enable was overwritten"
);
assert!(
cfg.daemon.enabled_dogs.contains(&enabled),
"{enabled}: an enable was overwritten by the other writer"
);
}
assert_eq!(
cfg.daemon.enabled_dogs.len(),
2 * EDITS_PER_WRITER,
"the config enables dogs nobody asked for"
);
}
#[test]
fn taking_dog_sections_returns_them_keyed_by_name() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nenabled_dogs = [\"metrics\"]\n\n[dog.metrics]\nbind = \"127.0.0.1:9615\"\n\n[dog.bark.sinks]\noncall = { kind = \"discord\" }\n",
)
.expect("write");
let taken = ShepToml::edit(&path, ShepToml::take_dog_sections).expect("edit");
assert_eq!(taken.keys().collect::<Vec<_>>(), vec!["bark", "metrics"]);
assert_eq!(taken["metrics"]["bind"].as_str(), Some("127.0.0.1:9615"));
}
#[test]
fn taking_dog_sections_leaves_every_other_section_alone() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"# keep me\n[daemon]\nenabled_dogs = [\"metrics\"]\n\n[dog.metrics]\nbind = \"127.0.0.1:9615\"\n\n[style]\nlevel = \"full\"\n",
)
.expect("write");
ShepToml::edit(&path, ShepToml::take_dog_sections).expect("edit");
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"# keep me\n[daemon]\nenabled_dogs = [\"metrics\"]\n\n[style]\nlevel = \"full\"\n"
);
}
#[test]
fn taking_from_a_file_with_no_dog_sections_changes_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shep.toml");
let before = "[daemon]\nlog_level = \"info\"\n";
std::fs::write(&path, before).expect("write");
let taken = ShepToml::edit(&path, ShepToml::take_dog_sections).expect("edit");
assert!(taken.is_empty());
assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
}
#[test]
fn taking_dog_sections_keeps_nested_tables_and_arrays_of_tables() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[dog.bark.sinks]\noncall = { kind = \"discord\", url = \"https://discord.com/api/webhooks/x\" }\n\n[[dog.bark.rules]]\non = \"gave_up\"\nsinks = [\"oncall\"]\n",
)
.expect("write");
let taken = ShepToml::edit(&path, ShepToml::take_dog_sections).expect("edit");
let bark = &taken["bark"];
assert_eq!(
bark["sinks"]["oncall"]["url"].as_str(),
Some("https://discord.com/api/webhooks/x"),
"a nested sub-table's own values must survive the take"
);
let rules = bark["rules"]
.as_array_of_tables()
.expect("rules is an array of tables");
assert_eq!(rules.len(), 1);
let rule = rules.get(0).expect("one rule");
assert_eq!(rule["on"].as_str(), Some("gave_up"));
assert_eq!(
rule["sinks"]
.as_array()
.and_then(|sinks| sinks.get(0))
.and_then(toml_edit::Value::as_str),
Some("oncall"),
"the array-of-tables entry keeps its own array field"
);
}
#[test]
fn taking_dog_sections_keeps_an_inline_table_dog() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[dog]\nmetrics = { bind = \"127.0.0.1:9615\" }\n").expect("write");
let taken = ShepToml::edit(&path, ShepToml::take_dog_sections).expect("edit");
assert_eq!(
taken["metrics"]["bind"].as_str(),
Some("127.0.0.1:9615"),
"an inline-table dog under [dog] must not be dropped"
);
}
}