Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5fn toml_quote(value: &str) -> String {
6    if value.contains('\\') {
7        format!("'{value}'")
8    } else {
9        format!("\"{value}\"")
10    }
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WriteAction {
15    Created,
16    Updated,
17    Already,
18}
19
20#[derive(Debug, Clone, Copy, Default)]
21pub struct WriteOptions {
22    pub overwrite_invalid: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WriteResult {
27    pub action: WriteAction,
28    pub note: Option<String>,
29}
30
31pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
32    write_config_with_options(target, binary, WriteOptions::default())
33}
34
35pub fn write_config_with_options(
36    target: &EditorTarget,
37    binary: &str,
38    opts: WriteOptions,
39) -> Result<WriteResult, String> {
40    if let Some(parent) = target.config_path.parent() {
41        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
42    }
43
44    match target.config_type {
45        ConfigType::McpJson => write_mcp_json(target, binary, opts),
46        ConfigType::Zed => write_zed_config(target, binary, opts),
47        ConfigType::Codex => write_codex_config(target, binary),
48        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
49        ConfigType::CopilotCli => write_copilot_cli(target, binary, opts),
50        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
51        ConfigType::Crush => write_crush_config(target, binary, opts),
52        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
53        ConfigType::Amp => write_amp_config(target, binary, opts),
54        ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
55        ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
56        ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
57    }
58}
59
60pub fn remove_lean_ctx_mcp_server(
61    path: &std::path::Path,
62    opts: WriteOptions,
63) -> Result<WriteResult, String> {
64    if !path.exists() {
65        return Ok(WriteResult {
66            action: WriteAction::Already,
67            note: Some("mcp.json not found".to_string()),
68        });
69    }
70
71    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
72    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
73        Ok(v) => v,
74        Err(e) => {
75            if !opts.overwrite_invalid {
76                return Err(e.to_string());
77            }
78            eprintln!(
79                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
80                path.display()
81            );
82            return Ok(WriteResult {
83                action: WriteAction::Already,
84                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
85            });
86        }
87    };
88
89    let obj = json
90        .as_object_mut()
91        .ok_or_else(|| "root JSON must be an object".to_string())?;
92
93    let Some(servers) = obj.get_mut("mcpServers") else {
94        return Ok(WriteResult {
95            action: WriteAction::Already,
96            note: Some("no mcpServers key".to_string()),
97        });
98    };
99    let servers_obj = servers
100        .as_object_mut()
101        .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
102
103    if servers_obj.remove("lean-ctx").is_none() {
104        return Ok(WriteResult {
105            action: WriteAction::Already,
106            note: Some("lean-ctx not configured".to_string()),
107        });
108    }
109
110    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
111    crate::config_io::write_atomic_with_backup(path, &formatted)?;
112    Ok(WriteResult {
113        action: WriteAction::Updated,
114        note: Some("removed lean-ctx from mcpServers".to_string()),
115    })
116}
117
118pub fn remove_lean_ctx_server(
119    target: &EditorTarget,
120    opts: WriteOptions,
121) -> Result<WriteResult, String> {
122    match target.config_type {
123        ConfigType::McpJson
124        | ConfigType::JetBrains
125        | ConfigType::GeminiSettings
126        | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
127        ConfigType::VsCodeMcp | ConfigType::CopilotCli => {
128            remove_lean_ctx_vscode_server(&target.config_path, opts)
129        }
130        ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
131        ConfigType::OpenCode | ConfigType::Crush => {
132            remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
133        }
134        ConfigType::Zed => {
135            remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
136        }
137        ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
138        ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
139    }
140}
141
142fn remove_lean_ctx_vscode_server(
143    path: &std::path::Path,
144    opts: WriteOptions,
145) -> Result<WriteResult, String> {
146    if !path.exists() {
147        return Ok(WriteResult {
148            action: WriteAction::Already,
149            note: Some("vscode mcp.json not found".to_string()),
150        });
151    }
152
153    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
154    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
155        Ok(v) => v,
156        Err(e) => {
157            if !opts.overwrite_invalid {
158                return Err(e.to_string());
159            }
160            eprintln!(
161                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
162                path.display()
163            );
164            return Ok(WriteResult {
165                action: WriteAction::Already,
166                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
167            });
168        }
169    };
170
171    let obj = json
172        .as_object_mut()
173        .ok_or_else(|| "root JSON must be an object".to_string())?;
174
175    let Some(servers) = obj.get_mut("servers") else {
176        return Ok(WriteResult {
177            action: WriteAction::Already,
178            note: Some("no servers key".to_string()),
179        });
180    };
181    let servers_obj = servers
182        .as_object_mut()
183        .ok_or_else(|| "\"servers\" must be an object".to_string())?;
184
185    if servers_obj.remove("lean-ctx").is_none() {
186        return Ok(WriteResult {
187            action: WriteAction::Already,
188            note: Some("lean-ctx not configured".to_string()),
189        });
190    }
191
192    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
193    crate::config_io::write_atomic_with_backup(path, &formatted)?;
194    Ok(WriteResult {
195        action: WriteAction::Updated,
196        note: Some("removed lean-ctx from servers".to_string()),
197    })
198}
199
200fn remove_lean_ctx_amp_server(
201    path: &std::path::Path,
202    opts: WriteOptions,
203) -> Result<WriteResult, String> {
204    if !path.exists() {
205        return Ok(WriteResult {
206            action: WriteAction::Already,
207            note: Some("amp settings not found".to_string()),
208        });
209    }
210
211    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
212    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
213        Ok(v) => v,
214        Err(e) => {
215            if !opts.overwrite_invalid {
216                return Err(e.to_string());
217            }
218            eprintln!(
219                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
220                path.display()
221            );
222            return Ok(WriteResult {
223                action: WriteAction::Already,
224                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
225            });
226        }
227    };
228
229    let obj = json
230        .as_object_mut()
231        .ok_or_else(|| "root JSON must be an object".to_string())?;
232    let Some(servers) = obj.get_mut("amp.mcpServers") else {
233        return Ok(WriteResult {
234            action: WriteAction::Already,
235            note: Some("no amp.mcpServers key".to_string()),
236        });
237    };
238    let servers_obj = servers
239        .as_object_mut()
240        .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
241
242    if servers_obj.remove("lean-ctx").is_none() {
243        return Ok(WriteResult {
244            action: WriteAction::Already,
245            note: Some("lean-ctx not configured".to_string()),
246        });
247    }
248
249    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
250    crate::config_io::write_atomic_with_backup(path, &formatted)?;
251    Ok(WriteResult {
252        action: WriteAction::Updated,
253        note: Some("removed lean-ctx from amp.mcpServers".to_string()),
254    })
255}
256
257fn remove_lean_ctx_named_json_server(
258    path: &std::path::Path,
259    container_key: &str,
260    opts: WriteOptions,
261) -> Result<WriteResult, String> {
262    if !path.exists() {
263        return Ok(WriteResult {
264            action: WriteAction::Already,
265            note: Some("config not found".to_string()),
266        });
267    }
268
269    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
270    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
271        Ok(v) => v,
272        Err(e) => {
273            if !opts.overwrite_invalid {
274                return Err(e.to_string());
275            }
276            eprintln!(
277                "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors — skipping removal.",
278                path.display()
279            );
280            return Ok(WriteResult {
281                action: WriteAction::Already,
282                note: Some("invalid JSON — cannot safely remove lean-ctx entry".to_string()),
283            });
284        }
285    };
286
287    let obj = json
288        .as_object_mut()
289        .ok_or_else(|| "root JSON must be an object".to_string())?;
290    let Some(container) = obj.get_mut(container_key) else {
291        return Ok(WriteResult {
292            action: WriteAction::Already,
293            note: Some(format!("no {container_key} key")),
294        });
295    };
296    let container_obj = container
297        .as_object_mut()
298        .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
299
300    if container_obj.remove("lean-ctx").is_none() {
301        return Ok(WriteResult {
302            action: WriteAction::Already,
303            note: Some("lean-ctx not configured".to_string()),
304        });
305    }
306
307    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
308    crate::config_io::write_atomic_with_backup(path, &formatted)?;
309    Ok(WriteResult {
310        action: WriteAction::Updated,
311        note: Some(format!("removed lean-ctx from {container_key}")),
312    })
313}
314
315fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
316    if !path.exists() {
317        return Ok(WriteResult {
318            action: WriteAction::Already,
319            note: Some("codex config not found".to_string()),
320        });
321    }
322    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
323    let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
324    if updated == content {
325        return Ok(WriteResult {
326            action: WriteAction::Already,
327            note: Some("lean-ctx not configured".to_string()),
328        });
329    }
330    crate::config_io::write_atomic_with_backup(path, &updated)?;
331    Ok(WriteResult {
332        action: WriteAction::Updated,
333        note: Some("removed [mcp_servers.lean-ctx]".to_string()),
334    })
335}
336
337fn remove_codex_toml_section(existing: &str, header: &str) -> String {
338    let prefix = header.trim_end_matches(']');
339    let mut out = String::with_capacity(existing.len());
340    let mut skipping = false;
341    for line in existing.lines() {
342        let trimmed = line.trim();
343        if trimmed.starts_with('[') && trimmed.ends_with(']') {
344            if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
345                skipping = true;
346                continue;
347            }
348            skipping = false;
349        }
350        if skipping {
351            continue;
352        }
353        out.push_str(line);
354        out.push('\n');
355    }
356    out
357}
358
359fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
360    if !path.exists() {
361        return Ok(WriteResult {
362            action: WriteAction::Already,
363            note: Some("hermes config not found".to_string()),
364        });
365    }
366    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
367    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
368    if updated == content {
369        return Ok(WriteResult {
370            action: WriteAction::Already,
371            note: Some("lean-ctx not configured".to_string()),
372        });
373    }
374    crate::config_io::write_atomic_with_backup(path, &updated)?;
375    Ok(WriteResult {
376        action: WriteAction::Updated,
377        note: Some("removed lean-ctx from mcp_servers".to_string()),
378    })
379}
380
381fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
382    let mut out = String::with_capacity(existing.len());
383    let mut in_mcp = false;
384    let mut skipping = false;
385    for line in existing.lines() {
386        let trimmed = line.trim_end();
387        if trimmed == "mcp_servers:" {
388            in_mcp = true;
389            out.push_str(line);
390            out.push('\n');
391            continue;
392        }
393
394        if in_mcp {
395            let is_child = line.starts_with("  ") && !line.starts_with("    ");
396            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
397
398            if is_toplevel {
399                in_mcp = false;
400                skipping = false;
401            }
402
403            if skipping {
404                if is_child || is_toplevel {
405                    skipping = false;
406                    out.push_str(line);
407                    out.push('\n');
408                }
409                continue;
410            }
411
412            if is_child && line.trim() == format!("{name}:") {
413                skipping = true;
414                continue;
415            }
416        }
417
418        out.push_str(line);
419        out.push('\n');
420    }
421    out
422}
423
424pub fn auto_approve_tools() -> Vec<&'static str> {
425    vec![
426        "ctx_read",
427        "ctx_shell",
428        "ctx_search",
429        "ctx_tree",
430        "ctx_overview",
431        "ctx_preload",
432        "ctx_compress",
433        "ctx_metrics",
434        "ctx_session",
435        "ctx_knowledge",
436        "ctx_agent",
437        "ctx_share",
438        "ctx_analyze",
439        "ctx_benchmark",
440        "ctx_cache",
441        "ctx_discover",
442        "ctx_smart_read",
443        "ctx_delta",
444        "ctx_edit",
445        "ctx_dedup",
446        "ctx_fill",
447        "ctx_intent",
448        "ctx_response",
449        "ctx_context",
450        "ctx_graph",
451        "ctx_multi_read",
452        "ctx_semantic_search",
453        "ctx_symbol",
454        "ctx_outline",
455        "ctx_callgraph",
456        "ctx_refactor",
457        "ctx_routes",
458        "ctx_cost",
459        "ctx_heatmap",
460        "ctx_gain",
461        "ctx_expand",
462        "ctx_task",
463        "ctx_impact",
464        "ctx_architecture",
465        "ctx_workflow",
466        "ctx_review",
467        "ctx_pack",
468        "ctx_index",
469        "ctx_artifacts",
470        "ctx_smells",
471        "ctx_proof",
472        "ctx_verify",
473        "ctx_execute",
474        "ctx_handoff",
475        "ctx_feedback",
476        "ctx_control",
477        "ctx_plan",
478        "ctx_compile",
479        "ctx_discover_tools",
480        "ctx_provider",
481        "ctx_radar",
482        "ctx_retrieve",
483        "ctx_compress_memory",
484        "ctx_load_tools",
485        "ctx",
486    ]
487}
488
489fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
490    let mut entry = serde_json::json!({
491        "command": binary,
492        "env": {
493            "LEAN_CTX_DATA_DIR": data_dir
494        }
495    });
496    if include_auto_approve {
497        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
498    }
499    entry
500}
501
502fn lean_ctx_server_entry_with_instructions(
503    binary: &str,
504    data_dir: &str,
505    include_auto_approve: bool,
506    agent_key: &str,
507) -> Value {
508    let mut entry = lean_ctx_server_entry(binary, data_dir, include_auto_approve);
509    let mode = crate::core::rules_canonical::Mode::from_hook_mode(
510        &crate::hooks::recommend_hook_mode(agent_key),
511    );
512    let instructions = crate::core::rules_canonical::mcp_instructions(mode);
513
514    let constraints = crate::core::client_constraints::by_client_id(agent_key);
515    if let Some(max_chars) = constraints.and_then(|c| c.mcp_instructions_max_chars) {
516        let truncated = if instructions.len() > max_chars {
517            &instructions[..max_chars]
518        } else {
519            instructions
520        };
521        entry["instructions"] = serde_json::json!(truncated);
522    }
523    entry
524}
525
526fn supports_auto_approve(target: &EditorTarget) -> bool {
527    crate::core::client_constraints::by_editor_name(target.name)
528        .is_some_and(|c| c.supports_auto_approve)
529}
530
531fn default_data_dir() -> Result<String, String> {
532    Ok(crate::core::data_dir::lean_ctx_data_dir()?
533        .to_string_lossy()
534        .to_string())
535}
536
537fn write_mcp_json(
538    target: &EditorTarget,
539    binary: &str,
540    opts: WriteOptions,
541) -> Result<WriteResult, String> {
542    let data_dir = default_data_dir()?;
543    let include_aa = supports_auto_approve(target);
544    let desired = if target.agent_key.is_empty() {
545        lean_ctx_server_entry(binary, &data_dir, include_aa)
546    } else {
547        lean_ctx_server_entry_with_instructions(binary, &data_dir, include_aa, &target.agent_key)
548    };
549
550    // Claude Code manages ~/.claude.json and may overwrite it on first start.
551    // Prefer the official CLI integration when available.
552    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
553    // spawning `claude mcp add-json` which can stall in non-interactive CI.
554    if (target.agent_key == "claude" || target.name == "Claude Code")
555        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
556    {
557        if let Ok(result) = try_claude_mcp_add(&desired) {
558            return Ok(result);
559        }
560    }
561
562    if target.config_path.exists() {
563        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
564        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
565            Ok(v) => v,
566            Err(_e) => {
567                return handle_invalid_json_write(
568                    &target.config_path,
569                    &content,
570                    "mcpServers",
571                    "lean-ctx",
572                    &desired,
573                    opts.overwrite_invalid,
574                );
575            }
576        };
577        let obj = json
578            .as_object_mut()
579            .ok_or_else(|| "root JSON must be an object".to_string())?;
580
581        let servers = obj
582            .entry("mcpServers")
583            .or_insert_with(|| serde_json::json!({}));
584        let servers_obj = servers
585            .as_object_mut()
586            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
587
588        let existing = servers_obj.get("lean-ctx").cloned();
589        if existing.as_ref() == Some(&desired) {
590            return Ok(WriteResult {
591                action: WriteAction::Already,
592                note: None,
593            });
594        }
595        servers_obj.insert("lean-ctx".to_string(), desired);
596
597        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
598        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
599        return Ok(WriteResult {
600            action: WriteAction::Updated,
601            note: None,
602        });
603    }
604
605    write_mcp_json_fresh(&target.config_path, &desired, None)
606}
607
608fn find_in_path(binary: &str) -> Option<std::path::PathBuf> {
609    let path_var = std::env::var("PATH").ok()?;
610    for dir in std::env::split_paths(&path_var) {
611        let candidate = dir.join(binary);
612        if candidate.is_file() {
613            return Some(candidate);
614        }
615    }
616    None
617}
618
619fn validate_claude_binary() -> Result<std::path::PathBuf, String> {
620    let path = find_in_path("claude").ok_or("claude binary not found in PATH")?;
621
622    let canonical =
623        std::fs::canonicalize(&path).map_err(|e| format!("cannot resolve claude path: {e}"))?;
624
625    let canonical_str = canonical.to_string_lossy();
626    let is_trusted = canonical_str.contains("/.claude/")
627        || canonical_str.contains("\\AppData\\")
628        || canonical_str.contains("/usr/local/bin/")
629        || canonical_str.contains("/opt/homebrew/")
630        || canonical_str.contains("/nix/store/")
631        || canonical_str.contains("/.npm/")
632        || canonical_str.contains("/.nvm/")
633        || canonical_str.contains("/node_modules/.bin/")
634        || std::env::var("LEAN_CTX_TRUST_CLAUDE_PATH").is_ok();
635
636    if !is_trusted {
637        return Err(format!(
638            "claude binary resolved to untrusted path: {canonical_str} — set LEAN_CTX_TRUST_CLAUDE_PATH=1 to override"
639        ));
640    }
641    Ok(canonical)
642}
643
644fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
645    use std::io::Write;
646    use std::process::{Command, Stdio};
647    use std::time::{Duration, Instant};
648
649    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
650
651    let mut cmd = if cfg!(windows) {
652        let mut c = Command::new("cmd");
653        c.args([
654            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
655        ]);
656        c
657    } else {
658        let claude_path = validate_claude_binary()?;
659        let mut c = Command::new(claude_path);
660        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
661        c
662    };
663
664    let mut child = cmd
665        .stdin(Stdio::piped())
666        .stdout(Stdio::null())
667        .stderr(Stdio::null())
668        .spawn()
669        .map_err(|e| e.to_string())?;
670
671    if let Some(mut stdin) = child.stdin.take() {
672        let _ = stdin.write_all(server_json.as_bytes());
673    }
674
675    let deadline = Duration::from_secs(3);
676    let start = Instant::now();
677    loop {
678        match child.try_wait() {
679            Ok(Some(status)) => {
680                return if status.success() {
681                    Ok(WriteResult {
682                        action: WriteAction::Updated,
683                        note: Some("via claude mcp add-json".to_string()),
684                    })
685                } else {
686                    Err("claude mcp add-json failed".to_string())
687                };
688            }
689            Ok(None) => {
690                if start.elapsed() > deadline {
691                    let _ = child.kill();
692                    let _ = child.wait();
693                    return Err("claude mcp add-json timed out".to_string());
694                }
695                std::thread::sleep(Duration::from_millis(20));
696            }
697            Err(e) => return Err(e.to_string()),
698        }
699    }
700}
701
702fn write_mcp_json_fresh(
703    path: &std::path::Path,
704    desired: &Value,
705    note: Option<String>,
706) -> Result<WriteResult, String> {
707    let content = serde_json::to_string_pretty(&serde_json::json!({
708        "mcpServers": { "lean-ctx": desired }
709    }))
710    .map_err(|e| e.to_string())?;
711    crate::config_io::write_atomic_with_backup(path, &content)?;
712    Ok(WriteResult {
713        action: if note.is_some() {
714            WriteAction::Updated
715        } else {
716            WriteAction::Created
717        },
718        note,
719    })
720}
721
722fn write_zed_config(
723    target: &EditorTarget,
724    binary: &str,
725    opts: WriteOptions,
726) -> Result<WriteResult, String> {
727    let desired = serde_json::json!({
728        "command": binary,
729        "args": [],
730        "env": {}
731    });
732
733    if target.config_path.exists() {
734        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
735        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
736            Ok(v) => v,
737            Err(_e) => {
738                return handle_invalid_json_write(
739                    &target.config_path,
740                    &content,
741                    "context_servers",
742                    "lean-ctx",
743                    &desired,
744                    opts.overwrite_invalid,
745                );
746            }
747        };
748        let obj = json
749            .as_object_mut()
750            .ok_or_else(|| "root JSON must be an object".to_string())?;
751
752        let servers = obj
753            .entry("context_servers")
754            .or_insert_with(|| serde_json::json!({}));
755        let servers_obj = servers
756            .as_object_mut()
757            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
758
759        let existing = servers_obj.get("lean-ctx").cloned();
760        if existing.as_ref() == Some(&desired) {
761            return Ok(WriteResult {
762                action: WriteAction::Already,
763                note: None,
764            });
765        }
766        servers_obj.insert("lean-ctx".to_string(), desired);
767
768        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
769        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
770        return Ok(WriteResult {
771            action: WriteAction::Updated,
772            note: None,
773        });
774    }
775
776    write_zed_config_fresh(&target.config_path, &desired, None)
777}
778
779fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
780    if target.config_path.exists() {
781        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
782        let updated = upsert_codex_toml(&content, binary);
783        if updated == content {
784            return Ok(WriteResult {
785                action: WriteAction::Already,
786                note: None,
787            });
788        }
789        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
790        return Ok(WriteResult {
791            action: WriteAction::Updated,
792            note: None,
793        });
794    }
795
796    let content = format!(
797        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
798        toml_quote(binary)
799    );
800    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
801    Ok(WriteResult {
802        action: WriteAction::Created,
803        note: None,
804    })
805}
806
807fn write_zed_config_fresh(
808    path: &std::path::Path,
809    desired: &Value,
810    note: Option<String>,
811) -> Result<WriteResult, String> {
812    let content = serde_json::to_string_pretty(&serde_json::json!({
813        "context_servers": { "lean-ctx": desired }
814    }))
815    .map_err(|e| e.to_string())?;
816    crate::config_io::write_atomic_with_backup(path, &content)?;
817    Ok(WriteResult {
818        action: if note.is_some() {
819            WriteAction::Updated
820        } else {
821            WriteAction::Created
822        },
823        note,
824    })
825}
826
827fn write_vscode_mcp(
828    target: &EditorTarget,
829    binary: &str,
830    opts: WriteOptions,
831) -> Result<WriteResult, String> {
832    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
833        .map(|d| d.to_string_lossy().to_string())
834        .unwrap_or_default();
835    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
836
837    if target.config_path.exists() {
838        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
839        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
840            Ok(v) => v,
841            Err(_e) => {
842                return handle_invalid_json_write(
843                    &target.config_path,
844                    &content,
845                    "servers",
846                    "lean-ctx",
847                    &desired,
848                    opts.overwrite_invalid,
849                );
850            }
851        };
852        let obj = json
853            .as_object_mut()
854            .ok_or_else(|| "root JSON must be an object".to_string())?;
855
856        let servers = obj
857            .entry("servers")
858            .or_insert_with(|| serde_json::json!({}));
859        let servers_obj = servers
860            .as_object_mut()
861            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
862
863        let existing = servers_obj.get("lean-ctx").cloned();
864        if existing.as_ref() == Some(&desired) {
865            return Ok(WriteResult {
866                action: WriteAction::Already,
867                note: None,
868            });
869        }
870        servers_obj.insert("lean-ctx".to_string(), desired);
871
872        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
873        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
874        return Ok(WriteResult {
875            action: WriteAction::Updated,
876            note: None,
877        });
878    }
879
880    write_vscode_mcp_fresh(&target.config_path, binary, None)
881}
882
883fn write_vscode_mcp_fresh(
884    path: &std::path::Path,
885    binary: &str,
886    note: Option<String>,
887) -> Result<WriteResult, String> {
888    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
889        .map(|d| d.to_string_lossy().to_string())
890        .unwrap_or_default();
891    let content = serde_json::to_string_pretty(&serde_json::json!({
892        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
893    }))
894    .map_err(|e| e.to_string())?;
895    crate::config_io::write_atomic_with_backup(path, &content)?;
896    Ok(WriteResult {
897        action: if note.is_some() {
898            WriteAction::Updated
899        } else {
900            WriteAction::Created
901        },
902        note,
903    })
904}
905
906fn write_copilot_cli(
907    target: &EditorTarget,
908    binary: &str,
909    opts: WriteOptions,
910) -> Result<WriteResult, String> {
911    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
912        .map(|d| d.to_string_lossy().to_string())
913        .unwrap_or_default();
914    let desired = serde_json::json!({
915        "type": "local",
916        "command": binary,
917        "args": ["mcp"],
918        "env": { "LEAN_CTX_DATA_DIR": data_dir },
919        "tools": ["*"]
920    });
921
922    if target.config_path.exists() {
923        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
924        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
925            Ok(v) => v,
926            Err(_e) => {
927                return handle_invalid_json_write(
928                    &target.config_path,
929                    &content,
930                    "mcpServers",
931                    "lean-ctx",
932                    &desired,
933                    opts.overwrite_invalid,
934                );
935            }
936        };
937        let obj = json
938            .as_object_mut()
939            .ok_or_else(|| "root JSON must be an object".to_string())?;
940
941        let servers = obj
942            .entry("mcpServers")
943            .or_insert_with(|| serde_json::json!({}));
944        let servers_obj = servers
945            .as_object_mut()
946            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
947
948        let existing = servers_obj.get("lean-ctx").cloned();
949        if existing.as_ref() == Some(&desired) {
950            return Ok(WriteResult {
951                action: WriteAction::Already,
952                note: None,
953            });
954        }
955
956        servers_obj.insert("lean-ctx".to_string(), desired);
957        let out = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
958        crate::config_io::write_atomic_with_backup(&target.config_path, &out)?;
959        return Ok(WriteResult {
960            action: WriteAction::Updated,
961            note: None,
962        });
963    }
964
965    // Fresh write
966    if let Some(parent) = target.config_path.parent() {
967        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
968    }
969    let content = serde_json::to_string_pretty(&serde_json::json!({
970        "mcpServers": {
971            "lean-ctx": {
972                "type": "local",
973                "command": binary,
974                "args": ["mcp"],
975                "env": { "LEAN_CTX_DATA_DIR": data_dir },
976                "tools": ["*"]
977            }
978        }
979    }))
980    .map_err(|e| e.to_string())?;
981    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
982    Ok(WriteResult {
983        action: WriteAction::Created,
984        note: None,
985    })
986}
987
988fn write_opencode_config(
989    target: &EditorTarget,
990    binary: &str,
991    opts: WriteOptions,
992) -> Result<WriteResult, String> {
993    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
994        .map(|d| d.to_string_lossy().to_string())
995        .unwrap_or_default();
996    let desired = serde_json::json!({
997        "type": "local",
998        "command": [binary],
999        "enabled": true,
1000        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1001    });
1002
1003    if target.config_path.exists() {
1004        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1005        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1006            Ok(v) => v,
1007            Err(_e) => {
1008                return handle_invalid_json_write(
1009                    &target.config_path,
1010                    &content,
1011                    "mcp",
1012                    "lean-ctx",
1013                    &desired,
1014                    opts.overwrite_invalid,
1015                );
1016            }
1017        };
1018        let obj = json
1019            .as_object_mut()
1020            .ok_or_else(|| "root JSON must be an object".to_string())?;
1021        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1022        let mcp_obj = mcp
1023            .as_object_mut()
1024            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1025
1026        let existing = mcp_obj.get("lean-ctx").cloned();
1027        if existing.as_ref() == Some(&desired) {
1028            return Ok(WriteResult {
1029                action: WriteAction::Already,
1030                note: None,
1031            });
1032        }
1033        mcp_obj.insert("lean-ctx".to_string(), desired);
1034
1035        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1036        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1037        return Ok(WriteResult {
1038            action: WriteAction::Updated,
1039            note: None,
1040        });
1041    }
1042
1043    write_opencode_fresh(&target.config_path, binary, None)
1044}
1045
1046fn write_opencode_fresh(
1047    path: &std::path::Path,
1048    binary: &str,
1049    note: Option<String>,
1050) -> Result<WriteResult, String> {
1051    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1052        .map(|d| d.to_string_lossy().to_string())
1053        .unwrap_or_default();
1054    let content = serde_json::to_string_pretty(&serde_json::json!({
1055        "$schema": "https://opencode.ai/config.json",
1056        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
1057    }))
1058    .map_err(|e| e.to_string())?;
1059    crate::config_io::write_atomic_with_backup(path, &content)?;
1060    Ok(WriteResult {
1061        action: if note.is_some() {
1062            WriteAction::Updated
1063        } else {
1064            WriteAction::Created
1065        },
1066        note,
1067    })
1068}
1069
1070fn write_jetbrains_config(
1071    target: &EditorTarget,
1072    binary: &str,
1073    opts: WriteOptions,
1074) -> Result<WriteResult, String> {
1075    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1076        .map(|d| d.to_string_lossy().to_string())
1077        .unwrap_or_default();
1078    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
1079    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
1080    // We write that snippet to a file for easy copy/paste.
1081    let desired = serde_json::json!({
1082        "command": binary,
1083        "args": [],
1084        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1085    });
1086
1087    if target.config_path.exists() {
1088        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1089        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1090            Ok(v) => v,
1091            Err(_e) => {
1092                return handle_invalid_json_write(
1093                    &target.config_path,
1094                    &content,
1095                    "mcpServers",
1096                    "lean-ctx",
1097                    &desired,
1098                    opts.overwrite_invalid,
1099                );
1100            }
1101        };
1102        let obj = json
1103            .as_object_mut()
1104            .ok_or_else(|| "root JSON must be an object".to_string())?;
1105
1106        let servers = obj
1107            .entry("mcpServers")
1108            .or_insert_with(|| serde_json::json!({}));
1109        let servers_obj = servers
1110            .as_object_mut()
1111            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1112
1113        let existing = servers_obj.get("lean-ctx").cloned();
1114        if existing.as_ref() == Some(&desired) {
1115            return Ok(WriteResult {
1116                action: WriteAction::Already,
1117                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1118            });
1119        }
1120        servers_obj.insert("lean-ctx".to_string(), desired);
1121
1122        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1123        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1124        return Ok(WriteResult {
1125            action: WriteAction::Updated,
1126            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1127        });
1128    }
1129
1130    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
1131    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1132    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1133    Ok(WriteResult {
1134        action: WriteAction::Created,
1135        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
1136    })
1137}
1138
1139fn write_amp_config(
1140    target: &EditorTarget,
1141    binary: &str,
1142    opts: WriteOptions,
1143) -> Result<WriteResult, String> {
1144    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1145        .map(|d| d.to_string_lossy().to_string())
1146        .unwrap_or_default();
1147    let entry = serde_json::json!({
1148        "command": binary,
1149        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1150    });
1151
1152    if target.config_path.exists() {
1153        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1154        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1155            Ok(v) => v,
1156            Err(_e) => {
1157                return handle_invalid_json_write(
1158                    &target.config_path,
1159                    &content,
1160                    "amp.mcpServers",
1161                    "lean-ctx",
1162                    &entry,
1163                    opts.overwrite_invalid,
1164                );
1165            }
1166        };
1167        let obj = json
1168            .as_object_mut()
1169            .ok_or_else(|| "root JSON must be an object".to_string())?;
1170        let servers = obj
1171            .entry("amp.mcpServers")
1172            .or_insert_with(|| serde_json::json!({}));
1173        let servers_obj = servers
1174            .as_object_mut()
1175            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1176
1177        let existing = servers_obj.get("lean-ctx").cloned();
1178        if existing.as_ref() == Some(&entry) {
1179            return Ok(WriteResult {
1180                action: WriteAction::Already,
1181                note: None,
1182            });
1183        }
1184        servers_obj.insert("lean-ctx".to_string(), entry);
1185
1186        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1187        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1188        return Ok(WriteResult {
1189            action: WriteAction::Updated,
1190            note: None,
1191        });
1192    }
1193
1194    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1195    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1196    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1197    Ok(WriteResult {
1198        action: WriteAction::Created,
1199        note: None,
1200    })
1201}
1202
1203fn write_crush_config(
1204    target: &EditorTarget,
1205    binary: &str,
1206    opts: WriteOptions,
1207) -> Result<WriteResult, String> {
1208    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1209        .map(|d| d.to_string_lossy().to_string())
1210        .unwrap_or_default();
1211    let desired = serde_json::json!({
1212        "type": "stdio",
1213        "command": binary,
1214        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1215    });
1216
1217    if target.config_path.exists() {
1218        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1219        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1220            Ok(v) => v,
1221            Err(_e) => {
1222                return handle_invalid_json_write(
1223                    &target.config_path,
1224                    &content,
1225                    "mcp",
1226                    "lean-ctx",
1227                    &desired,
1228                    opts.overwrite_invalid,
1229                );
1230            }
1231        };
1232        let obj = json
1233            .as_object_mut()
1234            .ok_or_else(|| "root JSON must be an object".to_string())?;
1235        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1236        let mcp_obj = mcp
1237            .as_object_mut()
1238            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1239
1240        let existing = mcp_obj.get("lean-ctx").cloned();
1241        if existing.as_ref() == Some(&desired) {
1242            return Ok(WriteResult {
1243                action: WriteAction::Already,
1244                note: None,
1245            });
1246        }
1247        mcp_obj.insert("lean-ctx".to_string(), desired);
1248
1249        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1250        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1251        return Ok(WriteResult {
1252            action: WriteAction::Updated,
1253            note: None,
1254        });
1255    }
1256
1257    write_crush_fresh(&target.config_path, &desired, None)
1258}
1259
1260fn write_crush_fresh(
1261    path: &std::path::Path,
1262    desired: &Value,
1263    note: Option<String>,
1264) -> Result<WriteResult, String> {
1265    let content = serde_json::to_string_pretty(&serde_json::json!({
1266        "mcp": { "lean-ctx": desired }
1267    }))
1268    .map_err(|e| e.to_string())?;
1269    crate::config_io::write_atomic_with_backup(path, &content)?;
1270    Ok(WriteResult {
1271        action: if note.is_some() {
1272            WriteAction::Updated
1273        } else {
1274            WriteAction::Created
1275        },
1276        note,
1277    })
1278}
1279
1280fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1281    let mut out = String::with_capacity(existing.len() + 128);
1282    let mut in_section = false;
1283    let mut saw_section = false;
1284    let mut wrote_command = false;
1285    let mut wrote_args = false;
1286    let mut inserted_parent_before_subtable = false;
1287
1288    let parent_block = format!(
1289        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1290        toml_quote(binary)
1291    );
1292
1293    for line in existing.lines() {
1294        let trimmed = line.trim();
1295        if trimmed == "[]" {
1296            continue;
1297        }
1298        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1299            if in_section && !wrote_command {
1300                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1301                wrote_command = true;
1302            }
1303            if in_section && !wrote_args {
1304                out.push_str("args = []\n");
1305                wrote_args = true;
1306            }
1307            in_section = trimmed == "[mcp_servers.lean-ctx]";
1308            if in_section {
1309                saw_section = true;
1310            } else if !saw_section
1311                && !inserted_parent_before_subtable
1312                && trimmed.starts_with("[mcp_servers.lean-ctx.")
1313            {
1314                out.push_str(&parent_block);
1315                inserted_parent_before_subtable = true;
1316            }
1317            out.push_str(line);
1318            out.push('\n');
1319            continue;
1320        }
1321
1322        if in_section {
1323            if trimmed.starts_with("command") && trimmed.contains('=') {
1324                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1325                wrote_command = true;
1326                continue;
1327            }
1328            if trimmed.starts_with("args") && trimmed.contains('=') {
1329                out.push_str("args = []\n");
1330                wrote_args = true;
1331                continue;
1332            }
1333        }
1334
1335        out.push_str(line);
1336        out.push('\n');
1337    }
1338
1339    if saw_section {
1340        if in_section && !wrote_command {
1341            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1342        }
1343        if in_section && !wrote_args {
1344            out.push_str("args = []\n");
1345        }
1346        return out;
1347    }
1348
1349    if inserted_parent_before_subtable {
1350        return out;
1351    }
1352
1353    if !out.ends_with('\n') {
1354        out.push('\n');
1355    }
1356    out.push_str("\n[mcp_servers.lean-ctx]\n");
1357    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1358    out.push_str("args = []\n");
1359    out
1360}
1361
1362fn write_gemini_settings(
1363    target: &EditorTarget,
1364    binary: &str,
1365    opts: WriteOptions,
1366) -> Result<WriteResult, String> {
1367    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1368        .map(|d| d.to_string_lossy().to_string())
1369        .unwrap_or_default();
1370    let entry = serde_json::json!({
1371        "command": binary,
1372        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1373        "trust": true,
1374    });
1375
1376    if target.config_path.exists() {
1377        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1378        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1379            Ok(v) => v,
1380            Err(_e) => {
1381                return handle_invalid_json_write(
1382                    &target.config_path,
1383                    &content,
1384                    "mcpServers",
1385                    "lean-ctx",
1386                    &entry,
1387                    opts.overwrite_invalid,
1388                );
1389            }
1390        };
1391        let obj = json
1392            .as_object_mut()
1393            .ok_or_else(|| "root JSON must be an object".to_string())?;
1394        let servers = obj
1395            .entry("mcpServers")
1396            .or_insert_with(|| serde_json::json!({}));
1397        let servers_obj = servers
1398            .as_object_mut()
1399            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1400
1401        let existing = servers_obj.get("lean-ctx").cloned();
1402        if existing.as_ref() == Some(&entry) {
1403            return Ok(WriteResult {
1404                action: WriteAction::Already,
1405                note: None,
1406            });
1407        }
1408        servers_obj.insert("lean-ctx".to_string(), entry);
1409
1410        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1411        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1412        return Ok(WriteResult {
1413            action: WriteAction::Updated,
1414            note: None,
1415        });
1416    }
1417
1418    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1419    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1420    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1421    Ok(WriteResult {
1422        action: WriteAction::Created,
1423        note: None,
1424    })
1425}
1426
1427fn write_hermes_yaml(
1428    target: &EditorTarget,
1429    binary: &str,
1430    _opts: WriteOptions,
1431) -> Result<WriteResult, String> {
1432    let data_dir = default_data_dir()?;
1433
1434    let lean_ctx_block = format!(
1435        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1436    );
1437
1438    if target.config_path.exists() {
1439        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1440
1441        if content.contains("lean-ctx") {
1442            return Ok(WriteResult {
1443                action: WriteAction::Already,
1444                note: None,
1445            });
1446        }
1447
1448        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1449        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1450        return Ok(WriteResult {
1451            action: WriteAction::Updated,
1452            note: None,
1453        });
1454    }
1455
1456    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1457    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1458    Ok(WriteResult {
1459        action: WriteAction::Created,
1460        note: None,
1461    })
1462}
1463
1464fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1465    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1466    let mut in_mcp_section = false;
1467    let mut saw_mcp_child = false;
1468    let mut inserted = false;
1469    let lines: Vec<&str> = existing.lines().collect();
1470
1471    for line in &lines {
1472        if !inserted && line.trim_end() == "mcp_servers:" {
1473            in_mcp_section = true;
1474            out.push_str(line);
1475            out.push('\n');
1476            continue;
1477        }
1478
1479        if in_mcp_section && !inserted {
1480            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1481            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1482
1483            if is_child {
1484                saw_mcp_child = true;
1485                out.push_str(line);
1486                out.push('\n');
1487                continue;
1488            }
1489
1490            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1491                out.push_str(lean_ctx_block);
1492                out.push('\n');
1493                inserted = true;
1494                in_mcp_section = false;
1495            }
1496        }
1497
1498        out.push_str(line);
1499        out.push('\n');
1500    }
1501
1502    if in_mcp_section && !inserted {
1503        out.push_str(lean_ctx_block);
1504        out.push('\n');
1505        inserted = true;
1506    }
1507
1508    if !inserted {
1509        if !out.ends_with('\n') {
1510            out.push('\n');
1511        }
1512        out.push_str("\nmcp_servers:\n");
1513        out.push_str(lean_ctx_block);
1514        out.push('\n');
1515    }
1516
1517    out
1518}
1519
1520fn write_qoder_settings(
1521    target: &EditorTarget,
1522    binary: &str,
1523    opts: WriteOptions,
1524) -> Result<WriteResult, String> {
1525    let data_dir = default_data_dir()?;
1526    let desired = serde_json::json!({
1527        "command": binary,
1528        "args": [],
1529        "env": {
1530            "LEAN_CTX_DATA_DIR": data_dir,
1531            "LEAN_CTX_FULL_TOOLS": "1"
1532        }
1533    });
1534
1535    if target.config_path.exists() {
1536        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1537        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1538            Ok(v) => v,
1539            Err(_e) => {
1540                return handle_invalid_json_write(
1541                    &target.config_path,
1542                    &content,
1543                    "mcpServers",
1544                    "lean-ctx",
1545                    &desired,
1546                    opts.overwrite_invalid,
1547                );
1548            }
1549        };
1550        let obj = json
1551            .as_object_mut()
1552            .ok_or_else(|| "root JSON must be an object".to_string())?;
1553        let servers = obj
1554            .entry("mcpServers")
1555            .or_insert_with(|| serde_json::json!({}));
1556        let servers_obj = servers
1557            .as_object_mut()
1558            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1559
1560        let existing = servers_obj.get("lean-ctx").cloned();
1561        if existing.as_ref() == Some(&desired) {
1562            return Ok(WriteResult {
1563                action: WriteAction::Already,
1564                note: None,
1565            });
1566        }
1567        servers_obj.insert("lean-ctx".to_string(), desired);
1568
1569        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1570        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1571        return Ok(WriteResult {
1572            action: WriteAction::Updated,
1573            note: None,
1574        });
1575    }
1576
1577    write_mcp_json_fresh(&target.config_path, &desired, None)
1578}
1579
1580fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
1581    if !path.exists() {
1582        return Ok(path.to_path_buf());
1583    }
1584    let parent = path
1585        .parent()
1586        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1587    let filename = path
1588        .file_name()
1589        .ok_or_else(|| "invalid path (no filename)".to_string())?
1590        .to_string_lossy();
1591    let pid = std::process::id();
1592    let nanos = std::time::SystemTime::now()
1593        .duration_since(std::time::UNIX_EPOCH)
1594        .map_or(0, |d| d.as_nanos());
1595    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1596    std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
1597    Ok(bak)
1598}
1599
1600/// Safe handler for invalid JSON config files. NEVER silently overwrites.
1601/// Strategy:
1602/// 1. If lean-ctx is already present in text → skip (no-op)
1603/// 2. Try text-based injection into the container key
1604/// 3. If injection fails → warn user with clear instructions, do NOT modify file
1605fn handle_invalid_json_write(
1606    path: &std::path::Path,
1607    content: &str,
1608    container_key: &str,
1609    entry_key: &str,
1610    value: &serde_json::Value,
1611    allow_inject: bool,
1612) -> Result<WriteResult, String> {
1613    if content.contains(&format!("\"{entry_key}\"")) {
1614        eprintln!(
1615            "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors but already contains \"{entry_key}\".",
1616            path.display()
1617        );
1618        eprintln!("   Skipping — your config is untouched.");
1619        return Ok(WriteResult {
1620            action: WriteAction::Already,
1621            note: Some(format!("invalid JSON, {entry_key} already present")),
1622        });
1623    }
1624
1625    if !allow_inject {
1626        return Err(format!(
1627            "{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n  Path: {}",
1628            path.display(),
1629            path.display()
1630        ));
1631    }
1632
1633    // Try text-based injection
1634    if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
1635        let bak = backup_invalid_file(path)?;
1636        crate::config_io::write_atomic_with_backup(path, &patched)?;
1637        eprintln!(
1638            "\x1b[32m✓\x1b[0m  Added {entry_key} to {} (text-based; file has syntax errors).",
1639            path.display()
1640        );
1641        eprintln!("   \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
1642        eprintln!("   Backup: {}", bak.display());
1643        return Ok(WriteResult {
1644            action: WriteAction::Updated,
1645            note: Some(format!(
1646                "text-injected into invalid JSON (backup: {})",
1647                bak.display()
1648            )),
1649        });
1650    }
1651
1652    // Cannot safely modify — inform user
1653    eprintln!(
1654        "\x1b[33m⚠\x1b[0m  {} contains invalid JSON that lean-ctx cannot safely modify.",
1655        path.display()
1656    );
1657    eprintln!("   \x1b[1mYour config was NOT changed.\x1b[0m");
1658    eprintln!("   To fix:");
1659    eprintln!(
1660        "     1. Open {} and correct the JSON syntax errors",
1661        path.display()
1662    );
1663    eprintln!("     2. Re-run: lean-ctx setup");
1664    eprintln!("   (Common issue: trailing commas, missing quotes, unmatched braces)");
1665    Ok(WriteResult {
1666        action: WriteAction::Already,
1667        note: Some(format!(
1668            "invalid JSON — user must fix manually: {}",
1669            path.display()
1670        )),
1671    })
1672}
1673
1674/// Attempt to inject an MCP entry into a JSON file using text manipulation.
1675/// Preserves the original file content even if it has syntax errors.
1676/// Returns None if text structure doesn't allow safe injection.
1677fn try_text_inject_mcp_entry(
1678    content: &str,
1679    container_key: &str,
1680    entry_key: &str,
1681    value: &serde_json::Value,
1682) -> Option<String> {
1683    let entry = serde_json::to_string_pretty(value).ok()?;
1684    let indented_entry = entry
1685        .lines()
1686        .enumerate()
1687        .map(|(i, line)| {
1688            if i == 0 {
1689                format!("    \"{entry_key}\": {line}")
1690            } else {
1691                format!("    {line}")
1692            }
1693        })
1694        .collect::<Vec<_>>()
1695        .join("\n");
1696
1697    // Strategy 1: find the target container key and inject after its opening brace.
1698    // Prioritize the exact container_key, then fall back to common alternatives.
1699    let quoted_container = format!("\"{container_key}\"");
1700    let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
1701        .chain(
1702            [
1703                "\"mcp\"",
1704                "\"mcpServers\"",
1705                "\"servers\"",
1706                "\"context_servers\"",
1707            ]
1708            .iter()
1709            .filter(|k| **k != quoted_container.as_str())
1710            .copied(),
1711        )
1712        .collect();
1713
1714    for container in &search_keys {
1715        if let Some(pos) = content.find(container) {
1716            let after = &content[pos..];
1717            if let Some(brace_offset) = after.find('{') {
1718                let insert_pos = pos + brace_offset + 1;
1719                let before = &content[..insert_pos];
1720                let rest = &content[insert_pos..];
1721                let needs_comma = !rest.trim_start().starts_with('}');
1722                let injection = if needs_comma {
1723                    format!("\n{indented_entry},")
1724                } else {
1725                    format!("\n{indented_entry}\n  ")
1726                };
1727                return Some(format!("{before}{injection}{rest}"));
1728            }
1729        }
1730    }
1731
1732    // Strategy 2: inject a new container block before the closing root brace
1733    if let Some(last_brace) = content.rfind('}') {
1734        let before = &content[..last_brace];
1735        let after = &content[last_brace..];
1736        let needs_comma = before.trim_end().ends_with('}')
1737            || before.trim_end().ends_with('"')
1738            || before.trim_end().ends_with(']');
1739        let comma = if needs_comma { "," } else { "" };
1740        let block = format!("{comma}\n  \"{container_key}\": {{\n{indented_entry}\n  }}\n");
1741        return Some(format!("{before}{block}{after}"));
1742    }
1743
1744    None
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use super::*;
1750    use std::path::PathBuf;
1751
1752    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1753        EditorTarget {
1754            name,
1755            agent_key: "test".to_string(),
1756            config_path: path,
1757            detect_path: PathBuf::from("/nonexistent"),
1758            config_type: ty,
1759        }
1760    }
1761
1762    #[test]
1763    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1764        let dir = tempfile::tempdir().unwrap();
1765        let path = dir.path().join("mcp.json");
1766        std::fs::write(
1767            &path,
1768            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1769        )
1770        .unwrap();
1771
1772        let t = target("test", path.clone(), ConfigType::McpJson);
1773        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1774        assert_eq!(res.action, WriteAction::Updated);
1775
1776        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1777        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1778        assert_eq!(
1779            json["mcpServers"]["lean-ctx"]["command"],
1780            "/new/path/lean-ctx"
1781        );
1782        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1783    }
1784
1785    #[test]
1786    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1787        let dir = tempfile::tempdir().unwrap();
1788        let path = dir.path().join("mcp.json");
1789        std::fs::write(
1790            &path,
1791            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1792        )
1793        .unwrap();
1794
1795        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1796        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1797        assert_eq!(res.action, WriteAction::Updated);
1798
1799        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1800        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1801        assert_eq!(
1802            json["mcpServers"]["lean-ctx"]["command"],
1803            "/new/path/lean-ctx"
1804        );
1805        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1806        assert!(
1807            json["mcpServers"]["lean-ctx"]["autoApprove"]
1808                .as_array()
1809                .unwrap()
1810                .len()
1811                > 5
1812        );
1813    }
1814
1815    #[test]
1816    fn crush_config_writes_mcp_root() {
1817        let dir = tempfile::tempdir().unwrap();
1818        let path = dir.path().join("crush.json");
1819        std::fs::write(
1820            &path,
1821            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1822        )
1823        .unwrap();
1824
1825        let t = target("test", path.clone(), ConfigType::Crush);
1826        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1827        assert_eq!(res.action, WriteAction::Updated);
1828
1829        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1830        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1831        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1832    }
1833
1834    #[test]
1835    fn codex_toml_upserts_existing_section() {
1836        let dir = tempfile::tempdir().unwrap();
1837        let path = dir.path().join("config.toml");
1838        std::fs::write(
1839            &path,
1840            r#"[mcp_servers.lean-ctx]
1841command = "old"
1842args = ["x"]
1843"#,
1844        )
1845        .unwrap();
1846
1847        let t = target("test", path.clone(), ConfigType::Codex);
1848        let res = write_codex_config(&t, "new").unwrap();
1849        assert_eq!(res.action, WriteAction::Updated);
1850
1851        let content = std::fs::read_to_string(&path).unwrap();
1852        assert!(content.contains(r#"command = "new""#));
1853        assert!(content.contains("args = []"));
1854    }
1855
1856    #[test]
1857    fn upsert_codex_toml_inserts_new_section_when_missing() {
1858        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1859        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1860        assert!(updated.contains("command = \"lean-ctx\""));
1861        assert!(updated.contains("args = []"));
1862    }
1863
1864    #[test]
1865    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1866        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1867        let updated = upsert_codex_toml("", win_path);
1868        assert!(
1869            updated.contains(&format!("command = '{win_path}'")),
1870            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1871        );
1872    }
1873
1874    #[test]
1875    fn codex_toml_uses_double_quotes_for_unix_paths() {
1876        let unix_path = "/usr/local/bin/lean-ctx";
1877        let updated = upsert_codex_toml("", unix_path);
1878        assert!(
1879            updated.contains(&format!("command = \"{unix_path}\"")),
1880            "Unix paths should use double quotes: {updated}"
1881        );
1882    }
1883
1884    #[test]
1885    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1886        let input = "\
1887[mcp_servers.lean-ctx.tools.ctx_multi_read]
1888approval_mode = \"approve\"
1889
1890[mcp_servers.lean-ctx.tools.ctx_read]
1891approval_mode = \"approve\"
1892";
1893        let updated = upsert_codex_toml(input, "lean-ctx");
1894        let parent_pos = updated
1895            .find("[mcp_servers.lean-ctx]\n")
1896            .expect("parent section must be inserted");
1897        let tools_pos = updated
1898            .find("[mcp_servers.lean-ctx.tools.")
1899            .expect("tool sub-tables must be preserved");
1900        assert!(
1901            parent_pos < tools_pos,
1902            "parent must come before tool sub-tables:\n{updated}"
1903        );
1904        assert!(updated.contains("command = \"lean-ctx\""));
1905        assert!(updated.contains("args = []"));
1906        assert!(updated.contains("approval_mode = \"approve\""));
1907    }
1908
1909    #[test]
1910    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1911        let input = "\
1912[mcp_servers.lean-ctx.tools.ctx_multi_read]
1913approval_mode = \"approve\"
1914
1915[mcp_servers.lean-ctx.tools.ctx_read]
1916approval_mode = \"approve\"
1917
1918[mcp_servers.lean-ctx.tools.ctx_search]
1919approval_mode = \"approve\"
1920
1921[mcp_servers.lean-ctx.tools.ctx_tree]
1922approval_mode = \"approve\"
1923";
1924        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1925        let updated = upsert_codex_toml(input, win_path);
1926        assert!(
1927            updated.contains(&format!("command = '{win_path}'")),
1928            "Windows path must use single quotes: {updated}"
1929        );
1930        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1931        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1932        assert!(parent_pos < first_tool);
1933        assert_eq!(
1934            updated.matches("[mcp_servers.lean-ctx]\n").count(),
1935            1,
1936            "parent section must appear exactly once"
1937        );
1938    }
1939
1940    #[test]
1941    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1942        let input = "\
1943[mcp_servers.lean-ctx]
1944command = \"old\"
1945args = [\"x\"]
1946
1947[mcp_servers.lean-ctx.tools.ctx_read]
1948approval_mode = \"approve\"
1949";
1950        let updated = upsert_codex_toml(input, "new");
1951        assert_eq!(
1952            updated.matches("[mcp_servers.lean-ctx]").count(),
1953            1,
1954            "must not duplicate parent section"
1955        );
1956        assert!(updated.contains("command = \"new\""));
1957        assert!(updated.contains("args = []"));
1958        assert!(updated.contains("approval_mode = \"approve\""));
1959    }
1960
1961    #[test]
1962    fn auto_approve_contains_core_tools() {
1963        let tools = auto_approve_tools();
1964        assert!(tools.contains(&"ctx_read"));
1965        assert!(tools.contains(&"ctx_shell"));
1966        assert!(tools.contains(&"ctx_search"));
1967        assert!(tools.contains(&"ctx_workflow"));
1968        assert!(tools.contains(&"ctx_cost"));
1969    }
1970
1971    #[test]
1972    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1973        let dir = tempfile::tempdir().unwrap();
1974        let path = dir.path().join("mcp.json");
1975        std::fs::write(
1976            &path,
1977            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1978        )
1979        .unwrap();
1980
1981        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1982        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1983        assert_eq!(res.action, WriteAction::Updated);
1984
1985        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1986        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1987        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1988        assert_eq!(
1989            json["mcpServers"]["lean-ctx"]["args"],
1990            serde_json::json!([])
1991        );
1992        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1993            .as_str()
1994            .is_some_and(|s| !s.trim().is_empty()));
1995        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1996        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1997        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1998    }
1999
2000    #[test]
2001    fn qoder_mcp_config_is_idempotent() {
2002        let dir = tempfile::tempdir().unwrap();
2003        let path = dir.path().join("mcp.json");
2004        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2005
2006        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2007        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2008
2009        assert_eq!(first.action, WriteAction::Created);
2010        assert_eq!(second.action, WriteAction::Already);
2011    }
2012
2013    #[test]
2014    fn qoder_mcp_config_creates_missing_parent_directories() {
2015        let dir = tempfile::tempdir().unwrap();
2016        let path = dir
2017            .path()
2018            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
2019        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2020
2021        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2022
2023        assert_eq!(res.action, WriteAction::Created);
2024        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2025        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2026    }
2027
2028    #[test]
2029    fn antigravity_config_omits_auto_approve() {
2030        let dir = tempfile::tempdir().unwrap();
2031        let path = dir.path().join("mcp_config.json");
2032
2033        let t = EditorTarget {
2034            name: "Antigravity",
2035            agent_key: "gemini".to_string(),
2036            config_path: path.clone(),
2037            detect_path: PathBuf::from("/nonexistent"),
2038            config_type: ConfigType::McpJson,
2039        };
2040        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
2041        assert_eq!(res.action, WriteAction::Created);
2042
2043        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2044        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
2045        assert_eq!(
2046            json["mcpServers"]["lean-ctx"]["command"],
2047            "/usr/local/bin/lean-ctx"
2048        );
2049    }
2050
2051    #[test]
2052    fn hermes_yaml_inserts_into_existing_mcp_servers() {
2053        let existing = "model: anthropic/claude-sonnet-4\n\nmcp_servers:\n  github:\n    command: \"npx\"\n    args: [\"-y\", \"@modelcontextprotocol/server-github\"]\n\ntool_allowlist:\n  - terminal\n";
2054        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
2055        let result = upsert_hermes_yaml_mcp(existing, block);
2056        assert!(result.contains("lean-ctx"));
2057        assert!(result.contains("model: anthropic/claude-sonnet-4"));
2058        assert!(result.contains("tool_allowlist:"));
2059        assert!(result.contains("github:"));
2060    }
2061
2062    #[test]
2063    fn hermes_yaml_creates_mcp_servers_section() {
2064        let existing = "model: openai/gpt-4o\n";
2065        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
2066        let result = upsert_hermes_yaml_mcp(existing, block);
2067        assert!(result.contains("mcp_servers:"));
2068        assert!(result.contains("lean-ctx"));
2069        assert!(result.contains("model: openai/gpt-4o"));
2070    }
2071
2072    #[test]
2073    fn hermes_yaml_skips_if_already_present() {
2074        let dir = tempfile::tempdir().unwrap();
2075        let path = dir.path().join("config.yaml");
2076        std::fs::write(
2077            &path,
2078            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
2079        )
2080        .unwrap();
2081        let t = target("test", path.clone(), ConfigType::HermesYaml);
2082        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
2083        assert_eq!(res.action, WriteAction::Already);
2084    }
2085
2086    #[test]
2087    fn remove_codex_section_also_removes_env_subtable() {
2088        let input = "\
2089[other]
2090x = 1
2091
2092[mcp_servers.lean-ctx]
2093args = []
2094command = \"/usr/local/bin/lean-ctx\"
2095
2096[mcp_servers.lean-ctx.env]
2097LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
2098
2099[features]
2100codex_hooks = true
2101";
2102        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2103        assert!(
2104            !result.contains("[mcp_servers.lean-ctx]"),
2105            "parent section must be removed"
2106        );
2107        assert!(
2108            !result.contains("LEAN_CTX_DATA_DIR"),
2109            "env sub-table must be removed too"
2110        );
2111        assert!(result.contains("[other]"), "unrelated sections preserved");
2112        assert!(
2113            result.contains("[features]"),
2114            "sections after must be preserved"
2115        );
2116    }
2117
2118    #[test]
2119    fn remove_codex_section_preserves_other_mcp_servers() {
2120        let input = "\
2121[mcp_servers.lean-ctx]
2122command = \"lean-ctx\"
2123
2124[mcp_servers.lean-ctx.env]
2125X = \"1\"
2126
2127[mcp_servers.other]
2128command = \"other\"
2129";
2130        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2131        assert!(!result.contains("[mcp_servers.lean-ctx]"));
2132        assert!(
2133            result.contains("[mcp_servers.other]"),
2134            "other MCP servers must be preserved"
2135        );
2136        assert!(result.contains("command = \"other\""));
2137    }
2138
2139    #[test]
2140    fn remove_codex_section_does_not_remove_similarly_named_server() {
2141        let input = "\
2142[mcp_servers.lean-ctx]
2143command = \"lean-ctx\"
2144
2145[mcp_servers.lean-ctx-probe]
2146command = \"probe\"
2147";
2148        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2149        assert!(
2150            !result.contains("[mcp_servers.lean-ctx]\n"),
2151            "target section must be removed"
2152        );
2153        assert!(
2154            result.contains("[mcp_servers.lean-ctx-probe]"),
2155            "similarly-named server must NOT be removed"
2156        );
2157        assert!(result.contains("command = \"probe\""));
2158    }
2159
2160    #[test]
2161    fn remove_codex_section_handles_no_match() {
2162        let input = "[other]\nx = 1\n";
2163        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2164        assert_eq!(result, "[other]\nx = 1\n");
2165    }
2166
2167    #[test]
2168    fn text_inject_into_existing_mcp_object() {
2169        let content = r#"{
2170  "mcp": {}
2171}"#;
2172        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2173        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2174        assert!(result.is_some());
2175        let patched = result.unwrap();
2176        assert!(patched.contains("\"lean-ctx\""));
2177        assert!(patched.contains("\"type\": \"local\""));
2178    }
2179
2180    #[test]
2181    fn text_inject_creates_container_when_missing() {
2182        let content = r#"{
2183  "some_other_key": "value"
2184}"#;
2185        let value = serde_json::json!({"command": "lean-ctx"});
2186        // For mcpServers container
2187        let result = try_text_inject_mcp_entry(content, "mcpServers", "lean-ctx", &value);
2188        assert!(result.is_some());
2189        let patched = result.unwrap();
2190        assert!(patched.contains("\"mcpServers\""));
2191        assert!(patched.contains("\"lean-ctx\""));
2192
2193        // For mcp container (OpenCode)
2194        let result2 = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2195        assert!(result2.is_some());
2196        let patched2 = result2.unwrap();
2197        assert!(patched2.contains("\"mcp\""));
2198        assert!(patched2.contains("\"lean-ctx\""));
2199
2200        // For context_servers container (Zed)
2201        let result3 = try_text_inject_mcp_entry(content, "context_servers", "lean-ctx", &value);
2202        assert!(result3.is_some());
2203        let patched3 = result3.unwrap();
2204        assert!(patched3.contains("\"context_servers\""));
2205        assert!(patched3.contains("\"lean-ctx\""));
2206    }
2207
2208    #[test]
2209    fn text_inject_into_populated_mcp_object() {
2210        let content = r#"{
2211  "mcp": {
2212    "other-server": {"type": "local"}
2213  }
2214}"#;
2215        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2216        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2217        assert!(result.is_some());
2218        let patched = result.unwrap();
2219        assert!(patched.contains("\"lean-ctx\""));
2220        assert!(patched.contains("\"other-server\""));
2221    }
2222
2223    #[test]
2224    fn handle_invalid_json_skips_when_entry_already_present() {
2225        let content = r#"{ invalid json "lean-ctx": stuff }"#;
2226        let value = serde_json::json!({"type": "local"});
2227        let result = handle_invalid_json_write(
2228            std::path::Path::new("/tmp/test.json"),
2229            content,
2230            "mcp",
2231            "lean-ctx",
2232            &value,
2233            true,
2234        );
2235        assert!(result.is_ok());
2236        let r = result.unwrap();
2237        assert_eq!(r.action, WriteAction::Already);
2238    }
2239
2240    #[test]
2241    fn handle_invalid_json_returns_error_when_inject_disabled() {
2242        let content = r"{ invalid json without key }";
2243        let value = serde_json::json!({"type": "local"});
2244        let result = handle_invalid_json_write(
2245            std::path::Path::new("/tmp/test.json"),
2246            content,
2247            "mcp",
2248            "lean-ctx",
2249            &value,
2250            false,
2251        );
2252        assert!(result.is_err());
2253    }
2254
2255    #[test]
2256    fn handle_invalid_json_does_not_overwrite_file() {
2257        let dir = tempfile::tempdir().unwrap();
2258        let path = dir.path().join("opencode.json");
2259        let invalid_content = r#"{ "mcp": { BROKEN "other": true } }"#;
2260        std::fs::write(&path, invalid_content).unwrap();
2261
2262        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2263        let result =
2264            handle_invalid_json_write(&path, invalid_content, "mcp", "lean-ctx", &value, true);
2265        assert!(result.is_ok());
2266        let r = result.unwrap();
2267        assert_eq!(r.action, WriteAction::Updated);
2268
2269        // Original file should still exist (not deleted/renamed)
2270        let final_content = std::fs::read_to_string(&path).unwrap();
2271        assert!(
2272            final_content.contains("lean-ctx"),
2273            "lean-ctx should be injected"
2274        );
2275        assert!(
2276            final_content.contains("BROKEN"),
2277            "original content preserved"
2278        );
2279    }
2280}