use std::io::Write as _;
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use toml_edit::{Array, DocumentMut, Item, Table, Value};
use crate::style::StyleLevel;
const CONFIG_FILE_MODE: u32 = 0o600;
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 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);
}
self.dog_table_mut(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);
}
#[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)
}
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(())
}
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))?;
tmp.as_file()
.sync_all()
.map_err(|source| self.io_error(source))?;
tmp.persist(&self.path)
.map_err(|err| self.io_error(err.error))?;
Ok(())
}
fn io_error(&self, source: std::io::Error) -> ShepTomlError {
ShepTomlError::Io {
path: self.path.clone(),
source,
}
}
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 dog_table_mut(&mut self, name: &str) -> &mut Table {
let dog = self
.doc
.entry("dog")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("dog is only ever written as a table");
dog.entry(name)
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("a dog's own section is only ever written as a table")
}
}
fn create_home_dir(dir: &Path) -> std::io::Result<()> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(shep_daemon::boot::DIR_MODE)
.create(dir)
}
fn create_config_file(parent: &Path) -> std::io::Result<tempfile::NamedTempFile> {
tempfile::Builder::new()
.prefix("shep")
.suffix(".toml.tmp")
.permissions(std::fs::Permissions::from_mode(CONFIG_FILE_MODE))
.tempfile_in(parent)
}
struct ConfigLock {
_flock: nix::fcntl::Flock<std::fs::File>,
}
impl ConfigLock {
fn acquire(path: &Path) -> std::io::Result<Self> {
use nix::fcntl::{Flock, FlockArg};
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(CONFIG_FILE_MODE)
.open(lock_path(path))?;
Flock::lock(file, FlockArg::LockExclusive)
.map(|flock| Self { _flock: flock })
.map_err(|(_file, errno)| std::io::Error::from(errno))
}
}
fn lock_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(".lock");
path.parent().unwrap_or_else(|| Path::new(".")).join(name)
}
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(test)]
mod tests {
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.contains_key("metrics"),
"a table to configure it through"
);
}
#[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");
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_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/rin/.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/rin/.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"
);
}
}