use std::fs; use std::io::ErrorKind; use std::path::Path; use codex_utils_path::resolve_symlink_write_paths; use codex_utils_path::write_atomically; use tokio::task; use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; use toml_edit::value; use crate::CONFIG_TOML_FILE; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginConfigEdit { SetEnabled { plugin_key: String, enabled: bool }, Clear { plugin_key: String }, } pub async fn set_user_plugin_enabled( codex_home: &Path, plugin_key: String, enabled: bool, ) -> std::io::Result<()> { apply_user_plugin_config_edits( codex_home, vec![PluginConfigEdit::SetEnabled { plugin_key, enabled, }], ) .await } pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io::Result<()> { apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await } pub async fn apply_user_plugin_config_edits( codex_home: &Path, edits: Vec, ) -> std::io::Result<()> { let codex_home = codex_home.to_path_buf(); task::spawn_blocking(move || apply_user_plugin_config_edits_blocking(&codex_home, edits)) .await .map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))? } fn apply_user_plugin_config_edits_blocking( codex_home: &Path, edits: Vec, ) -> std::io::Result<()> { if edits.is_empty() { return Ok(()); } let config_path = codex_home.join(CONFIG_TOML_FILE); let write_paths = resolve_symlink_write_paths(&config_path)?; let mut doc = read_or_create_document(write_paths.read_path.as_deref())?; let mut mutated = false; for edit in edits { mutated |= match edit { PluginConfigEdit::SetEnabled { plugin_key, enabled, } => set_plugin_enabled(&mut doc, &plugin_key, enabled), PluginConfigEdit::Clear { plugin_key } => clear_plugin(&mut doc, &plugin_key), }; } if !mutated { return Ok(()); } write_atomically(&write_paths.write_path, &doc.to_string()) } fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result { let Some(config_path) = config_path else { return Ok(DocumentMut::new()); }; match fs::read_to_string(config_path) { Ok(raw) => raw .parse::() .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)), Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()), Err(err) => Err(err), } } fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) -> bool { let Some(plugins) = ensure_plugins_table(doc) else { return false; }; let Some(plugin) = ensure_table_for_write(&mut plugins[plugin_key]) else { return false; }; let mut replacement = value(enabled); if let Some(existing) = plugin.get("enabled") { preserve_decor(existing, &mut replacement); } plugin["enabled"] = replacement; true } fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool { let root = doc.as_table_mut(); let Some(plugins_item) = root.get_mut("plugins") else { return false; }; let Some(plugins) = ensure_table_for_read(plugins_item) else { return false; }; plugins.remove(plugin_key).is_some() } fn ensure_plugins_table(doc: &mut DocumentMut) -> Option<&mut TomlTable> { let root = doc.as_table_mut(); if !root.contains_key("plugins") { root.insert("plugins", TomlItem::Table(new_implicit_table())); } ensure_table_for_write(root.get_mut("plugins")?) } fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { match item { TomlItem::Table(table) => Some(table), TomlItem::Value(value) => { let table = value .as_inline_table() .map_or_else(new_implicit_table, table_from_inline); *item = TomlItem::Table(table); item.as_table_mut() } TomlItem::None => { *item = TomlItem::Table(new_implicit_table()); item.as_table_mut() } _ => None, } } fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { match item { TomlItem::Table(_) => {} TomlItem::Value(value) => { let inline = value.as_inline_table()?.clone(); *item = TomlItem::Table(table_from_inline(&inline)); } _ => return None, } item.as_table_mut() } fn table_from_inline(inline: &toml_edit::InlineTable) -> TomlTable { let mut table = new_implicit_table(); for (key, value) in inline.iter() { let mut value = value.clone(); value.decor_mut().set_suffix(""); table.insert(key, TomlItem::Value(value)); } table } fn new_implicit_table() -> TomlTable { let mut table = TomlTable::new(); table.set_implicit(true); table } fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) { if let (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) = (existing, replacement) { replacement_value .decor_mut() .clone_from(existing_value.decor()); } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use tempfile::TempDir; #[tokio::test] async fn set_user_plugin_enabled_writes_plugin_entry() { let codex_home = TempDir::new().unwrap(); set_user_plugin_enabled( codex_home.path(), "demo@market".to_string(), /*enabled*/ true, ) .await .unwrap(); let config = read_config(codex_home.path()); let expected: toml::Value = toml::from_str( r#" [plugins."demo@market"] enabled = true "#, ) .unwrap(); assert_eq!(config, expected); } #[tokio::test] async fn set_user_plugin_enabled_preserves_existing_plugin_fields() { let codex_home = TempDir::new().unwrap(); fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [plugins."demo@market"] enabled = false source = "/tmp/plugin" "#, ) .unwrap(); set_user_plugin_enabled( codex_home.path(), "demo@market".to_string(), /*enabled*/ true, ) .await .unwrap(); let config = read_config(codex_home.path()); let expected: toml::Value = toml::from_str( r#" [plugins."demo@market"] enabled = true source = "/tmp/plugin" "#, ) .unwrap(); assert_eq!(config, expected); } #[tokio::test] async fn clear_user_plugin_removes_empty_plugins_table() { let codex_home = TempDir::new().unwrap(); fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#" [plugins."demo@market"] enabled = true "#, ) .unwrap(); clear_user_plugin(codex_home.path(), "demo@market".to_string()) .await .unwrap(); assert_eq!( fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap(), "" ); } #[tokio::test] async fn clear_user_plugin_missing_entry_does_not_create_config() { let codex_home = TempDir::new().unwrap(); clear_user_plugin(codex_home.path(), "demo@market".to_string()) .await .unwrap(); assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists()); } #[tokio::test] #[cfg(unix)] async fn set_user_plugin_enabled_follows_config_symlink() { use std::os::unix::fs::symlink; let codex_home = TempDir::new().unwrap(); let target_path = codex_home.path().join("target_config.toml"); symlink(&target_path, codex_home.path().join(CONFIG_TOML_FILE)).unwrap(); set_user_plugin_enabled( codex_home.path(), "demo@market".to_string(), /*enabled*/ true, ) .await .unwrap(); let config = toml::from_str::(&fs::read_to_string(target_path).unwrap()).unwrap(); let expected: toml::Value = toml::from_str( r#" [plugins."demo@market"] enabled = true "#, ) .unwrap(); assert_eq!(config, expected); } fn read_config(codex_home: &Path) -> toml::Value { toml::from_str(&fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap()).unwrap() } }