1use std::path::{Path, PathBuf};
9
10use crate::compiler::mcp::{HeaderValue, McpTransport};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14use toml_edit::{Array, DocumentMut, Item, Table, Value, value};
15
16use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
17
18#[derive(Debug)]
19pub struct CodexAdapter;
20
21const CODEX_CONFIG_TOML: &str = "config.toml";
22const LEGACY_CODEX_MCP_JSON: &str = "codex_mcp.json";
23
24impl TargetAdapter for CodexAdapter {
25 fn name(&self) -> &str {
26 ".codex"
27 }
28
29 fn skill_variant_key(&self) -> Option<&str> {
30 Some("codex")
31 }
32
33 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
34 match kind {
35 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
36 _ => None,
37 }
38 }
39
40 fn write_config_entries(
41 &self,
42 entries: &[ConfigEntry],
43 target_dir: &Path,
44 ) -> Result<Vec<PathBuf>, MarsError> {
45 let mut written = Vec::new();
46
47 let mcp_servers: Vec<&McpServerEntry> = entries
48 .iter()
49 .filter_map(|e| {
50 if let ConfigEntry::McpServer(s) = e {
51 Some(s)
52 } else {
53 None
54 }
55 })
56 .collect();
57
58 let hooks: Vec<&HookEntry> = entries
59 .iter()
60 .filter_map(|e| {
61 if let ConfigEntry::Hook(h) = e {
62 Some(h)
63 } else {
64 None
65 }
66 })
67 .collect();
68
69 if !mcp_servers.is_empty() {
70 let path = write_codex_mcp_toml(target_dir, &mcp_servers)?;
71 written.push(path);
72 }
73
74 if !hooks.is_empty() {
75 let path = write_codex_hooks_json(target_dir, &hooks)?;
76 written.push(path);
77 }
78
79 Ok(written)
80 }
81
82 fn emit_pre_write_diagnostics(
83 &self,
84 entries: &[ConfigEntry],
85 target_dir: &Path,
86 diag: &mut crate::diagnostic::DiagnosticCollector,
87 ) {
88 let has_mcp_entries = entries
89 .iter()
90 .any(|entry| matches!(entry, ConfigEntry::McpServer(_)));
91 if !has_mcp_entries {
92 return;
93 }
94
95 let legacy_path = target_dir.join(LEGACY_CODEX_MCP_JSON);
96 if legacy_path.is_file() {
97 diag.info(
98 "legacy-config-cleanup",
99 format!(
100 "target `.codex`: removing legacy MCP config `{}` during sync",
101 legacy_path.display()
102 ),
103 );
104 }
105
106 let config_path = target_dir.join(CODEX_CONFIG_TOML);
107 if config_path.is_file()
108 && let Err(err) = parse_existing_toml_document(&config_path)
109 {
110 diag.warn(
111 "codex-config-parse-error",
112 format!(
113 "target `.codex`: cannot parse `{}`; skipping Codex MCP writes/removals until fixed: {err}",
114 config_path.display()
115 ),
116 );
117 }
118 }
119
120 fn remove_config_entries(
121 &self,
122 entry_keys: &[String],
123 target_dir: &Path,
124 ) -> Result<(), MarsError> {
125 remove_legacy_codex_mcp_json(target_dir)?;
126 remove_codex_mcp_entries(entry_keys, target_dir)?;
127 remove_codex_hook_entries(entry_keys, target_dir)?;
128 Ok(())
129 }
130}
131
132fn write_codex_mcp_toml(
144 target_dir: &Path,
145 servers: &[&McpServerEntry],
146) -> Result<PathBuf, MarsError> {
147 let path = target_dir.join(CODEX_CONFIG_TOML);
148 remove_legacy_codex_mcp_json(target_dir)?;
149
150 let mut doc = load_or_new_toml_document(&path)?;
151 let mcp_servers = ensure_mcp_servers_table(&mut doc, &path)?;
152
153 for server in servers {
154 let mut server_table = Table::new();
155 match server.transport {
156 McpTransport::Stdio => {
157 if let Some(command) = server.command.as_ref() {
158 server_table["command"] = value(command.as_str());
159 }
160 server_table["args"] = toml_string_array(server.args.clone());
161 }
162 McpTransport::Http => {
163 if let Some(url) = server.url.as_ref() {
164 server_table["url"] = value(url.as_str());
165 }
166
167 let mut bearer_token_env_var: Option<String> = None;
168 let mut http_headers = Table::new();
169 for (header, value_ref) in &server.headers {
170 match value_ref {
171 HeaderValue::Plain(plain_value) => {
172 http_headers[header.as_str()] = value(plain_value.as_str());
173 }
174 HeaderValue::EnvRef(env_ref) => {
175 if header.eq_ignore_ascii_case("Authorization") {
176 bearer_token_env_var = Some(env_ref.var_name().to_string());
177 } else {
178 http_headers[header.as_str()] = value(env_ref.var_name());
179 }
180 }
181 }
182 }
183
184 if let Some(token_var) = bearer_token_env_var {
185 server_table["bearer_token_env_var"] = value(token_var);
186 }
187 if !http_headers.is_empty() {
188 server_table["http_headers"] = Item::Table(http_headers);
189 }
190 }
191 }
192
193 if !server.env.is_empty() {
195 let env_vars: Vec<String> = server.env.values().cloned().collect();
196 server_table["env"] = toml_string_array(env_vars);
197 }
198
199 mcp_servers.insert(server.name.as_str(), Item::Table(server_table));
200 }
201
202 crate::fs::atomic_write(&path, doc.to_string().as_bytes())?;
203 Ok(path)
204}
205
206fn remove_codex_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
207 let path = target_dir.join(CODEX_CONFIG_TOML);
208 if !path.is_file() {
209 return Ok(());
210 }
211
212 let mut doc = load_or_new_toml_document(&path)?;
213 let mcp_servers = ensure_mcp_servers_table(&mut doc, &path)?;
214
215 for key in entry_keys {
216 if let Some(name) = key.strip_prefix("mcp:") {
217 mcp_servers.remove(name);
218 }
219 }
220
221 crate::fs::atomic_write(&path, doc.to_string().as_bytes())?;
222 Ok(())
223}
224
225fn load_or_new_toml_document(path: &Path) -> Result<DocumentMut, MarsError> {
226 if !path.is_file() {
227 return Ok(DocumentMut::new());
228 }
229
230 parse_existing_toml_document(path)
231}
232
233fn parse_existing_toml_document(path: &Path) -> Result<DocumentMut, MarsError> {
234 let raw = std::fs::read_to_string(path).map_err(MarsError::from)?;
235 raw.parse::<DocumentMut>().map_err(|e| {
236 MarsError::Config(crate::error::ConfigError::Invalid {
237 message: format!(
238 "{}: failed to parse TOML; refusing to overwrite existing config: {e}",
239 path.display()
240 ),
241 })
242 })
243}
244
245fn toml_string_array(values: impl IntoIterator<Item = String>) -> Item {
246 let mut array = Array::new();
247 for value in values {
248 array.push(value);
249 }
250 Item::Value(Value::Array(array))
251}
252
253fn ensure_mcp_servers_table<'a>(
254 doc: &'a mut DocumentMut,
255 path: &Path,
256) -> Result<&'a mut Table, MarsError> {
257 let root = doc.as_table_mut();
258
259 let mcp_item = root
260 .entry("mcp")
261 .or_insert_with(|| Item::Table(Table::new()));
262 let mcp_table = mcp_item.as_table_mut().ok_or_else(|| {
263 MarsError::Config(crate::error::ConfigError::Invalid {
264 message: format!("{}: mcp is not a table", path.display()),
265 })
266 })?;
267
268 let servers_item = mcp_table
269 .entry("servers")
270 .or_insert_with(|| Item::Table(Table::new()));
271 servers_item.as_table_mut().ok_or_else(|| {
272 MarsError::Config(crate::error::ConfigError::Invalid {
273 message: format!("{}: mcp.servers is not a table", path.display()),
274 })
275 })
276}
277
278fn remove_legacy_codex_mcp_json(target_dir: &Path) -> Result<(), MarsError> {
279 let legacy_path = target_dir.join(LEGACY_CODEX_MCP_JSON);
280 if !legacy_path.is_file() {
281 return Ok(());
282 }
283
284 std::fs::remove_file(&legacy_path).map_err(MarsError::from)
285}
286
287fn write_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
300 let path = target_dir.join("codex_hooks.json");
301
302 let mut root: serde_json::Value = if path.is_file() {
303 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
304 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
305 } else {
306 serde_json::json!({})
307 };
308
309 let hooks_section = root
310 .as_object_mut()
311 .ok_or_else(|| {
312 MarsError::Config(crate::error::ConfigError::Invalid {
313 message: format!("{} is not a JSON object", path.display()),
314 })
315 })?
316 .entry("hooks")
317 .or_insert_with(|| serde_json::json!({}));
318
319 let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
320 MarsError::Config(crate::error::ConfigError::Invalid {
321 message: format!("{}: hooks is not an object", path.display()),
322 })
323 })?;
324
325 for hook in hooks {
326 let command = hook_command(&hook.script_path);
327 let native_event = hook.native_event.clone();
328 let event_hooks = hooks_map
329 .entry(native_event.clone())
330 .or_insert_with(|| serde_json::json!([]))
331 .as_array_mut()
332 .ok_or_else(|| {
333 MarsError::Config(crate::error::ConfigError::Invalid {
334 message: format!("{}: hooks.{native_event} is not an array", path.display()),
335 })
336 })?;
337 remove_managed_hook_commands(event_hooks, &hook.name);
338 event_hooks.push(serde_json::Value::String(command));
339 }
340
341 let content = serde_json::to_string_pretty(&root).map_err(|e| {
342 MarsError::Config(crate::error::ConfigError::Invalid {
343 message: format!("failed to serialize {}: {e}", path.display()),
344 })
345 })?;
346 crate::fs::atomic_write(&path, content.as_bytes())?;
347
348 Ok(path)
349}
350
351fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
352 commands.retain(|cmd| {
353 cmd.as_str()
354 .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
355 .unwrap_or(true)
356 });
357}
358
359fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
360 let normalized = command.replace('\\', "/").replace("//", "/");
361 normalized.contains(&format!("/hooks/{hook_name}/"))
362}
363
364fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
365 let path = target_dir.join("codex_hooks.json");
366 if !path.is_file() {
367 return Ok(());
368 }
369
370 let hook_keys: Vec<(String, &str)> = entry_keys
371 .iter()
372 .filter_map(|k| {
373 let rest = k.strip_prefix("hook:")?;
374 let (event, name) = rest.split_once(':')?;
375 Some((codex_hook_event(event)?.to_string(), name))
376 })
377 .collect();
378
379 if hook_keys.is_empty() {
380 return Ok(());
381 }
382
383 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
384 let mut root: serde_json::Value =
385 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
386
387 if let Some(hooks_map) = root
388 .as_object_mut()
389 .and_then(|o| o.get_mut("hooks"))
390 .and_then(|v| v.as_object_mut())
391 {
392 for (event, name) in &hook_keys {
393 if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
394 arr.retain(|cmd| {
395 let cmd_str = cmd.as_str().unwrap_or("");
396 !is_managed_hook_command_for(cmd_str, name)
398 });
399 }
400 }
401 }
402
403 let content = serde_json::to_string_pretty(&root).map_err(|e| {
404 MarsError::Config(crate::error::ConfigError::Invalid {
405 message: format!("failed to serialize {}: {e}", path.display()),
406 })
407 })?;
408 crate::fs::atomic_write(&path, content.as_bytes())?;
409 Ok(())
410}
411
412fn codex_hook_event(event: &str) -> Option<&'static str> {
413 match event {
414 "session.start" => Some("start"),
415 "session.end" => Some("stop"),
416 "tool.pre" => Some("pre-exec"),
417 "tool.post" => Some("post-exec"),
418 _ => None,
419 }
420}
421
422#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::diagnostic::DiagnosticCollector;
430 use indexmap::IndexMap;
431 use tempfile::TempDir;
432 use toml::Value as TomlValue;
433
434 fn make_stdio_mcp_entry(name: &str) -> ConfigEntry {
435 let mut env = IndexMap::new();
436 env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
437 ConfigEntry::McpServer(McpServerEntry {
438 name: name.to_string(),
439 transport: McpTransport::Stdio,
440 command: Some("npx".to_string()),
441 args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
442 env,
443 url: None,
444 headers: IndexMap::new(),
445 })
446 }
447
448 fn make_http_mcp_entry(name: &str) -> ConfigEntry {
449 let mut headers = IndexMap::new();
450 headers.insert(
451 "Authorization".to_string(),
452 HeaderValue::EnvRef(crate::compiler::mcp::EnvRef::Env {
453 var: "API_TOKEN".to_string(),
454 }),
455 );
456 headers.insert(
457 "X-Custom".to_string(),
458 HeaderValue::Plain("static-value".to_string()),
459 );
460
461 ConfigEntry::McpServer(McpServerEntry {
462 name: name.to_string(),
463 transport: McpTransport::Http,
464 command: None,
465 args: vec![],
466 env: IndexMap::new(),
467 url: Some("https://api.example.com/mcp".to_string()),
468 headers,
469 })
470 }
471
472 fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
473 ConfigEntry::Hook(HookEntry {
474 name: name.to_string(),
475 event: "tool.pre".to_string(),
476 native_event: native.to_string(),
477 script_path: script_path.to_string(),
478 order: 0,
479 })
480 }
481
482 #[test]
483 fn write_mcp_uses_config_toml_schema_and_preserves_non_mcp_content() {
484 let tmp = TempDir::new().unwrap();
485 std::fs::write(
486 tmp.path().join(CODEX_CONFIG_TOML),
487 r#"
488[ui]
489theme = "dark"
490"#,
491 )
492 .unwrap();
493 std::fs::write(tmp.path().join(LEGACY_CODEX_MCP_JSON), "{}").unwrap();
494
495 let adapter = CodexAdapter;
496 adapter
497 .write_config_entries(
498 &[
499 make_stdio_mcp_entry("stdio-server"),
500 make_http_mcp_entry("http-server"),
501 ],
502 tmp.path(),
503 )
504 .unwrap();
505
506 assert!(!tmp.path().join(LEGACY_CODEX_MCP_JSON).exists());
507
508 let raw = std::fs::read_to_string(tmp.path().join(CODEX_CONFIG_TOML)).unwrap();
509 let toml: TomlValue = toml::from_str(&raw).unwrap();
510 assert_eq!(toml["ui"]["theme"].as_str(), Some("dark"));
511
512 let stdio = &toml["mcp"]["servers"]["stdio-server"];
513 assert_eq!(stdio["command"].as_str(), Some("npx"));
514 assert_eq!(stdio["args"][0].as_str(), Some("-y"));
515 let env_arr = stdio["env"].as_array().unwrap();
516 assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
517
518 let http = &toml["mcp"]["servers"]["http-server"];
519 assert_eq!(http["url"].as_str(), Some("https://api.example.com/mcp"));
520 assert_eq!(http["bearer_token_env_var"].as_str(), Some("API_TOKEN"));
521 assert_eq!(
522 http["http_headers"]["X-Custom"].as_str(),
523 Some("static-value")
524 );
525 assert!(http.get("command").is_none());
526 }
527
528 #[test]
529 fn emit_pre_write_diagnostics_flags_legacy_cleanup_and_toml_parse_errors() {
530 let adapter = CodexAdapter;
531
532 let legacy_tmp = TempDir::new().unwrap();
533 std::fs::write(legacy_tmp.path().join(LEGACY_CODEX_MCP_JSON), "{}").unwrap();
534 let mut legacy_diag = DiagnosticCollector::new();
535 adapter.emit_pre_write_diagnostics(
536 &[make_stdio_mcp_entry("context7")],
537 legacy_tmp.path(),
538 &mut legacy_diag,
539 );
540 let legacy_messages = legacy_diag.drain();
541 assert_eq!(legacy_messages.len(), 1);
542 assert_eq!(legacy_messages[0].code, "legacy-config-cleanup");
543
544 let invalid_tmp = TempDir::new().unwrap();
545 std::fs::write(
546 invalid_tmp.path().join(CODEX_CONFIG_TOML),
547 "[ui
548",
549 )
550 .unwrap();
551 let mut invalid_diag = DiagnosticCollector::new();
552 adapter.emit_pre_write_diagnostics(
553 &[make_stdio_mcp_entry("context7")],
554 invalid_tmp.path(),
555 &mut invalid_diag,
556 );
557 let invalid_messages = invalid_diag.drain();
558 assert_eq!(invalid_messages.len(), 1);
559 assert_eq!(invalid_messages[0].code, "codex-config-parse-error");
560 }
561
562 #[test]
563 fn invalid_toml_is_not_clobbered_during_write_or_remove() {
564 let original = r#"[ui]
565theme = "dark"
566invalid =
567"#;
568
569 let write_tmp = TempDir::new().unwrap();
570 std::fs::write(write_tmp.path().join(CODEX_CONFIG_TOML), original).unwrap();
571 let adapter = CodexAdapter;
572 let write_err = adapter
573 .write_config_entries(&[make_stdio_mcp_entry("context7")], write_tmp.path())
574 .expect_err("invalid TOML should fail and not be overwritten");
575 assert!(write_err.to_string().contains("failed to parse TOML"));
576 assert_eq!(
577 std::fs::read_to_string(write_tmp.path().join(CODEX_CONFIG_TOML)).unwrap(),
578 original
579 );
580
581 let remove_tmp = TempDir::new().unwrap();
582 std::fs::write(remove_tmp.path().join(CODEX_CONFIG_TOML), original).unwrap();
583 let remove_err = adapter
584 .remove_config_entries(&["mcp:context7".to_string()], remove_tmp.path())
585 .expect_err("invalid TOML should fail and not be overwritten");
586 assert!(remove_err.to_string().contains("failed to parse TOML"));
587 assert_eq!(
588 std::fs::read_to_string(remove_tmp.path().join(CODEX_CONFIG_TOML)).unwrap(),
589 original
590 );
591 }
592
593 #[test]
594 fn write_hooks_replaces_existing_managed_hook_for_same_name_and_event() {
595 let tmp = TempDir::new().unwrap();
596 let adapter = CodexAdapter;
597 adapter
598 .write_config_entries(
599 &[make_hook_entry_with_path(
600 "audit",
601 "pre-exec",
602 "/old/hooks/audit/run.sh",
603 )],
604 tmp.path(),
605 )
606 .unwrap();
607 adapter
608 .write_config_entries(
609 &[make_hook_entry_with_path(
610 "audit",
611 "pre-exec",
612 "/new/hooks/audit/run.sh",
613 )],
614 tmp.path(),
615 )
616 .unwrap();
617
618 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
619 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
620 let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
621 assert_eq!(hooks.len(), 1);
622 assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
623 }
624
625 #[test]
626 fn remove_entries_clean_up_mcp_and_only_matching_hook_event() {
627 let tmp = TempDir::new().unwrap();
628 let adapter = CodexAdapter;
629
630 adapter
631 .write_config_entries(
632 &[
633 make_stdio_mcp_entry("to-remove"),
634 make_stdio_mcp_entry("to-keep"),
635 make_hook_entry_with_path("audit", "pre-exec", "/pkg/hooks/audit/run.sh"),
636 make_hook_entry_with_path("audit", "post-exec", "/pkg/hooks/audit/run.sh"),
637 ],
638 tmp.path(),
639 )
640 .unwrap();
641
642 adapter
643 .remove_config_entries(
644 &[
645 "mcp:to-remove".to_string(),
646 "hook:tool.pre:audit".to_string(),
647 ],
648 tmp.path(),
649 )
650 .unwrap();
651
652 let raw_toml = std::fs::read_to_string(tmp.path().join(CODEX_CONFIG_TOML)).unwrap();
653 let toml: TomlValue = toml::from_str(&raw_toml).unwrap();
654 assert!(toml["mcp"]["servers"].get("to-remove").is_none());
655 assert!(toml["mcp"]["servers"]["to-keep"].is_table());
656
657 let raw_hooks = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
658 let json: serde_json::Value = serde_json::from_str(&raw_hooks).unwrap();
659 assert!(json["hooks"]["pre-exec"].as_array().unwrap().is_empty());
660 assert_eq!(json["hooks"]["post-exec"].as_array().unwrap().len(), 1);
661 }
662
663 #[test]
664 fn remove_hook_entries_match_windows_backslash_paths_without_partial_name_collision() {
665 let tmp = TempDir::new().unwrap();
666 let existing = serde_json::json!({
667 "hooks": {
668 "pre-exec": [
669 r#"bash "C:\\pkg\\hooks\\audit\\run.sh""#,
670 r#"bash "C:\\pkg\\hooks\\audit-extended\\run.sh""#
671 ]
672 }
673 });
674 std::fs::write(
675 tmp.path().join("codex_hooks.json"),
676 serde_json::to_string_pretty(&existing).unwrap(),
677 )
678 .unwrap();
679
680 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
681
682 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
683 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
684 let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
685 assert_eq!(hooks.len(), 1);
686 assert!(hooks[0].as_str().unwrap().contains("audit-extended"));
687 }
688}