use crate::error::{RailError, RailResult};
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, Value};
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.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 {
if let Some(table) = current.as_table() {
current = table.get(part)?;
} else {
return None;
}
}
Some(current)
}
pub fn contains_path(&self, path: &str) -> bool {
self.get(path).is_some()
}
pub fn ensure_section(&mut self, section: &str) -> bool {
if self.doc.contains_key(section) {
return false;
}
let table = self.doc.as_table_mut();
table.insert(section, Item::Table(Table::new()));
true
}
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 {
if let Some(table) = current.as_table_mut() {
current = table.get_mut(part)?;
} else {
return None;
}
}
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()?;
let content = self.doc.to_string();
let temp_path = self.path.with_extension("toml.tmp");
fs::write(&temp_path, content).map_err(|e| RailError::message(format!("Failed to write temp file: {}", e)))?;
fs::rename(&temp_path, &self.path).map_err(|e| RailError::message(format!("Failed to rename temp file: {}", e)))?;
Ok(())
}
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(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 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_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"));
}
}