Skip to main content

codex_config/
plugin_edit.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::Path;
4
5use codex_utils_path::resolve_symlink_write_paths;
6use codex_utils_path::write_atomically;
7use tokio::task;
8use toml_edit::DocumentMut;
9use toml_edit::Item as TomlItem;
10use toml_edit::Table as TomlTable;
11use toml_edit::value;
12
13use crate::CONFIG_TOML_FILE;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum PluginConfigEdit {
17    SetEnabled { plugin_key: String, enabled: bool },
18    Clear { plugin_key: String },
19}
20
21pub async fn set_user_plugin_enabled(
22    codex_home: &Path,
23    plugin_key: String,
24    enabled: bool,
25) -> std::io::Result<()> {
26    apply_user_plugin_config_edits(
27        codex_home,
28        vec![PluginConfigEdit::SetEnabled {
29            plugin_key,
30            enabled,
31        }],
32    )
33    .await
34}
35
36pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io::Result<()> {
37    apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await
38}
39
40pub async fn apply_user_plugin_config_edits(
41    codex_home: &Path,
42    edits: Vec<PluginConfigEdit>,
43) -> std::io::Result<()> {
44    let codex_home = codex_home.to_path_buf();
45    task::spawn_blocking(move || apply_user_plugin_config_edits_blocking(&codex_home, edits))
46        .await
47        .map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))?
48}
49
50fn apply_user_plugin_config_edits_blocking(
51    codex_home: &Path,
52    edits: Vec<PluginConfigEdit>,
53) -> std::io::Result<()> {
54    if edits.is_empty() {
55        return Ok(());
56    }
57
58    let config_path = codex_home.join(CONFIG_TOML_FILE);
59    let write_paths = resolve_symlink_write_paths(&config_path)?;
60    let mut doc = read_or_create_document(write_paths.read_path.as_deref())?;
61    let mut mutated = false;
62    for edit in edits {
63        mutated |= match edit {
64            PluginConfigEdit::SetEnabled {
65                plugin_key,
66                enabled,
67            } => set_plugin_enabled(&mut doc, &plugin_key, enabled),
68            PluginConfigEdit::Clear { plugin_key } => clear_plugin(&mut doc, &plugin_key),
69        };
70    }
71    if !mutated {
72        return Ok(());
73    }
74    write_atomically(&write_paths.write_path, &doc.to_string())
75}
76
77fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result<DocumentMut> {
78    let Some(config_path) = config_path else {
79        return Ok(DocumentMut::new());
80    };
81    match fs::read_to_string(config_path) {
82        Ok(raw) => raw
83            .parse::<DocumentMut>()
84            .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)),
85        Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()),
86        Err(err) => Err(err),
87    }
88}
89
90fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) -> bool {
91    let Some(plugins) = ensure_plugins_table(doc) else {
92        return false;
93    };
94    let Some(plugin) = ensure_table_for_write(&mut plugins[plugin_key]) else {
95        return false;
96    };
97    let mut replacement = value(enabled);
98    if let Some(existing) = plugin.get("enabled") {
99        preserve_decor(existing, &mut replacement);
100    }
101    plugin["enabled"] = replacement;
102    true
103}
104
105fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool {
106    let root = doc.as_table_mut();
107    let Some(plugins_item) = root.get_mut("plugins") else {
108        return false;
109    };
110    let Some(plugins) = ensure_table_for_read(plugins_item) else {
111        return false;
112    };
113    plugins.remove(plugin_key).is_some()
114}
115
116fn ensure_plugins_table(doc: &mut DocumentMut) -> Option<&mut TomlTable> {
117    let root = doc.as_table_mut();
118    if !root.contains_key("plugins") {
119        root.insert("plugins", TomlItem::Table(new_implicit_table()));
120    }
121    ensure_table_for_write(root.get_mut("plugins")?)
122}
123
124fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
125    match item {
126        TomlItem::Table(table) => Some(table),
127        TomlItem::Value(value) => {
128            let table = value
129                .as_inline_table()
130                .map_or_else(new_implicit_table, table_from_inline);
131            *item = TomlItem::Table(table);
132            item.as_table_mut()
133        }
134        TomlItem::None => {
135            *item = TomlItem::Table(new_implicit_table());
136            item.as_table_mut()
137        }
138        _ => None,
139    }
140}
141
142fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
143    match item {
144        TomlItem::Table(_) => {}
145        TomlItem::Value(value) => {
146            let inline = value.as_inline_table()?.clone();
147            *item = TomlItem::Table(table_from_inline(&inline));
148        }
149        _ => return None,
150    }
151    item.as_table_mut()
152}
153
154fn table_from_inline(inline: &toml_edit::InlineTable) -> TomlTable {
155    let mut table = new_implicit_table();
156    for (key, value) in inline.iter() {
157        let mut value = value.clone();
158        value.decor_mut().set_suffix("");
159        table.insert(key, TomlItem::Value(value));
160    }
161    table
162}
163
164fn new_implicit_table() -> TomlTable {
165    let mut table = TomlTable::new();
166    table.set_implicit(true);
167    table
168}
169
170fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {
171    if let (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) =
172        (existing, replacement)
173    {
174        replacement_value
175            .decor_mut()
176            .clone_from(existing_value.decor());
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use pretty_assertions::assert_eq;
184    use tempfile::TempDir;
185
186    #[tokio::test]
187    async fn set_user_plugin_enabled_writes_plugin_entry() {
188        let codex_home = TempDir::new().unwrap();
189
190        set_user_plugin_enabled(
191            codex_home.path(),
192            "demo@market".to_string(),
193            /*enabled*/ true,
194        )
195        .await
196        .unwrap();
197
198        let config = read_config(codex_home.path());
199        let expected: toml::Value = toml::from_str(
200            r#"
201[plugins."demo@market"]
202enabled = true
203        "#,
204        )
205        .unwrap();
206        assert_eq!(config, expected);
207    }
208
209    #[tokio::test]
210    async fn set_user_plugin_enabled_preserves_existing_plugin_fields() {
211        let codex_home = TempDir::new().unwrap();
212        fs::write(
213            codex_home.path().join(CONFIG_TOML_FILE),
214            r#"
215[plugins."demo@market"]
216enabled = false
217source = "/tmp/plugin"
218"#,
219        )
220        .unwrap();
221
222        set_user_plugin_enabled(
223            codex_home.path(),
224            "demo@market".to_string(),
225            /*enabled*/ true,
226        )
227        .await
228        .unwrap();
229
230        let config = read_config(codex_home.path());
231        let expected: toml::Value = toml::from_str(
232            r#"
233[plugins."demo@market"]
234enabled = true
235source = "/tmp/plugin"
236        "#,
237        )
238        .unwrap();
239        assert_eq!(config, expected);
240    }
241
242    #[tokio::test]
243    async fn clear_user_plugin_removes_empty_plugins_table() {
244        let codex_home = TempDir::new().unwrap();
245        fs::write(
246            codex_home.path().join(CONFIG_TOML_FILE),
247            r#"
248[plugins."demo@market"]
249enabled = true
250"#,
251        )
252        .unwrap();
253
254        clear_user_plugin(codex_home.path(), "demo@market".to_string())
255            .await
256            .unwrap();
257
258        assert_eq!(
259            fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap(),
260            ""
261        );
262    }
263
264    #[tokio::test]
265    async fn clear_user_plugin_missing_entry_does_not_create_config() {
266        let codex_home = TempDir::new().unwrap();
267
268        clear_user_plugin(codex_home.path(), "demo@market".to_string())
269            .await
270            .unwrap();
271
272        assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists());
273    }
274
275    #[tokio::test]
276    #[cfg(unix)]
277    async fn set_user_plugin_enabled_follows_config_symlink() {
278        use std::os::unix::fs::symlink;
279
280        let codex_home = TempDir::new().unwrap();
281        let target_path = codex_home.path().join("target_config.toml");
282        symlink(&target_path, codex_home.path().join(CONFIG_TOML_FILE)).unwrap();
283
284        set_user_plugin_enabled(
285            codex_home.path(),
286            "demo@market".to_string(),
287            /*enabled*/ true,
288        )
289        .await
290        .unwrap();
291
292        let config =
293            toml::from_str::<toml::Value>(&fs::read_to_string(target_path).unwrap()).unwrap();
294        let expected: toml::Value = toml::from_str(
295            r#"
296[plugins."demo@market"]
297enabled = true
298        "#,
299        )
300        .unwrap();
301        assert_eq!(config, expected);
302    }
303
304    fn read_config(codex_home: &Path) -> toml::Value {
305        toml::from_str(&fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap()).unwrap()
306    }
307}