codex_config/
plugin_edit.rs1use 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 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 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 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}