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<PluginConfigEdit>,) -> 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<PluginConfigEdit>,) -> 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<DocumentMut> { let Some(config_path) = config_path else { return Ok(DocumentMut::new()); }; match fs::read_to_string(config_path) { Ok(raw) => raw .parse::<DocumentMut>() .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 = falsesource = "/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 = truesource = "/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::<toml::Value>(&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() }}