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 mut out = String::with_capacity(existing.len());
324    let mut skipping = false;
325    for line in existing.lines() {
326        let trimmed = line.trim();
327        if !skipping && trimmed == header {
328            skipping = true;
329            continue;
330        }
331        if skipping {
332            if trimmed.starts_with('[') && trimmed.ends_with(']') {
333                skipping = false;
334                out.push_str(line);
335                out.push('\n');
336            }
337            continue;
338        }
339        out.push_str(line);
340        out.push('\n');
341    }
342    out
343}
344
345fn remove_lean_ctx_hermes_yaml_server(path: &std::path::Path) -> Result<WriteResult, String> {
346    if !path.exists() {
347        return Ok(WriteResult {
348            action: WriteAction::Already,
349            note: Some("hermes config not found".to_string()),
350        });
351    }
352    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
353    let updated = remove_hermes_yaml_mcp_server_block(&content, "lean-ctx");
354    if updated == content {
355        return Ok(WriteResult {
356            action: WriteAction::Already,
357            note: Some("lean-ctx not configured".to_string()),
358        });
359    }
360    crate::config_io::write_atomic_with_backup(path, &updated)?;
361    Ok(WriteResult {
362        action: WriteAction::Updated,
363        note: Some("removed lean-ctx from mcp_servers".to_string()),
364    })
365}
366
367fn remove_hermes_yaml_mcp_server_block(existing: &str, name: &str) -> String {
368    let mut out = String::with_capacity(existing.len());
369    let mut in_mcp = false;
370    let mut skipping = false;
371    for line in existing.lines() {
372        let trimmed = line.trim_end();
373        if trimmed == "mcp_servers:" {
374            in_mcp = true;
375            out.push_str(line);
376            out.push('\n');
377            continue;
378        }
379
380        if in_mcp {
381            let is_child = line.starts_with("  ") && !line.starts_with("    ");
382            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
383
384            if is_toplevel {
385                in_mcp = false;
386                skipping = false;
387            }
388
389            if skipping {
390                if is_child || is_toplevel {
391                    skipping = false;
392                    out.push_str(line);
393                    out.push('\n');
394                }
395                continue;
396            }
397
398            if is_child && line.trim() == format!("{name}:") {
399                skipping = true;
400                continue;
401            }
402        }
403
404        out.push_str(line);
405        out.push('\n');
406    }
407    out
408}
409
410pub fn auto_approve_tools() -> Vec<&'static str> {
411    vec![
412        "ctx_read",
413        "ctx_shell",
414        "ctx_search",
415        "ctx_tree",
416        "ctx_overview",
417        "ctx_preload",
418        "ctx_compress",
419        "ctx_metrics",
420        "ctx_session",
421        "ctx_knowledge",
422        "ctx_agent",
423        "ctx_share",
424        "ctx_analyze",
425        "ctx_benchmark",
426        "ctx_cache",
427        "ctx_discover",
428        "ctx_smart_read",
429        "ctx_delta",
430        "ctx_edit",
431        "ctx_dedup",
432        "ctx_fill",
433        "ctx_intent",
434        "ctx_response",
435        "ctx_context",
436        "ctx_graph",
437        "ctx_wrapped",
438        "ctx_multi_read",
439        "ctx_semantic_search",
440        "ctx_symbol",
441        "ctx_outline",
442        "ctx_callers",
443        "ctx_callees",
444        "ctx_callgraph",
445        "ctx_routes",
446        "ctx_graph_diagram",
447        "ctx_cost",
448        "ctx_heatmap",
449        "ctx_task",
450        "ctx_impact",
451        "ctx_architecture",
452        "ctx_workflow",
453        "ctx",
454    ]
455}
456
457fn lean_ctx_server_entry(binary: &str, data_dir: &str, include_auto_approve: bool) -> Value {
458    let mut entry = serde_json::json!({
459        "command": binary,
460        "env": {
461            "LEAN_CTX_DATA_DIR": data_dir
462        }
463    });
464    if include_auto_approve {
465        entry["autoApprove"] = serde_json::json!(auto_approve_tools());
466    }
467    entry
468}
469
470fn supports_auto_approve(target: &EditorTarget) -> bool {
471    crate::core::client_constraints::by_editor_name(target.name)
472        .is_some_and(|c| c.supports_auto_approve)
473}
474
475fn default_data_dir() -> Result<String, String> {
476    Ok(crate::core::data_dir::lean_ctx_data_dir()?
477        .to_string_lossy()
478        .to_string())
479}
480
481fn write_mcp_json(
482    target: &EditorTarget,
483    binary: &str,
484    opts: WriteOptions,
485) -> Result<WriteResult, String> {
486    let data_dir = default_data_dir()?;
487    let include_aa = supports_auto_approve(target);
488    let desired = lean_ctx_server_entry(binary, &data_dir, include_aa);
489
490    // Claude Code manages ~/.claude.json and may overwrite it on first start.
491    // Prefer the official CLI integration when available.
492    // Skip when LEAN_CTX_QUIET=1 (bootstrap --json / setup --json) to avoid
493    // spawning `claude mcp add-json` which can stall in non-interactive CI.
494    if (target.agent_key == "claude" || target.name == "Claude Code")
495        && !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
496    {
497        if let Ok(result) = try_claude_mcp_add(&desired) {
498            return Ok(result);
499        }
500    }
501
502    if target.config_path.exists() {
503        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
504        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
505            Ok(v) => v,
506            Err(e) => {
507                if !opts.overwrite_invalid {
508                    return Err(e.to_string());
509                }
510                backup_invalid_file(&target.config_path)?;
511                return write_mcp_json_fresh(
512                    &target.config_path,
513                    &desired,
514                    Some("overwrote invalid JSON".to_string()),
515                );
516            }
517        };
518        let obj = json
519            .as_object_mut()
520            .ok_or_else(|| "root JSON must be an object".to_string())?;
521
522        let servers = obj
523            .entry("mcpServers")
524            .or_insert_with(|| serde_json::json!({}));
525        let servers_obj = servers
526            .as_object_mut()
527            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
528
529        let existing = servers_obj.get("lean-ctx").cloned();
530        if existing.as_ref() == Some(&desired) {
531            return Ok(WriteResult {
532                action: WriteAction::Already,
533                note: None,
534            });
535        }
536        servers_obj.insert("lean-ctx".to_string(), desired);
537
538        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
539        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
540        return Ok(WriteResult {
541            action: WriteAction::Updated,
542            note: None,
543        });
544    }
545
546    write_mcp_json_fresh(&target.config_path, &desired, None)
547}
548
549fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
550    use std::io::Write;
551    use std::process::{Command, Stdio};
552    use std::time::{Duration, Instant};
553
554    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
555
556    // On Windows, `claude` may be a `.cmd` shim and cannot be executed directly
557    // via CreateProcess; route through `cmd /C` for reliable invocation.
558    let mut cmd = if cfg!(windows) {
559        let mut c = Command::new("cmd");
560        c.args([
561            "/C", "claude", "mcp", "add-json", "--scope", "user", "lean-ctx",
562        ]);
563        c
564    } else {
565        let mut c = Command::new("claude");
566        c.args(["mcp", "add-json", "--scope", "user", "lean-ctx"]);
567        c
568    };
569
570    let mut child = cmd
571        .stdin(Stdio::piped())
572        .stdout(Stdio::null())
573        .stderr(Stdio::null())
574        .spawn()
575        .map_err(|e| e.to_string())?;
576
577    if let Some(mut stdin) = child.stdin.take() {
578        let _ = stdin.write_all(server_json.as_bytes());
579    }
580
581    let deadline = Duration::from_secs(3);
582    let start = Instant::now();
583    loop {
584        match child.try_wait() {
585            Ok(Some(status)) => {
586                return if status.success() {
587                    Ok(WriteResult {
588                        action: WriteAction::Updated,
589                        note: Some("via claude mcp add-json".to_string()),
590                    })
591                } else {
592                    Err("claude mcp add-json failed".to_string())
593                };
594            }
595            Ok(None) => {
596                if start.elapsed() > deadline {
597                    let _ = child.kill();
598                    let _ = child.wait();
599                    return Err("claude mcp add-json timed out".to_string());
600                }
601                std::thread::sleep(Duration::from_millis(20));
602            }
603            Err(e) => return Err(e.to_string()),
604        }
605    }
606}
607
608fn write_mcp_json_fresh(
609    path: &std::path::Path,
610    desired: &Value,
611    note: Option<String>,
612) -> Result<WriteResult, String> {
613    let content = serde_json::to_string_pretty(&serde_json::json!({
614        "mcpServers": { "lean-ctx": desired }
615    }))
616    .map_err(|e| e.to_string())?;
617    crate::config_io::write_atomic_with_backup(path, &content)?;
618    Ok(WriteResult {
619        action: if note.is_some() {
620            WriteAction::Updated
621        } else {
622            WriteAction::Created
623        },
624        note,
625    })
626}
627
628fn write_zed_config(
629    target: &EditorTarget,
630    binary: &str,
631    opts: WriteOptions,
632) -> Result<WriteResult, String> {
633    let desired = serde_json::json!({
634        "source": "custom",
635        "command": binary,
636        "args": [],
637        "env": {}
638    });
639
640    if target.config_path.exists() {
641        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
642        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
643            Ok(v) => v,
644            Err(e) => {
645                if !opts.overwrite_invalid {
646                    return Err(e.to_string());
647                }
648                backup_invalid_file(&target.config_path)?;
649                return write_zed_config_fresh(
650                    &target.config_path,
651                    &desired,
652                    Some("overwrote invalid JSON".to_string()),
653                );
654            }
655        };
656        let obj = json
657            .as_object_mut()
658            .ok_or_else(|| "root JSON must be an object".to_string())?;
659
660        let servers = obj
661            .entry("context_servers")
662            .or_insert_with(|| serde_json::json!({}));
663        let servers_obj = servers
664            .as_object_mut()
665            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
666
667        let existing = servers_obj.get("lean-ctx").cloned();
668        if existing.as_ref() == Some(&desired) {
669            return Ok(WriteResult {
670                action: WriteAction::Already,
671                note: None,
672            });
673        }
674        servers_obj.insert("lean-ctx".to_string(), desired);
675
676        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
677        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
678        return Ok(WriteResult {
679            action: WriteAction::Updated,
680            note: None,
681        });
682    }
683
684    write_zed_config_fresh(&target.config_path, &desired, None)
685}
686
687fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
688    if target.config_path.exists() {
689        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
690        let updated = upsert_codex_toml(&content, binary);
691        if updated == content {
692            return Ok(WriteResult {
693                action: WriteAction::Already,
694                note: None,
695            });
696        }
697        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
698        return Ok(WriteResult {
699            action: WriteAction::Updated,
700            note: None,
701        });
702    }
703
704    let content = format!(
705        "[mcp_servers.lean-ctx]\ncommand = {}\nargs = []\n",
706        toml_quote(binary)
707    );
708    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
709    Ok(WriteResult {
710        action: WriteAction::Created,
711        note: None,
712    })
713}
714
715fn write_zed_config_fresh(
716    path: &std::path::Path,
717    desired: &Value,
718    note: Option<String>,
719) -> Result<WriteResult, String> {
720    let content = serde_json::to_string_pretty(&serde_json::json!({
721        "context_servers": { "lean-ctx": desired }
722    }))
723    .map_err(|e| e.to_string())?;
724    crate::config_io::write_atomic_with_backup(path, &content)?;
725    Ok(WriteResult {
726        action: if note.is_some() {
727            WriteAction::Updated
728        } else {
729            WriteAction::Created
730        },
731        note,
732    })
733}
734
735fn write_vscode_mcp(
736    target: &EditorTarget,
737    binary: &str,
738    opts: WriteOptions,
739) -> Result<WriteResult, String> {
740    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
741        .map(|d| d.to_string_lossy().to_string())
742        .unwrap_or_default();
743    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
744
745    if target.config_path.exists() {
746        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
747        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
748            Ok(v) => v,
749            Err(e) => {
750                if !opts.overwrite_invalid {
751                    return Err(e.to_string());
752                }
753                backup_invalid_file(&target.config_path)?;
754                return write_vscode_mcp_fresh(
755                    &target.config_path,
756                    binary,
757                    Some("overwrote invalid JSON".to_string()),
758                );
759            }
760        };
761        let obj = json
762            .as_object_mut()
763            .ok_or_else(|| "root JSON must be an object".to_string())?;
764
765        let servers = obj
766            .entry("servers")
767            .or_insert_with(|| serde_json::json!({}));
768        let servers_obj = servers
769            .as_object_mut()
770            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
771
772        let existing = servers_obj.get("lean-ctx").cloned();
773        if existing.as_ref() == Some(&desired) {
774            return Ok(WriteResult {
775                action: WriteAction::Already,
776                note: None,
777            });
778        }
779        servers_obj.insert("lean-ctx".to_string(), desired);
780
781        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
782        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
783        return Ok(WriteResult {
784            action: WriteAction::Updated,
785            note: None,
786        });
787    }
788
789    write_vscode_mcp_fresh(&target.config_path, binary, None)
790}
791
792fn write_vscode_mcp_fresh(
793    path: &std::path::Path,
794    binary: &str,
795    note: Option<String>,
796) -> Result<WriteResult, String> {
797    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
798        .map(|d| d.to_string_lossy().to_string())
799        .unwrap_or_default();
800    let content = serde_json::to_string_pretty(&serde_json::json!({
801        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
802    }))
803    .map_err(|e| e.to_string())?;
804    crate::config_io::write_atomic_with_backup(path, &content)?;
805    Ok(WriteResult {
806        action: if note.is_some() {
807            WriteAction::Updated
808        } else {
809            WriteAction::Created
810        },
811        note,
812    })
813}
814
815fn write_opencode_config(
816    target: &EditorTarget,
817    binary: &str,
818    opts: WriteOptions,
819) -> Result<WriteResult, String> {
820    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
821        .map(|d| d.to_string_lossy().to_string())
822        .unwrap_or_default();
823    let desired = serde_json::json!({
824        "type": "local",
825        "command": [binary],
826        "enabled": true,
827        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
828    });
829
830    if target.config_path.exists() {
831        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
832        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
833            Ok(v) => v,
834            Err(e) => {
835                if !opts.overwrite_invalid {
836                    return Err(e.to_string());
837                }
838                backup_invalid_file(&target.config_path)?;
839                return write_opencode_fresh(
840                    &target.config_path,
841                    binary,
842                    Some("overwrote invalid JSON".to_string()),
843                );
844            }
845        };
846        let obj = json
847            .as_object_mut()
848            .ok_or_else(|| "root JSON must be an object".to_string())?;
849        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
850        let mcp_obj = mcp
851            .as_object_mut()
852            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
853
854        let existing = mcp_obj.get("lean-ctx").cloned();
855        if existing.as_ref() == Some(&desired) {
856            return Ok(WriteResult {
857                action: WriteAction::Already,
858                note: None,
859            });
860        }
861        mcp_obj.insert("lean-ctx".to_string(), desired);
862
863        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
864        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
865        return Ok(WriteResult {
866            action: WriteAction::Updated,
867            note: None,
868        });
869    }
870
871    write_opencode_fresh(&target.config_path, binary, None)
872}
873
874fn write_opencode_fresh(
875    path: &std::path::Path,
876    binary: &str,
877    note: Option<String>,
878) -> Result<WriteResult, String> {
879    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
880        .map(|d| d.to_string_lossy().to_string())
881        .unwrap_or_default();
882    let content = serde_json::to_string_pretty(&serde_json::json!({
883        "$schema": "https://opencode.ai/config.json",
884        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
885    }))
886    .map_err(|e| e.to_string())?;
887    crate::config_io::write_atomic_with_backup(path, &content)?;
888    Ok(WriteResult {
889        action: if note.is_some() {
890            WriteAction::Updated
891        } else {
892            WriteAction::Created
893        },
894        note,
895    })
896}
897
898fn write_jetbrains_config(
899    target: &EditorTarget,
900    binary: &str,
901    opts: WriteOptions,
902) -> Result<WriteResult, String> {
903    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
904        .map(|d| d.to_string_lossy().to_string())
905        .unwrap_or_default();
906    // JetBrains AI Assistant expects an "mcpServers" mapping in the JSON snippet
907    // you paste into Settings | Tools | AI Assistant | Model Context Protocol (MCP).
908    // We write that snippet to a file for easy copy/paste.
909    let desired = serde_json::json!({
910        "command": binary,
911        "args": [],
912        "env": { "LEAN_CTX_DATA_DIR": data_dir }
913    });
914
915    if target.config_path.exists() {
916        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
917        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
918            Ok(v) => v,
919            Err(e) => {
920                if !opts.overwrite_invalid {
921                    return Err(e.to_string());
922                }
923                backup_invalid_file(&target.config_path)?;
924                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
925                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
926                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
927                return Ok(WriteResult {
928                    action: WriteAction::Updated,
929                    note: Some(
930                        "overwrote invalid JSON (paste this snippet into JetBrains MCP settings)"
931                            .to_string(),
932                    ),
933                });
934            }
935        };
936        let obj = json
937            .as_object_mut()
938            .ok_or_else(|| "root JSON must be an object".to_string())?;
939
940        let servers = obj
941            .entry("mcpServers")
942            .or_insert_with(|| serde_json::json!({}));
943        let servers_obj = servers
944            .as_object_mut()
945            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
946
947        let existing = servers_obj.get("lean-ctx").cloned();
948        if existing.as_ref() == Some(&desired) {
949            return Ok(WriteResult {
950                action: WriteAction::Already,
951                note: Some("paste this snippet into JetBrains MCP settings".to_string()),
952            });
953        }
954        servers_obj.insert("lean-ctx".to_string(), desired);
955
956        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
957        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
958        return Ok(WriteResult {
959            action: WriteAction::Updated,
960            note: Some("paste this snippet into JetBrains MCP settings".to_string()),
961        });
962    }
963
964    let config = serde_json::json!({ "mcpServers": { "lean-ctx": desired } });
965    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
966    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
967    Ok(WriteResult {
968        action: WriteAction::Created,
969        note: Some("paste this snippet into JetBrains MCP settings".to_string()),
970    })
971}
972
973fn write_amp_config(
974    target: &EditorTarget,
975    binary: &str,
976    opts: WriteOptions,
977) -> Result<WriteResult, String> {
978    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
979        .map(|d| d.to_string_lossy().to_string())
980        .unwrap_or_default();
981    let entry = serde_json::json!({
982        "command": binary,
983        "env": { "LEAN_CTX_DATA_DIR": data_dir }
984    });
985
986    if target.config_path.exists() {
987        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
988        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
989            Ok(v) => v,
990            Err(e) => {
991                if !opts.overwrite_invalid {
992                    return Err(e.to_string());
993                }
994                backup_invalid_file(&target.config_path)?;
995                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
996                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
997                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
998                return Ok(WriteResult {
999                    action: WriteAction::Updated,
1000                    note: Some("overwrote invalid JSON".to_string()),
1001                });
1002            }
1003        };
1004        let obj = json
1005            .as_object_mut()
1006            .ok_or_else(|| "root JSON must be an object".to_string())?;
1007        let servers = obj
1008            .entry("amp.mcpServers")
1009            .or_insert_with(|| serde_json::json!({}));
1010        let servers_obj = servers
1011            .as_object_mut()
1012            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
1013
1014        let existing = servers_obj.get("lean-ctx").cloned();
1015        if existing.as_ref() == Some(&entry) {
1016            return Ok(WriteResult {
1017                action: WriteAction::Already,
1018                note: None,
1019            });
1020        }
1021        servers_obj.insert("lean-ctx".to_string(), entry);
1022
1023        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1024        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1025        return Ok(WriteResult {
1026            action: WriteAction::Updated,
1027            note: None,
1028        });
1029    }
1030
1031    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1032    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1033    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1034    Ok(WriteResult {
1035        action: WriteAction::Created,
1036        note: None,
1037    })
1038}
1039
1040fn write_crush_config(
1041    target: &EditorTarget,
1042    binary: &str,
1043    opts: WriteOptions,
1044) -> Result<WriteResult, String> {
1045    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1046        .map(|d| d.to_string_lossy().to_string())
1047        .unwrap_or_default();
1048    let desired = serde_json::json!({
1049        "type": "stdio",
1050        "command": binary,
1051        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1052    });
1053
1054    if target.config_path.exists() {
1055        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1056        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1057            Ok(v) => v,
1058            Err(e) => {
1059                if !opts.overwrite_invalid {
1060                    return Err(e.to_string());
1061                }
1062                backup_invalid_file(&target.config_path)?;
1063                return write_crush_fresh(
1064                    &target.config_path,
1065                    &desired,
1066                    Some("overwrote invalid JSON".to_string()),
1067                );
1068            }
1069        };
1070        let obj = json
1071            .as_object_mut()
1072            .ok_or_else(|| "root JSON must be an object".to_string())?;
1073        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1074        let mcp_obj = mcp
1075            .as_object_mut()
1076            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
1077
1078        let existing = mcp_obj.get("lean-ctx").cloned();
1079        if existing.as_ref() == Some(&desired) {
1080            return Ok(WriteResult {
1081                action: WriteAction::Already,
1082                note: None,
1083            });
1084        }
1085        mcp_obj.insert("lean-ctx".to_string(), desired);
1086
1087        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1088        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1089        return Ok(WriteResult {
1090            action: WriteAction::Updated,
1091            note: None,
1092        });
1093    }
1094
1095    write_crush_fresh(&target.config_path, &desired, None)
1096}
1097
1098fn write_crush_fresh(
1099    path: &std::path::Path,
1100    desired: &Value,
1101    note: Option<String>,
1102) -> Result<WriteResult, String> {
1103    let content = serde_json::to_string_pretty(&serde_json::json!({
1104        "mcp": { "lean-ctx": desired }
1105    }))
1106    .map_err(|e| e.to_string())?;
1107    crate::config_io::write_atomic_with_backup(path, &content)?;
1108    Ok(WriteResult {
1109        action: if note.is_some() {
1110            WriteAction::Updated
1111        } else {
1112            WriteAction::Created
1113        },
1114        note,
1115    })
1116}
1117
1118fn upsert_codex_toml(existing: &str, binary: &str) -> String {
1119    let mut out = String::with_capacity(existing.len() + 128);
1120    let mut in_section = false;
1121    let mut saw_section = false;
1122    let mut wrote_command = false;
1123    let mut wrote_args = false;
1124
1125    for line in existing.lines() {
1126        let trimmed = line.trim();
1127        if trimmed == "[]" {
1128            continue;
1129        }
1130        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1131            if in_section && !wrote_command {
1132                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1133                wrote_command = true;
1134            }
1135            if in_section && !wrote_args {
1136                out.push_str("args = []\n");
1137                wrote_args = true;
1138            }
1139            in_section = trimmed == "[mcp_servers.lean-ctx]";
1140            if in_section {
1141                saw_section = true;
1142            }
1143            out.push_str(line);
1144            out.push('\n');
1145            continue;
1146        }
1147
1148        if in_section {
1149            if trimmed.starts_with("command") && trimmed.contains('=') {
1150                out.push_str(&format!("command = {}\n", toml_quote(binary)));
1151                wrote_command = true;
1152                continue;
1153            }
1154            if trimmed.starts_with("args") && trimmed.contains('=') {
1155                out.push_str("args = []\n");
1156                wrote_args = true;
1157                continue;
1158            }
1159        }
1160
1161        out.push_str(line);
1162        out.push('\n');
1163    }
1164
1165    if saw_section {
1166        if in_section && !wrote_command {
1167            out.push_str(&format!("command = {}\n", toml_quote(binary)));
1168        }
1169        if in_section && !wrote_args {
1170            out.push_str("args = []\n");
1171        }
1172        return out;
1173    }
1174
1175    if !out.ends_with('\n') {
1176        out.push('\n');
1177    }
1178    out.push_str("\n[mcp_servers.lean-ctx]\n");
1179    out.push_str(&format!("command = {}\n", toml_quote(binary)));
1180    out.push_str("args = []\n");
1181    out
1182}
1183
1184fn write_gemini_settings(
1185    target: &EditorTarget,
1186    binary: &str,
1187    opts: WriteOptions,
1188) -> Result<WriteResult, String> {
1189    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1190        .map(|d| d.to_string_lossy().to_string())
1191        .unwrap_or_default();
1192    let entry = serde_json::json!({
1193        "command": binary,
1194        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1195        "trust": true,
1196    });
1197
1198    if target.config_path.exists() {
1199        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1200        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1201            Ok(v) => v,
1202            Err(e) => {
1203                if !opts.overwrite_invalid {
1204                    return Err(e.to_string());
1205                }
1206                backup_invalid_file(&target.config_path)?;
1207                let fresh = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1208                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
1209                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1210                return Ok(WriteResult {
1211                    action: WriteAction::Updated,
1212                    note: Some("overwrote invalid JSON".to_string()),
1213                });
1214            }
1215        };
1216        let obj = json
1217            .as_object_mut()
1218            .ok_or_else(|| "root JSON must be an object".to_string())?;
1219        let servers = obj
1220            .entry("mcpServers")
1221            .or_insert_with(|| serde_json::json!({}));
1222        let servers_obj = servers
1223            .as_object_mut()
1224            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1225
1226        let existing = servers_obj.get("lean-ctx").cloned();
1227        if existing.as_ref() == Some(&entry) {
1228            return Ok(WriteResult {
1229                action: WriteAction::Already,
1230                note: None,
1231            });
1232        }
1233        servers_obj.insert("lean-ctx".to_string(), entry);
1234
1235        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1236        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1237        return Ok(WriteResult {
1238            action: WriteAction::Updated,
1239            note: None,
1240        });
1241    }
1242
1243    let config = serde_json::json!({ "mcpServers": { "lean-ctx": entry } });
1244    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
1245    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1246    Ok(WriteResult {
1247        action: WriteAction::Created,
1248        note: None,
1249    })
1250}
1251
1252fn write_hermes_yaml(
1253    target: &EditorTarget,
1254    binary: &str,
1255    _opts: WriteOptions,
1256) -> Result<WriteResult, String> {
1257    let data_dir = default_data_dir()?;
1258
1259    let lean_ctx_block = format!(
1260        "  lean-ctx:\n    command: \"{binary}\"\n    env:\n      LEAN_CTX_DATA_DIR: \"{data_dir}\""
1261    );
1262
1263    if target.config_path.exists() {
1264        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1265
1266        if content.contains("lean-ctx") {
1267            return Ok(WriteResult {
1268                action: WriteAction::Already,
1269                note: None,
1270            });
1271        }
1272
1273        let updated = upsert_hermes_yaml_mcp(&content, &lean_ctx_block);
1274        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
1275        return Ok(WriteResult {
1276            action: WriteAction::Updated,
1277            note: None,
1278        });
1279    }
1280
1281    let content = format!("mcp_servers:\n{lean_ctx_block}\n");
1282    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
1283    Ok(WriteResult {
1284        action: WriteAction::Created,
1285        note: None,
1286    })
1287}
1288
1289fn upsert_hermes_yaml_mcp(existing: &str, lean_ctx_block: &str) -> String {
1290    let mut out = String::with_capacity(existing.len() + lean_ctx_block.len() + 32);
1291    let mut in_mcp_section = false;
1292    let mut saw_mcp_child = false;
1293    let mut inserted = false;
1294    let lines: Vec<&str> = existing.lines().collect();
1295
1296    for line in &lines {
1297        if !inserted && line.trim_end() == "mcp_servers:" {
1298            in_mcp_section = true;
1299            out.push_str(line);
1300            out.push('\n');
1301            continue;
1302        }
1303
1304        if in_mcp_section && !inserted {
1305            let is_child = line.starts_with("  ") && !line.trim().is_empty();
1306            let is_toplevel = !line.starts_with(' ') && !line.trim().is_empty();
1307
1308            if is_child {
1309                saw_mcp_child = true;
1310                out.push_str(line);
1311                out.push('\n');
1312                continue;
1313            }
1314
1315            if saw_mcp_child && (line.trim().is_empty() || is_toplevel) {
1316                out.push_str(lean_ctx_block);
1317                out.push('\n');
1318                inserted = true;
1319                in_mcp_section = false;
1320            }
1321        }
1322
1323        out.push_str(line);
1324        out.push('\n');
1325    }
1326
1327    if in_mcp_section && !inserted {
1328        out.push_str(lean_ctx_block);
1329        out.push('\n');
1330        inserted = true;
1331    }
1332
1333    if !inserted {
1334        if !out.ends_with('\n') {
1335            out.push('\n');
1336        }
1337        out.push_str("\nmcp_servers:\n");
1338        out.push_str(lean_ctx_block);
1339        out.push('\n');
1340    }
1341
1342    out
1343}
1344
1345fn write_qoder_settings(
1346    target: &EditorTarget,
1347    binary: &str,
1348    opts: WriteOptions,
1349) -> Result<WriteResult, String> {
1350    let data_dir = default_data_dir()?;
1351    let desired = serde_json::json!({
1352        "command": binary,
1353        "args": [],
1354        "env": {
1355            "LEAN_CTX_DATA_DIR": data_dir,
1356            "LEAN_CTX_FULL_TOOLS": "1"
1357        }
1358    });
1359
1360    if target.config_path.exists() {
1361        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
1362        let mut json = match crate::core::jsonc::parse_jsonc(&content) {
1363            Ok(v) => v,
1364            Err(e) => {
1365                if !opts.overwrite_invalid {
1366                    return Err(e.to_string());
1367                }
1368                backup_invalid_file(&target.config_path)?;
1369                return write_mcp_json_fresh(
1370                    &target.config_path,
1371                    &desired,
1372                    Some("overwrote invalid JSON".to_string()),
1373                );
1374            }
1375        };
1376        let obj = json
1377            .as_object_mut()
1378            .ok_or_else(|| "root JSON must be an object".to_string())?;
1379        let servers = obj
1380            .entry("mcpServers")
1381            .or_insert_with(|| serde_json::json!({}));
1382        let servers_obj = servers
1383            .as_object_mut()
1384            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
1385
1386        let existing = servers_obj.get("lean-ctx").cloned();
1387        if existing.as_ref() == Some(&desired) {
1388            return Ok(WriteResult {
1389                action: WriteAction::Already,
1390                note: None,
1391            });
1392        }
1393        servers_obj.insert("lean-ctx".to_string(), desired);
1394
1395        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1396        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
1397        return Ok(WriteResult {
1398            action: WriteAction::Updated,
1399            note: None,
1400        });
1401    }
1402
1403    write_mcp_json_fresh(&target.config_path, &desired, None)
1404}
1405
1406fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
1407    if !path.exists() {
1408        return Ok(());
1409    }
1410    let parent = path
1411        .parent()
1412        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
1413    let filename = path
1414        .file_name()
1415        .ok_or_else(|| "invalid path (no filename)".to_string())?
1416        .to_string_lossy();
1417    let pid = std::process::id();
1418    let nanos = std::time::SystemTime::now()
1419        .duration_since(std::time::UNIX_EPOCH)
1420        .map_or(0, |d| d.as_nanos());
1421    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
1422    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
1423    Ok(())
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429    use std::path::PathBuf;
1430
1431    fn target(name: &'static str, path: PathBuf, ty: ConfigType) -> EditorTarget {
1432        EditorTarget {
1433            name,
1434            agent_key: "test".to_string(),
1435            config_path: path,
1436            detect_path: PathBuf::from("/nonexistent"),
1437            config_type: ty,
1438        }
1439    }
1440
1441    #[test]
1442    fn mcp_json_upserts_and_preserves_other_servers_without_auto_approve() {
1443        let dir = tempfile::tempdir().unwrap();
1444        let path = dir.path().join("mcp.json");
1445        std::fs::write(
1446            &path,
1447            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1448        )
1449        .unwrap();
1450
1451        let t = target("test", path.clone(), ConfigType::McpJson);
1452        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1453        assert_eq!(res.action, WriteAction::Updated);
1454
1455        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1456        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1457        assert_eq!(
1458            json["mcpServers"]["lean-ctx"]["command"],
1459            "/new/path/lean-ctx"
1460        );
1461        assert!(json["mcpServers"]["lean-ctx"].get("autoApprove").is_none());
1462    }
1463
1464    #[test]
1465    fn mcp_json_upserts_and_preserves_other_servers_with_auto_approve_for_cursor() {
1466        let dir = tempfile::tempdir().unwrap();
1467        let path = dir.path().join("mcp.json");
1468        std::fs::write(
1469            &path,
1470            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
1471        )
1472        .unwrap();
1473
1474        let t = target("Cursor", path.clone(), ConfigType::McpJson);
1475        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
1476        assert_eq!(res.action, WriteAction::Updated);
1477
1478        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1479        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
1480        assert_eq!(
1481            json["mcpServers"]["lean-ctx"]["command"],
1482            "/new/path/lean-ctx"
1483        );
1484        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
1485        assert!(
1486            json["mcpServers"]["lean-ctx"]["autoApprove"]
1487                .as_array()
1488                .unwrap()
1489                .len()
1490                > 5
1491        );
1492    }
1493
1494    #[test]
1495    fn crush_config_writes_mcp_root() {
1496        let dir = tempfile::tempdir().unwrap();
1497        let path = dir.path().join("crush.json");
1498        std::fs::write(
1499            &path,
1500            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
1501        )
1502        .unwrap();
1503
1504        let t = target("test", path.clone(), ConfigType::Crush);
1505        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
1506        assert_eq!(res.action, WriteAction::Updated);
1507
1508        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1509        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
1510        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
1511    }
1512
1513    #[test]
1514    fn codex_toml_upserts_existing_section() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let path = dir.path().join("config.toml");
1517        std::fs::write(
1518            &path,
1519            r#"[mcp_servers.lean-ctx]
1520command = "old"
1521args = ["x"]
1522"#,
1523        )
1524        .unwrap();
1525
1526        let t = target("test", path.clone(), ConfigType::Codex);
1527        let res = write_codex_config(&t, "new").unwrap();
1528        assert_eq!(res.action, WriteAction::Updated);
1529
1530        let content = std::fs::read_to_string(&path).unwrap();
1531        assert!(content.contains(r#"command = "new""#));
1532        assert!(content.contains("args = []"));
1533    }
1534
1535    #[test]
1536    fn upsert_codex_toml_inserts_new_section_when_missing() {
1537        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
1538        assert!(updated.contains("[mcp_servers.lean-ctx]"));
1539        assert!(updated.contains("command = \"lean-ctx\""));
1540        assert!(updated.contains("args = []"));
1541    }
1542
1543    #[test]
1544    fn codex_toml_uses_single_quotes_for_backslash_paths() {
1545        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
1546        let updated = upsert_codex_toml("", win_path);
1547        assert!(
1548            updated.contains(&format!("command = '{win_path}'")),
1549            "Windows paths must use TOML single quotes to avoid backslash escapes: {updated}"
1550        );
1551    }
1552
1553    #[test]
1554    fn codex_toml_uses_double_quotes_for_unix_paths() {
1555        let unix_path = "/usr/local/bin/lean-ctx";
1556        let updated = upsert_codex_toml("", unix_path);
1557        assert!(
1558            updated.contains(&format!("command = \"{unix_path}\"")),
1559            "Unix paths should use double quotes: {updated}"
1560        );
1561    }
1562
1563    #[test]
1564    fn auto_approve_contains_core_tools() {
1565        let tools = auto_approve_tools();
1566        assert!(tools.contains(&"ctx_read"));
1567        assert!(tools.contains(&"ctx_shell"));
1568        assert!(tools.contains(&"ctx_search"));
1569        assert!(tools.contains(&"ctx_workflow"));
1570        assert!(tools.contains(&"ctx_cost"));
1571    }
1572
1573    #[test]
1574    fn qoder_mcp_config_preserves_probe_and_upserts_lean_ctx() {
1575        let dir = tempfile::tempdir().unwrap();
1576        let path = dir.path().join("mcp.json");
1577        std::fs::write(
1578            &path,
1579            r#"{ "mcpServers": { "lean-ctx-probe": { "command": "cmd", "args": ["/C", "echo", "lean-ctx-probe"] } } }"#,
1580        )
1581        .unwrap();
1582
1583        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1584        let res = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1585        assert_eq!(res.action, WriteAction::Updated);
1586
1587        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1588        assert_eq!(json["mcpServers"]["lean-ctx-probe"]["command"], "cmd");
1589        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1590        assert_eq!(
1591            json["mcpServers"]["lean-ctx"]["args"],
1592            serde_json::json!([])
1593        );
1594        assert!(json["mcpServers"]["lean-ctx"]["env"]["LEAN_CTX_DATA_DIR"]
1595            .as_str()
1596            .is_some_and(|s| !s.trim().is_empty()));
1597        assert!(json["mcpServers"]["lean-ctx"]["identifier"].is_null());
1598        assert!(json["mcpServers"]["lean-ctx"]["source"].is_null());
1599        assert!(json["mcpServers"]["lean-ctx"]["version"].is_null());
1600    }
1601
1602    #[test]
1603    fn qoder_mcp_config_is_idempotent() {
1604        let dir = tempfile::tempdir().unwrap();
1605        let path = dir.path().join("mcp.json");
1606        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1607
1608        let first = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1609        let second = write_qoder_settings(&t, "lean-ctx", WriteOptions::default()).unwrap();
1610
1611        assert_eq!(first.action, WriteAction::Created);
1612        assert_eq!(second.action, WriteAction::Already);
1613    }
1614
1615    #[test]
1616    fn qoder_mcp_config_creates_missing_parent_directories() {
1617        let dir = tempfile::tempdir().unwrap();
1618        let path = dir
1619            .path()
1620            .join("Library/Application Support/Qoder/SharedClientCache/mcp.json");
1621        let t = target("Qoder", path.clone(), ConfigType::QoderSettings);
1622
1623        let res = write_config_with_options(&t, "lean-ctx", WriteOptions::default()).unwrap();
1624
1625        assert_eq!(res.action, WriteAction::Created);
1626        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1627        assert_eq!(json["mcpServers"]["lean-ctx"]["command"], "lean-ctx");
1628    }
1629
1630    #[test]
1631    fn antigravity_config_omits_auto_approve() {
1632        let dir = tempfile::tempdir().unwrap();
1633        let path = dir.path().join("mcp_config.json");
1634
1635        let t = EditorTarget {
1636            name: "Antigravity",
1637            agent_key: "gemini".to_string(),
1638            config_path: path.clone(),
1639            detect_path: PathBuf::from("/nonexistent"),
1640            config_type: ConfigType::McpJson,
1641        };
1642        let res = write_mcp_json(&t, "/usr/local/bin/lean-ctx", WriteOptions::default()).unwrap();
1643        assert_eq!(res.action, WriteAction::Created);
1644
1645        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1646        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_null());
1647        assert_eq!(
1648            json["mcpServers"]["lean-ctx"]["command"],
1649            "/usr/local/bin/lean-ctx"
1650        );
1651    }
1652
1653    #[test]
1654    fn hermes_yaml_inserts_into_existing_mcp_servers() {
1655        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";
1656        let block = "  lean-ctx:\n    command: \"lean-ctx\"\n    env:\n      LEAN_CTX_DATA_DIR: \"/home/user/.lean-ctx\"";
1657        let result = upsert_hermes_yaml_mcp(existing, block);
1658        assert!(result.contains("lean-ctx"));
1659        assert!(result.contains("model: anthropic/claude-sonnet-4"));
1660        assert!(result.contains("tool_allowlist:"));
1661        assert!(result.contains("github:"));
1662    }
1663
1664    #[test]
1665    fn hermes_yaml_creates_mcp_servers_section() {
1666        let existing = "model: openai/gpt-4o\n";
1667        let block = "  lean-ctx:\n    command: \"lean-ctx\"";
1668        let result = upsert_hermes_yaml_mcp(existing, block);
1669        assert!(result.contains("mcp_servers:"));
1670        assert!(result.contains("lean-ctx"));
1671        assert!(result.contains("model: openai/gpt-4o"));
1672    }
1673
1674    #[test]
1675    fn hermes_yaml_skips_if_already_present() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let path = dir.path().join("config.yaml");
1678        std::fs::write(
1679            &path,
1680            "mcp_servers:\n  lean-ctx:\n    command: \"lean-ctx\"\n",
1681        )
1682        .unwrap();
1683        let t = target("test", path.clone(), ConfigType::HermesYaml);
1684        let res = write_hermes_yaml(&t, "lean-ctx", WriteOptions::default()).unwrap();
1685        assert_eq!(res.action, WriteAction::Already);
1686    }
1687}