Skip to main content

lean_ctx/core/editor_registry/
writers.rs

1use serde_json::Value;
2
3use super::types::{ConfigType, EditorTarget};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WriteAction {
7    Created,
8    Updated,
9    Already,
10}
11
12#[derive(Debug, Clone, Copy, Default)]
13pub struct WriteOptions {
14    pub overwrite_invalid: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WriteResult {
19    pub action: WriteAction,
20    pub note: Option<String>,
21}
22
23pub fn write_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
24    write_config_with_options(target, binary, WriteOptions::default())
25}
26
27pub fn write_config_with_options(
28    target: &EditorTarget,
29    binary: &str,
30    opts: WriteOptions,
31) -> Result<WriteResult, String> {
32    if let Some(parent) = target.config_path.parent() {
33        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
34    }
35
36    match target.config_type {
37        ConfigType::McpJson => write_mcp_json(target, binary, opts),
38        ConfigType::Zed => write_zed_config(target, binary, opts),
39        ConfigType::Codex => write_codex_config(target, binary),
40        ConfigType::VsCodeMcp => write_vscode_mcp(target, binary, opts),
41        ConfigType::OpenCode => write_opencode_config(target, binary, opts),
42        ConfigType::Crush => write_crush_config(target, binary, opts),
43        ConfigType::JetBrains => write_jetbrains_config(target, binary, opts),
44        ConfigType::Amp => write_amp_config(target, binary, opts),
45    }
46}
47
48pub fn auto_approve_tools() -> Vec<&'static str> {
49    vec![
50        "ctx_read",
51        "ctx_shell",
52        "ctx_search",
53        "ctx_tree",
54        "ctx_overview",
55        "ctx_preload",
56        "ctx_compress",
57        "ctx_metrics",
58        "ctx_session",
59        "ctx_knowledge",
60        "ctx_agent",
61        "ctx_share",
62        "ctx_analyze",
63        "ctx_benchmark",
64        "ctx_cache",
65        "ctx_discover",
66        "ctx_smart_read",
67        "ctx_delta",
68        "ctx_edit",
69        "ctx_dedup",
70        "ctx_fill",
71        "ctx_intent",
72        "ctx_response",
73        "ctx_context",
74        "ctx_graph",
75        "ctx_wrapped",
76        "ctx_multi_read",
77        "ctx_semantic_search",
78        "ctx_symbol",
79        "ctx_outline",
80        "ctx_callers",
81        "ctx_callees",
82        "ctx_routes",
83        "ctx_graph_diagram",
84        "ctx_cost",
85        "ctx_heatmap",
86        "ctx_task",
87        "ctx_impact",
88        "ctx_architecture",
89        "ctx_workflow",
90        "ctx",
91    ]
92}
93
94fn lean_ctx_server_entry(binary: &str, data_dir: &str) -> Value {
95    serde_json::json!({
96        "command": binary,
97        "env": {
98            "LEAN_CTX_DATA_DIR": data_dir
99        },
100        "autoApprove": auto_approve_tools()
101    })
102}
103
104fn default_data_dir() -> Result<String, String> {
105    Ok(crate::core::data_dir::lean_ctx_data_dir()?
106        .to_string_lossy()
107        .to_string())
108}
109
110fn write_mcp_json(
111    target: &EditorTarget,
112    binary: &str,
113    opts: WriteOptions,
114) -> Result<WriteResult, String> {
115    let data_dir = default_data_dir()?;
116    let desired = lean_ctx_server_entry(binary, &data_dir);
117
118    // Claude Code manages ~/.claude.json and may overwrite it on first start.
119    // Prefer the official CLI integration when available.
120    if target.agent_key == "claude" || target.name == "Claude Code" {
121        if let Ok(result) = try_claude_mcp_add(&desired) {
122            return Ok(result);
123        }
124    }
125
126    if target.config_path.exists() {
127        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
128        let mut json = match serde_json::from_str::<Value>(&content) {
129            Ok(v) => v,
130            Err(e) => {
131                if !opts.overwrite_invalid {
132                    return Err(e.to_string());
133                }
134                backup_invalid_file(&target.config_path)?;
135                return write_mcp_json_fresh(
136                    &target.config_path,
137                    desired,
138                    Some("overwrote invalid JSON".to_string()),
139                );
140            }
141        };
142        let obj = json
143            .as_object_mut()
144            .ok_or_else(|| "root JSON must be an object".to_string())?;
145
146        let servers = obj
147            .entry("mcpServers")
148            .or_insert_with(|| serde_json::json!({}));
149        let servers_obj = servers
150            .as_object_mut()
151            .ok_or_else(|| "\"mcpServers\" must be an object".to_string())?;
152
153        let existing = servers_obj.get("lean-ctx").cloned();
154        if existing.as_ref() == Some(&desired) {
155            return Ok(WriteResult {
156                action: WriteAction::Already,
157                note: None,
158            });
159        }
160        servers_obj.insert("lean-ctx".to_string(), desired);
161
162        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
163        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
164        return Ok(WriteResult {
165            action: WriteAction::Updated,
166            note: None,
167        });
168    }
169
170    write_mcp_json_fresh(&target.config_path, desired, None)
171}
172
173fn try_claude_mcp_add(desired: &Value) -> Result<WriteResult, String> {
174    use std::io::Write;
175    use std::process::{Command, Stdio};
176
177    let server_json = serde_json::to_string(desired).map_err(|e| e.to_string())?;
178
179    let mut child = Command::new("claude")
180        .args(["mcp", "add-json", "--scope", "user", "lean-ctx"])
181        .stdin(Stdio::piped())
182        .stdout(Stdio::null())
183        .stderr(Stdio::null())
184        .spawn()
185        .map_err(|e| e.to_string())?;
186
187    if let Some(stdin) = child.stdin.as_mut() {
188        stdin
189            .write_all(server_json.as_bytes())
190            .map_err(|e| e.to_string())?;
191    }
192    let status = child.wait().map_err(|e| e.to_string())?;
193
194    if status.success() {
195        Ok(WriteResult {
196            action: WriteAction::Updated,
197            note: Some("via claude mcp add-json".to_string()),
198        })
199    } else {
200        Err("claude mcp add-json failed".to_string())
201    }
202}
203
204fn write_mcp_json_fresh(
205    path: &std::path::Path,
206    desired: Value,
207    note: Option<String>,
208) -> Result<WriteResult, String> {
209    let content = serde_json::to_string_pretty(&serde_json::json!({
210        "mcpServers": { "lean-ctx": desired }
211    }))
212    .map_err(|e| e.to_string())?;
213    crate::config_io::write_atomic_with_backup(path, &content)?;
214    Ok(WriteResult {
215        action: if note.is_some() {
216            WriteAction::Updated
217        } else {
218            WriteAction::Created
219        },
220        note,
221    })
222}
223
224fn write_zed_config(
225    target: &EditorTarget,
226    binary: &str,
227    opts: WriteOptions,
228) -> Result<WriteResult, String> {
229    let desired = serde_json::json!({
230        "source": "custom",
231        "command": binary,
232        "args": [],
233        "env": {}
234    });
235
236    if target.config_path.exists() {
237        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
238        let mut json = match serde_json::from_str::<Value>(&content) {
239            Ok(v) => v,
240            Err(e) => {
241                if !opts.overwrite_invalid {
242                    return Err(e.to_string());
243                }
244                backup_invalid_file(&target.config_path)?;
245                return write_zed_config_fresh(
246                    &target.config_path,
247                    desired,
248                    Some("overwrote invalid JSON".to_string()),
249                );
250            }
251        };
252        let obj = json
253            .as_object_mut()
254            .ok_or_else(|| "root JSON must be an object".to_string())?;
255
256        let servers = obj
257            .entry("context_servers")
258            .or_insert_with(|| serde_json::json!({}));
259        let servers_obj = servers
260            .as_object_mut()
261            .ok_or_else(|| "\"context_servers\" must be an object".to_string())?;
262
263        let existing = servers_obj.get("lean-ctx").cloned();
264        if existing.as_ref() == Some(&desired) {
265            return Ok(WriteResult {
266                action: WriteAction::Already,
267                note: None,
268            });
269        }
270        servers_obj.insert("lean-ctx".to_string(), desired);
271
272        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
273        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
274        return Ok(WriteResult {
275            action: WriteAction::Updated,
276            note: None,
277        });
278    }
279
280    write_zed_config_fresh(&target.config_path, desired, None)
281}
282
283fn write_codex_config(target: &EditorTarget, binary: &str) -> Result<WriteResult, String> {
284    if target.config_path.exists() {
285        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
286        let updated = upsert_codex_toml(&content, binary);
287        if updated == content {
288            return Ok(WriteResult {
289                action: WriteAction::Already,
290                note: None,
291            });
292        }
293        crate::config_io::write_atomic_with_backup(&target.config_path, &updated)?;
294        return Ok(WriteResult {
295            action: WriteAction::Updated,
296            note: None,
297        });
298    }
299
300    let content = format!(
301        "[mcp_servers.lean-ctx]\ncommand = \"{}\"\nargs = []\n",
302        binary
303    );
304    crate::config_io::write_atomic_with_backup(&target.config_path, &content)?;
305    Ok(WriteResult {
306        action: WriteAction::Created,
307        note: None,
308    })
309}
310
311fn write_zed_config_fresh(
312    path: &std::path::Path,
313    desired: Value,
314    note: Option<String>,
315) -> Result<WriteResult, String> {
316    let content = serde_json::to_string_pretty(&serde_json::json!({
317        "context_servers": { "lean-ctx": desired }
318    }))
319    .map_err(|e| e.to_string())?;
320    crate::config_io::write_atomic_with_backup(path, &content)?;
321    Ok(WriteResult {
322        action: if note.is_some() {
323            WriteAction::Updated
324        } else {
325            WriteAction::Created
326        },
327        note,
328    })
329}
330
331fn write_vscode_mcp(
332    target: &EditorTarget,
333    binary: &str,
334    opts: WriteOptions,
335) -> Result<WriteResult, String> {
336    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
337        .map(|d| d.to_string_lossy().to_string())
338        .unwrap_or_default();
339    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
340
341    if target.config_path.exists() {
342        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
343        let mut json = match serde_json::from_str::<Value>(&content) {
344            Ok(v) => v,
345            Err(e) => {
346                if !opts.overwrite_invalid {
347                    return Err(e.to_string());
348                }
349                backup_invalid_file(&target.config_path)?;
350                return write_vscode_mcp_fresh(
351                    &target.config_path,
352                    binary,
353                    Some("overwrote invalid JSON".to_string()),
354                );
355            }
356        };
357        let obj = json
358            .as_object_mut()
359            .ok_or_else(|| "root JSON must be an object".to_string())?;
360
361        let servers = obj
362            .entry("servers")
363            .or_insert_with(|| serde_json::json!({}));
364        let servers_obj = servers
365            .as_object_mut()
366            .ok_or_else(|| "\"servers\" must be an object".to_string())?;
367
368        let existing = servers_obj.get("lean-ctx").cloned();
369        if existing.as_ref() == Some(&desired) {
370            return Ok(WriteResult {
371                action: WriteAction::Already,
372                note: None,
373            });
374        }
375        servers_obj.insert("lean-ctx".to_string(), desired);
376
377        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
378        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
379        return Ok(WriteResult {
380            action: WriteAction::Updated,
381            note: None,
382        });
383    }
384
385    write_vscode_mcp_fresh(&target.config_path, binary, None)
386}
387
388fn write_vscode_mcp_fresh(
389    path: &std::path::Path,
390    binary: &str,
391    note: Option<String>,
392) -> Result<WriteResult, String> {
393    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
394        .map(|d| d.to_string_lossy().to_string())
395        .unwrap_or_default();
396    let content = serde_json::to_string_pretty(&serde_json::json!({
397        "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } } }
398    }))
399    .map_err(|e| e.to_string())?;
400    crate::config_io::write_atomic_with_backup(path, &content)?;
401    Ok(WriteResult {
402        action: if note.is_some() {
403            WriteAction::Updated
404        } else {
405            WriteAction::Created
406        },
407        note,
408    })
409}
410
411fn write_opencode_config(
412    target: &EditorTarget,
413    binary: &str,
414    opts: WriteOptions,
415) -> Result<WriteResult, String> {
416    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
417        .map(|d| d.to_string_lossy().to_string())
418        .unwrap_or_default();
419    let desired = serde_json::json!({
420        "type": "local",
421        "command": [binary],
422        "enabled": true,
423        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
424    });
425
426    if target.config_path.exists() {
427        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
428        let mut json = match serde_json::from_str::<Value>(&content) {
429            Ok(v) => v,
430            Err(e) => {
431                if !opts.overwrite_invalid {
432                    return Err(e.to_string());
433                }
434                backup_invalid_file(&target.config_path)?;
435                return write_opencode_fresh(
436                    &target.config_path,
437                    binary,
438                    Some("overwrote invalid JSON".to_string()),
439                );
440            }
441        };
442        let obj = json
443            .as_object_mut()
444            .ok_or_else(|| "root JSON must be an object".to_string())?;
445        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
446        let mcp_obj = mcp
447            .as_object_mut()
448            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
449
450        let existing = mcp_obj.get("lean-ctx").cloned();
451        if existing.as_ref() == Some(&desired) {
452            return Ok(WriteResult {
453                action: WriteAction::Already,
454                note: None,
455            });
456        }
457        mcp_obj.insert("lean-ctx".to_string(), desired);
458
459        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
460        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
461        return Ok(WriteResult {
462            action: WriteAction::Updated,
463            note: None,
464        });
465    }
466
467    write_opencode_fresh(&target.config_path, binary, None)
468}
469
470fn write_opencode_fresh(
471    path: &std::path::Path,
472    binary: &str,
473    note: Option<String>,
474) -> Result<WriteResult, String> {
475    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
476        .map(|d| d.to_string_lossy().to_string())
477        .unwrap_or_default();
478    let content = serde_json::to_string_pretty(&serde_json::json!({
479        "$schema": "https://opencode.ai/config.json",
480        "mcp": { "lean-ctx": { "type": "local", "command": [binary], "enabled": true, "environment": { "LEAN_CTX_DATA_DIR": data_dir } } }
481    }))
482    .map_err(|e| e.to_string())?;
483    crate::config_io::write_atomic_with_backup(path, &content)?;
484    Ok(WriteResult {
485        action: if note.is_some() {
486            WriteAction::Updated
487        } else {
488            WriteAction::Created
489        },
490        note,
491    })
492}
493
494fn write_jetbrains_config(
495    target: &EditorTarget,
496    binary: &str,
497    opts: WriteOptions,
498) -> Result<WriteResult, String> {
499    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
500        .map(|d| d.to_string_lossy().to_string())
501        .unwrap_or_default();
502    let entry = serde_json::json!({
503        "name": "lean-ctx",
504        "command": binary,
505        "args": [],
506        "env": { "LEAN_CTX_DATA_DIR": data_dir }
507    });
508
509    if target.config_path.exists() {
510        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
511        let mut json = match serde_json::from_str::<Value>(&content) {
512            Ok(v) => v,
513            Err(e) => {
514                if !opts.overwrite_invalid {
515                    return Err(e.to_string());
516                }
517                backup_invalid_file(&target.config_path)?;
518                let fresh = serde_json::json!({ "servers": [entry] });
519                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
520                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
521                return Ok(WriteResult {
522                    action: WriteAction::Updated,
523                    note: Some("overwrote invalid JSON".to_string()),
524                });
525            }
526        };
527        let obj = json
528            .as_object_mut()
529            .ok_or_else(|| "root JSON must be an object".to_string())?;
530        let servers = obj
531            .entry("servers")
532            .or_insert_with(|| serde_json::json!([]));
533        if let Some(arr) = servers.as_array_mut() {
534            let already = arr
535                .iter()
536                .any(|s| s.get("name").and_then(|n| n.as_str()) == Some("lean-ctx"));
537            if already {
538                return Ok(WriteResult {
539                    action: WriteAction::Already,
540                    note: None,
541                });
542            }
543            arr.push(entry);
544        }
545        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
546        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
547        return Ok(WriteResult {
548            action: WriteAction::Updated,
549            note: None,
550        });
551    }
552
553    let config = serde_json::json!({ "servers": [entry] });
554    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
555    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
556    Ok(WriteResult {
557        action: WriteAction::Created,
558        note: None,
559    })
560}
561
562fn write_amp_config(
563    target: &EditorTarget,
564    binary: &str,
565    opts: WriteOptions,
566) -> Result<WriteResult, String> {
567    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
568        .map(|d| d.to_string_lossy().to_string())
569        .unwrap_or_default();
570    let entry = serde_json::json!({
571        "command": binary,
572        "env": { "LEAN_CTX_DATA_DIR": data_dir }
573    });
574
575    if target.config_path.exists() {
576        let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
577        let mut json = match serde_json::from_str::<Value>(&content) {
578            Ok(v) => v,
579            Err(e) => {
580                if !opts.overwrite_invalid {
581                    return Err(e.to_string());
582                }
583                backup_invalid_file(&target.config_path)?;
584                let fresh = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
585                let formatted = serde_json::to_string_pretty(&fresh).map_err(|e| e.to_string())?;
586                crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
587                return Ok(WriteResult {
588                    action: WriteAction::Updated,
589                    note: Some("overwrote invalid JSON".to_string()),
590                });
591            }
592        };
593        let obj = json
594            .as_object_mut()
595            .ok_or_else(|| "root JSON must be an object".to_string())?;
596        let servers = obj
597            .entry("amp.mcpServers")
598            .or_insert_with(|| serde_json::json!({}));
599        let servers_obj = servers
600            .as_object_mut()
601            .ok_or_else(|| "\"amp.mcpServers\" must be an object".to_string())?;
602
603        let existing = servers_obj.get("lean-ctx").cloned();
604        if existing.as_ref() == Some(&entry) {
605            return Ok(WriteResult {
606                action: WriteAction::Already,
607                note: None,
608            });
609        }
610        servers_obj.insert("lean-ctx".to_string(), entry);
611
612        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
613        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
614        return Ok(WriteResult {
615            action: WriteAction::Updated,
616            note: None,
617        });
618    }
619
620    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
621    let formatted = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
622    crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
623    Ok(WriteResult {
624        action: WriteAction::Created,
625        note: None,
626    })
627}
628
629fn write_crush_config(
630    target: &EditorTarget,
631    binary: &str,
632    opts: WriteOptions,
633) -> Result<WriteResult, String> {
634    let desired = serde_json::json!({ "type": "stdio", "command": binary });
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 serde_json::from_str::<Value>(&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_crush_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        let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
656        let mcp_obj = mcp
657            .as_object_mut()
658            .ok_or_else(|| "\"mcp\" must be an object".to_string())?;
659
660        let existing = mcp_obj.get("lean-ctx").cloned();
661        if existing.as_ref() == Some(&desired) {
662            return Ok(WriteResult {
663                action: WriteAction::Already,
664                note: None,
665            });
666        }
667        mcp_obj.insert("lean-ctx".to_string(), desired);
668
669        let formatted = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
670        crate::config_io::write_atomic_with_backup(&target.config_path, &formatted)?;
671        return Ok(WriteResult {
672            action: WriteAction::Updated,
673            note: None,
674        });
675    }
676
677    write_crush_fresh(&target.config_path, desired, None)
678}
679
680fn write_crush_fresh(
681    path: &std::path::Path,
682    desired: Value,
683    note: Option<String>,
684) -> Result<WriteResult, String> {
685    let content = serde_json::to_string_pretty(&serde_json::json!({
686        "mcp": { "lean-ctx": desired }
687    }))
688    .map_err(|e| e.to_string())?;
689    crate::config_io::write_atomic_with_backup(path, &content)?;
690    Ok(WriteResult {
691        action: if note.is_some() {
692            WriteAction::Updated
693        } else {
694            WriteAction::Created
695        },
696        note,
697    })
698}
699
700fn upsert_codex_toml(existing: &str, binary: &str) -> String {
701    let mut out = String::with_capacity(existing.len() + 128);
702    let mut in_section = false;
703    let mut saw_section = false;
704    let mut wrote_command = false;
705    let mut wrote_args = false;
706
707    for line in existing.lines() {
708        let trimmed = line.trim();
709        if trimmed.starts_with('[') && trimmed.ends_with(']') {
710            if in_section && !wrote_command {
711                out.push_str(&format!("command = \"{}\"\n", binary));
712                wrote_command = true;
713            }
714            if in_section && !wrote_args {
715                out.push_str("args = []\n");
716                wrote_args = true;
717            }
718            in_section = trimmed == "[mcp_servers.lean-ctx]";
719            if in_section {
720                saw_section = true;
721            }
722            out.push_str(line);
723            out.push('\n');
724            continue;
725        }
726
727        if in_section {
728            if trimmed.starts_with("command") && trimmed.contains('=') {
729                out.push_str(&format!("command = \"{}\"\n", binary));
730                wrote_command = true;
731                continue;
732            }
733            if trimmed.starts_with("args") && trimmed.contains('=') {
734                out.push_str("args = []\n");
735                wrote_args = true;
736                continue;
737            }
738        }
739
740        out.push_str(line);
741        out.push('\n');
742    }
743
744    if saw_section {
745        if in_section && !wrote_command {
746            out.push_str(&format!("command = \"{}\"\n", binary));
747        }
748        if in_section && !wrote_args {
749            out.push_str("args = []\n");
750        }
751        return out;
752    }
753
754    if !out.ends_with('\n') {
755        out.push('\n');
756    }
757    out.push_str("\n[mcp_servers.lean-ctx]\n");
758    out.push_str(&format!("command = \"{}\"\n", binary));
759    out.push_str("args = []\n");
760    out
761}
762
763fn backup_invalid_file(path: &std::path::Path) -> Result<(), String> {
764    if !path.exists() {
765        return Ok(());
766    }
767    let parent = path
768        .parent()
769        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
770    let filename = path
771        .file_name()
772        .ok_or_else(|| "invalid path (no filename)".to_string())?
773        .to_string_lossy();
774    let pid = std::process::id();
775    let nanos = std::time::SystemTime::now()
776        .duration_since(std::time::UNIX_EPOCH)
777        .map(|d| d.as_nanos())
778        .unwrap_or(0);
779    let bak = parent.join(format!("{filename}.lean-ctx.invalid.{pid}.{nanos}.bak"));
780    std::fs::rename(path, bak).map_err(|e| e.to_string())?;
781    Ok(())
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use std::path::PathBuf;
788
789    fn target(path: PathBuf, ty: ConfigType) -> EditorTarget {
790        EditorTarget {
791            name: "test",
792            agent_key: "test".to_string(),
793            config_path: path,
794            detect_path: PathBuf::from("/nonexistent"),
795            config_type: ty,
796        }
797    }
798
799    #[test]
800    fn mcp_json_upserts_and_preserves_other_servers() {
801        let dir = tempfile::tempdir().unwrap();
802        let path = dir.path().join("mcp.json");
803        std::fs::write(
804            &path,
805            r#"{ "mcpServers": { "other": { "command": "other-bin" }, "lean-ctx": { "command": "/old/path/lean-ctx", "autoApprove": [] } } }"#,
806        )
807        .unwrap();
808
809        let t = target(path.clone(), ConfigType::McpJson);
810        let res = write_mcp_json(&t, "/new/path/lean-ctx", WriteOptions::default()).unwrap();
811        assert_eq!(res.action, WriteAction::Updated);
812
813        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
814        assert_eq!(json["mcpServers"]["other"]["command"], "other-bin");
815        assert_eq!(
816            json["mcpServers"]["lean-ctx"]["command"],
817            "/new/path/lean-ctx"
818        );
819        assert!(json["mcpServers"]["lean-ctx"]["autoApprove"].is_array());
820        assert!(
821            json["mcpServers"]["lean-ctx"]["autoApprove"]
822                .as_array()
823                .unwrap()
824                .len()
825                > 5
826        );
827    }
828
829    #[test]
830    fn crush_config_writes_mcp_root() {
831        let dir = tempfile::tempdir().unwrap();
832        let path = dir.path().join("crush.json");
833        std::fs::write(
834            &path,
835            r#"{ "mcp": { "lean-ctx": { "type": "stdio", "command": "old" } } }"#,
836        )
837        .unwrap();
838
839        let t = target(path.clone(), ConfigType::Crush);
840        let res = write_crush_config(&t, "new", WriteOptions::default()).unwrap();
841        assert_eq!(res.action, WriteAction::Updated);
842
843        let json: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
844        assert_eq!(json["mcp"]["lean-ctx"]["type"], "stdio");
845        assert_eq!(json["mcp"]["lean-ctx"]["command"], "new");
846    }
847
848    #[test]
849    fn codex_toml_upserts_existing_section() {
850        let dir = tempfile::tempdir().unwrap();
851        let path = dir.path().join("config.toml");
852        std::fs::write(
853            &path,
854            r#"[mcp_servers.lean-ctx]
855command = "old"
856args = ["x"]
857"#,
858        )
859        .unwrap();
860
861        let t = target(path.clone(), ConfigType::Codex);
862        let res = write_codex_config(&t, "new").unwrap();
863        assert_eq!(res.action, WriteAction::Updated);
864
865        let content = std::fs::read_to_string(&path).unwrap();
866        assert!(content.contains(r#"command = "new""#));
867        assert!(content.contains("args = []"));
868    }
869
870    #[test]
871    fn upsert_codex_toml_inserts_new_section_when_missing() {
872        let updated = upsert_codex_toml("[other]\nx=1\n", "lean-ctx");
873        assert!(updated.contains("[mcp_servers.lean-ctx]"));
874        assert!(updated.contains("command = \"lean-ctx\""));
875        assert!(updated.contains("args = []"));
876    }
877
878    #[test]
879    fn auto_approve_contains_core_tools() {
880        let tools = auto_approve_tools();
881        assert!(tools.contains(&"ctx_read"));
882        assert!(tools.contains(&"ctx_shell"));
883        assert!(tools.contains(&"ctx_search"));
884        assert!(tools.contains(&"ctx_workflow"));
885        assert!(tools.contains(&"ctx_cost"));
886    }
887}