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 table = existing_source.parse::<toml::Table>().ok();
let declared = table.as_ref().map(declared_dog_names);
let has_dog_table = table
.as_ref()
.is_some_and(|table| matches!(table.get("dog"), Some(toml::Value::Table(_))));
if declared.as_ref().is_some_and(BTreeSet::is_empty) && !has_dog_table {
return Ok(Vec::new());
}
move_sections(paths, declared.as_ref())
}
fn move_sections(
paths: &ShepPaths,
declared: Option<&BTreeSet<String>>,
) -> Result<Vec<String>, DogMigrationError> {
ShepToml::try_edit(&paths.daemon_config, |doc| {
let incoming: BTreeMap<String, Item> = doc
.take_dog_sections()
.into_iter()
.filter(|(_, section)| item_has_content(section))
.collect();
let missing: Vec<String> = match declared {
Some(names) => names
.iter()
.filter(|name| !incoming.contains_key(*name))
.cloned()
.collect(),
None => Vec::new(),
};
if incoming.is_empty() && declared.is_some_and(BTreeSet::is_empty) {
return Ok(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_has_content);
if let Some(name) = incoming.keys().find(|name| collides(name)) {
return Err(DogMigrationError::WouldOverwrite {
name: name.clone(),
holds_env: section_holds_env(&incoming[name]),
});
}
let moved: Vec<String> = incoming.keys().cloned().collect();
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 dog_section_exists(path: &Path, name: &str) -> Result<bool, DogMigrationError> {
Ok(read_dogs_document(path)?.contains_key(name))
}
pub(crate) fn seed_dog_defaults(
paths: &ShepPaths,
name: &str,
defaults: &str,
) -> Result<DefaultsOutcome, DogMigrationError> {
read_dogs_document(&paths.dogs_config)?;
let Ok(parsed) = defaults.parse::<DocumentMut>() else {
return Ok(DefaultsOutcome::Unreadable);
};
if parsed.is_empty() {
return Ok(DefaultsOutcome::Empty);
}
let _lock = ConfigLock::acquire(&paths.dogs_config).map_err(DogMigrationError::Lock)?;
let mut merged = read_dogs_document(&paths.dogs_config)?;
if merged.contains_key(name) {
return Ok(DefaultsOutcome::Kept);
}
let mut next = merged
.iter()
.filter_map(|(_, item)| last_position(item))
.max()
.map_or(0, |last| last + 1);
let mut section = parsed.as_table().clone();
section.set_implicit(false);
let mut item = Item::Table(section);
renumber_tables(&mut item, &mut next);
merged.insert(name, item);
write_dogs_config(&paths.dogs_config, &merged.to_string()).map_err(DogMigrationError::Write)?;
Ok(DefaultsOutcome::Written)
}
#[derive(Debug)]
pub(crate) enum DefaultsOutcome {
Written,
Kept,
Empty,
Unreadable,
}
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 item_has_content(item: &Item) -> bool {
!item.as_table_like().is_some_and(TableLike::is_empty)
}
fn section_holds_env(item: &Item) -> bool {
item.as_table_like()
.is_some_and(|table| table.contains_key("env"))
}
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,
holds_env: bool,
},
SectionsUnreadable {
names: Vec<String>,
},
Write(std::io::Error),
Lock(std::io::Error),
Toml(ShepTomlError),
}
fn adopted_dogs_entry(name: &str) -> String {
format!(r#"{name} = {{ path = "...", env = {{ KEY = "{{{{secret:name}}}}" }} }}"#)
}
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,
holds_env: false,
} => 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::WouldOverwrite {
name,
holds_env: true,
} => write!(
f,
"dog '{name}' is configured in both shep.toml and dogs.toml; a dog's env goes \
beside its path in [daemon.adopted_dogs], as {entry}; move env there, \
choose the remaining settings to keep in [{name}] in dogs.toml, then delete \
[dog.{name}] from shep.toml and start the daemon again",
entry = adopted_dogs_entry(name),
),
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)]
#[path = "dog_migration_tests.rs"]
mod tests;