Codex Handbook
config/src/plugin_edit.rs 307 lines
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()    }}