Skip to main content

lean_ctx/hooks/agents/
codex.rs

1use super::super::{
2    ensure_codex_hooks_enabled as shared_ensure_codex_hooks_enabled, ensure_state_dir,
3    install_codex_instruction_docs, mcp_server_quiet_mode, resolve_hook_command_binary,
4    upsert_lean_ctx_codex_hook_entries, write_file,
5};
6
7pub fn install_codex_hook() {
8    let Some(codex_dir) = crate::core::home::resolve_codex_dir() else {
9        tracing::error!("Cannot resolve codex directory");
10        return;
11    };
12    if !ensure_state_dir(&codex_dir) {
13        return;
14    }
15
16    let hook_config_changed = install_codex_hook_config(&codex_dir);
17    let installed_docs = install_codex_instruction_docs(&codex_dir);
18
19    if !mcp_server_quiet_mode() {
20        if hook_config_changed {
21            eprintln!(
22                "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
23                codex_dir.display()
24            );
25        }
26        if installed_docs {
27            eprintln!("Installed Codex instructions at {}", codex_dir.display());
28        } else {
29            eprintln!("Codex AGENTS.md already configured.");
30        }
31    }
32}
33
34fn install_codex_hook_config(codex_dir: &std::path::Path) -> bool {
35    let binary = resolve_hook_command_binary();
36    let session_start_cmd = format!("{binary} hook codex-session-start");
37    let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
38    let hooks_json_path = codex_dir.join("hooks.json");
39
40    let mut changed = false;
41    let mut root = if hooks_json_path.exists() {
42        if let Some(parsed) = std::fs::read_to_string(&hooks_json_path)
43            .ok()
44            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
45        {
46            parsed
47        } else {
48            changed = true;
49            serde_json::json!({ "hooks": {} })
50        }
51    } else {
52        changed = true;
53        serde_json::json!({ "hooks": {} })
54    };
55
56    if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
57        changed = true;
58    }
59
60    // Observe hooks for context awareness
61    let observe_cmd = format!("{binary} hook observe");
62    if ensure_codex_observe_hooks(&mut root, &observe_cmd) {
63        changed = true;
64    }
65
66    if changed {
67        write_file(
68            &hooks_json_path,
69            &serde_json::to_string_pretty(&root).unwrap_or_default(),
70        );
71    }
72
73    let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
74    if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
75        changed = true;
76    }
77
78    let config_toml_path = codex_dir.join("config.toml");
79    let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
80
81    // Hybrid mode: ensure MCP server entry exists in config.toml so Codex
82    // Desktop/Cloud can reach lean-ctx even without CLI hooks.
83    let mcp_updated = ensure_codex_mcp_server(
84        &config_content,
85        &binary,
86        &super::super::mcp_server_env_pairs(),
87    );
88    let hooks_updated =
89        ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
90
91    let final_content = hooks_updated
92        .or(mcp_updated)
93        .unwrap_or_else(|| config_content.clone());
94    if final_content != config_content {
95        write_file(&config_toml_path, &final_content);
96        changed = true;
97        if !mcp_server_quiet_mode() {
98            eprintln!(
99                "Updated Codex config (MCP server + hooks) in {}",
100                config_toml_path.display()
101            );
102        }
103    }
104
105    changed
106}
107
108fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
109    let original = root.clone();
110    let Some(hooks_obj) = root
111        .as_object_mut()
112        .and_then(|r| r.get_mut("hooks"))
113        .and_then(|h| h.as_object_mut())
114    else {
115        return false;
116    };
117
118    let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
119    for event in observe_events {
120        let arr = hooks_obj
121            .entry(event.to_string())
122            .or_insert_with(|| serde_json::json!([]));
123        let Some(entries) = arr.as_array_mut() else {
124            continue;
125        };
126        let already = entries.iter().any(|e| {
127            e.get("hooks")
128                .and_then(|h| h.as_array())
129                .is_some_and(|hooks| {
130                    hooks.iter().any(|hook| {
131                        hook.get("command")
132                            .and_then(|c| c.as_str())
133                            .is_some_and(|c| c.contains("hook observe"))
134                    })
135                })
136        });
137        if !already {
138            entries.push(serde_json::json!({
139                "matcher": ".*",
140                "hooks": [{ "type": "command", "command": observe_cmd, "timeout": 5 }]
141            }));
142        }
143    }
144
145    *root != original
146}
147
148/// Idempotent upsert of the `[mcp_servers.lean-ctx]` entry in Codex `config.toml`.
149///
150/// Uses a format-preserving TOML editor so existing user content/comments and an
151/// orphaned `[mcp_servers.lean-ctx.env]` (issue #189) are normalized into a
152/// single valid section instead of producing a duplicate table header.
153///
154/// `env_pairs` is injected (not read from global state) so the pure
155/// config-rewriting logic is hermetically testable; the caller passes
156/// [`crate::hooks::mcp_server_env_pairs`]. Existing `command`/`args` are left
157/// untouched to respect user customization; env keys are upserted so a stale
158/// install gains `LEAN_CTX_PROJECT_ROOT`/`LEAN_CTX_EXTRA_ROOTS` (#403). Returns
159/// `None` when nothing changed, or when the file is not valid TOML (never
160/// clobbers an unparseable user config).
161fn ensure_codex_mcp_server(
162    config_content: &str,
163    binary: &str,
164    env_pairs: &[(String, String)],
165) -> Option<String> {
166    let mut doc = config_content.parse::<toml_edit::DocumentMut>().ok()?;
167    let original = doc.to_string();
168
169    // `[mcp_servers]` stays implicit so we never emit a bare parent header.
170    let servers = doc["mcp_servers"].or_insert(toml_edit::table());
171    if let Some(t) = servers.as_table_mut() {
172        t.set_implicit(true);
173    }
174
175    // `[mcp_servers.lean-ctx]` must be explicit so its header is rendered before
176    // the `.env` child table (fixes the orphaned-env ordering from #189).
177    let lean = servers["lean-ctx"].or_insert(toml_edit::table());
178    let lean_tbl = lean.as_table_mut()?;
179    lean_tbl.set_implicit(false);
180
181    // Respect user customization: only fill `command`/`args` when absent.
182    if !lean_tbl.contains_key("command") {
183        lean_tbl["command"] = toml_edit::value(binary);
184    }
185    if !lean_tbl.contains_key("args") {
186        lean_tbl["args"] = toml_edit::value(toml_edit::Array::new());
187    }
188
189    let env = lean_tbl["env"].or_insert(toml_edit::table());
190    if let Some(env_tbl) = env.as_table_mut() {
191        for (key, val) in env_pairs {
192            let key = key.as_str();
193            if env_tbl.get(key).and_then(toml_edit::Item::as_str) != Some(val.as_str()) {
194                env_tbl[key] = toml_edit::value(val.as_str());
195            }
196        }
197    }
198
199    let updated = doc.to_string();
200    (updated != original).then_some(updated)
201}
202
203fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
204    shared_ensure_codex_hooks_enabled(config_content)
205}
206
207/// Install deny hook for Codex in Replace mode.
208/// Adds a PreToolUse deny entry to `hooks.json` for Read/Grep/Glob.
209pub(crate) fn install_codex_deny_hook() {
210    let Some(codex_dir) = crate::core::home::resolve_codex_dir() else {
211        return;
212    };
213    let binary = resolve_hook_command_binary();
214    let deny_cmd = format!("{binary} hook deny");
215
216    let hooks_json_path = codex_dir.join("hooks.json");
217    let mut root = if hooks_json_path.exists() {
218        std::fs::read_to_string(&hooks_json_path)
219            .ok()
220            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
221            .unwrap_or_else(|| serde_json::json!({ "hooks": {} }))
222    } else {
223        serde_json::json!({ "hooks": {} })
224    };
225
226    let hooks = root
227        .as_object_mut()
228        .unwrap()
229        .entry("hooks")
230        .or_insert_with(|| serde_json::json!({}));
231    let hooks_obj = hooks.as_object_mut().unwrap();
232
233    let pre = hooks_obj
234        .entry("PreToolUse")
235        .or_insert_with(|| serde_json::json!([]));
236    if let Some(arr) = pre.as_array_mut() {
237        let has_deny = arr.iter().any(|e| {
238            e.get("command")
239                .and_then(|c| c.as_str())
240                .is_some_and(|c| c.contains("hook deny"))
241        });
242        if !has_deny {
243            arr.push(serde_json::json!({
244                "matcher": "Read|Grep|Glob",
245                "command": deny_cmd
246            }));
247        }
248    }
249
250    write_file(
251        &hooks_json_path,
252        &serde_json::to_string_pretty(&root).unwrap_or_default(),
253    );
254
255    if !mcp_server_quiet_mode() {
256        eprintln!("  \x1b[32m✓\x1b[0m Codex deny hook installed (Replace mode)");
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{
263        ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
264    };
265    use serde_json::json;
266
267    /// Minimal env block (data dir only) for the config-rewrite tests that do
268    /// not exercise project-root/extra-roots propagation.
269    fn data_dir_pairs() -> Vec<(String, String)> {
270        vec![(
271            "LEAN_CTX_DATA_DIR".to_string(),
272            "/Users/user/.lean-ctx".to_string(),
273        )]
274    }
275
276    #[test]
277    fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
278        let mut input = json!({
279            "hooks": {
280                "PreToolUse": [
281                    {
282                        "matcher": "Bash",
283                        "hooks": [{
284                            "type": "command",
285                            "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
286                            "timeout": 15
287                        }]
288                    },
289                    {
290                        "matcher": "Bash",
291                        "hooks": [{
292                            "type": "command",
293                            "command": "echo keep-me",
294                            "timeout": 5
295                        }]
296                    }
297                ],
298                "SessionStart": [
299                    {
300                        "matcher": "startup|resume|clear",
301                        "hooks": [{
302                            "type": "command",
303                            "command": "lean-ctx hook codex-session-start",
304                            "timeout": 15
305                        }]
306                    }
307                ],
308                "PostToolUse": [
309                    {
310                        "matcher": "Bash",
311                        "hooks": [{
312                            "type": "command",
313                            "command": "echo keep-post",
314                            "timeout": 5
315                        }]
316                    }
317                ]
318            }
319        });
320
321        let changed = upsert_lean_ctx_codex_hook_entries(
322            &mut input,
323            "lean-ctx hook codex-session-start",
324            "lean-ctx hook codex-pretooluse",
325        );
326        assert!(changed, "legacy hooks should be migrated");
327
328        let pre_tool_use = input["hooks"]["PreToolUse"]
329            .as_array()
330            .expect("PreToolUse array should remain");
331        assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
332        assert_eq!(
333            pre_tool_use[0]["hooks"][0]["command"].as_str(),
334            Some("echo keep-me")
335        );
336        assert_eq!(
337            pre_tool_use[1]["hooks"][0]["command"].as_str(),
338            Some("lean-ctx hook codex-pretooluse")
339        );
340        assert_eq!(
341            input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
342            Some("lean-ctx hook codex-session-start")
343        );
344        assert_eq!(
345            input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
346            Some("echo keep-post")
347        );
348    }
349
350    #[test]
351    fn ignores_non_lean_ctx_codex_entries() {
352        let custom = json!({
353            "matcher": "Bash",
354            "hooks": [{
355                "type": "command",
356                "command": "echo keep-me",
357                "timeout": 5
358            }]
359        });
360        assert!(
361            !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
362            "custom Codex hooks must be preserved"
363        );
364    }
365
366    #[test]
367    fn detects_managed_codex_session_start_entry() {
368        let managed = json!({
369            "matcher": "startup|resume|clear",
370            "hooks": [{
371                "type": "command",
372                "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
373                "timeout": 15
374            }]
375        });
376        assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
377            "SessionStart",
378            &managed
379        ));
380    }
381
382    #[test]
383    fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
384        let input = "\
385[features]
386other = true
387codex_hooks = false
388
389[mcp_servers.other]
390command = \"other\"
391";
392
393        let output =
394            ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
395
396        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
397        assert!(!output.contains("codex_hooks = false"));
398    }
399
400    #[test]
401    fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
402        let input = "\
403[features]
404other = true
405
406[mcp_servers.lean-ctx]
407command = \"lean-ctx\"
408codex_hooks = true
409";
410
411        let output = ensure_codex_hooks_enabled(input)
412            .expect("stray codex_hooks assignment should be normalized");
413
414        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
415        assert_eq!(output.matches("hooks = true").count(), 1);
416        assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
417    }
418
419    #[test]
420    fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
421        let input = "\
422[mcp_servers.lean-ctx]
423command = \"lean-ctx\"
424";
425
426        let output =
427            ensure_codex_hooks_enabled(input).expect("missing features section should be added");
428
429        assert!(output.ends_with("\n[features]\nhooks = true\n"));
430    }
431
432    #[test]
433    fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
434        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
435        let _ = std::fs::remove_dir_all(&tmp);
436        std::fs::create_dir_all(&tmp).unwrap();
437
438        crate::hooks::support::install_codex_instruction_docs(&tmp);
439
440        let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
441        assert!(
442            lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
443            "LEAN-CTX.md must steer the agent to the MCP tools"
444        );
445        // Regression guard for #350: never assert as fact that Desktop/Cloud hooks
446        // do not run — they can (gated by trust via /hooks, varies by version).
447        let normalized = lean_ctx_md.replace('\n', " ");
448        assert!(
449            !normalized.contains("hooks do not run")
450                && !normalized.contains("no automatic compression"),
451            "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
452        );
453
454        let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
455        assert!(
456            agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
457            "AGENTS.md block must steer to the reliable MCP tools"
458        );
459        let agents_norm = agents_md.replace('\n', " ");
460        assert!(
461            !agents_norm.contains("hooks do not run"),
462            "AGENTS.md must not claim Codex hooks never run (#350)"
463        );
464
465        let _ = std::fs::remove_dir_all(&tmp);
466    }
467
468    #[test]
469    fn install_codex_docs_preserves_existing_user_instructions() {
470        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
471        let _ = std::fs::remove_dir_all(&tmp);
472        std::fs::create_dir_all(&tmp).unwrap();
473
474        let agents_md = tmp.join("AGENTS.md");
475        let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
476        std::fs::write(&agents_md, user_content).unwrap();
477
478        crate::hooks::support::install_codex_instruction_docs(&tmp);
479
480        let result = std::fs::read_to_string(&agents_md).unwrap();
481        assert!(
482            result.contains("My Custom Instructions"),
483            "user content must be preserved"
484        );
485        assert!(
486            result.contains("Always use tabs"),
487            "user rules must be preserved"
488        );
489        assert!(
490            result.contains(crate::core::rules_canonical::AGENTS_BLOCK_START),
491            "lean-ctx block must be appended"
492        );
493        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
494        assert!(
495            result.contains(&expected_ref),
496            "lean-ctx reference must use codex_dir path"
497        );
498
499        let _ = std::fs::remove_dir_all(&tmp);
500    }
501
502    #[test]
503    fn install_codex_docs_updates_only_marked_block() {
504        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
505        let _ = std::fs::remove_dir_all(&tmp);
506        std::fs::create_dir_all(&tmp).unwrap();
507
508        let agents_md = tmp.join("AGENTS.md");
509        let content_with_block = format!(
510            "# My Instructions\n\nCustom rule here.\n\n{}\n## lean-ctx\n\n@OLD-LEAN-CTX.md\n{}\n\n## Other Section\nKeep this.\n",
511            crate::core::rules_canonical::AGENTS_BLOCK_START,
512            crate::core::rules_canonical::AGENTS_BLOCK_END,
513        );
514        std::fs::write(&agents_md, content_with_block).unwrap();
515
516        crate::hooks::support::install_codex_instruction_docs(&tmp);
517
518        let result = std::fs::read_to_string(&agents_md).unwrap();
519        assert!(
520            result.contains("Custom rule here."),
521            "user content before block preserved"
522        );
523        assert!(
524            result.contains("Other Section"),
525            "user content after block preserved"
526        );
527        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
528        assert!(
529            result.contains(&expected_ref),
530            "block updated to current reference"
531        );
532        assert!(
533            !result.contains("OLD-LEAN-CTX"),
534            "old block content replaced"
535        );
536
537        let _ = std::fs::remove_dir_all(&tmp);
538    }
539
540    #[test]
541    fn ensure_mcp_server_adds_section_when_missing() {
542        let input = "[features]\ncodex_hooks = true\n";
543        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
544            .expect("should add MCP section");
545        assert!(result.contains("[mcp_servers.lean-ctx]"));
546        assert!(result.contains("command = \"lean-ctx\""));
547        assert!(result.contains("args = []"));
548        assert!(result.contains("[features]\ncodex_hooks = true\n"));
549    }
550
551    #[test]
552    fn ensure_mcp_server_noop_when_already_complete() {
553        // Parent + args + an env block already carrying every desired key: the
554        // upsert must be a true no-op (no churn on every session start).
555        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
556                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
557        assert!(
558            ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
559            "should not modify config when MCP section already has all keys"
560        );
561    }
562
563    #[test]
564    fn ensure_mcp_server_preserves_existing_sections() {
565        let input = "[mcp_servers.other]\ncommand = \"other\"\n";
566        let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
567            .expect("should add lean-ctx section");
568        assert!(result.contains("[mcp_servers.other]"));
569        assert!(result.contains("[mcp_servers.lean-ctx]"));
570        assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
571    }
572
573    #[test]
574    fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
575        let input = "\
576[mcp_servers.lean-ctx.env]
577LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
578";
579        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
580            .expect("should insert parent section before orphaned env");
581        let parent_pos = result
582            .find("[mcp_servers.lean-ctx]")
583            .expect("parent section must exist");
584        let env_pos = result
585            .find("[mcp_servers.lean-ctx.env]")
586            .expect("env sub-table must be preserved");
587        assert!(
588            parent_pos < env_pos,
589            "parent section must come before env sub-table"
590        );
591        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
592        assert!(result.contains("LEAN_CTX_DATA_DIR"));
593        assert_eq!(
594            result.matches("[mcp_servers.lean-ctx.env]").count(),
595            1,
596            "must not duplicate the env table (would be invalid TOML)"
597        );
598    }
599
600    #[test]
601    fn ensure_mcp_server_handles_issue_189_scenario() {
602        let input = "\
603source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
604source_type = \"local\"
605
606[mcp_servers.lean-ctx.env]
607LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
608";
609        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
610            .expect("should fix orphaned config from issue #189");
611        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
612        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
613        assert!(result.contains("[mcp_servers.lean-ctx.env]"));
614        assert!(result.contains("LEAN_CTX_DATA_DIR"));
615
616        let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
617        let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
618        assert!(parent_pos < env_pos);
619        assert_eq!(
620            result.matches("[mcp_servers.lean-ctx.env]").count(),
621            1,
622            "issue #189 fix must merge into one env table, not duplicate it"
623        );
624        // Original sibling content must survive the normalization.
625        assert!(result.contains("source_type = \"local\""));
626    }
627
628    #[test]
629    fn ensure_mcp_server_quotes_windows_backslash_paths() {
630        let input = "[features]\ncodex_hooks = true\n";
631        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
632        let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
633            .expect("should add MCP section");
634        // Quote style is the TOML editor's concern; what matters is that the
635        // backslash path round-trips to exactly the same string and stays valid.
636        let doc = result
637            .parse::<toml_edit::DocumentMut>()
638            .expect("output must be valid TOML");
639        assert_eq!(
640            doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
641            Some(win_path),
642            "Windows backslash path must round-trip exactly: {result}"
643        );
644    }
645
646    #[test]
647    fn ensure_mcp_server_does_not_match_similarly_named_section() {
648        let input = "\
649[mcp_servers.lean-ctx-other]
650command = \"other\"
651";
652        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
653            .expect("should add lean-ctx section despite similarly-named section");
654        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
655        assert!(result.contains("[mcp_servers.lean-ctx-other]"));
656    }
657
658    #[test]
659    fn ensure_mcp_server_writes_project_and_extra_roots() {
660        // #403: when init captured a project root + sibling worktrees, those
661        // must be propagated into the env block so the long-lived MCP server
662        // resolves explicit paths under every root.
663        let pairs = vec![
664            (
665                "LEAN_CTX_DATA_DIR".to_string(),
666                "/home/u/.lean-ctx".to_string(),
667            ),
668            (
669                "LEAN_CTX_PROJECT_ROOT".to_string(),
670                "/work/main".to_string(),
671            ),
672            (
673                "LEAN_CTX_EXTRA_ROOTS".to_string(),
674                "/work/wt-a:/work/wt-b".to_string(),
675            ),
676        ];
677        let result =
678            ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
679
680        let doc = result
681            .parse::<toml_edit::DocumentMut>()
682            .expect("output must be valid TOML");
683        let env = &doc["mcp_servers"]["lean-ctx"]["env"];
684        assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
685        assert_eq!(
686            env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
687            Some("/work/wt-a:/work/wt-b")
688        );
689        assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
690    }
691
692    #[test]
693    fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
694        // Pre-existing install (only DATA_DIR) must gain the new roots without
695        // duplicating the section, and the operation must be idempotent.
696        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
697                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
698        let pairs = vec![
699            (
700                "LEAN_CTX_DATA_DIR".to_string(),
701                "/home/u/.lean-ctx".to_string(),
702            ),
703            (
704                "LEAN_CTX_PROJECT_ROOT".to_string(),
705                "/work/main".to_string(),
706            ),
707        ];
708
709        let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
710            .expect("should upsert the missing project root");
711        assert_eq!(
712            result.matches("[mcp_servers.lean-ctx]").count(),
713            1,
714            "must not duplicate the parent section"
715        );
716        let doc = result
717            .parse::<toml_edit::DocumentMut>()
718            .expect("output must be valid TOML");
719        assert_eq!(
720            doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
721            Some("/work/main")
722        );
723
724        // Second pass over the upserted config is a no-op.
725        assert!(
726            ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
727            "upsert must be idempotent"
728        );
729    }
730}