use crate::error::{RailError, RailResult};
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, Value};
#[derive(Debug)]
pub struct TomlEditor {
path: PathBuf,
doc: DocumentMut,
original_content: String,
}
impl TomlEditor {
pub fn open(path: &Path) -> RailResult<Self> {
let content = fs::read_to_string(path)
.map_err(|e| RailError::message(format!("Failed to read TOML file {}: {}", path.display(), e)))?;
let doc = content
.parse::<DocumentMut>()
.map_err(|e| RailError::message(format!("Failed to parse TOML file {}: {}", path.display(), e)))?;
Ok(Self {
path: path.to_path_buf(),
doc,
original_content: content,
})
}
pub fn doc(&self) -> &DocumentMut {
&self.doc
}
pub fn doc_mut(&mut self) -> &mut DocumentMut {
&mut self.doc
}
pub fn set(&mut self, path: &str, value: impl Into<Value>) -> RailResult<()> {
let parts: Vec<&str> = path.split('.').collect();
if parts.iter().any(|part| part.is_empty()) {
return Err(RailError::message("Empty path provided"));
}
let mut current = self.doc.as_item_mut();
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
if let Some(table) = current.as_table_mut() {
table.insert(part, Item::Value(value.into()));
} else if let Some(table) = current.as_inline_table_mut() {
table.insert(*part, value.into());
} else {
return Err(RailError::message(format!(
"cannot set value at path '{}': parent is not a table",
path
)));
}
return Ok(());
} else {
if let Some(table) = current.as_table_mut() {
current = table.entry(part).or_insert(Item::Table(Table::new()));
} else {
return Err(RailError::message(format!(
"cannot navigate path '{}': '{}' is not a table",
path, part
)));
}
}
}
Ok(())
}
pub fn get(&self, path: &str) -> Option<&Item> {
let parts: Vec<&str> = path.split('.').collect();
let mut current = self.doc.as_item();
for part in parts {
let table = current.as_table()?;
current = table.get(part)?;
}
Some(current)
}
pub fn contains_path(&self, path: &str) -> bool {
self.get(path).is_some()
}
pub fn remove(&mut self, path: &str) -> bool {
fn remove_from(table: &mut Table, parts: &[&str]) -> bool {
let Some((part, remaining)) = parts.split_first() else {
return false;
};
if remaining.is_empty() {
return table.remove(part).is_some();
}
let removed = table
.get_mut(part)
.and_then(Item::as_table_mut)
.is_some_and(|child| remove_from(child, remaining));
if removed && table.get(part).and_then(Item::as_table).is_some_and(Table::is_empty) {
table.remove(part);
}
removed
}
remove_from(self.doc.as_table_mut(), &path.split('.').collect::<Vec<_>>())
}
pub fn ensure_section(&mut self, section: &str) -> bool {
let mut created = false;
let mut current = self.doc.as_item_mut();
for part in section.split('.') {
let Some(table) = current.as_table_mut() else {
return created;
};
if !table.contains_key(part) {
table.insert(part, Item::Table(Table::new()));
created = true;
}
let Some(next) = table.get_mut(part) else {
return created;
};
current = next;
}
created
}
pub fn set_raw_with_comment(&mut self, path: &str, toml_value: &str, comment: Option<&str>) -> RailResult<()> {
let parse_str = format!("__key__ = {}", toml_value);
let parsed: DocumentMut = parse_str
.parse()
.map_err(|e| RailError::message(format!("Invalid TOML value '{}': {}", toml_value, e)))?;
let mut value = parsed
.get("__key__")
.and_then(|i| i.as_value())
.ok_or_else(|| RailError::message(format!("Failed to extract value from '{}'", toml_value)))?
.clone();
if let Some(c) = comment {
value.decor_mut().set_suffix(format!(" # {}", c));
}
self.set(path, value)
}
pub fn has_changes(&self) -> bool {
self.doc.to_string() != self.original_content
}
pub fn original_content(&self) -> &str {
&self.original_content
}
pub fn array_push(&mut self, path: &str, value: impl Into<Value>) -> RailResult<()> {
let item = self
.get_mut_item(path)
.ok_or_else(|| RailError::message(format!("Path not found: {}", path)))?;
if let Some(array) = item.as_array_mut() {
array.push(value.into());
Ok(())
} else {
Err(RailError::message(format!("Item at '{}' is not an array", path)))
}
}
fn get_mut_item(&mut self, path: &str) -> Option<&mut Item> {
let parts: Vec<&str> = path.split('.').collect();
let mut current = self.doc.as_item_mut();
for part in parts {
let table = current.as_table_mut()?;
current = table.get_mut(part)?;
}
Some(current)
}
pub fn validate(&self) -> RailResult<()> {
let content = self.doc.to_string();
content
.parse::<DocumentMut>()
.map(|_| ())
.map_err(|e| RailError::message(format!("Validation failed: {}", e)))
}
pub fn write(self) -> RailResult<()> {
self.validate()?;
crate::utils::write_file_atomic(&self.path, self.doc.to_string().as_bytes())
}
pub fn write_with_backup(self) -> RailResult<()> {
let backup_path = self.path.with_extension("toml.bak");
fs::write(&backup_path, &self.original_content)
.map_err(|e| RailError::message(format!("Failed to create backup: {}", e)))?;
self.write()
}
pub fn rollback(&self) -> RailResult<()> {
let backup_path = self.path.with_extension("toml.bak");
if backup_path.exists() {
fs::rename(&backup_path, &self.path)
.map_err(|e| RailError::message(format!("Failed to restore backup: {}", e)))?;
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct TomlBatchEditor {
editors: Vec<TomlEditor>,
}
impl TomlBatchEditor {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, editor: TomlEditor) -> &mut Self {
self.editors.push(editor);
self
}
pub fn validate_all(&self) -> RailResult<()> {
for editor in &self.editors {
editor.validate()?;
}
Ok(())
}
pub fn commit(self) -> RailResult<()> {
self.validate_all()?;
for editor in &self.editors {
let backup_path = editor.path.with_extension("toml.bak");
fs::write(&backup_path, &editor.original_content).map_err(|e| {
RailError::message(format!("Failed to create backup for {}: {}", editor.path.display(), e))
})?;
}
for editor in self.editors {
editor.write()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn set_rejects_empty_path_components_without_mutation() {
let file = NamedTempFile::new().unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
for path in ["", ".package", "package.", "package..name"] {
assert!(editor.set(path, "bad").is_err());
assert_eq!(editor.doc.to_string(), "");
}
}
#[test]
fn test_editor_set_value() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
write!(file, "[package]\nname = \"old\"\n").unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
editor.set("package.name", "new").unwrap();
assert!(editor.doc.to_string().contains("name = \"new\""));
}
#[test]
fn test_editor_set_preserves_unrelated_formatting() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
let original = r#"# package metadata
[package] # retained table comment
name = "old"
version = '0.1.0' # retained value comment
[package.metadata.'cargo-rail']
values = [
"one", # retained array comment
"two",
]
"#;
write!(file, "{original}").unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
assert!(!editor.has_changes());
editor.set("package.name", "new").unwrap();
assert_eq!(
editor.doc.to_string(),
original.replacen("name = \"old\"", "name = \"new\"", 1)
);
}
#[test]
fn test_editor_array_push() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
write!(file, "[package]\nauthors = [\"me\"]\n").unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
editor.array_push("package.authors", "you").unwrap();
let content = editor.doc.to_string();
assert!(content.contains("\"me\""));
assert!(content.contains("\"you\""));
}
#[test]
fn test_editor_hyphenated_section() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
write!(file, "[package]\nname = \"test\"\n").unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
editor.ensure_section("change-detection");
editor
.set_raw_with_comment(
"change-detection.infrastructure",
r#"[".github/**"]"#,
Some("test comment"),
)
.unwrap();
let content = editor.doc.to_string();
assert!(
content.contains("[change-detection]"),
"Should have [change-detection] section, got:\n{}",
content
);
assert!(
content.contains("infrastructure"),
"Should have infrastructure field, got:\n{}",
content
);
}
#[test]
fn test_editor_contains_path_hyphenated() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
write!(
file,
r#"[package]
name = "test"
[change-detection]
infrastructure = [".github/**"]
"#
)
.unwrap();
let editor = TomlEditor::open(file.path()).unwrap();
assert!(editor.contains_path("package.name"));
assert!(editor.contains_path("change-detection.infrastructure"));
assert!(!editor.contains_path("change-detection.custom"));
}
#[test]
fn test_remove_prunes_empty_parent_tables() {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
writeln!(file, "[crates.demo.sync]").unwrap();
let mut editor = TomlEditor::open(file.path()).unwrap();
assert!(editor.remove("crates.demo.sync"));
assert!(editor.doc().is_empty());
}
#[cfg(unix)]
#[test]
fn test_write_does_not_follow_a_fixed_name_temp_symlink() {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir().unwrap();
let config = directory.path().join("rail.toml");
let victim = directory.path().join("victim");
fs::write(&config, "value = \"old\"\n").unwrap();
fs::write(&victim, "untouched\n").unwrap();
symlink(&victim, config.with_extension("toml.tmp")).unwrap();
let mut editor = TomlEditor::open(&config).unwrap();
editor.set("value", "new").unwrap();
editor.write().unwrap();
assert_eq!(fs::read_to_string(&victim).unwrap(), "untouched\n");
assert!(fs::read_to_string(&config).unwrap().contains("new"));
}
}