use std::path::PathBuf;
use serde_json::{Value, json};
use super::{json_edit, json_obj_at, json_prune_at, json_prune_obj, json_remove, remove_file_idem, write_file_idem};
fn scratch(name: &str) -> PathBuf {
let dir = crate::scratch::path("ez-confedit");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn json_edit_creates_preserves_and_detects_change() {
let path = scratch("cfg.json");
std::fs::write(&path, br#"{"userKey":123,"mcpServers":{"theirs":{"command":"x"}}}"#).unwrap();
let changed = json_edit(&path, |root| {
json_obj_at(root, &["mcpServers"]).insert("ours".into(), json!({"command":"host"}));
Ok(())
})
.unwrap();
assert!(changed, "adding a server must report changed");
let bytes = std::fs::read(&path).unwrap();
let back: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back["userKey"], json!(123), "unknown top-level key survived");
assert_eq!(back["mcpServers"]["theirs"]["command"], json!("x"), "user's server survived");
assert_eq!(back["mcpServers"]["ours"]["command"], json!("host"));
assert_ne!(bytes.first(), Some(&0xEF), "no UTF-8 BOM");
assert_eq!(bytes.last(), Some(&b'\n'), "trailing newline");
let before = std::fs::read(&path).unwrap();
let changed = json_edit(&path, |root| {
json_obj_at(root, &["mcpServers"]).insert("ours".into(), json!({"command":"host"}));
Ok(())
})
.unwrap();
assert!(!changed, "re-inserting the same value must report unchanged");
assert_eq!(std::fs::read(&path).unwrap(), before, "unchanged edit must not rewrite");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn json_edit_missing_file_semantics() {
let created = scratch("new.json");
let changed = json_edit(&created, |root| {
json_obj_at(root, &["mcp"]).insert("s".into(), json!({"command":"c"}));
Ok(())
})
.unwrap();
assert!(changed);
assert!(created.exists());
let untouched = scratch("noop.json");
let changed = json_edit(&untouched, |_root| Ok(())).unwrap();
assert!(!changed);
assert!(!untouched.exists(), "a no-op edit must not create an empty file");
std::fs::remove_dir_all(created.parent().unwrap()).ok();
std::fs::remove_dir_all(untouched.parent().unwrap()).ok();
}
#[test]
fn json_edit_treats_empty_file_as_object() {
let path = scratch("empty.json");
std::fs::write(&path, b" \n\t").unwrap();
let changed = json_edit(&path, |root| {
json_obj_at(root, &["mcpServers"]).insert("ours".into(), json!({"command":"host"}));
Ok(())
})
.unwrap();
assert!(changed);
let back: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(back["mcpServers"]["ours"]["command"], json!("host"));
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
fn prune_drop(root: &mut Value, path: &[&str], key: &str) -> bool {
json_prune_obj(root, path, |obj| {
obj.remove(key);
Ok(())
})
.unwrap()
}
#[test]
fn json_prune_drops_only_a_container_our_removal_emptied() {
let mut root = json!({"theme":"dark","mcpServers":{"ours":{"command":"host"}}});
assert!(prune_drop(&mut root, &["mcpServers"], "ours"));
assert_eq!(root, json!({"theme":"dark"}), "the container our own key emptied must go");
let mut root = json!({"mcpServers":{"ours":{"command":"host"},"theirs":{"command":"x"}}});
assert!(!prune_drop(&mut root, &["mcpServers"], "ours"));
assert_eq!(root, json!({"mcpServers":{"theirs":{"command":"x"}}}), "a container still holding theirs must stay");
let mut root = json!({"theme":"dark","ui":{}});
assert!(!prune_drop(&mut root, &["ui"], "statusLine"));
assert_eq!(root, json!({"theme":"dark","ui":{}}), "a container the user had empty must survive untouched");
let mut root = json!({"theme":"dark"});
assert!(!prune_drop(&mut root, &["ui"], "statusLine"));
assert_eq!(root, json!({"theme":"dark"}), "a missing container must not be created by a removal");
let mut root = json!({"a":{"b":{"ours":1}}});
assert!(prune_drop(&mut root, &["a", "b"], "ours"));
assert_eq!(root, json!({}), "an ancestor emptied by the level we dropped must go too");
let mut root = json!({"a":{"keep":1,"b":{"ours":1}}});
assert!(prune_drop(&mut root, &["a", "b"], "ours"));
assert_eq!(root, json!({"a":{"keep":1}}), "an ancestor still holding a user key must stay");
let mut root = json!({"instructions":["/ours.md"]});
let pruned = json_prune_at(&mut root, &["instructions"], |list| {
list.as_array_mut().unwrap().retain(|e| e != "/ours.md");
Ok(())
})
.unwrap();
assert!(pruned);
assert_eq!(root, json!({}), "an array key our entry emptied must go");
}
#[cfg(feature = "goose")]
#[test]
fn yaml_prune_drops_only_a_mapping_our_removal_emptied() {
use serde_norway::Value as Yaml;
use super::yaml_prune_map;
fn parse(text: &str) -> Yaml {
serde_norway::from_str(text).unwrap()
}
fn drop_key(root: &mut Yaml, container: &str, key: &str) -> bool {
yaml_prune_map(root, container, |map| {
map.remove(key);
Ok(())
})
.unwrap()
}
let mut root = parse("GOOSE_MODEL: gpt-x\nextensions:\n ours:\n cmd: host\n");
assert!(drop_key(&mut root, "extensions", "ours"));
assert_eq!(root, parse("GOOSE_MODEL: gpt-x\n"), "the mapping our own key emptied must go");
let mut root = parse("extensions:\n ours:\n cmd: host\n theirs:\n cmd: x\n");
assert!(!drop_key(&mut root, "extensions", "ours"));
assert_eq!(root, parse("extensions:\n theirs:\n cmd: x\n"), "a mapping still holding theirs must stay");
let mut root = parse("GOOSE_MODEL: gpt-x\nextensions: {}\n");
assert!(!drop_key(&mut root, "extensions", "ours"));
assert_eq!(root, parse("GOOSE_MODEL: gpt-x\nextensions: {}\n"), "a mapping the user had empty must survive untouched");
let mut root = parse("GOOSE_MODEL: gpt-x\n");
assert!(!drop_key(&mut root, "extensions", "ours"));
assert_eq!(root, parse("GOOSE_MODEL: gpt-x\n"), "a missing mapping must not be created by a removal");
let mut root = parse("extensions: mine\n");
assert!(!drop_key(&mut root, "extensions", "ours"));
assert_eq!(root, parse("extensions: mine\n"), "a non-mapping value must not be touched");
}
#[test]
fn json_remove_drops_a_file_left_holding_nothing() {
let ours = scratch("ours.json");
std::fs::write(&ours, br#"{"mcpServers":{"ours":{"command":"host"}}}"#).unwrap();
let changed = json_remove(&ours, |root| {
prune_drop(root, &["mcpServers"], "ours");
Ok(())
})
.unwrap();
assert!(changed);
assert!(!ours.exists(), "a root emptied by our own removal must take the file with it");
let shared = scratch("shared.json");
std::fs::write(&shared, br#"{"theme":"dark","mcpServers":{"ours":{"command":"host"}}}"#).unwrap();
let changed = json_remove(&shared, |root| {
prune_drop(root, &["mcpServers"], "ours");
Ok(())
})
.unwrap();
assert!(changed);
let back: Value = serde_json::from_slice(&std::fs::read(&shared).unwrap()).unwrap();
assert_eq!(back, json!({"theme":"dark"}), "a root still holding a user key must survive as a file");
let empty = scratch("empty.json");
std::fs::write(&empty, b"{}").unwrap();
assert!(!json_remove(&empty, |_root| Ok(())).unwrap());
assert_eq!(std::fs::read(&empty).unwrap(), b"{}", "a no-op teardown must not touch a user's empty config");
let missing = scratch("missing.json");
assert!(!json_remove(&missing, |_root| Ok(())).unwrap());
assert!(!missing.exists(), "a no-op teardown must not create a file");
let install_side = scratch("install.json");
std::fs::write(&install_side, br#"{"mcpServers":{"ours":{"command":"host"}}}"#).unwrap();
let changed = json_edit(&install_side, |root| {
prune_drop(root, &["mcpServers"], "ours");
Ok(())
})
.unwrap();
assert!(changed);
let back: Value = serde_json::from_slice(&std::fs::read(&install_side).unwrap()).unwrap();
assert_eq!(back, json!({}), "`json_edit` must never delete a file, however empty the edit leaves it");
for p in [&ours, &shared, &empty, &missing, &install_side] {
std::fs::remove_dir_all(p.parent().unwrap()).ok();
}
}
#[test]
fn write_and_remove_file_idem() {
let path = scratch("f.txt");
assert!(write_file_idem(&path, b"one").unwrap());
assert!(!write_file_idem(&path, b"one").unwrap(), "same bytes must not rewrite");
assert!(write_file_idem(&path, b"two").unwrap(), "changed bytes must rewrite");
assert!(remove_file_idem(&path).unwrap(), "present file removal reports true");
assert!(!remove_file_idem(&path).unwrap(), "absent file removal reports false");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[cfg(any(feature = "codex", feature = "kimi"))]
#[test]
fn toml_prune_drops_only_a_container_our_removal_emptied() {
use toml_edit::{DocumentMut, Item};
use super::toml_prune;
fn drop_server(doc: &mut DocumentMut, name: &str) -> bool {
toml_prune(doc, "mcp_servers", |item| {
if let Some(table) = item.as_table_mut() {
table.remove(name);
}
Ok(())
})
.unwrap()
}
let mut doc: DocumentMut = "model = \"gpt-5.4\"\n\n[mcp_servers.ours]\ncommand = \"host\"\n".parse().unwrap();
assert!(drop_server(&mut doc, "ours"));
assert!(doc.get("mcp_servers").is_none(), "the container our own key emptied must go:\n{doc}");
assert_eq!(doc.to_string(), "model = \"gpt-5.4\"\n", "the user's own key must survive untouched");
let mut doc: DocumentMut = "[mcp_servers.ours]\ncommand = \"host\"\n\n[mcp_servers.theirs]\ncommand = \"x\"\n".parse().unwrap();
assert!(!drop_server(&mut doc, "ours"));
assert_eq!(doc.to_string(), "\n[mcp_servers.theirs]\ncommand = \"x\"\n", "a table still holding theirs must stay");
let mut doc: DocumentMut = "# mine\n[mcp_servers]\n".parse().unwrap();
assert!(!drop_server(&mut doc, "ours"));
assert_eq!(doc.to_string(), "# mine\n[mcp_servers]\n", "a table we never wrote into must survive untouched");
let mut doc: DocumentMut = "model = \"gpt-5.4\"\n".parse().unwrap();
assert!(!drop_server(&mut doc, "ours"));
assert_eq!(doc.to_string(), "model = \"gpt-5.4\"\n", "a missing container must not be created by a removal");
let mut doc: DocumentMut = "mcp_servers = \"mine\"\n".parse().unwrap();
assert!(!drop_server(&mut doc, "ours"));
assert_eq!(doc.to_string(), "mcp_servers = \"mine\"\n", "a non-table value must not be touched");
let mut doc: DocumentMut = "[[hooks]]\nevent = \"Stop\"\ncommand = \"ours\"\n".parse().unwrap();
let pruned = toml_prune(&mut doc, "hooks", |item| {
if let Some(arr) = item.as_array_of_tables_mut() {
arr.retain(|t| t.get("command").and_then(Item::as_str) != Some("ours"));
}
Ok(())
})
.unwrap();
assert!(pruned);
assert!(doc.as_table().is_empty(), "an array-of-tables our entry emptied must go:\n{doc}");
}
#[cfg(any(feature = "codex", feature = "kimi"))]
#[test]
fn toml_remove_drops_a_file_left_holding_nothing() {
use toml_edit::{DocumentMut, Item};
use super::{toml_edit as toml_edit_fn, toml_prune, toml_remove};
fn drop_ours(doc: &mut DocumentMut) -> Result<bool, crate::error::Error> {
toml_prune(doc, "mcp_servers", |item| {
if let Some(table) = item.as_table_mut() {
table.remove("ours");
}
Ok(())
})
}
let ours = scratch("ours.toml");
std::fs::write(&ours, "# my codex config\n[mcp_servers.ours]\ncommand = \"host\"\n").unwrap();
assert!(toml_remove(&ours, drop_ours).unwrap());
assert!(!ours.exists(), "a root emptied by our own removal must take the file with it");
let mut doc: DocumentMut = "[mcp_servers.ours]\ncommand = \"host\"\n".parse().unwrap();
doc.as_table_mut().get_mut("mcp_servers").and_then(Item::as_table_mut).unwrap().remove("ours");
assert!(doc.to_string().is_empty(), "premise: an emptied implicit table renders to nothing");
assert!(!doc.as_table().is_empty(), "premise: it still keys the root, so an unpruned root reads as non-empty");
let shared = scratch("shared.toml");
let seed = "# the user's own codex config\nmodel = \"gpt-5.4\"\n\n[mcp_servers.ours]\ncommand = \"host\"\n";
std::fs::write(&shared, seed).unwrap();
assert!(toml_remove(&shared, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&shared).unwrap(),
"# the user's own codex config\nmodel = \"gpt-5.4\"\n",
"a root still holding a user key must survive as a file, comment intact"
);
let foreign = scratch("foreign.toml");
let seed = "# theirs\n[mcp_servers.theirs]\ncommand = \"x\"\n\n[mcp_servers.ours]\ncommand = \"host\"\n";
std::fs::write(&foreign, seed).unwrap();
assert!(toml_remove(&foreign, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&foreign).unwrap(),
"# theirs\n[mcp_servers.theirs]\ncommand = \"x\"\n",
"a table still holding a foreign server must survive as a file, comment intact"
);
let valueless = scratch("valueless.toml");
std::fs::write(&valueless, "[their_section]\n\n[mcp_servers.ours]\ncommand = \"host\"\n").unwrap();
assert!(toml_remove(&valueless, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&valueless).unwrap(),
"[their_section]\n",
"a file whose only survivor is an empty user section must not be taken"
);
let trailing = scratch("trailing.toml");
std::fs::write(&trailing, "[mcp_servers.ours]\ncommand = \"host\"\n\n# TODO revisit\n").unwrap();
assert!(toml_remove(&trailing, drop_ours).unwrap());
assert!(!trailing.exists(), "the delete must fire on an emptied root whose leftover decor still renders");
let comment_only = scratch("comment.toml");
std::fs::write(&comment_only, "# only a comment\n").unwrap();
assert!(!toml_remove(&comment_only, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&comment_only).unwrap(),
"# only a comment\n",
"a comment-only config must survive a no-op teardown"
);
for (label, seed) in [("crlf", "# my config\r\n".as_bytes()), ("bom", "\u{feff}# my config\n".as_bytes())] {
let path = scratch("windows.toml");
std::fs::write(&path, seed).unwrap();
assert!(!toml_remove(&path, drop_ours).unwrap(), "a {label} comment-only config must report no change");
assert_eq!(std::fs::read(&path).unwrap(), seed, "a {label} comment-only config must survive a teardown byte-for-byte");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
let residue = scratch("residue.toml");
std::fs::write(&residue, "").unwrap();
assert!(!toml_remove(&residue, drop_ours).unwrap());
assert!(residue.exists(), "a file the user is already keeping empty is not ours to take");
let missing = scratch("missing.toml");
assert!(!toml_remove(&missing, drop_ours).unwrap());
assert!(!missing.exists(), "a no-op teardown must not create a file");
let install_side = scratch("install.toml");
std::fs::write(&install_side, "[mcp_servers.ours]\ncommand = \"host\"\n").unwrap();
assert!(toml_edit_fn(&install_side, |doc| drop_ours(doc).map(|_| ())).unwrap());
assert_eq!(
std::fs::read_to_string(&install_side).unwrap(),
"",
"`toml_edit` must never delete a file, however empty the edit leaves it"
);
for p in [&ours, &shared, &foreign, &valueless, &trailing, &comment_only, &residue, &missing, &install_side] {
std::fs::remove_dir_all(p.parent().unwrap()).ok();
}
}
#[cfg(feature = "goose")]
#[test]
fn yaml_remove_drops_a_file_left_holding_nothing() {
use serde_norway::Value as Yaml;
use super::{yaml_edit, yaml_prune_map, yaml_remove};
fn drop_ours(root: &mut Yaml) -> Result<(), crate::error::Error> {
yaml_prune_map(root, "extensions", |exts| {
exts.remove("ours");
Ok(())
})
.map(|_| ())
}
let ours = scratch("ours.yaml");
std::fs::write(&ours, "# my goose config\nextensions:\n ours:\n cmd: host\n").unwrap();
assert!(yaml_remove(&ours, drop_ours).unwrap());
assert!(!ours.exists(), "a root emptied by our own removal must take the file with it");
let shared = scratch("shared.yaml");
std::fs::write(&shared, "GOOSE_MODEL: gpt-x\nextensions:\n ours:\n cmd: host\n").unwrap();
assert!(yaml_remove(&shared, drop_ours).unwrap());
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "GOOSE_MODEL: gpt-x\n", "a root still holding a user key must survive as a file");
let foreign = scratch("foreign.yaml");
std::fs::write(&foreign, "extensions:\n ours:\n cmd: host\n theirs:\n cmd: x\n").unwrap();
assert!(yaml_remove(&foreign, drop_ours).unwrap());
let back: Yaml = serde_norway::from_slice(&std::fs::read(&foreign).unwrap()).unwrap();
assert_eq!(back, serde_norway::from_str::<Yaml>("extensions:\n theirs:\n cmd: x\n").unwrap(), "the foreign extension must survive");
let comment_only = scratch("comment.yaml");
std::fs::write(&comment_only, "# only a comment\n").unwrap();
assert!(!yaml_remove(&comment_only, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&comment_only).unwrap(),
"# only a comment\n",
"a comment-only config must survive a no-op teardown"
);
let residue = scratch("residue.yaml");
std::fs::write(&residue, "{}\n").unwrap();
assert!(!yaml_remove(&residue, drop_ours).unwrap());
assert_eq!(std::fs::read_to_string(&residue).unwrap(), "{}\n", "a file the user is already keeping empty is not ours to take");
let user_empty = scratch("user-empty.yaml");
std::fs::write(&user_empty, "# mine\nextensions: {}\n").unwrap();
assert!(!yaml_remove(&user_empty, drop_ours).unwrap());
assert_eq!(
std::fs::read_to_string(&user_empty).unwrap(),
"# mine\nextensions: {}\n",
"a mapping the user had empty must survive untouched"
);
let missing = scratch("missing.yaml");
assert!(!yaml_remove(&missing, drop_ours).unwrap());
assert!(!missing.exists(), "a no-op teardown must not create a file");
let install_side = scratch("install.yaml");
std::fs::write(&install_side, "extensions:\n ours:\n cmd: host\n").unwrap();
assert!(yaml_edit(&install_side, drop_ours).unwrap());
assert!(install_side.exists(), "`yaml_edit` must never delete a file, however empty the edit leaves it");
for p in [&ours, &shared, &foreign, &comment_only, &residue, &user_empty, &missing, &install_side] {
std::fs::remove_dir_all(p.parent().unwrap()).ok();
}
}