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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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        .map_err(|_| "LEAN_CTX_DATA_DIR unavailable".to_string())?;
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            let has_correct_binary = content.contains(binary);
1443            let has_correct_data_dir = content.contains(&data_dir);
1444            if has_correct_binary && has_correct_data_dir {
1445                return Ok(WriteResult {
1446                    action: WriteAction::Already,
1447                    note: None,
1448                });
1449            }
1450            let cleaned = remove_hermes_yaml_lean_ctx_block(&content);
1451            let updated = upsert_hermes_yaml_mcp(&cleaned, &lean_ctx_block);
1452            crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1453            return Ok(WriteResult {
1454                action: WriteAction::Updated,
1455                note: None,
1456            });
1457        }
1458
1459        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1460        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1461        return Ok(WriteResult {
1462            action: WriteAction::Updated,
1463            note: None,
1464        });
1465    }
1466
1467    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1468    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1469    Ok(WriteResult {
1470        action: WriteAction::Created,
1471        note: None,
1472    })
1473}
1474
1475fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1476    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1477    let mut in_mcp_section = false;
1478    let mut saw_mcp_child = false;
1479    let mut inserted = false;
1480    let lines: Vec<&str> = existing.lines().collect();
1481
1482    for line in &lines {
1483        if !inserted && line.trim_end() == "mcp_servers:" {
1484            in_mcp_section = true;
1485            out.push_str(line);
1486            out.push('\n');
1487            continue;
1488        }
1489
1490        if in_mcp_section && !inserted {
1491            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1492            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1493
1494            if is_child {
1495                saw_mcp_child = true;
1496                out.push_str(line);
1497                out.push('\n');
1498                continue;
1499            }
1500
1501            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1502                out.push_str(lean_ctx_block);
1503                out.push('\n');
1504                inserted = true;
1505                in_mcp_section = false;
1506            }
1507        }
1508
1509        out.push_str(line);
1510        out.push('\n');
1511    }
1512
1513    if in_mcp_section && !inserted {
1514        out.push_str(lean_ctx_block);
1515        out.push('\n');
1516        inserted = true;
1517    }
1518
1519    if !inserted {
1520        if !out.ends_with('\n') {
1521            out.push('\n');
1522        }
1523        out.push_str("\nmcp_servers:\n");
1524        out.push_str(lean_ctx_block);
1525        out.push('\n');
1526    }
1527
1528    out
1529}
1530
1531fn remove_hermes_yaml_lean_ctx_block(content: &str) -> String {
1532    let mut out = String::with_capacity(content.len());
1533    let mut skip = false;
1534    for line in content.lines() {
1535        if line.trim_start().starts_with("lean-ctx:")
1536            && (line.starts_with("  ") || line.starts_with('\t'))
1537        {
1538            skip = true;
1539            continue;
1540        }
1541        if skip {
1542            let indented = line.starts_with("    ") || line.starts_with("\t\t");
1543            let empty = line.trim().is_empty();
1544            if indented || empty {
1545                continue;
1546            }
1547            skip = false;
1548        }
1549        out.push_str(line);
1550        out.push('\n');
1551    }
1552    out
1553}
1554
1555fn write_qoder_settings(
1556    target: &EditorTarget,
1557    binary: &str,
1558    opts: WriteOptions,
1559) -> Result<WriteResult, String> {
1560    let data_dir = default_data_dir()?;
1561    let desired = serde_json::json!({
1562        "command": binary,
1563        "args": [],
1564        "env": {
1565            "LEAN_CTX_DATA_DIR": data_dir,
1566            "LEAN_CTX_FULL_TOOLS": "1"
1567        }
1568    });
1569
1570    if target.config_path.exists() {
1571        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1572        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1573            Ok(v) => v,
1574            Err(_e) => {
1575                return handle_invalid_json_write(
1576                    &target.config_path,
1577                    &content,
1578                    "mcpServers",
1579                    "lean-ctx",
1580                    &desired,
1581                    opts.overwrite_invalid,
1582                );
1583            }
1584        };
1585        let obj = json
1586            .as_object_mut()
1587            .ok_or_else(|| "root JSON must be an object".to_string())?;
1588        let servers = obj
1589            .entry("mcpServers")
1590            .or_insert_with(|| serde_json::json!({}));
1591        let servers_obj = servers
1592            .as_object_mut()
1593            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1594
1595        let existing = servers_obj.get("lean-ctx").cloned();
1596        if existing.as_ref() == Some(&desired) {
1597            return Ok(WriteResult {
1598                action: WriteAction::Already,
1599                note: None,
1600            });
1601        }
1602        servers_obj.insert("lean-ctx".to_string(), desired);
1603
1604        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1605        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1606        return Ok(WriteResult {
1607            action: WriteAction::Updated,
1608            note: None,
1609        });
1610    }
1611
1612    write_mcp_json_fresh(&target.config_path, &desired, None)
1613}
1614
1615fn backup_invalid_file(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
1616    if !path.exists() {
1617        return Ok(path.to_path_buf());
1618    }
1619    let parent = path
1620        .parent()
1621        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1622    let filename = path
1623        .file_name()
1624        .ok_or_else(|| "invalid path (no filename)".to_string())?
1625        .to_string_lossy();
1626    let pid = std::process::id();
1627    let nanos = std::time::SystemTime::now()
1628        .duration_since(std::time::UNIX_EPOCH)
1629        .map_or(0, |d| d.as_nanos());
1630    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1631    std::fs::copy(path, &bak).map_err(|e| e.to_string())?;
1632    Ok(bak)
1633}
1634
1635/// Safe handler for invalid JSON config files. NEVER silently overwrites.
1636/// Strategy:
1637/// 1. If lean-ctx is already present in text → skip (no-op)
1638/// 2. Try text-based injection into the container key
1639/// 3. If injection fails → warn user with clear instructions, do NOT modify file
1640fn handle_invalid_json_write(
1641    path: &std::path::Path,
1642    content: &str,
1643    container_key: &str,
1644    entry_key: &str,
1645    value: &serde_json::Value,
1646    allow_inject: bool,
1647) -> Result<WriteResult, String> {
1648    if content.contains(&format!("\"{entry_key}\"")) {
1649        eprintln!(
1650            "\x1b[33m⚠\x1b[0m  {} has JSON syntax errors but already contains \"{entry_key}\".",
1651            path.display()
1652        );
1653        eprintln!("   Skipping — your config is untouched.");
1654        return Ok(WriteResult {
1655            action: WriteAction::Already,
1656            note: Some(format!("invalid JSON, {entry_key} already present")),
1657        });
1658    }
1659
1660    if !allow_inject {
1661        return Err(format!(
1662            "{} contains invalid JSON. Fix the syntax and re-run lean-ctx setup.\n  Path: {}",
1663            path.display(),
1664            path.display()
1665        ));
1666    }
1667
1668    // Try text-based injection
1669    if let Some(patched) = try_text_inject_mcp_entry(content, container_key, entry_key, value) {
1670        let bak = backup_invalid_file(path)?;
1671        crate::config_io::write_atomic_with_backup(path, &patched)?;
1672        eprintln!(
1673            "\x1b[32m✓\x1b[0m  Added {entry_key} to {} (text-based; file has syntax errors).",
1674            path.display()
1675        );
1676        eprintln!("   \x1b[33mNote:\x1b[0m Your config has JSON syntax errors — please fix them.");
1677        eprintln!("   Backup: {}", bak.display());
1678        return Ok(WriteResult {
1679            action: WriteAction::Updated,
1680            note: Some(format!(
1681                "text-injected into invalid JSON (backup: {})",
1682                bak.display()
1683            )),
1684        });
1685    }
1686
1687    // Cannot safely modify — inform user
1688    eprintln!(
1689        "\x1b[33m⚠\x1b[0m  {} contains invalid JSON that lean-ctx cannot safely modify.",
1690        path.display()
1691    );
1692    eprintln!("   \x1b[1mYour config was NOT changed.\x1b[0m");
1693    eprintln!("   To fix:");
1694    eprintln!(
1695        "     1. Open {} and correct the JSON syntax errors",
1696        path.display()
1697    );
1698    eprintln!("     2. Re-run: lean-ctx setup");
1699    eprintln!("   (Common issue: trailing commas, missing quotes, unmatched braces)");
1700    Ok(WriteResult {
1701        action: WriteAction::Already,
1702        note: Some(format!(
1703            "invalid JSON — user must fix manually: {}",
1704            path.display()
1705        )),
1706    })
1707}
1708
1709/// Attempt to inject an MCP entry into a JSON file using text manipulation.
1710/// Preserves the original file content even if it has syntax errors.
1711/// Returns None if text structure doesn't allow safe injection.
1712fn try_text_inject_mcp_entry(
1713    content: &str,
1714    container_key: &str,
1715    entry_key: &str,
1716    value: &serde_json::Value,
1717) -> Option<String> {
1718    let entry = serde_json::to_string_pretty(value).ok()?;
1719    let indented_entry = entry
1720        .lines()
1721        .enumerate()
1722        .map(|(i, line)| {
1723            if i == 0 {
1724                format!("    \"{entry_key}\": {line}")
1725            } else {
1726                format!("    {line}")
1727            }
1728        })
1729        .collect::<Vec<_>>()
1730        .join("\n");
1731
1732    // Strategy 1: find the target container key and inject after its opening brace.
1733    // Prioritize the exact container_key, then fall back to common alternatives.
1734    let quoted_container = format!("\"{container_key}\"");
1735    let search_keys: Vec<&str> = std::iter::once(quoted_container.as_str())
1736        .chain(
1737            [
1738                "\"mcp\"",
1739                "\"mcpServers\"",
1740                "\"servers\"",
1741                "\"context_servers\"",
1742            ]
1743            .iter()
1744            .filter(|k| **k != quoted_container.as_str())
1745            .copied(),
1746        )
1747        .collect();
1748
1749    for container in &search_keys {
1750        if let Some(pos) = content.find(container) {
1751            let after = &content[pos..];
1752            if let Some(brace_offset) = after.find('{') {
1753                let insert_pos = pos + brace_offset + 1;
1754                let before = &content[..insert_pos];
1755                let rest = &content[insert_pos..];
1756                let needs_comma = !rest.trim_start().starts_with('}');
1757                let injection = if needs_comma {
1758                    format!("\n{indented_entry},")
1759                } else {
1760                    format!("\n{indented_entry}\n  ")
1761                };
1762                return Some(format!("{before}{injection}{rest}"));
1763            }
1764        }
1765    }
1766
1767    // Strategy 2: inject a new container block before the closing root brace
1768    if let Some(last_brace) = content.rfind('}') {
1769        let before = &content[..last_brace];
1770        let after = &content[last_brace..];
1771        let needs_comma = before.trim_end().ends_with('}')
1772            || before.trim_end().ends_with('"')
1773            || before.trim_end().ends_with(']');
1774        let comma = if needs_comma { "," } else { "" };
1775        let block = format!("{comma}\n  \"{container_key}\": {{\n{indented_entry}\n  }}\n");
1776        return Some(format!("{before}{block}{after}"));
1777    }
1778
1779    None
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784    use super::*;
1785    use std::path::PathBuf;
1786
1787    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1788        EditorTarget {
1789            name,
1790            agent_key: "test".to_string(),
1791            config_path: path,
1792            detect_path: PathBuf::from("/nonexistent"),
1793            config_type: ty,
1794        }
1795    }
1796
1797    #[test]
1798    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let path = dir.path().join("mcp.json");
1801        std::fs::write(
1802            &path,
1803            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1804        )
1805        .unwrap();
1806
1807        let t = target("test", path.clone(), ConfigType::McpJson);
1808        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1809        assert_eq!(res.action, WriteAction::Updated);
1810
1811        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1812        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1813        assert_eq!(
1814            json["mcpServers"]["lean-ctx"]["command"],
1815            "/new/path/lean-ctx"
1816        );
1817        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1818    }
1819
1820    #[test]
1821    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1822        let dir = tempfile::tempdir().unwrap();
1823        let path = dir.path().join("mcp.json");
1824        std::fs::write(
1825            &path,
1826            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1827        )
1828        .unwrap();
1829
1830        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1831        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1832        assert_eq!(res.action, WriteAction::Updated);
1833
1834        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1835        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1836        assert_eq!(
1837            json["mcpServers"]["lean-ctx"]["command"],
1838            "/new/path/lean-ctx"
1839        );
1840        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1841        assert!(
1842            json["mcpServers"]["lean-ctx"]["autoApprove"]
1843                .as_array()
1844                .unwrap()
1845                .len()
1846                > 5
1847        );
1848    }
1849
1850    #[test]
1851    fn crush_config_writes_mcp_root() {
1852        let dir = tempfile::tempdir().unwrap();
1853        let path = dir.path().join("crush.json");
1854        std::fs::write(
1855            &path,
1856            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1857        )
1858        .unwrap();
1859
1860        let t = target("test", path.clone(), ConfigType::Crush);
1861        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1862        assert_eq!(res.action, WriteAction::Updated);
1863
1864        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1865        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1866        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1867    }
1868
1869    #[test]
1870    fn codex_toml_upserts_existing_section() {
1871        let dir = tempfile::tempdir().unwrap();
1872        let path = dir.path().join("config.toml");
1873        std::fs::write(
1874            &path,
1875            r#"[mcp_servers.lean-ctx]
1876command = "old"
1877args = ["x"]
1878"#,
1879        )
1880        .unwrap();
1881
1882        let t = target("test", path.clone(), ConfigType::Codex);
1883        let res = write_codex_config(&t, "new").unwrap();
1884        assert_eq!(res.action, WriteAction::Updated);
1885
1886        let content = std::fs::read_to_string(&path).unwrap();
1887        assert!(content.contains(r#"command = "new""#));
1888        assert!(content.contains("args = []"));
1889    }
1890
1891    #[test]
1892    fn upsert_codex_toml_inserts_new_section_when_missing() {
1893        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1894        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1895        assert!(updated.contains("command = \"lean-ctx\""));
1896        assert!(updated.contains("args = []"));
1897    }
1898
1899    #[test]
1900    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1901        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1902        let updated = upsert_codex_toml("", win_path);
1903        assert!(
1904            updated.contains(&format!("command = '{win_path}'")),
1905            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1906        );
1907    }
1908
1909    #[test]
1910    fn codex_toml_uses_double_quotes_for_unix_paths() {
1911        let unix_path = "/usr/local/bin/lean-ctx";
1912        let updated = upsert_codex_toml("", unix_path);
1913        assert!(
1914            updated.contains(&format!("command = \"{unix_path}\"")),
1915            "Unix paths should use double quotes: {updated}"
1916        );
1917    }
1918
1919    #[test]
1920    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1921        let input = "\
1922[mcp_servers.lean-ctx.tools.ctx_multi_read]
1923approval_mode = \"approve\"
1924
1925[mcp_servers.lean-ctx.tools.ctx_read]
1926approval_mode = \"approve\"
1927";
1928        let updated = upsert_codex_toml(input, "lean-ctx");
1929        let parent_pos = updated
1930            .find("[mcp_servers.lean-ctx]\n")
1931            .expect("parent section must be inserted");
1932        let tools_pos = updated
1933            .find("[mcp_servers.lean-ctx.tools.")
1934            .expect("tool sub-tables must be preserved");
1935        assert!(
1936            parent_pos < tools_pos,
1937            "parent must come before tool sub-tables:\n{updated}"
1938        );
1939        assert!(updated.contains("command = \"lean-ctx\""));
1940        assert!(updated.contains("args = []"));
1941        assert!(updated.contains("approval_mode = \"approve\""));
1942    }
1943
1944    #[test]
1945    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1946        let input = "\
1947[mcp_servers.lean-ctx.tools.ctx_multi_read]
1948approval_mode = \"approve\"
1949
1950[mcp_servers.lean-ctx.tools.ctx_read]
1951approval_mode = \"approve\"
1952
1953[mcp_servers.lean-ctx.tools.ctx_search]
1954approval_mode = \"approve\"
1955
1956[mcp_servers.lean-ctx.tools.ctx_tree]
1957approval_mode = \"approve\"
1958";
1959        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1960        let updated = upsert_codex_toml(input, win_path);
1961        assert!(
1962            updated.contains(&format!("command = '{win_path}'")),
1963            "Windows path must use single quotes: {updated}"
1964        );
1965        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1966        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1967        assert!(parent_pos < first_tool);
1968        assert_eq!(
1969            updated.matches("[mcp_servers.lean-ctx]\n").count(),
1970            1,
1971            "parent section must appear exactly once"
1972        );
1973    }
1974
1975    #[test]
1976    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1977        let input = "\
1978[mcp_servers.lean-ctx]
1979command = \"old\"
1980args = [\"x\"]
1981
1982[mcp_servers.lean-ctx.tools.ctx_read]
1983approval_mode = \"approve\"
1984";
1985        let updated = upsert_codex_toml(input, "new");
1986        assert_eq!(
1987            updated.matches("[mcp_servers.lean-ctx]").count(),
1988            1,
1989            "must not duplicate parent section"
1990        );
1991        assert!(updated.contains("command = \"new\""));
1992        assert!(updated.contains("args = []"));
1993        assert!(updated.contains("approval_mode = \"approve\""));
1994    }
1995
1996    #[test]
1997    fn auto_approve_contains_core_tools() {
1998        let tools = auto_approve_tools();
1999        assert!(tools.contains(&"ctx_read"));
2000        assert!(tools.contains(&"ctx_shell"));
2001        assert!(tools.contains(&"ctx_search"));
2002        assert!(tools.contains(&"ctx_workflow"));
2003        assert!(tools.contains(&"ctx_cost"));
2004    }
2005
2006    #[test]
2007    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let path = dir.path().join("mcp.json");
2010        std::fs::write(
2011            &path,
2012            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
2013        )
2014        .unwrap();
2015
2016        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2017        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2018        assert_eq!(res.action, WriteAction::Updated);
2019
2020        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2021        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
2022        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2023        assert_eq!(
2024            json["mcpServers"]["lean-ctx"]["args"],
2025            serde_json::json!([])
2026        );
2027        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
2028            .as_str()
2029            .is_some_and(|s| !s.trim().is_empty()));
2030        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
2031        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
2032        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
2033    }
2034
2035    #[test]
2036    fn qoder_mcp_config_is_idempotent() {
2037        let dir = tempfile::tempdir().unwrap();
2038        let path = dir.path().join("mcp.json");
2039        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2040
2041        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2042        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
2043
2044        assert_eq!(first.action, WriteAction::Created);
2045        assert_eq!(second.action, WriteAction::Already);
2046    }
2047
2048    #[test]
2049    fn qoder_mcp_config_creates_missing_parent_directories() {
2050        let dir = tempfile::tempdir().unwrap();
2051        let path = dir
2052            .path()
2053            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
2054        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
2055
2056        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
2057
2058        assert_eq!(res.action, WriteAction::Created);
2059        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2060        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
2061    }
2062
2063    #[test]
2064    fn antigravity_config_omits_auto_approve() {
2065        let dir = tempfile::tempdir().unwrap();
2066        let path = dir.path().join("mcp_config.json");
2067
2068        let t = EditorTarget {
2069            name: "Antigravity",
2070            agent_key: "gemini".to_string(),
2071            config_path: path.clone(),
2072            detect_path: PathBuf::from("/nonexistent"),
2073            config_type: ConfigType::McpJson,
2074        };
2075        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
2076        assert_eq!(res.action, WriteAction::Created);
2077
2078        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2079        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
2080        assert_eq!(
2081            json["mcpServers"]["lean-ctx"]["command"],
2082            "/usr/local/bin/lean-ctx"
2083        );
2084    }
2085
2086    #[test]
2087    fn hermes_yaml_inserts_into_existing_mcp_servers() {
2088        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";
2089        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
2090        let result = upsert_hermes_yaml_mcp(existing, block);
2091        assert!(result.contains("lean-ctx"));
2092        assert!(result.contains("model: anthropic/claude-sonnet-4"));
2093        assert!(result.contains("tool_allowlist:"));
2094        assert!(result.contains("github:"));
2095    }
2096
2097    #[test]
2098    fn hermes_yaml_creates_mcp_servers_section() {
2099        let existing = "model: openai/gpt-4o\n";
2100        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
2101        let result = upsert_hermes_yaml_mcp(existing, block);
2102        assert!(result.contains("mcp_servers:"));
2103        assert!(result.contains("lean-ctx"));
2104        assert!(result.contains("model: openai/gpt-4o"));
2105    }
2106
2107    #[test]
2108    fn hermes_yaml_skips_if_already_present() {
2109        let dir = tempfile::tempdir().unwrap();
2110        let path = dir.path().join("config.yaml");
2111        let data_dir = crate::core::data_dir::lean_ctx_data_dir()
2112            .map(|d| d.to_string_lossy().to_string())
2113            .unwrap_or_default();
2114        std::fs::write(
2115            &path,
2116            format!("mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\"\n"),
2117        )
2118        .unwrap();
2119        let t = target("test", path.clone(), ConfigType::HermesYaml);
2120        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
2121        assert_eq!(res.action, WriteAction::Already);
2122    }
2123
2124    #[test]
2125    fn remove_codex_section_also_removes_env_subtable() {
2126        let input = "\
2127[other]
2128x = 1
2129
2130[mcp_servers.lean-ctx]
2131args = []
2132command = \"/usr/local/bin/lean-ctx\"
2133
2134[mcp_servers.lean-ctx.env]
2135LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
2136
2137[features]
2138codex_hooks = true
2139";
2140        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2141        assert!(
2142            !result.contains("[mcp_servers.lean-ctx]"),
2143            "parent section must be removed"
2144        );
2145        assert!(
2146            !result.contains("LEAN_CTX_DATA_DIR"),
2147            "env sub-table must be removed too"
2148        );
2149        assert!(result.contains("[other]"), "unrelated sections preserved");
2150        assert!(
2151            result.contains("[features]"),
2152            "sections after must be preserved"
2153        );
2154    }
2155
2156    #[test]
2157    fn remove_codex_section_preserves_other_mcp_servers() {
2158        let input = "\
2159[mcp_servers.lean-ctx]
2160command = \"lean-ctx\"
2161
2162[mcp_servers.lean-ctx.env]
2163X = \"1\"
2164
2165[mcp_servers.other]
2166command = \"other\"
2167";
2168        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2169        assert!(!result.contains("[mcp_servers.lean-ctx]"));
2170        assert!(
2171            result.contains("[mcp_servers.other]"),
2172            "other MCP servers must be preserved"
2173        );
2174        assert!(result.contains("command = \"other\""));
2175    }
2176
2177    #[test]
2178    fn remove_codex_section_does_not_remove_similarly_named_server() {
2179        let input = "\
2180[mcp_servers.lean-ctx]
2181command = \"lean-ctx\"
2182
2183[mcp_servers.lean-ctx-probe]
2184command = \"probe\"
2185";
2186        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2187        assert!(
2188            !result.contains("[mcp_servers.lean-ctx]\n"),
2189            "target section must be removed"
2190        );
2191        assert!(
2192            result.contains("[mcp_servers.lean-ctx-probe]"),
2193            "similarly-named server must NOT be removed"
2194        );
2195        assert!(result.contains("command = \"probe\""));
2196    }
2197
2198    #[test]
2199    fn remove_codex_section_handles_no_match() {
2200        let input = "[other]\nx = 1\n";
2201        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
2202        assert_eq!(result, "[other]\nx = 1\n");
2203    }
2204
2205    #[test]
2206    fn text_inject_into_existing_mcp_object() {
2207        let content = r#"{
2208  "mcp": {}
2209}"#;
2210        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2211        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2212        assert!(result.is_some());
2213        let patched = result.unwrap();
2214        assert!(patched.contains("\"lean-ctx\""));
2215        assert!(patched.contains("\"type\": \"local\""));
2216    }
2217
2218    #[test]
2219    fn text_inject_creates_container_when_missing() {
2220        let content = r#"{
2221  "some_other_key": "value"
2222}"#;
2223        let value = serde_json::json!({"command": "lean-ctx"});
2224        // For mcpServers container
2225        let result = try_text_inject_mcp_entry(content, "mcpServers", "lean-ctx", &value);
2226        assert!(result.is_some());
2227        let patched = result.unwrap();
2228        assert!(patched.contains("\"mcpServers\""));
2229        assert!(patched.contains("\"lean-ctx\""));
2230
2231        // For mcp container (OpenCode)
2232        let result2 = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2233        assert!(result2.is_some());
2234        let patched2 = result2.unwrap();
2235        assert!(patched2.contains("\"mcp\""));
2236        assert!(patched2.contains("\"lean-ctx\""));
2237
2238        // For context_servers container (Zed)
2239        let result3 = try_text_inject_mcp_entry(content, "context_servers", "lean-ctx", &value);
2240        assert!(result3.is_some());
2241        let patched3 = result3.unwrap();
2242        assert!(patched3.contains("\"context_servers\""));
2243        assert!(patched3.contains("\"lean-ctx\""));
2244    }
2245
2246    #[test]
2247    fn text_inject_into_populated_mcp_object() {
2248        let content = r#"{
2249  "mcp": {
2250    "other-server": {"type": "local"}
2251  }
2252}"#;
2253        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2254        let result = try_text_inject_mcp_entry(content, "mcp", "lean-ctx", &value);
2255        assert!(result.is_some());
2256        let patched = result.unwrap();
2257        assert!(patched.contains("\"lean-ctx\""));
2258        assert!(patched.contains("\"other-server\""));
2259    }
2260
2261    #[test]
2262    fn handle_invalid_json_skips_when_entry_already_present() {
2263        let content = r#"{ invalid json "lean-ctx": stuff }"#;
2264        let value = serde_json::json!({"type": "local"});
2265        let result = handle_invalid_json_write(
2266            std::path::Path::new("/tmp/test.json"),
2267            content,
2268            "mcp",
2269            "lean-ctx",
2270            &value,
2271            true,
2272        );
2273        assert!(result.is_ok());
2274        let r = result.unwrap();
2275        assert_eq!(r.action, WriteAction::Already);
2276    }
2277
2278    #[test]
2279    fn handle_invalid_json_returns_error_when_inject_disabled() {
2280        let content = r"{ invalid json without key }";
2281        let value = serde_json::json!({"type": "local"});
2282        let result = handle_invalid_json_write(
2283            std::path::Path::new("/tmp/test.json"),
2284            content,
2285            "mcp",
2286            "lean-ctx",
2287            &value,
2288            false,
2289        );
2290        assert!(result.is_err());
2291    }
2292
2293    #[test]
2294    fn handle_invalid_json_does_not_overwrite_file() {
2295        let dir = tempfile::tempdir().unwrap();
2296        let path = dir.path().join("opencode.json");
2297        let invalid_content = r#"{ "mcp": { BROKEN "other": true } }"#;
2298        std::fs::write(&path, invalid_content).unwrap();
2299
2300        let value = serde_json::json!({"type": "local", "command": ["lean-ctx"]});
2301        let result =
2302            handle_invalid_json_write(&path, invalid_content, "mcp", "lean-ctx", &value, true);
2303        assert!(result.is_ok());
2304        let r = result.unwrap();
2305        assert_eq!(r.action, WriteAction::Updated);
2306
2307        // Original file should still exist (not deleted/renamed)
2308        let final_content = std::fs::read_to_string(&path).unwrap();
2309        assert!(
2310            final_content.contains("lean-ctx"),
2311            "lean-ctx should be injected"
2312        );
2313        assert!(
2314            final_content.contains("BROKEN"),
2315            "original content preserved"
2316        );
2317    }
2318}