use serde::{Serialize, de::DeserializeOwned};
use std::path::Path;
use toml_edit::{DocumentMut, Item, Table, TableLike};
use super::atomic_write;
#[derive(Debug)]
pub(crate) enum TomlFileError {
Read(std::io::Error),
Write(std::io::Error),
Deserialize(toml::de::Error),
Serialize(toml::ser::Error),
Parse(toml_edit::TomlError),
}
pub(crate) fn write_toml<T>(path: &Path, value: &T) -> Result<(), TomlFileError>
where
T: Serialize + DeserializeOwned,
{
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let contents = toml::to_string(value).map_err(TomlFileError::Serialize)?;
return atomic_write(path, contents.as_bytes()).map_err(TomlFileError::Write);
}
Err(err) => return Err(TomlFileError::Read(err)),
};
let old: T = toml::from_str(&contents).map_err(TomlFileError::Deserialize)?;
let old = toml::Value::try_from(old).map_err(TomlFileError::Serialize)?;
let new = toml::Value::try_from(value).map_err(TomlFileError::Serialize)?;
if old == new {
return Ok(());
}
let mut document = contents
.parse::<DocumentMut>()
.map_err(TomlFileError::Parse)?;
apply_diff(document.as_table_mut(), &old, &new);
atomic_write(path, document.to_string().as_bytes()).map_err(TomlFileError::Write)
}
fn apply_diff(document: &mut dyn TableLike, old: &toml::Value, new: &toml::Value) {
let (toml::Value::Table(old), toml::Value::Table(new)) = (old, new) else {
unreachable!("configuration roots must serialize as TOML tables");
};
apply_table_diff(document, old, new);
}
fn apply_table_diff(
document: &mut dyn TableLike,
old: &toml::map::Map<String, toml::Value>,
new: &toml::map::Map<String, toml::Value>,
) {
for (key, old_value) in old {
let Some(new_value) = new.get(key) else {
document.remove(key);
continue;
};
if old_value == new_value {
continue;
}
match (old_value, new_value) {
(toml::Value::Table(old_table), toml::Value::Table(new_table)) => {
if document.get(key).is_none_or(|item| !item.is_table_like()) {
document.insert(key, Item::Table(Table::new()));
}
let table = document
.get_mut(key)
.and_then(Item::as_table_like_mut)
.expect("a table was just inserted");
apply_table_diff(table, old_table, new_table);
}
_ => replace_value(document, key, new_value),
}
}
for (key, new_value) in new {
if !old.contains_key(key) {
document.insert(key, value_to_item(new_value));
}
}
}
fn replace_value(document: &mut dyn TableLike, key: &str, new: &toml::Value) {
let decor = document
.get(key)
.and_then(Item::as_value)
.map(|value| value.decor().clone());
let mut replacement = value_to_item(new);
if let (Some(decor), Some(value)) = (decor, replacement.as_value_mut()) {
*value.decor_mut() = decor;
}
if let Some(item) = document.get_mut(key) {
*item = replacement;
} else {
document.insert(key, replacement);
}
}
fn value_to_item(value: &toml::Value) -> Item {
let value = Serialize::serialize(value, toml_edit::ser::ValueSerializer::new())
.expect("toml::Value always serializes as a toml_edit::Value");
let item = Item::Value(value);
match item.into_table() {
Ok(table) => Item::Table(table),
Err(item) => item,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
struct Config {
name: Option<String>,
mode: String,
values: Vec<String>,
nested: Option<Nested>,
}
impl Default for Config {
fn default() -> Self {
Self {
name: None,
mode: "standard".to_string(),
values: Vec::new(),
nested: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Nested {
value: String,
stale: Option<String>,
}
fn fixture(contents: &str) -> (tempfile::TempDir, std::path::PathBuf, Config) {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, contents).unwrap();
let config = toml::from_str(contents).unwrap();
(tmp, path, config)
}
#[test]
fn scalar_change_preserves_comments_and_formatting() {
let before = "# header\nname = \"Old\" # inline\n\n# trailing\n";
let (_tmp, path, mut config) = fixture(before);
config.name = Some("New".to_string());
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"# header\nname = \"New\" # inline\n\n# trailing\n"
);
}
#[test]
fn unrelated_change_does_not_insert_absent_defaults() {
let before = "name = \"Old\"\n";
let (_tmp, path, mut config) = fixture(before);
config.name = Some("New".to_string());
write_toml(&path, &config).unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), "name = \"New\"\n");
}
#[test]
fn changed_default_is_inserted() {
let before = "name = \"Old\"\n";
let (_tmp, path, mut config) = fixture(before);
config.mode = "custom".to_string();
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"name = \"Old\"\nmode = \"custom\"\n"
);
}
#[test]
fn clearing_optional_field_preserves_sibling_comment() {
let before = "name = \"Old\"\n# mode comment\nmode = \"custom\"\n";
let (_tmp, path, mut config) = fixture(before);
config.name = None;
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"# mode comment\nmode = \"custom\"\n"
);
}
#[test]
fn unknown_root_keys_and_nested_fields_are_untouched() {
let before =
"unknown = 'root'\n\n[nested]\nvalue = \"old\"\nunknown = { spelling = 'kept' }\n";
let (_tmp, path, mut config) = fixture(before);
config.nested.as_mut().unwrap().value = "new".to_string();
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"unknown = 'root'\n\n[nested]\nvalue = \"new\"\nunknown = { spelling = 'kept' }\n"
);
}
#[test]
fn unrelated_root_change_leaves_nested_table_byte_identical() {
let before =
"name = \"Old\"\n\n[nested] # table comment\nvalue = 'kept'\nstale = \"also kept\"\n";
let (_tmp, path, mut config) = fixture(before);
config.name = Some("New".to_string());
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"name = \"New\"\n\n[nested] # table comment\nvalue = 'kept'\nstale = \"also kept\"\n"
);
}
#[test]
fn replacing_nested_config_removes_stale_known_fields() {
let before = "[nested]\nvalue = \"old\"\nstale = \"remove me\"\n";
let (_tmp, path, mut config) = fixture(before);
config.nested = Some(Nested {
value: "new".to_string(),
stale: None,
});
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"[nested]\nvalue = \"new\"\n"
);
}
#[test]
fn clearing_nested_config_removes_its_table() {
let before = "name = \"kept\"\n\n[nested]\nvalue = \"old\"\n";
let (_tmp, path, mut config) = fixture(before);
config.nested = None;
write_toml(&path, &config).unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), "name = \"kept\"\n");
}
#[test]
fn changed_array_keeps_outer_decoration() {
let before = "values = [ \"one\", # inner\n \"two\" ] # outer\n";
let (_tmp, path, mut config) = fixture(before);
config.values = vec!["new".to_string()];
write_toml(&path, &config).unwrap();
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"values = [\"new\"] # outer\n"
);
}
#[test]
fn no_op_preserves_missing_final_newline() {
let before = "name = 'same'";
let (_tmp, path, config) = fixture(before);
write_toml(&path, &config).unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), before);
}
#[test]
fn missing_file_and_parent_directories_are_created() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/dir/config.toml");
let config = Config {
name: Some("new".to_string()),
..Config::default()
};
write_toml(&path, &config).unwrap();
assert_eq!(
toml::from_str::<Config>(&std::fs::read_to_string(path).unwrap()).unwrap(),
config
);
}
#[test]
fn malformed_file_errors_without_changing_contents() {
let before = "name = [";
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, before).unwrap();
let error = write_toml(&path, &Config::default()).unwrap_err();
assert!(matches!(error, TomlFileError::Deserialize(_)));
assert_eq!(std::fs::read_to_string(path).unwrap(), before);
}
#[test]
fn schema_invalid_file_errors_without_changing_contents() {
let before = "mode = 12\n";
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
std::fs::write(&path, before).unwrap();
let error = write_toml(&path, &Config::default()).unwrap_err();
assert!(matches!(error, TomlFileError::Deserialize(_)));
assert_eq!(std::fs::read_to_string(path).unwrap(), before);
}
}