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 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}