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