use core::fmt;
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write as _;
use std::path::Path;
use shep_core::config::{DogsConfig, DogsConfigError};
use shep_core::paths::ShepPaths;
use toml_edit::{DocumentMut, Item, TableLike};
use super::shep_toml::{ConfigLock, ShepToml, ShepTomlError, create_config_file};
pub(crate) fn migrate_dog_sections(paths: &ShepPaths) -> Result<Vec<String>, DogMigrationError> {
let existing_source = match std::fs::read_to_string(&paths.daemon_config) {
Ok(source) => source,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(DogMigrationError::Read(err)),
};
let declared = existing_source
.parse::<toml::Table>()
.ok()
.map(|table| declared_dog_names(&table));
if declared.as_ref().is_some_and(BTreeSet::is_empty) {
return Ok(Vec::new());
}
ShepToml::try_edit(&paths.daemon_config, |doc| {
let incoming: BTreeMap<String, Item> = doc
.take_dog_sections()
.into_iter()
.filter(|(_, section)| !section.as_table_like().is_some_and(TableLike::is_empty))
.collect();
let missing: Vec<String> = match &declared {
Some(names) => names
.iter()
.filter(|name| !incoming.contains_key(*name))
.cloned()
.collect(),
None => Vec::new(),
};
if !missing.is_empty() || incoming.is_empty() {
return Err(DogMigrationError::SectionsUnreadable { names: missing });
}
let _dogs_lock =
ConfigLock::acquire(&paths.dogs_config).map_err(DogMigrationError::Lock)?;
let mut merged = read_dogs_document(&paths.dogs_config)?;
let collides = |name: &String| {
merged
.get(name)
.is_some_and(|item| !item.as_table_like().is_some_and(TableLike::is_empty))
};
if let Some(name) = incoming.keys().find(|name| collides(name)) {
return Err(DogMigrationError::WouldOverwrite { name: name.clone() });
}
let mut moved: Vec<String> = incoming.keys().cloned().collect();
moved.sort();
let mut next = merged
.iter()
.filter_map(|(_, item)| last_position(item))
.max()
.map_or(0, |last| last + 1);
for (name, mut section) in incoming {
renumber_tables(&mut section, &mut next);
merged.insert(&name, section);
}
write_dogs_config(&paths.dogs_config, &merged.to_string())
.map_err(DogMigrationError::Write)?;
Ok(moved)
})
}
pub(crate) fn forget_dog_section(path: &Path, name: &str) -> Result<bool, DogMigrationError> {
let _lock = ConfigLock::acquire(path).map_err(DogMigrationError::Lock)?;
let mut doc = read_dogs_document(path)?;
if doc.remove(name).is_none() {
return Ok(false);
}
write_dogs_config(path, &doc.to_string()).map_err(DogMigrationError::Write)?;
Ok(true)
}
fn read_dogs_document(path: &Path) -> Result<DocumentMut, DogMigrationError> {
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(DocumentMut::new());
}
Err(err) => return Err(DogMigrationError::ReadDogs(err)),
};
DogsConfig::load(Some(&source)).map_err(DogMigrationError::Parse)?;
source.parse::<DocumentMut>().map_err(|_| {
DogMigrationError::ReadDogs(std::io::Error::other(
"dogs.toml parses as TOML values and not as an editable document",
))
})
}
fn declared_dog_names(table: &toml::Table) -> BTreeSet<String> {
match table.get("dog") {
Some(toml::Value::Table(dog)) => dog
.iter()
.filter(|(_, value)| !declares_nothing(value))
.map(|(name, _)| name.clone())
.collect(),
_ => BTreeSet::new(),
}
}
fn declares_nothing(value: &toml::Value) -> bool {
match value {
toml::Value::Table(table) => table.is_empty(),
toml::Value::Array(items) => items.iter().all(declares_nothing),
_ => false,
}
}
fn last_position(item: &Item) -> Option<usize> {
match item {
Item::Table(table) => table
.position()
.into_iter()
.chain(table.iter().filter_map(|(_, child)| last_position(child)))
.max(),
Item::ArrayOfTables(array) => array
.iter()
.flat_map(|table| {
table
.position()
.into_iter()
.chain(table.iter().filter_map(|(_, child)| last_position(child)))
})
.max(),
Item::Value(_) | Item::None => None,
}
}
fn renumber_tables(item: &mut Item, next: &mut usize) {
match item {
Item::Table(table) => {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
renumber_tables(child, next);
}
}
Item::ArrayOfTables(array) => {
for table in array.iter_mut() {
table.set_position(*next);
*next += 1;
for (_, child) in table.iter_mut() {
renumber_tables(child, next);
}
}
}
Item::Value(_) | Item::None => {}
}
}
fn write_dogs_config(path: &Path, rendered: &str) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = create_config_file(parent)?;
tmp.write_all(rendered.as_bytes())?;
shep_core::atomic_file::publish(tmp, path)
}
#[derive(Debug)]
#[non_exhaustive]
pub(crate) enum DogMigrationError {
Read(std::io::Error),
ReadDogs(std::io::Error),
Parse(DogsConfigError),
WouldOverwrite {
name: String,
},
SectionsUnreadable {
names: Vec<String>,
},
Write(std::io::Error),
Lock(std::io::Error),
Toml(ShepTomlError),
}
impl fmt::Display for DogMigrationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read(err) => write!(f, "shep.toml could not be read: {err}"),
Self::ReadDogs(err) => write!(f, "dogs.toml could not be read: {err}"),
Self::Parse(err) => write!(f, "dogs.toml could not be read: {err}"),
Self::WouldOverwrite { name } => write!(
f,
"dog '{name}' is configured in both shep.toml and dogs.toml; \
delete one of the two sections and start the daemon again"
),
Self::SectionsUnreadable { names } if names.is_empty() => write!(
f,
"shep.toml has entries under [dog] that are not dog sections; \
give each dog a table of its own, or delete them"
),
Self::SectionsUnreadable { names } => write!(
f,
"shep.toml has entries under [dog] that are not dog sections ({}); \
give each dog a table of its own, or delete them",
names.join(", ")
),
Self::Write(err) => write!(f, "dogs.toml could not be written: {err}"),
Self::Lock(err) => write!(f, "dogs.toml could not be locked: {err}"),
Self::Toml(err) => write!(f, "shep.toml could not be rewritten: {err}"),
}
}
}
impl core::error::Error for DogMigrationError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Read(err) | Self::ReadDogs(err) | Self::Write(err) | Self::Lock(err) => Some(err),
Self::Parse(err) => Some(err),
Self::Toml(err) => Some(err),
Self::WouldOverwrite { .. } | Self::SectionsUnreadable { .. } => None,
}
}
}
impl From<ShepTomlError> for DogMigrationError {
fn from(source: ShepTomlError) -> Self {
Self::Toml(source)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ROUNDS_OF_CONTENTION: u32 = 20;
fn home_with(shep_toml: &str) -> (tempfile::TempDir, ShepPaths) {
let dir = tempfile::tempdir().expect("tempdir");
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.home).expect("home");
std::fs::write(&paths.daemon_config, shep_toml).expect("write");
(dir, paths)
}
#[test]
fn sections_move_and_the_original_loses_them() {
let (_dir, paths) = home_with(
"# mine\n[daemon]\nenabled_dogs = [\"metrics\"]\n\n[dog.metrics]\nbind = \"127.0.0.1:9615\"\n",
);
let moved = migrate_dog_sections(&paths).expect("migrate");
assert_eq!(moved, vec!["metrics".to_string()]);
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
"# mine\n[daemon]\nenabled_dogs = [\"metrics\"]\n"
);
let written = std::fs::read_to_string(&paths.dogs_config).expect("read");
let parsed = DogsConfig::load(Some(&written)).expect("valid");
assert_eq!(
parsed.dog["metrics"]["bind"].as_str(),
Some("127.0.0.1:9615")
);
}
#[test]
fn a_file_with_no_dog_sections_is_not_rewritten() {
let before = "[daemon]\nlog_level = \"info\"\n";
let (_dir, paths) = home_with(before);
let moved = migrate_dog_sections(&paths).expect("migrate");
assert!(moved.is_empty());
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
before
);
assert!(!paths.dogs_config.exists(), "no sections means no file");
}
#[test]
fn a_second_boot_writes_nothing() {
let (_dir, paths) = home_with("[dog.metrics]\nbind = \"127.0.0.1:9615\"\n");
migrate_dog_sections(&paths).expect("first");
let after_first = std::fs::read_to_string(&paths.dogs_config).expect("read");
let moved = migrate_dog_sections(&paths).expect("second");
assert!(moved.is_empty());
assert_eq!(
std::fs::read_to_string(&paths.dogs_config).expect("read"),
after_first
);
}
#[test]
fn entries_under_dog_that_come_back_as_nothing_make_the_migration_refuse() {
let (_dir, paths) = home_with("[dog]\nmetrics = 5\n");
let err = migrate_dog_sections(&paths).expect_err("a section that will not travel");
assert!(matches!(err, DogMigrationError::SectionsUnreadable { .. }));
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
"[dog]\nmetrics = 5\n",
"a refused migration strikes nothing"
);
assert!(!paths.dogs_config.exists(), "nor writes anything");
}
#[test]
fn an_empty_dog_table_is_left_exactly_as_it_was() {
let before = "[daemon]\nlog_level = \"info\"\n\n[dog]\n";
let (_dir, paths) = home_with(before);
let moved = migrate_dog_sections(&paths).expect("migrate");
assert!(moved.is_empty());
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
before
);
assert!(!paths.dogs_config.exists(), "no sections means no file");
}
#[test]
fn an_entry_that_comes_back_beside_a_section_but_not_as_one_makes_the_migration_refuse() {
let (_dir, paths) =
home_with("[dog]\nstray = 5\n\n[dog.metrics]\nbind = \"127.0.0.1:9615\"\n");
let err = migrate_dog_sections(&paths).expect_err("stray would be dropped");
let DogMigrationError::SectionsUnreadable { names } = &err else {
panic!("expected a refusal naming what would be lost, got {err:?}");
};
assert_eq!(names, &vec!["stray".to_string()]);
assert!(err.to_string().contains("stray"), "{err}");
assert!(
std::fs::read_to_string(&paths.daemon_config)
.expect("read")
.contains("stray = 5"),
"a refused migration strikes nothing"
);
assert!(!paths.dogs_config.exists(), "nor writes anything");
}
#[cfg(unix)]
#[test]
fn the_written_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let (_dir, paths) = home_with("[dog.metrics]\nbind = \"127.0.0.1:9615\"\n");
migrate_dog_sections(&paths).expect("migrate");
let mode = std::fs::metadata(&paths.dogs_config)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "mode was {mode:o}");
}
#[test]
fn two_rehomes_at_once_both_land() {
use std::sync::{Arc, Barrier};
for round in 0..ROUNDS_OF_CONTENTION {
let (_dir, paths) = home_with("");
std::fs::write(
&paths.dogs_config,
"[otel]\nendpoint = \"127.0.0.1:4317\"\n\n[watchdog]\nevery = \"30s\"\n",
)
.expect("write");
let gate = Arc::new(Barrier::new(2));
let removals: Vec<_> = ["otel", "watchdog"]
.into_iter()
.map(|name| {
let gate = Arc::clone(&gate);
let path = paths.dogs_config.clone();
std::thread::spawn(move || {
gate.wait();
forget_dog_section(&path, name).expect("rehome")
})
})
.collect();
for removal in removals {
assert!(removal.join().expect("thread"), "each found its own dog");
}
let left = read_dogs_document(&paths.dogs_config).expect("read");
assert!(
left.is_empty(),
"round {round}: both removals must survive, {:?} came back",
left.iter().map(|(name, _)| name).collect::<Vec<_>>()
);
}
}
#[test]
fn a_boot_migration_and_a_rehome_at_once_both_land() {
use std::sync::{Arc, Barrier};
for round in 0..ROUNDS_OF_CONTENTION {
let (_dir, paths) = home_with("[dog.metrics]\nbind = \"127.0.0.1:9615\"\n");
std::fs::write(
&paths.dogs_config,
"[otel]\nendpoint = \"127.0.0.1:4317\"\n",
)
.expect("write");
let gate = Arc::new(Barrier::new(2));
let migrating = {
let gate = Arc::clone(&gate);
let paths = paths.clone();
std::thread::spawn(move || {
gate.wait();
migrate_dog_sections(&paths).expect("migrate")
})
};
let rehoming = {
let gate = Arc::clone(&gate);
let path = paths.dogs_config.clone();
std::thread::spawn(move || {
gate.wait();
forget_dog_section(&path, "otel").expect("rehome")
})
};
let moved = migrating.join().expect("thread");
let _removed = rehoming.join().expect("thread");
let left = read_dogs_document(&paths.dogs_config).expect("read");
assert_eq!(moved, vec!["metrics".to_string()], "round {round}");
assert_eq!(
left.iter().map(|(name, _)| name).collect::<Vec<_>>(),
vec!["metrics"],
"round {round}: the migrated section stays and the rehomed one goes"
);
}
}
#[test]
fn forgetting_a_dog_keeps_every_other_comment_and_shape() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("dogs.toml");
std::fs::write(
&path,
"# hand-written, do not clobber\n[otel]\nendpoint = \"127.0.0.1:4317\"\nheaders = { auth = \"x\" }\n\n[metrics]\nbind = \"127.0.0.1:9615\"\n",
)
.expect("write");
assert!(forget_dog_section(&path, "metrics").expect("forget"));
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"# hand-written, do not clobber\n[otel]\nendpoint = \"127.0.0.1:4317\"\nheaders = { auth = \"x\" }\n"
);
}
#[test]
fn migrating_into_a_hand_edited_file_keeps_both_files_comments() {
let (_dir, paths) = home_with(
"[daemon]\nlog_level = \"info\"\n\n# scrape target\n[dog.metrics]\nbind = \"127.0.0.1:9615\" # loopback only\n",
);
std::fs::write(
&paths.dogs_config,
"# hand-written, do not clobber\n[otel]\nendpoint = \"127.0.0.1:4317\"\nheaders = { auth = \"x\" }\n",
)
.expect("write");
let moved = migrate_dog_sections(&paths).expect("migrate");
assert_eq!(moved, vec!["metrics".to_string()]);
assert_eq!(
std::fs::read_to_string(&paths.dogs_config).expect("read"),
"# hand-written, do not clobber\n[otel]\nendpoint = \"127.0.0.1:4317\"\nheaders = { auth = \"x\" }\n\n# scrape target\n[metrics]\nbind = \"127.0.0.1:9615\" # loopback only\n"
);
}
#[test]
fn a_moved_dog_with_sub_tables_lands_in_one_piece_at_the_end() {
let (_dir, paths) = home_with(
"[dog.bark.sinks]\noncall = { url = \"u\" }\n\n[[dog.bark.rules]]\non = \"gave_up\"\n",
);
std::fs::write(
&paths.dogs_config,
"[a]\nk = 1\n\n[b]\nk = 2\n\n[c]\nk = 3\n",
)
.expect("write");
migrate_dog_sections(&paths).expect("migrate");
let written = std::fs::read_to_string(&paths.dogs_config).expect("read");
let headers: Vec<&str> = written
.lines()
.filter(|line| line.starts_with('['))
.collect();
assert_eq!(
headers,
vec!["[a]", "[b]", "[c]", "[bark.sinks]", "[[bark.rules]]"],
"the migration appends; it does not interleave: {written}"
);
}
#[test]
fn a_moved_dog_lands_after_a_destination_of_nested_and_array_tables() {
let (_dir, paths) = home_with(
"[daemon]\nlog_level = \"info\"\n\n[dog.metrics]\nbind = \"127.0.0.1:19615\"\n",
);
let before = "[bark.sinks]\noncall = { url = \"u\" }\n\n[[bark.rules]]\non = \"gave_up\"\n\n[[bark.rules]]\non = \"restarted\"\n";
std::fs::write(&paths.dogs_config, before).expect("write");
migrate_dog_sections(&paths).expect("migrate");
assert_eq!(
std::fs::read_to_string(&paths.dogs_config).expect("read"),
format!("{before}\n[metrics]\nbind = \"127.0.0.1:19615\"\n")
);
}
#[test]
fn an_empty_section_colliding_with_a_configured_one_is_not_a_refusal() {
let before = "[daemon]\nenabled_dogs = [\"metrics\"]\n\n[dog.metrics]\n";
let (_dir, paths) = home_with(before);
let dogs = "[metrics]\nbind = \"127.0.0.1:19616\"\n";
std::fs::write(&paths.dogs_config, dogs).expect("write");
let moved = migrate_dog_sections(&paths).expect("an empty table is not a second value");
assert!(moved.is_empty());
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
before
);
assert_eq!(
std::fs::read_to_string(&paths.dogs_config).expect("read"),
dogs
);
}
#[test]
fn an_empty_array_of_tables_under_dog_is_skipped_rather_than_refused() {
let before = "[[dog.metrics]]\n";
let (_dir, paths) = home_with(before);
let moved = migrate_dog_sections(&paths).expect("nothing declared, nothing to lose");
assert!(moved.is_empty());
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
before
);
}
#[test]
fn an_array_of_tables_with_values_still_makes_the_migration_refuse() {
let before = "[[dog.metrics]]\nbind = \"127.0.0.1:9615\"\n";
let (_dir, paths) = home_with(before);
let err = migrate_dog_sections(&paths).expect_err("values with nowhere to go");
assert!(matches!(
err,
DogMigrationError::SectionsUnreadable { ref names } if names == &["metrics".to_string()]
));
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
before
);
}
#[test]
fn an_empty_section_beside_a_real_one_does_not_block_it() {
let (_dir, paths) =
home_with("[dog.metrics]\n\n[dog.bark.sinks]\noncall = { url = \"u\" }\n");
let moved = migrate_dog_sections(&paths).expect("migrate");
assert_eq!(moved, vec!["bark".to_string()]);
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
""
);
let written = std::fs::read_to_string(&paths.dogs_config).expect("read");
assert!(
!written.contains("metrics"),
"an empty scaffold is not carried across: {written}"
);
}
#[test]
fn a_bare_header_in_dogs_toml_does_not_refuse_a_configured_section() {
let (_dir, paths) = home_with("[dog.metrics]\nbind = \"127.0.0.1:19615\"\n");
std::fs::write(&paths.dogs_config, "[metrics]\n").expect("write");
let moved = migrate_dog_sections(&paths).expect("an empty destination is not a value");
assert_eq!(moved, vec!["metrics".to_string()]);
let written = std::fs::read_to_string(&paths.dogs_config).expect("read");
let parsed = DogsConfig::load(Some(&written)).expect("valid");
assert_eq!(
parsed.dog["metrics"]["bind"].as_str(),
Some("127.0.0.1:19615"),
"the section that had values is the one left standing: {written}"
);
}
#[test]
fn a_header_spelled_with_whitespace_still_migrates() {
let (_dir, paths) = home_with(
"[daemon]\nenabled_dogs = [\"metrics\"]\n\n[ dog.metrics ]\nbind = \"127.0.0.1:19615\"\n",
);
let moved = migrate_dog_sections(&paths).expect("migrate");
assert_eq!(moved, vec!["metrics".to_string()]);
assert_eq!(
std::fs::read_to_string(&paths.daemon_config).expect("read"),
"[daemon]\nenabled_dogs = [\"metrics\"]\n",
"the section leaves shep.toml"
);
let written = std::fs::read_to_string(&paths.dogs_config).expect("read");
let parsed = DogsConfig::load(Some(&written)).expect("valid");
assert_eq!(
parsed.dog["metrics"]["bind"].as_str(),
Some("127.0.0.1:19615")
);
}
#[cfg(unix)]
#[test]
fn a_boot_with_nothing_to_move_never_reopens_the_file() {
use std::os::unix::fs::MetadataExt as _;
let (_dir, paths) = home_with("[daemon]\nlog_level = \"info\"\n\n[dog]\n");
let before = std::fs::metadata(&paths.daemon_config).expect("stat").ino();
assert!(migrate_dog_sections(&paths).expect("migrate").is_empty());
let after = std::fs::metadata(&paths.daemon_config).expect("stat").ino();
assert_eq!(before, after, "an idle boot must not rewrite shep.toml");
}
#[test]
fn an_existing_dogs_file_makes_the_migration_refuse() {
let (_dir, paths) = home_with("[dog.metrics]\nbind = \"127.0.0.1:9615\"\n");
std::fs::write(&paths.dogs_config, "[metrics]\nbind = \"0.0.0.0:9615\"\n").expect("write");
let err = migrate_dog_sections(&paths).expect_err("both files hold metrics");
assert!(matches!(
err,
DogMigrationError::WouldOverwrite { ref name } if name == "metrics"
));
assert!(
std::fs::read_to_string(&paths.daemon_config)
.expect("read")
.contains("[dog.metrics]"),
"a refused migration strikes nothing"
);
}
}