use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use crate::error::{Error, IoContext, Result};
pub(crate) fn json_edit(path: &Path, edit: impl FnOnce(&mut Value) -> Result<()>) -> Result<bool> {
json_write(path, false, edit)
}
pub(crate) fn json_remove(path: &Path, edit: impl FnOnce(&mut Value) -> Result<()>) -> Result<bool> {
json_write(path, true, edit)
}
fn json_write(path: &Path, drop_empty_root: bool, edit: impl FnOnce(&mut Value) -> Result<()>) -> Result<bool> {
let mut root = match fs::read(path) {
Ok(bytes) if bytes.iter().all(u8::is_ascii_whitespace) => Value::Object(Map::new()),
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
if !root.is_object() {
return Err(Error::Config { path: path.display().to_string(), detail: "config root is not a JSON object".into() });
}
let before = root.clone();
edit(&mut root)?;
if root == before {
return Ok(false);
}
if drop_empty_root && root.as_object().is_some_and(Map::is_empty) {
remove_file_idem(path)?;
return Ok(true);
}
let mut bytes = serde_json::to_vec_pretty(&root).map_err(|source| Error::Json { what: "config".into(), source })?;
bytes.push(b'\n');
atomic_write(path, &bytes)?;
Ok(true)
}
#[cfg(any(feature = "codex", feature = "kimi"))]
pub(crate) fn toml_edit(path: &Path, edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<()>) -> Result<bool> {
toml_write(path, |doc| edit(doc).map(|()| false))
}
#[cfg(any(feature = "codex", feature = "kimi"))]
pub(crate) fn toml_remove(path: &Path, edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>) -> Result<bool> {
toml_write(path, edit)
}
#[cfg(any(feature = "codex", feature = "kimi"))]
fn toml_write(path: &Path, edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>) -> Result<bool> {
let existing = match fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
let mut doc: toml_edit::DocumentMut = existing
.as_deref()
.unwrap_or("")
.parse()
.map_err(|e: toml_edit::TomlError| Error::Config { path: path.display().to_string(), detail: e.to_string() })?;
let before = doc.to_string();
let emptied_root = edit(&mut doc)?;
let rendered = doc.to_string();
if rendered == before {
return Ok(false);
}
if emptied_root && doc.as_table().is_empty() {
remove_file_idem(path)?;
return Ok(true);
}
atomic_write(path, rendered.as_bytes())?;
Ok(true)
}
#[cfg(any(feature = "codex", feature = "kimi"))]
pub(crate) fn toml_prune(
doc: &mut toml_edit::DocumentMut, key: &str, edit: impl FnOnce(&mut toml_edit::Item) -> Result<()>,
) -> Result<bool> {
let root = doc.as_table_mut();
let Some(item) = root.get_mut(key) else { return Ok(false) };
let was_empty = toml_item_is_empty(item);
edit(item)?;
if !was_empty && toml_item_is_empty(item) {
root.remove(key);
return Ok(true);
}
Ok(false)
}
#[cfg(any(feature = "codex", feature = "kimi"))]
fn toml_item_is_empty(item: &toml_edit::Item) -> bool {
use toml_edit::Item;
match item {
Item::Table(table) => table.is_empty(),
Item::ArrayOfTables(tables) => tables.is_empty(),
_ => false,
}
}
#[cfg(feature = "goose")]
pub(crate) fn yaml_edit(path: &Path, edit: impl FnOnce(&mut serde_norway::Value) -> Result<()>) -> Result<bool> {
yaml_write(path, false, edit)
}
#[cfg(feature = "goose")]
pub(crate) fn yaml_remove(path: &Path, edit: impl FnOnce(&mut serde_norway::Value) -> Result<()>) -> Result<bool> {
yaml_write(path, true, edit)
}
#[cfg(feature = "goose")]
fn yaml_write(path: &Path, drop_empty_root: bool, edit: impl FnOnce(&mut serde_norway::Value) -> Result<()>) -> Result<bool> {
use serde_norway::{Mapping, Value as Yaml};
let mut root = match fs::read(path) {
Ok(bytes) if bytes.iter().all(u8::is_ascii_whitespace) => Yaml::Mapping(Mapping::new()),
Ok(bytes) => {
serde_norway::from_slice(&bytes).map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Yaml::Mapping(Mapping::new()),
Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
};
if root.is_null() {
root = Yaml::Mapping(Mapping::new());
}
if !root.is_mapping() {
return Err(Error::Config { path: path.display().to_string(), detail: "config root is not a YAML mapping".into() });
}
let before = root.clone();
edit(&mut root)?;
if root == before {
return Ok(false);
}
if drop_empty_root && root.as_mapping().is_some_and(Mapping::is_empty) {
remove_file_idem(path)?;
return Ok(true);
}
let text = serde_norway::to_string(&root)
.map_err(|e| Error::Config { path: path.display().to_string(), detail: format!("rendering YAML: {e}") })?;
atomic_write(path, text.as_bytes())?;
Ok(true)
}
pub(crate) fn json_obj_at<'a>(root: &'a mut Value, path: &[&str]) -> &'a mut Map<String, Value> {
let mut cur = root;
for key in path {
cur = ensure_object(cur).entry((*key).to_string()).or_insert_with(|| Value::Object(Map::new()));
}
ensure_object(cur)
}
pub(crate) fn json_prune_at(root: &mut Value, path: &[&str], edit: impl FnOnce(&mut Value) -> Result<()>) -> Result<bool> {
let Some((key, rest)) = path.split_first() else {
edit(root)?;
return Ok(false);
};
let Some(map) = root.as_object_mut() else { return Ok(false) };
let Some(child) = map.get_mut(*key) else { return Ok(false) };
let was_empty = is_empty_container(child);
let pruned_below = json_prune_at(child, rest, edit)?;
let now_empty = is_empty_container(child);
if !was_empty && now_empty {
map.remove(*key);
return Ok(true);
}
Ok(pruned_below)
}
pub(crate) fn json_prune_obj(root: &mut Value, path: &[&str], edit: impl FnOnce(&mut Map<String, Value>) -> Result<()>) -> Result<bool> {
json_prune_at(root, path, |value| match value.as_object_mut() {
Some(map) => edit(map),
None => Ok(()),
})
}
fn is_empty_container(value: &Value) -> bool {
match value {
Value::Object(map) => map.is_empty(),
Value::Array(items) => items.is_empty(),
_ => false,
}
}
#[cfg(feature = "goose")]
pub(crate) fn yaml_prune_map(
root: &mut serde_norway::Value, key: &str, edit: impl FnOnce(&mut serde_norway::Mapping) -> Result<()>,
) -> Result<bool> {
let Some(map) = root.as_mapping_mut() else { return Ok(false) };
let Some(child) = map.get_mut(key).and_then(serde_norway::Value::as_mapping_mut) else { return Ok(false) };
let was_empty = child.is_empty();
edit(child)?;
if !was_empty && child.is_empty() {
map.remove(key);
return Ok(true);
}
Ok(false)
}
fn ensure_object(v: &mut Value) -> &mut Map<String, Value> {
if !matches!(v, Value::Object(_)) {
*v = Value::Object(Map::new());
}
let Value::Object(map) = v else { unreachable!("just set to an object") };
map
}
pub(crate) fn write_file_idem(path: &Path, bytes: &[u8]) -> Result<bool> {
if let Ok(existing) = fs::read(path)
&& existing == bytes
{
return Ok(false);
}
atomic_write(path, bytes)?;
Ok(true)
}
pub(crate) fn remove_file_idem(path: &Path) -> Result<bool> {
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(Error::Io { context: format!("removing {}", path.display()), source }),
}
}
pub(crate) fn yaml_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.starts_with(|c: char| c.is_ascii_whitespace())
|| s.ends_with(|c: char| c.is_ascii_whitespace())
|| s.contains(['"', '\\', '\n', '\r', '\t', ':', '#', '[', ']', '{', '}', ',', '&', '*', '!', '|', '>', '\'', '%', '@', '`'])
|| matches!(s.to_ascii_lowercase().as_str(), "true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~");
if !needs_quote {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('"');
out
}
pub(crate) fn yaml_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c => out.push(c),
}
}
out.push('"');
out
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).io_ctx(|| format!("creating {}", parent.display()))?;
}
let tmp = tmp_sibling(path);
fs::write(&tmp, bytes).io_ctx(|| format!("writing {}", tmp.display()))?;
if let Err(source) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(Error::Io { context: format!("renaming {} -> {}", tmp.display(), path.display()), source });
}
Ok(())
}
fn tmp_sibling(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
name.push(format!(".tmp.{:016x}.{}", fastrand::u64(..), std::process::id()));
path.with_file_name(name)
}
#[cfg(test)]
#[path = "../../tests/unit/confedit.rs"]
mod confedit_tests;