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::OpenCode => write_opencode_config(target, binary, opts),
50        ConfigType::Crush => write_crush_config(target, binary, opts),
51        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
52        ConfigType::Amp => write_amp_config(target, binary, opts),
53        ConfigType::HermesYaml => write_hermes_yaml(target, binary, opts),
54        ConfigType::GeminiSettings => write_gemini_settings(target, binary, opts),
55        ConfigType::QoderSettings => write_qoder_settings(target, binary, opts),
56    }
57}
58
59pub fn remove_lean_ctx_mcp_server(
60    path: &std::path::Path,
61    opts: WriteOptions,
62) -> Result<WriteResult, String> {
63    if !path.exists() {
64        return Ok(WriteResult {
65            action: WriteAction::Already,
66            note: Some("mcp.json not found".to_string()),
67        });
68    }
69
70    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
71    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
72        Ok(v) => v,
73        Err(e) => {
74            if !opts.overwrite_invalid {
75                return Err(e.to_string());
76            }
77            backup_invalid_file(path)?;
78            return Ok(WriteResult {
79                action: WriteAction::Updated,
80                note: Some("backed up invalid JSON; did not modify".to_string()),
81            });
82        }
83    };
84
85    let obj = json
86        .as_object_mut()
87        .ok_or_else(|| "root JSON must be an object".to_string())?;
88
89    let Some(servers) = obj.get_mut("mcpServers") else {
90        return Ok(WriteResult {
91            action: WriteAction::Already,
92            note: Some("no mcpServers key".to_string()),
93        });
94    };
95    let servers_obj = servers
96        .as_object_mut()
97        .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
98
99    if servers_obj.remove("lean-ctx").is_none() {
100        return Ok(WriteResult {
101            action: WriteAction::Already,
102            note: Some("lean-ctx not configured".to_string()),
103        });
104    }
105
106    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
107    crate::config_io::write_atomic_with_backup(path, &formatted)?;
108    Ok(WriteResult {
109        action: WriteAction::Updated,
110        note: Some("removed lean-ctx from mcpServers".to_string()),
111    })
112}
113
114pub fn remove_lean_ctx_server(
115    target: &EditorTarget,
116    opts: WriteOptions,
117) -> Result<WriteResult, String> {
118    match target.config_type {
119        ConfigType::McpJson
120        | ConfigType::JetBrains
121        | ConfigType::GeminiSettings
122        | ConfigType::QoderSettings => remove_lean_ctx_mcp_server(&target.config_path, opts),
123        ConfigType::VsCodeMcp => remove_lean_ctx_vscode_server(&target.config_path, opts),
124        ConfigType::Codex => remove_lean_ctx_codex_server(&target.config_path),
125        ConfigType::OpenCode | ConfigType::Crush => {
126            remove_lean_ctx_named_json_server(&target.config_path, "mcp", opts)
127        }
128        ConfigType::Zed => {
129            remove_lean_ctx_named_json_server(&target.config_path, "context_servers", opts)
130        }
131        ConfigType::Amp => remove_lean_ctx_amp_server(&target.config_path, opts),
132        ConfigType::HermesYaml => remove_lean_ctx_hermes_yaml_server(&target.config_path),
133    }
134}
135
136fn remove_lean_ctx_vscode_server(
137    path: &std::path::Path,
138    opts: WriteOptions,
139) -> Result<WriteResult, String> {
140    if !path.exists() {
141        return Ok(WriteResult {
142            action: WriteAction::Already,
143            note: Some("vscode mcp.json not found".to_string()),
144        });
145    }
146
147    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
148    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
149        Ok(v) => v,
150        Err(e) => {
151            if !opts.overwrite_invalid {
152                return Err(e.to_string());
153            }
154            backup_invalid_file(path)?;
155            return Ok(WriteResult {
156                action: WriteAction::Updated,
157                note: Some("backed up invalid JSON; did not modify".to_string()),
158            });
159        }
160    };
161
162    let obj = json
163        .as_object_mut()
164        .ok_or_else(|| "root JSON must be an object".to_string())?;
165
166    let Some(servers) = obj.get_mut("servers") else {
167        return Ok(WriteResult {
168            action: WriteAction::Already,
169            note: Some("no servers key".to_string()),
170        });
171    };
172    let servers_obj = servers
173        .as_object_mut()
174        .ok_or_else(|| "\"servers\" must be an object".to_string())?;
175
176    if servers_obj.remove("lean-ctx").is_none() {
177        return Ok(WriteResult {
178            action: WriteAction::Already,
179            note: Some("lean-ctx not configured".to_string()),
180        });
181    }
182
183    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
184    crate::config_io::write_atomic_with_backup(path, &formatted)?;
185    Ok(WriteResult {
186        action: WriteAction::Updated,
187        note: Some("removed lean-ctx from servers".to_string()),
188    })
189}
190
191fn remove_lean_ctx_amp_server(
192    path: &std::path::Path,
193    opts: WriteOptions,
194) -> Result<WriteResult, String> {
195    if !path.exists() {
196        return Ok(WriteResult {
197            action: WriteAction::Already,
198            note: Some("amp settings not found".to_string()),
199        });
200    }
201
202    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
203    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
204        Ok(v) => v,
205        Err(e) => {
206            if !opts.overwrite_invalid {
207                return Err(e.to_string());
208            }
209            backup_invalid_file(path)?;
210            return Ok(WriteResult {
211                action: WriteAction::Updated,
212                note: Some("backed up invalid JSON; did not modify".to_string()),
213            });
214        }
215    };
216
217    let obj = json
218        .as_object_mut()
219        .ok_or_else(|| "root JSON must be an object".to_string())?;
220    let Some(servers) = obj.get_mut("amp.mcpServers") else {
221        return Ok(WriteResult {
222            action: WriteAction::Already,
223            note: Some("no amp.mcpServers key".to_string()),
224        });
225    };
226    let servers_obj = servers
227        .as_object_mut()
228        .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
229
230    if servers_obj.remove("lean-ctx").is_none() {
231        return Ok(WriteResult {
232            action: WriteAction::Already,
233            note: Some("lean-ctx not configured".to_string()),
234        });
235    }
236
237    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
238    crate::config_io::write_atomic_with_backup(path, &formatted)?;
239    Ok(WriteResult {
240        action: WriteAction::Updated,
241        note: Some("removed lean-ctx from amp.mcpServers".to_string()),
242    })
243}
244
245fn remove_lean_ctx_named_json_server(
246    path: &std::path::Path,
247    container_key: &str,
248    opts: WriteOptions,
249) -> Result<WriteResult, String> {
250    if !path.exists() {
251        return Ok(WriteResult {
252            action: WriteAction::Already,
253            note: Some("config not found".to_string()),
254        });
255    }
256
257    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
258    let mut json = match crate::core::jsonc::parse_jsonc(&content) {
259        Ok(v) => v,
260        Err(e) => {
261            if !opts.overwrite_invalid {
262                return Err(e.to_string());
263            }
264            backup_invalid_file(path)?;
265            return Ok(WriteResult {
266                action: WriteAction::Updated,
267                note: Some("backed up invalid JSON; did not modify".to_string()),
268            });
269        }
270    };
271
272    let obj = json
273        .as_object_mut()
274        .ok_or_else(|| "root JSON must be an object".to_string())?;
275    let Some(container) = obj.get_mut(container_key) else {
276        return Ok(WriteResult {
277            action: WriteAction::Already,
278            note: Some(format!("no {container_key} key")),
279        });
280    };
281    let container_obj = container
282        .as_object_mut()
283        .ok_or_else(|| format!("\"{container_key}\" must be an object"))?;
284
285    if container_obj.remove("lean-ctx").is_none() {
286        return Ok(WriteResult {
287            action: WriteAction::Already,
288            note: Some("lean-ctx not configured".to_string()),
289        });
290    }
291
292    let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
293    crate::config_io::write_atomic_with_backup(path, &formatted)?;
294    Ok(WriteResult {
295        action: WriteAction::Updated,
296        note: Some(format!("removed lean-ctx from {container_key}")),
297    })
298}
299
300fn remove_lean_ctx_codex_server(path: &std::path::Path) -> Result<WriteResult, String> {
301    if !path.exists() {
302        return Ok(WriteResult {
303            action: WriteAction::Already,
304            note: Some("codex config not found".to_string()),
305        });
306    }
307    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
308    let updated = remove_codex_toml_section(&content, "[mcp_servers.lean-ctx]");
309    if updated == content {
310        return Ok(WriteResult {
311            action: WriteAction::Already,
312            note: Some("lean-ctx not configured".to_string()),
313        });
314    }
315    crate::config_io::write_atomic_with_backup(path, &updated)?;
316    Ok(WriteResult {
317        action: WriteAction::Updated,
318        note: Some("removed [mcp_servers.lean-ctx]".to_string()),
319    })
320}
321
322fn remove_codex_toml_section(existing: &str, header: &str) -> String {
323    let prefix = header.trim_end_matches(']');
324    let mut out = String::with_capacity(existing.len());
325    let mut skipping = false;
326    for line in existing.lines() {
327        let trimmed = line.trim();
328        if trimmed.starts_with('[') && trimmed.ends_with(']') {
329            if trimmed == header || trimmed.starts_with(&format!("{prefix}.")) {
330                skipping = true;
331                continue;
332            }
333            skipping = false;
334        }
335        if skipping {
336            continue;
337        }
338        out.push_str(line);
339        out.push('\n');
340    }
341    out
342}
343
344fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
345    if !path.exists() {
346        return Ok(WriteResult {
347            action: WriteAction::Already,
348            note: Some("hermes config not found".to_string()),
349        });
350    }
351    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
352    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
353    if updated == content {
354        return Ok(WriteResult {
355            action: WriteAction::Already,
356            note: Some("lean-ctx not configured".to_string()),
357        });
358    }
359    crate::config_io::write_atomic_with_backup(path, &updated)?;
360    Ok(WriteResult {
361        action: WriteAction::Updated,
362        note: Some("removed lean-ctx from mcp_servers".to_string()),
363    })
364}
365
366fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
367    let mut out = String::with_capacity(existing.len());
368    let mut in_mcp = false;
369    let mut skipping = false;
370    for line in existing.lines() {
371        let trimmed = line.trim_end();
372        if trimmed == "mcp_servers:" {
373            in_mcp = true;
374            out.push_str(line);
375            out.push('\n');
376            continue;
377        }
378
379        if in_mcp {
380            let is_child = line.starts_with("  ") && !line.starts_with("    ");
381            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
382
383            if is_toplevel {
384                in_mcp = false;
385                skipping = false;
386            }
387
388            if skipping {
389                if is_child || is_toplevel {
390                    skipping = false;
391                    out.push_str(line);
392                    out.push('\n');
393                }
394                continue;
395            }
396
397            if is_child && line.trim() == format!("{name}:") {
398                skipping = true;
399                continue;
400            }
401        }
402
403        out.push_str(line);
404        out.push('\n');
405    }
406    out
407}
408
409pub fn auto_approve_tools() -> Vec<&'static str> {
410    vec![
411        "ctx_read",
412        "ctx_shell",
413        "ctx_search",
414        "ctx_tree",
415        "ctx_overview",
416        "ctx_preload",
417        "ctx_compress",
418        "ctx_metrics",
419        "ctx_session",
420        "ctx_knowledge",
421        "ctx_agent",
422        "ctx_share",
423        "ctx_analyze",
424        "ctx_benchmark",
425        "ctx_cache",
426        "ctx_discover",
427        "ctx_smart_read",
428        "ctx_delta",
429        "ctx_edit",
430        "ctx_dedup",
431        "ctx_fill",
432        "ctx_intent",
433        "ctx_response",
434        "ctx_context",
435        "ctx_graph",
436        "ctx_wrapped",
437        "ctx_multi_read",
438        "ctx_semantic_search",
439        "ctx_symbol",
440        "ctx_outline",
441        "ctx_callers",
442        "ctx_callees",
443        "ctx_callgraph",
444        "ctx_routes",
445        "ctx_graph_diagram",
446        "ctx_cost",
447        "ctx_heatmap",
448        "ctx_task",
449        "ctx_impact",
450        "ctx_architecture",
451        "ctx_workflow",
452        "ctx",
453    ]
454}
455
456fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
457    let mut entry = serde_json::json!({
458        "command": binary,
459        "env": {
460            "LEAN_CTX_DATA_DIR": data_dir
461        }
462    });
463    if include_auto_approve {
464        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
465    }
466    entry
467}
468
469fn supports_auto_approve(target: &EditorTarget) -> bool {
470    crate::core::client_constraints::by_editor_name(target.name)
471        .is_some_and(|c| c.supports_auto_approve)
472}
473
474fn default_data_dir() -> Result<String, String> {
475    Ok(crate::core::data_dir::lean_ctx_data_dir()?
476        .to_string_lossy()
477        .to_string())
478}
479
480fn write_mcp_json(
481    target: &EditorTarget,
482    binary: &str,
483    opts: WriteOptions,
484) -> Result<WriteResult, String> {
485    let data_dir = default_data_dir()?;
486    let include_aa = supports_auto_approve(target);
487    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
488
489    // Claude Code manages ~/.claude.json and may overwrite it on first start.
490    // Prefer the official CLI integration when available.
491    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
492    // spawning `claude mcp add-json` which can stall in non-interactive CI.
493    if (target.agent_key == "claude" || target.name == "Claude Code")
494        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
495    {
496        if let Ok(result) = try_claude_mcp_add(&desired) {
497            return Ok(result);
498        }
499    }
500
501    if target.config_path.exists() {
502        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
503        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
504            Ok(v) => v,
505            Err(e) => {
506                if !opts.overwrite_invalid {
507                    return Err(e.to_string());
508                }
509                backup_invalid_file(&target.config_path)?;
510                return write_mcp_json_fresh(
511                    &target.config_path,
512                    &desired,
513                    Some("overwrote invalid JSON".to_string()),
514                );
515            }
516        };
517        let obj = json
518            .as_object_mut()
519            .ok_or_else(|| "root JSON must be an object".to_string())?;
520
521        let servers = obj
522            .entry("mcpServers")
523            .or_insert_with(|| serde_json::json!({}));
524        let servers_obj = servers
525            .as_object_mut()
526            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
527
528        let existing = servers_obj.get("lean-ctx").cloned();
529        if existing.as_ref() == Some(&desired) {
530            return Ok(WriteResult {
531                action: WriteAction::Already,
532                note: None,
533            });
534        }
535        servers_obj.insert("lean-ctx".to_string(), desired);
536
537        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
538        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
539        return Ok(WriteResult {
540            action: WriteAction::Updated,
541            note: None,
542        });
543    }
544
545    write_mcp_json_fresh(&target.config_path, &desired, None)
546}
547
548fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
549    use std::io::Write;
550    use std::process::{Command, Stdio};
551    use std::time::{Duration, Instant};
552
553    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
554
555    // On Windows, `claude` may be a `.cmd` shim and cannot be executed directly
556    // via CreateProcess; route through `cmd /C` for reliable invocation.
557    let mut cmd = if cfg!(windows) {
558        let mut c = Command::new("cmd");
559        c.args([
560            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
561        ]);
562        c
563    } else {
564        let mut c = Command::new("claude");
565        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
566        c
567    };
568
569    let mut child = cmd
570        .stdin(Stdio::piped())
571        .stdout(Stdio::null())
572        .stderr(Stdio::null())
573        .spawn()
574        .map_err(|e| e.to_string())?;
575
576    if let Some(mut stdin) = child.stdin.take() {
577        let _ = stdin.write_all(server_json.as_bytes());
578    }
579
580    let deadline = Duration::from_secs(3);
581    let start = Instant::now();
582    loop {
583        match child.try_wait() {
584            Ok(Some(status)) => {
585                return if status.success() {
586                    Ok(WriteResult {
587                        action: WriteAction::Updated,
588                        note: Some("via claude mcp add-json".to_string()),
589                    })
590                } else {
591                    Err("claude mcp add-json failed".to_string())
592                };
593            }
594            Ok(None) => {
595                if start.elapsed() > deadline {
596                    let _ = child.kill();
597                    let _ = child.wait();
598                    return Err("claude mcp add-json timed out".to_string());
599                }
600                std::thread::sleep(Duration::from_millis(20));
601            }
602            Err(e) => return Err(e.to_string()),
603        }
604    }
605}
606
607fn write_mcp_json_fresh(
608    path: &std::path::Path,
609    desired: &Value,
610    note: Option<String>,
611) -> Result<WriteResult, String> {
612    let content = serde_json::to_string_pretty(&serde_json::json!({
613        "mcpServers": { "lean-ctx": desired }
614    }))
615    .map_err(|e| e.to_string())?;
616    crate::config_io::write_atomic_with_backup(path, &content)?;
617    Ok(WriteResult {
618        action: if note.is_some() {
619            WriteAction::Updated
620        } else {
621            WriteAction::Created
622        },
623        note,
624    })
625}
626
627fn write_zed_config(
628    target: &EditorTarget,
629    binary: &str,
630    opts: WriteOptions,
631) -> Result<WriteResult, String> {
632    let desired = serde_json::json!({
633        "source": "custom",
634        "command": binary,
635        "args": [],
636        "env": {}
637    });
638
639    if target.config_path.exists() {
640        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
641        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
642            Ok(v) => v,
643            Err(e) => {
644                if !opts.overwrite_invalid {
645                    return Err(e.to_string());
646                }
647                backup_invalid_file(&target.config_path)?;
648                return write_zed_config_fresh(
649                    &target.config_path,
650                    &desired,
651                    Some("overwrote invalid JSON".to_string()),
652                );
653            }
654        };
655        let obj = json
656            .as_object_mut()
657            .ok_or_else(|| "root JSON must be an object".to_string())?;
658
659        let servers = obj
660            .entry("context_servers")
661            .or_insert_with(|| serde_json::json!({}));
662        let servers_obj = servers
663            .as_object_mut()
664            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
665
666        let existing = servers_obj.get("lean-ctx").cloned();
667        if existing.as_ref() == Some(&desired) {
668            return Ok(WriteResult {
669                action: WriteAction::Already,
670                note: None,
671            });
672        }
673        servers_obj.insert("lean-ctx".to_string(), desired);
674
675        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
676        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
677        return Ok(WriteResult {
678            action: WriteAction::Updated,
679            note: None,
680        });
681    }
682
683    write_zed_config_fresh(&target.config_path, &desired, None)
684}
685
686fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
687    if target.config_path.exists() {
688        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
689        let updated = upsert_codex_toml(&content, binary);
690        if updated == content {
691            return Ok(WriteResult {
692                action: WriteAction::Already,
693                note: None,
694            });
695        }
696        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
697        return Ok(WriteResult {
698            action: WriteAction::Updated,
699            note: None,
700        });
701    }
702
703    let content = format!(
704        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
705        toml_quote(binary)
706    );
707    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
708    Ok(WriteResult {
709        action: WriteAction::Created,
710        note: None,
711    })
712}
713
714fn write_zed_config_fresh(
715    path: &std::path::Path,
716    desired: &Value,
717    note: Option<String>,
718) -> Result<WriteResult, String> {
719    let content = serde_json::to_string_pretty(&serde_json::json!({
720        "context_servers": { "lean-ctx": desired }
721    }))
722    .map_err(|e| e.to_string())?;
723    crate::config_io::write_atomic_with_backup(path, &content)?;
724    Ok(WriteResult {
725        action: if note.is_some() {
726            WriteAction::Updated
727        } else {
728            WriteAction::Created
729        },
730        note,
731    })
732}
733
734fn write_vscode_mcp(
735    target: &EditorTarget,
736    binary: &str,
737    opts: WriteOptions,
738) -> Result<WriteResult, String> {
739    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
740        .map(|d| d.to_string_lossy().to_string())
741        .unwrap_or_default();
742    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
743
744    if target.config_path.exists() {
745        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
746        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
747            Ok(v) => v,
748            Err(e) => {
749                if !opts.overwrite_invalid {
750                    return Err(e.to_string());
751                }
752                backup_invalid_file(&target.config_path)?;
753                return write_vscode_mcp_fresh(
754                    &target.config_path,
755                    binary,
756                    Some("overwrote invalid JSON".to_string()),
757                );
758            }
759        };
760        let obj = json
761            .as_object_mut()
762            .ok_or_else(|| "root JSON must be an object".to_string())?;
763
764        let servers = obj
765            .entry("servers")
766            .or_insert_with(|| serde_json::json!({}));
767        let servers_obj = servers
768            .as_object_mut()
769            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
770
771        let existing = servers_obj.get("lean-ctx").cloned();
772        if existing.as_ref() == Some(&desired) {
773            return Ok(WriteResult {
774                action: WriteAction::Already,
775                note: None,
776            });
777        }
778        servers_obj.insert("lean-ctx".to_string(), desired);
779
780        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
781        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
782        return Ok(WriteResult {
783            action: WriteAction::Updated,
784            note: None,
785        });
786    }
787
788    write_vscode_mcp_fresh(&target.config_path, binary, None)
789}
790
791fn write_vscode_mcp_fresh(
792    path: &std::path::Path,
793    binary: &str,
794    note: Option<String>,
795) -> Result<WriteResult, String> {
796    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
797        .map(|d| d.to_string_lossy().to_string())
798        .unwrap_or_default();
799    let content = serde_json::to_string_pretty(&serde_json::json!({
800        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
801    }))
802    .map_err(|e| e.to_string())?;
803    crate::config_io::write_atomic_with_backup(path, &content)?;
804    Ok(WriteResult {
805        action: if note.is_some() {
806            WriteAction::Updated
807        } else {
808            WriteAction::Created
809        },
810        note,
811    })
812}
813
814fn write_opencode_config(
815    target: &EditorTarget,
816    binary: &str,
817    opts: WriteOptions,
818) -> Result<WriteResult, String> {
819    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
820        .map(|d| d.to_string_lossy().to_string())
821        .unwrap_or_default();
822    let desired = serde_json::json!({
823        "type": "local",
824        "command": [binary],
825        "enabled": true,
826        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
827    });
828
829    if target.config_path.exists() {
830        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
831        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
832            Ok(v) => v,
833            Err(e) => {
834                if !opts.overwrite_invalid {
835                    return Err(e.to_string());
836                }
837                backup_invalid_file(&target.config_path)?;
838                return write_opencode_fresh(
839                    &target.config_path,
840                    binary,
841                    Some("overwrote invalid JSON".to_string()),
842                );
843            }
844        };
845        let obj = json
846            .as_object_mut()
847            .ok_or_else(|| "root JSON must be an object".to_string())?;
848        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
849        let mcp_obj = mcp
850            .as_object_mut()
851            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
852
853        let existing = mcp_obj.get("lean-ctx").cloned();
854        if existing.as_ref() == Some(&desired) {
855            return Ok(WriteResult {
856                action: WriteAction::Already,
857                note: None,
858            });
859        }
860        mcp_obj.insert("lean-ctx".to_string(), desired);
861
862        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
863        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
864        return Ok(WriteResult {
865            action: WriteAction::Updated,
866            note: None,
867        });
868    }
869
870    write_opencode_fresh(&target.config_path, binary, None)
871}
872
873fn write_opencode_fresh(
874    path: &std::path::Path,
875    binary: &str,
876    note: Option<String>,
877) -> Result<WriteResult, String> {
878    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
879        .map(|d| d.to_string_lossy().to_string())
880        .unwrap_or_default();
881    let content = serde_json::to_string_pretty(&serde_json::json!({
882        "$schema": "https://opencode.ai/config.json",
883        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
884    }))
885    .map_err(|e| e.to_string())?;
886    crate::config_io::write_atomic_with_backup(path, &content)?;
887    Ok(WriteResult {
888        action: if note.is_some() {
889            WriteAction::Updated
890        } else {
891            WriteAction::Created
892        },
893        note,
894    })
895}
896
897fn write_jetbrains_config(
898    target: &EditorTarget,
899    binary: &str,
900    opts: WriteOptions,
901) -> Result<WriteResult, String> {
902    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
903        .map(|d| d.to_string_lossy().to_string())
904        .unwrap_or_default();
905    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
906    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
907    // We write that snippet to a file for easy copy/paste.
908    let desired = serde_json::json!({
909        "command": binary,
910        "args": [],
911        "env": { "LEAN_CTX_DATA_DIR": data_dir }
912    });
913
914    if target.config_path.exists() {
915        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
916        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
917            Ok(v) => v,
918            Err(e) => {
919                if !opts.overwrite_invalid {
920                    return Err(e.to_string());
921                }
922                backup_invalid_file(&target.config_path)?;
923                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
924                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
925                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
926                return Ok(WriteResult {
927                    action: WriteAction::Updated,
928                    note: Some(
929                        "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
930                            .to_string(),
931                    ),
932                });
933            }
934        };
935        let obj = json
936            .as_object_mut()
937            .ok_or_else(|| "root JSON must be an object".to_string())?;
938
939        let servers = obj
940            .entry("mcpServers")
941            .or_insert_with(|| serde_json::json!({}));
942        let servers_obj = servers
943            .as_object_mut()
944            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
945
946        let existing = servers_obj.get("lean-ctx").cloned();
947        if existing.as_ref() == Some(&desired) {
948            return Ok(WriteResult {
949                action: WriteAction::Already,
950                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
951            });
952        }
953        servers_obj.insert("lean-ctx".to_string(), desired);
954
955        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
956        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
957        return Ok(WriteResult {
958            action: WriteAction::Updated,
959            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
960        });
961    }
962
963    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
964    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
965    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
966    Ok(WriteResult {
967        action: WriteAction::Created,
968        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
969    })
970}
971
972fn write_amp_config(
973    target: &EditorTarget,
974    binary: &str,
975    opts: WriteOptions,
976) -> Result<WriteResult, String> {
977    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
978        .map(|d| d.to_string_lossy().to_string())
979        .unwrap_or_default();
980    let entry = serde_json::json!({
981        "command": binary,
982        "env": { "LEAN_CTX_DATA_DIR": data_dir }
983    });
984
985    if target.config_path.exists() {
986        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
987        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
988            Ok(v) => v,
989            Err(e) => {
990                if !opts.overwrite_invalid {
991                    return Err(e.to_string());
992                }
993                backup_invalid_file(&target.config_path)?;
994                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
995                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
996                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
997                return Ok(WriteResult {
998                    action: WriteAction::Updated,
999                    note: Some("overwrote invalid JSON".to_string()),
1000                });
1001            }
1002        };
1003        let obj = json
1004            .as_object_mut()
1005            .ok_or_else(|| "root JSON must be an object".to_string())?;
1006        let servers = obj
1007            .entry("amp.mcpServers")
1008            .or_insert_with(|| serde_json::json!({}));
1009        let servers_obj = servers
1010            .as_object_mut()
1011            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1012
1013        let existing = servers_obj.get("lean-ctx").cloned();
1014        if existing.as_ref() == Some(&entry) {
1015            return Ok(WriteResult {
1016                action: WriteAction::Already,
1017                note: None,
1018            });
1019        }
1020        servers_obj.insert("lean-ctx".to_string(), entry);
1021
1022        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1023        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1024        return Ok(WriteResult {
1025            action: WriteAction::Updated,
1026            note: None,
1027        });
1028    }
1029
1030    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1031    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1032    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1033    Ok(WriteResult {
1034        action: WriteAction::Created,
1035        note: None,
1036    })
1037}
1038
1039fn write_crush_config(
1040    target: &EditorTarget,
1041    binary: &str,
1042    opts: WriteOptions,
1043) -> Result<WriteResult, String> {
1044    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1045        .map(|d| d.to_string_lossy().to_string())
1046        .unwrap_or_default();
1047    let desired = serde_json::json!({
1048        "type": "stdio",
1049        "command": binary,
1050        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1051    });
1052
1053    if target.config_path.exists() {
1054        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1055        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1056            Ok(v) => v,
1057            Err(e) => {
1058                if !opts.overwrite_invalid {
1059                    return Err(e.to_string());
1060                }
1061                backup_invalid_file(&target.config_path)?;
1062                return write_crush_fresh(
1063                    &target.config_path,
1064                    &desired,
1065                    Some("overwrote invalid JSON".to_string()),
1066                );
1067            }
1068        };
1069        let obj = json
1070            .as_object_mut()
1071            .ok_or_else(|| "root JSON must be an object".to_string())?;
1072        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1073        let mcp_obj = mcp
1074            .as_object_mut()
1075            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1076
1077        let existing = mcp_obj.get("lean-ctx").cloned();
1078        if existing.as_ref() == Some(&desired) {
1079            return Ok(WriteResult {
1080                action: WriteAction::Already,
1081                note: None,
1082            });
1083        }
1084        mcp_obj.insert("lean-ctx".to_string(), desired);
1085
1086        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1087        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1088        return Ok(WriteResult {
1089            action: WriteAction::Updated,
1090            note: None,
1091        });
1092    }
1093
1094    write_crush_fresh(&target.config_path, &desired, None)
1095}
1096
1097fn write_crush_fresh(
1098    path: &std::path::Path,
1099    desired: &Value,
1100    note: Option<String>,
1101) -> Result<WriteResult, String> {
1102    let content = serde_json::to_string_pretty(&serde_json::json!({
1103        "mcp": { "lean-ctx": desired }
1104    }))
1105    .map_err(|e| e.to_string())?;
1106    crate::config_io::write_atomic_with_backup(path, &content)?;
1107    Ok(WriteResult {
1108        action: if note.is_some() {
1109            WriteAction::Updated
1110        } else {
1111            WriteAction::Created
1112        },
1113        note,
1114    })
1115}
1116
1117fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1118    let mut out = String::with_capacity(existing.len() + 128);
1119    let mut in_section = false;
1120    let mut saw_section = false;
1121    let mut wrote_command = false;
1122    let mut wrote_args = false;
1123    let mut inserted_parent_before_subtable = false;
1124
1125    let parent_block = format!(
1126        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n\n",
1127        toml_quote(binary)
1128    );
1129
1130    for line in existing.lines() {
1131        let trimmed = line.trim();
1132        if trimmed == "[]" {
1133            continue;
1134        }
1135        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1136            if in_section && !wrote_command {
1137                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1138                wrote_command = true;
1139            }
1140            if in_section && !wrote_args {
1141                out.push_str("args = []\n");
1142                wrote_args = true;
1143            }
1144            in_section = trimmed == "[mcp_servers.lean-ctx]";
1145            if in_section {
1146                saw_section = true;
1147            } else if !saw_section
1148                && !inserted_parent_before_subtable
1149                && trimmed.starts_with("[mcp_servers.lean-ctx.")
1150            {
1151                out.push_str(&parent_block);
1152                inserted_parent_before_subtable = true;
1153            }
1154            out.push_str(line);
1155            out.push('\n');
1156            continue;
1157        }
1158
1159        if in_section {
1160            if trimmed.starts_with("command") && trimmed.contains('=') {
1161                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1162                wrote_command = true;
1163                continue;
1164            }
1165            if trimmed.starts_with("args") && trimmed.contains('=') {
1166                out.push_str("args = []\n");
1167                wrote_args = true;
1168                continue;
1169            }
1170        }
1171
1172        out.push_str(line);
1173        out.push('\n');
1174    }
1175
1176    if saw_section {
1177        if in_section && !wrote_command {
1178            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1179        }
1180        if in_section && !wrote_args {
1181            out.push_str("args = []\n");
1182        }
1183        return out;
1184    }
1185
1186    if inserted_parent_before_subtable {
1187        return out;
1188    }
1189
1190    if !out.ends_with('\n') {
1191        out.push('\n');
1192    }
1193    out.push_str("\n[mcp_servers.lean-ctx]\n");
1194    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1195    out.push_str("args = []\n");
1196    out
1197}
1198
1199fn write_gemini_settings(
1200    target: &EditorTarget,
1201    binary: &str,
1202    opts: WriteOptions,
1203) -> Result<WriteResult, String> {
1204    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1205        .map(|d| d.to_string_lossy().to_string())
1206        .unwrap_or_default();
1207    let entry = serde_json::json!({
1208        "command": binary,
1209        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1210        "trust": true,
1211    });
1212
1213    if target.config_path.exists() {
1214        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1215        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1216            Ok(v) => v,
1217            Err(e) => {
1218                if !opts.overwrite_invalid {
1219                    return Err(e.to_string());
1220                }
1221                backup_invalid_file(&target.config_path)?;
1222                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1223                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1224                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1225                return Ok(WriteResult {
1226                    action: WriteAction::Updated,
1227                    note: Some("overwrote invalid JSON".to_string()),
1228                });
1229            }
1230        };
1231        let obj = json
1232            .as_object_mut()
1233            .ok_or_else(|| "root JSON must be an object".to_string())?;
1234        let servers = obj
1235            .entry("mcpServers")
1236            .or_insert_with(|| serde_json::json!({}));
1237        let servers_obj = servers
1238            .as_object_mut()
1239            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1240
1241        let existing = servers_obj.get("lean-ctx").cloned();
1242        if existing.as_ref() == Some(&entry) {
1243            return Ok(WriteResult {
1244                action: WriteAction::Already,
1245                note: None,
1246            });
1247        }
1248        servers_obj.insert("lean-ctx".to_string(), entry);
1249
1250        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1251        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1252        return Ok(WriteResult {
1253            action: WriteAction::Updated,
1254            note: None,
1255        });
1256    }
1257
1258    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1259    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1260    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1261    Ok(WriteResult {
1262        action: WriteAction::Created,
1263        note: None,
1264    })
1265}
1266
1267fn write_hermes_yaml(
1268    target: &EditorTarget,
1269    binary: &str,
1270    _opts: WriteOptions,
1271) -> Result<WriteResult, String> {
1272    let data_dir = default_data_dir()?;
1273
1274    let lean_ctx_block = format!(
1275        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1276    );
1277
1278    if target.config_path.exists() {
1279        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1280
1281        if content.contains("lean-ctx") {
1282            return Ok(WriteResult {
1283                action: WriteAction::Already,
1284                note: None,
1285            });
1286        }
1287
1288        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1289        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1290        return Ok(WriteResult {
1291            action: WriteAction::Updated,
1292            note: None,
1293        });
1294    }
1295
1296    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1297    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1298    Ok(WriteResult {
1299        action: WriteAction::Created,
1300        note: None,
1301    })
1302}
1303
1304fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1305    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1306    let mut in_mcp_section = false;
1307    let mut saw_mcp_child = false;
1308    let mut inserted = false;
1309    let lines: Vec<&str> = existing.lines().collect();
1310
1311    for line in &lines {
1312        if !inserted && line.trim_end() == "mcp_servers:" {
1313            in_mcp_section = true;
1314            out.push_str(line);
1315            out.push('\n');
1316            continue;
1317        }
1318
1319        if in_mcp_section && !inserted {
1320            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1321            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1322
1323            if is_child {
1324                saw_mcp_child = true;
1325                out.push_str(line);
1326                out.push('\n');
1327                continue;
1328            }
1329
1330            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1331                out.push_str(lean_ctx_block);
1332                out.push('\n');
1333                inserted = true;
1334                in_mcp_section = false;
1335            }
1336        }
1337
1338        out.push_str(line);
1339        out.push('\n');
1340    }
1341
1342    if in_mcp_section && !inserted {
1343        out.push_str(lean_ctx_block);
1344        out.push('\n');
1345        inserted = true;
1346    }
1347
1348    if !inserted {
1349        if !out.ends_with('\n') {
1350            out.push('\n');
1351        }
1352        out.push_str("\nmcp_servers:\n");
1353        out.push_str(lean_ctx_block);
1354        out.push('\n');
1355    }
1356
1357    out
1358}
1359
1360fn write_qoder_settings(
1361    target: &EditorTarget,
1362    binary: &str,
1363    opts: WriteOptions,
1364) -> Result<WriteResult, String> {
1365    let data_dir = default_data_dir()?;
1366    let desired = serde_json::json!({
1367        "command": binary,
1368        "args": [],
1369        "env": {
1370            "LEAN_CTX_DATA_DIR": data_dir,
1371            "LEAN_CTX_FULL_TOOLS": "1"
1372        }
1373    });
1374
1375    if target.config_path.exists() {
1376        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1377        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1378            Ok(v) => v,
1379            Err(e) => {
1380                if !opts.overwrite_invalid {
1381                    return Err(e.to_string());
1382                }
1383                backup_invalid_file(&target.config_path)?;
1384                return write_mcp_json_fresh(
1385                    &target.config_path,
1386                    &desired,
1387                    Some("overwrote invalid JSON".to_string()),
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(&desired) {
1403            return Ok(WriteResult {
1404                action: WriteAction::Already,
1405                note: None,
1406            });
1407        }
1408        servers_obj.insert("lean-ctx".to_string(), desired);
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    write_mcp_json_fresh(&target.config_path, &desired, None)
1419}
1420
1421fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1422    if !path.exists() {
1423        return Ok(());
1424    }
1425    let parent = path
1426        .parent()
1427        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1428    let filename = path
1429        .file_name()
1430        .ok_or_else(|| "invalid path (no filename)".to_string())?
1431        .to_string_lossy();
1432    let pid = std::process::id();
1433    let nanos = std::time::SystemTime::now()
1434        .duration_since(std::time::UNIX_EPOCH)
1435        .map_or(0, |d| d.as_nanos());
1436    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1437    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1438    Ok(())
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444    use std::path::PathBuf;
1445
1446    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1447        EditorTarget {
1448            name,
1449            agent_key: "test".to_string(),
1450            config_path: path,
1451            detect_path: PathBuf::from("/nonexistent"),
1452            config_type: ty,
1453        }
1454    }
1455
1456    #[test]
1457    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1458        let dir = tempfile::tempdir().unwrap();
1459        let path = dir.path().join("mcp.json");
1460        std::fs::write(
1461            &path,
1462            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1463        )
1464        .unwrap();
1465
1466        let t = target("test", path.clone(), ConfigType::McpJson);
1467        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1468        assert_eq!(res.action, WriteAction::Updated);
1469
1470        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1471        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1472        assert_eq!(
1473            json["mcpServers"]["lean-ctx"]["command"],
1474            "/new/path/lean-ctx"
1475        );
1476        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1477    }
1478
1479    #[test]
1480    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1481        let dir = tempfile::tempdir().unwrap();
1482        let path = dir.path().join("mcp.json");
1483        std::fs::write(
1484            &path,
1485            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1486        )
1487        .unwrap();
1488
1489        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1490        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1491        assert_eq!(res.action, WriteAction::Updated);
1492
1493        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1494        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1495        assert_eq!(
1496            json["mcpServers"]["lean-ctx"]["command"],
1497            "/new/path/lean-ctx"
1498        );
1499        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1500        assert!(
1501            json["mcpServers"]["lean-ctx"]["autoApprove"]
1502                .as_array()
1503                .unwrap()
1504                .len()
1505                > 5
1506        );
1507    }
1508
1509    #[test]
1510    fn crush_config_writes_mcp_root() {
1511        let dir = tempfile::tempdir().unwrap();
1512        let path = dir.path().join("crush.json");
1513        std::fs::write(
1514            &path,
1515            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1516        )
1517        .unwrap();
1518
1519        let t = target("test", path.clone(), ConfigType::Crush);
1520        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1521        assert_eq!(res.action, WriteAction::Updated);
1522
1523        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1524        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1525        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1526    }
1527
1528    #[test]
1529    fn codex_toml_upserts_existing_section() {
1530        let dir = tempfile::tempdir().unwrap();
1531        let path = dir.path().join("config.toml");
1532        std::fs::write(
1533            &path,
1534            r#"[mcp_servers.lean-ctx]
1535command = "old"
1536args = ["x"]
1537"#,
1538        )
1539        .unwrap();
1540
1541        let t = target("test", path.clone(), ConfigType::Codex);
1542        let res = write_codex_config(&t, "new").unwrap();
1543        assert_eq!(res.action, WriteAction::Updated);
1544
1545        let content = std::fs::read_to_string(&path).unwrap();
1546        assert!(content.contains(r#"command = "new""#));
1547        assert!(content.contains("args = []"));
1548    }
1549
1550    #[test]
1551    fn upsert_codex_toml_inserts_new_section_when_missing() {
1552        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1553        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1554        assert!(updated.contains("command = \"lean-ctx\""));
1555        assert!(updated.contains("args = []"));
1556    }
1557
1558    #[test]
1559    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1560        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1561        let updated = upsert_codex_toml("", win_path);
1562        assert!(
1563            updated.contains(&format!("command = '{win_path}'")),
1564            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1565        );
1566    }
1567
1568    #[test]
1569    fn codex_toml_uses_double_quotes_for_unix_paths() {
1570        let unix_path = "/usr/local/bin/lean-ctx";
1571        let updated = upsert_codex_toml("", unix_path);
1572        assert!(
1573            updated.contains(&format!("command = \"{unix_path}\"")),
1574            "Unix paths should use double quotes: {updated}"
1575        );
1576    }
1577
1578    #[test]
1579    fn upsert_codex_toml_inserts_parent_before_orphaned_tool_subtables() {
1580        let input = "\
1581[mcp_servers.lean-ctx.tools.ctx_multi_read]
1582approval_mode = \"approve\"
1583
1584[mcp_servers.lean-ctx.tools.ctx_read]
1585approval_mode = \"approve\"
1586";
1587        let updated = upsert_codex_toml(input, "lean-ctx");
1588        let parent_pos = updated
1589            .find("[mcp_servers.lean-ctx]\n")
1590            .expect("parent section must be inserted");
1591        let tools_pos = updated
1592            .find("[mcp_servers.lean-ctx.tools.")
1593            .expect("tool sub-tables must be preserved");
1594        assert!(
1595            parent_pos < tools_pos,
1596            "parent must come before tool sub-tables:\n{updated}"
1597        );
1598        assert!(updated.contains("command = \"lean-ctx\""));
1599        assert!(updated.contains("args = []"));
1600        assert!(updated.contains("approval_mode = \"approve\""));
1601    }
1602
1603    #[test]
1604    fn upsert_codex_toml_handles_issue_191_windows_scenario() {
1605        let input = "\
1606[mcp_servers.lean-ctx.tools.ctx_multi_read]
1607approval_mode = \"approve\"
1608
1609[mcp_servers.lean-ctx.tools.ctx_read]
1610approval_mode = \"approve\"
1611
1612[mcp_servers.lean-ctx.tools.ctx_search]
1613approval_mode = \"approve\"
1614
1615[mcp_servers.lean-ctx.tools.ctx_tree]
1616approval_mode = \"approve\"
1617";
1618        let win_path = r"C:\Users\wudon\AppData\Roaming\npm\lean-ctx.cmd";
1619        let updated = upsert_codex_toml(input, win_path);
1620        assert!(
1621            updated.contains(&format!("command = '{win_path}'")),
1622            "Windows path must use single quotes: {updated}"
1623        );
1624        let parent_pos = updated.find("[mcp_servers.lean-ctx]\n").unwrap();
1625        let first_tool = updated.find("[mcp_servers.lean-ctx.tools.").unwrap();
1626        assert!(parent_pos < first_tool);
1627        assert_eq!(
1628            updated.matches("[mcp_servers.lean-ctx]\n").count(),
1629            1,
1630            "parent section must appear exactly once"
1631        );
1632    }
1633
1634    #[test]
1635    fn upsert_codex_toml_does_not_duplicate_parent_when_present() {
1636        let input = "\
1637[mcp_servers.lean-ctx]
1638command = \"old\"
1639args = [\"x\"]
1640
1641[mcp_servers.lean-ctx.tools.ctx_read]
1642approval_mode = \"approve\"
1643";
1644        let updated = upsert_codex_toml(input, "new");
1645        assert_eq!(
1646            updated.matches("[mcp_servers.lean-ctx]").count(),
1647            1,
1648            "must not duplicate parent section"
1649        );
1650        assert!(updated.contains("command = \"new\""));
1651        assert!(updated.contains("args = []"));
1652        assert!(updated.contains("approval_mode = \"approve\""));
1653    }
1654
1655    #[test]
1656    fn auto_approve_contains_core_tools() {
1657        let tools = auto_approve_tools();
1658        assert!(tools.contains(&"ctx_read"));
1659        assert!(tools.contains(&"ctx_shell"));
1660        assert!(tools.contains(&"ctx_search"));
1661        assert!(tools.contains(&"ctx_workflow"));
1662        assert!(tools.contains(&"ctx_cost"));
1663    }
1664
1665    #[test]
1666    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1667        let dir = tempfile::tempdir().unwrap();
1668        let path = dir.path().join("mcp.json");
1669        std::fs::write(
1670            &path,
1671            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1672        )
1673        .unwrap();
1674
1675        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1676        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1677        assert_eq!(res.action, WriteAction::Updated);
1678
1679        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1680        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1681        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1682        assert_eq!(
1683            json["mcpServers"]["lean-ctx"]["args"],
1684            serde_json::json!([])
1685        );
1686        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1687            .as_str()
1688            .is_some_and(|s| !s.trim().is_empty()));
1689        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1690        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1691        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1692    }
1693
1694    #[test]
1695    fn qoder_mcp_config_is_idempotent() {
1696        let dir = tempfile::tempdir().unwrap();
1697        let path = dir.path().join("mcp.json");
1698        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1699
1700        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1701        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1702
1703        assert_eq!(first.action, WriteAction::Created);
1704        assert_eq!(second.action, WriteAction::Already);
1705    }
1706
1707    #[test]
1708    fn qoder_mcp_config_creates_missing_parent_directories() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let path = dir
1711            .path()
1712            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1713        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1714
1715        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1716
1717        assert_eq!(res.action, WriteAction::Created);
1718        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1719        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1720    }
1721
1722    #[test]
1723    fn antigravity_config_omits_auto_approve() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let path = dir.path().join("mcp_config.json");
1726
1727        let t = EditorTarget {
1728            name: "Antigravity",
1729            agent_key: "gemini".to_string(),
1730            config_path: path.clone(),
1731            detect_path: PathBuf::from("/nonexistent"),
1732            config_type: ConfigType::McpJson,
1733        };
1734        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1735        assert_eq!(res.action, WriteAction::Created);
1736
1737        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1738        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1739        assert_eq!(
1740            json["mcpServers"]["lean-ctx"]["command"],
1741            "/usr/local/bin/lean-ctx"
1742        );
1743    }
1744
1745    #[test]
1746    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1747        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";
1748        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1749        let result = upsert_hermes_yaml_mcp(existing, block);
1750        assert!(result.contains("lean-ctx"));
1751        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1752        assert!(result.contains("tool_allowlist:"));
1753        assert!(result.contains("github:"));
1754    }
1755
1756    #[test]
1757    fn hermes_yaml_creates_mcp_servers_section() {
1758        let existing = "model: openai/gpt-4o\n";
1759        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1760        let result = upsert_hermes_yaml_mcp(existing, block);
1761        assert!(result.contains("mcp_servers:"));
1762        assert!(result.contains("lean-ctx"));
1763        assert!(result.contains("model: openai/gpt-4o"));
1764    }
1765
1766    #[test]
1767    fn hermes_yaml_skips_if_already_present() {
1768        let dir = tempfile::tempdir().unwrap();
1769        let path = dir.path().join("config.yaml");
1770        std::fs::write(
1771            &path,
1772            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1773        )
1774        .unwrap();
1775        let t = target("test", path.clone(), ConfigType::HermesYaml);
1776        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1777        assert_eq!(res.action, WriteAction::Already);
1778    }
1779
1780    #[test]
1781    fn remove_codex_section_also_removes_env_subtable() {
1782        let input = "\
1783[other]
1784x = 1
1785
1786[mcp_servers.lean-ctx]
1787args = []
1788command = \"/usr/local/bin/lean-ctx\"
1789
1790[mcp_servers.lean-ctx.env]
1791LEAN_CTX_DATA_DIR = \"/home/user/.lean-ctx\"
1792
1793[features]
1794codex_hooks = true
1795";
1796        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1797        assert!(
1798            !result.contains("[mcp_servers.lean-ctx]"),
1799            "parent section must be removed"
1800        );
1801        assert!(
1802            !result.contains("LEAN_CTX_DATA_DIR"),
1803            "env sub-table must be removed too"
1804        );
1805        assert!(result.contains("[other]"), "unrelated sections preserved");
1806        assert!(
1807            result.contains("[features]"),
1808            "sections after must be preserved"
1809        );
1810    }
1811
1812    #[test]
1813    fn remove_codex_section_preserves_other_mcp_servers() {
1814        let input = "\
1815[mcp_servers.lean-ctx]
1816command = \"lean-ctx\"
1817
1818[mcp_servers.lean-ctx.env]
1819X = \"1\"
1820
1821[mcp_servers.other]
1822command = \"other\"
1823";
1824        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1825        assert!(!result.contains("[mcp_servers.lean-ctx]"));
1826        assert!(
1827            result.contains("[mcp_servers.other]"),
1828            "other MCP servers must be preserved"
1829        );
1830        assert!(result.contains("command = \"other\""));
1831    }
1832
1833    #[test]
1834    fn remove_codex_section_does_not_remove_similarly_named_server() {
1835        let input = "\
1836[mcp_servers.lean-ctx]
1837command = \"lean-ctx\"
1838
1839[mcp_servers.lean-ctx-probe]
1840command = \"probe\"
1841";
1842        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1843        assert!(
1844            !result.contains("[mcp_servers.lean-ctx]\n"),
1845            "target section must be removed"
1846        );
1847        assert!(
1848            result.contains("[mcp_servers.lean-ctx-probe]"),
1849            "similarly-named server must NOT be removed"
1850        );
1851        assert!(result.contains("command = \"probe\""));
1852    }
1853
1854    #[test]
1855    fn remove_codex_section_handles_no_match() {
1856        let input = "[other]\nx = 1\n";
1857        let result = remove_codex_toml_section(input, "[mcp_servers.lean-ctx]");
1858        assert_eq!(result, "[other]\nx = 1\n");
1859    }
1860}