1use crate::common::{HostFormat, HostKind};
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, HashMap};
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct McpServerEntry {
19 pub name: String,
20 pub command: String,
21 pub args: Vec<String>,
22 pub env: HashMap<String, String>,
23}
24
25pub const DEFAULT_MUX_SERVICE_NAME: &str = "rust-memex";
26pub const DEFAULT_MUX_SOCKET_PATH: &str = "~/.rmcp-servers/rust-memex/sockets/main.sock";
27pub const DEFAULT_MUX_CONFIG_PATH: &str = "~/.rmcp-servers/rust-memex/mux_config.toml";
28const DEFAULT_MUX_STATUS_PATH: &str = "~/.rmcp-servers/rust-memex/status/main.json";
29const RUST_MEMEX_SERVER_NAME: &str = "rust_memex";
30const MUX_MAX_ACTIVE_CLIENTS: usize = 5;
31const MUX_REQUEST_TIMEOUT_MS: u64 = 30_000;
32const MUX_RESTART_BACKOFF_MS: u64 = 1_000;
33const MUX_RESTART_BACKOFF_MAX_MS: u64 = 30_000;
34const MUX_MAX_RESTARTS: u64 = 5;
35
36#[derive(Debug, Clone, Serialize)]
37struct MuxConfigFile {
38 servers: BTreeMap<String, MuxServiceConfig>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42struct MuxServiceConfig {
43 socket: String,
44 cmd: String,
45 args: Vec<String>,
46 max_active_clients: usize,
47 max_request_bytes: usize,
48 request_timeout_ms: u64,
49 restart_backoff_ms: u64,
50 restart_backoff_max_ms: u64,
51 max_restarts: u64,
52 lazy_start: bool,
53 tray: bool,
54 service_name: String,
55 log_level: String,
56 status_file: String,
57}
58
59#[derive(Debug, Clone)]
60pub struct HostDetection {
61 pub kind: HostKind,
62 pub path: PathBuf,
63 pub format: HostFormat,
64 pub exists: bool,
65 pub has_rust_memex: bool,
66 pub servers: Vec<McpServerEntry>,
67}
68
69impl HostDetection {
70 pub fn status_icon(&self) -> &'static str {
71 if !self.exists {
72 "[ ]"
73 } else if self.has_rust_memex {
74 "[x]"
75 } else {
76 "[~]"
77 }
78 }
79
80 pub fn status_text(&self) -> &'static str {
81 if !self.exists {
82 "Not found"
83 } else if self.has_rust_memex {
84 "Configured"
85 } else {
86 "Detected (no memex server entry)"
87 }
88 }
89}
90
91fn matches_memex_server(entry: &McpServerEntry) -> bool {
92 entry.name.contains("rust_memex")
93 || entry.name.contains("rust-memex")
94 || entry.command.contains("rust_memex")
95 || entry.command.contains("rust-memex")
96}
97
98fn home_dir() -> Option<PathBuf> {
99 std::env::var("HOME")
100 .or_else(|_| std::env::var("USERPROFILE"))
101 .ok()
102 .map(PathBuf::from)
103}
104
105fn expand_home_path(path: &str) -> PathBuf {
106 if let Some(stripped) = path.strip_prefix("~/")
107 && let Some(home) = home_dir()
108 {
109 return home.join(stripped);
110 }
111
112 PathBuf::from(path)
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ExtendedHostKind {
119 Standard(HostKind),
121 ClaudeCode,
123 Junie,
125}
126
127impl ExtendedHostKind {
128 pub fn label(&self) -> &'static str {
129 match self {
130 ExtendedHostKind::Standard(k) => k.display_name(),
131 ExtendedHostKind::ClaudeCode => "Claude Code",
132 ExtendedHostKind::Junie => "Junie",
133 }
134 }
135}
136
137fn get_host_config_path(kind: HostKind) -> Option<(PathBuf, HostFormat)> {
138 let home = home_dir()?;
139
140 match kind {
141 HostKind::Codex => Some((home.join(".codex/config.toml"), HostFormat::Toml)),
142 HostKind::Cursor => {
143 #[cfg(target_os = "macos")]
144 let path = home.join(
145 "Library/Application Support/Cursor/User/globalStorage/cursor.mcp/config.json",
146 );
147 #[cfg(target_os = "linux")]
148 let path = home.join(".config/Cursor/User/globalStorage/cursor.mcp/config.json");
149 #[cfg(target_os = "windows")]
150 let path =
151 home.join("AppData/Roaming/Cursor/User/globalStorage/cursor.mcp/config.json");
152 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
153 let path = home.join(".config/Cursor/config.json");
154 Some((path, HostFormat::Json))
155 }
156 HostKind::Claude => {
157 #[cfg(target_os = "macos")]
158 let path = home.join("Library/Application Support/Claude/claude_desktop_config.json");
159 #[cfg(target_os = "linux")]
160 let path = home.join(".config/Claude/claude_desktop_config.json");
161 #[cfg(target_os = "windows")]
162 let path = home.join("AppData/Roaming/Claude/claude_desktop_config.json");
163 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
164 let path = home.join(".config/Claude/claude_desktop_config.json");
165 Some((path, HostFormat::Json))
166 }
167 HostKind::JetBrains => {
168 #[cfg(target_os = "macos")]
170 let path = home.join("Library/Application Support/JetBrains/mcp.json");
171 #[cfg(target_os = "linux")]
172 let path = home.join(".config/JetBrains/mcp.json");
173 #[cfg(target_os = "windows")]
174 let path = home.join("AppData/Roaming/JetBrains/mcp.json");
175 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
176 let path = home.join(".config/JetBrains/mcp.json");
177 Some((path, HostFormat::Json))
178 }
179 HostKind::VSCode => {
180 #[cfg(target_os = "macos")]
181 let path = home.join("Library/Application Support/Code/User/globalStorage/anthropic.claude-vscode/settings/cline_mcp_settings.json");
182 #[cfg(target_os = "linux")]
183 let path = home.join(".config/Code/User/globalStorage/anthropic.claude-vscode/settings/cline_mcp_settings.json");
184 #[cfg(target_os = "windows")]
185 let path = home.join("AppData/Roaming/Code/User/globalStorage/anthropic.claude-vscode/settings/cline_mcp_settings.json");
186 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
187 let path = home.join(".config/Code/cline_mcp_settings.json");
188 Some((path, HostFormat::Json))
189 }
190 HostKind::Unknown => None,
191 }
192}
193
194pub fn get_extended_host_config_path(kind: ExtendedHostKind) -> Option<(PathBuf, HostFormat)> {
196 let home = home_dir()?;
197
198 match kind {
199 ExtendedHostKind::Standard(k) => get_host_config_path(k),
200 ExtendedHostKind::ClaudeCode => Some((home.join(".claude.json"), HostFormat::Json)),
201 ExtendedHostKind::Junie => Some((home.join(".junie/mcp.json"), HostFormat::Json)),
202 }
203}
204
205fn parse_toml_mcp_servers(content: &str) -> Vec<McpServerEntry> {
206 let mut servers = Vec::new();
207
208 if let Ok(root) = content.parse::<toml::Table>()
211 && let Some(mcp_servers) = root.get("mcp_servers").and_then(|v| v.as_table())
212 {
213 for (name, config) in mcp_servers {
214 let command = config
215 .get("command")
216 .and_then(|v| v.as_str())
217 .unwrap_or("")
218 .to_string();
219
220 let args = config
221 .get("args")
222 .and_then(|v| v.as_array())
223 .map(|arr| {
224 arr.iter()
225 .filter_map(|v| v.as_str().map(String::from))
226 .collect()
227 })
228 .unwrap_or_default();
229
230 let env = config
231 .get("env")
232 .and_then(|v| v.as_table())
233 .map(|t| {
234 t.iter()
235 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
236 .collect()
237 })
238 .unwrap_or_default();
239
240 servers.push(McpServerEntry {
241 name: name.clone(),
242 command,
243 args,
244 env,
245 });
246 }
247 }
248
249 servers
250}
251
252fn parse_json_mcp_servers(content: &str) -> Vec<McpServerEntry> {
253 let mut servers = Vec::new();
254
255 if let Ok(value) = serde_json::from_str::<serde_json::Value>(content) {
256 let mcp_servers = value.get("mcpServers").or_else(|| value.get("mcp_servers"));
257
258 if let Some(mcp_obj) = mcp_servers.and_then(|v| v.as_object()) {
259 for (name, config) in mcp_obj {
260 let command = config
261 .get("command")
262 .and_then(|v| v.as_str())
263 .unwrap_or("")
264 .to_string();
265
266 let args = config
267 .get("args")
268 .and_then(|v| v.as_array())
269 .map(|arr| {
270 arr.iter()
271 .filter_map(|v| v.as_str().map(String::from))
272 .collect()
273 })
274 .unwrap_or_default();
275
276 let env = config
277 .get("env")
278 .and_then(|v| v.as_object())
279 .map(|obj| {
280 obj.iter()
281 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
282 .collect()
283 })
284 .unwrap_or_default();
285
286 servers.push(McpServerEntry {
287 name: name.clone(),
288 command,
289 args,
290 env,
291 });
292 }
293 }
294 }
295
296 servers
297}
298
299fn detect_single_host(kind: HostKind) -> Option<HostDetection> {
300 let (path, format) = get_host_config_path(kind)?;
301 let exists = path.exists();
302
303 let (has_rust_memex, servers) = if exists {
304 if let Ok(content) = std::fs::read_to_string(&path) {
305 let servers = match format {
306 HostFormat::Toml => parse_toml_mcp_servers(&content),
307 HostFormat::Json => parse_json_mcp_servers(&content),
308 };
309 let has_rmcp = servers.iter().any(matches_memex_server);
310 (has_rmcp, servers)
311 } else {
312 (false, Vec::new())
313 }
314 } else {
315 (false, Vec::new())
316 };
317
318 Some(HostDetection {
319 kind,
320 path,
321 format,
322 exists,
323 has_rust_memex,
324 servers,
325 })
326}
327
328pub fn detect_hosts() -> Vec<HostDetection> {
330 let kinds = [
331 HostKind::Codex,
332 HostKind::Cursor,
333 HostKind::Claude,
334 HostKind::JetBrains,
335 HostKind::VSCode,
336 ];
337
338 kinds
339 .iter()
340 .filter_map(|&k| detect_single_host(k))
341 .collect()
342}
343
344fn direct_command_args(config_path: &str, http_port: Option<u16>) -> Vec<String> {
345 let mut args = vec!["serve".to_string()];
346 if let Some(port) = http_port {
347 args.push("--http-port".to_string());
348 args.push(port.to_string());
349 }
350 args.push("--config".to_string());
351 args.push(config_path.to_string());
352 args
353}
354
355fn proxy_command_args(sock_path: &str) -> Vec<String> {
356 vec!["--socket".to_string(), sock_path.to_string()]
357}
358
359fn build_server_entry(command: &str, args: Vec<String>) -> McpServerEntry {
360 McpServerEntry {
361 name: RUST_MEMEX_SERVER_NAME.to_string(),
362 command: command.to_string(),
363 args,
364 env: HashMap::new(),
365 }
366}
367
368fn build_direct_host_entry(
369 binary_path: &str,
370 config_path: &str,
371 http_port: Option<u16>,
372) -> McpServerEntry {
373 build_server_entry(binary_path, direct_command_args(config_path, http_port))
374}
375
376fn build_mux_host_entry(proxy_command: &str, sock_path: &str) -> McpServerEntry {
377 build_server_entry(proxy_command, proxy_command_args(sock_path))
378}
379
380fn entry_description(entry: &McpServerEntry) -> &'static str {
381 if entry.command.contains("rust_mux_proxy") || entry.command.contains("rust-mux-proxy") {
382 "RAG memory via shared rust-mux proxy"
383 } else {
384 "RAG memory with vector search"
385 }
386}
387
388fn json_server_config(entry: &McpServerEntry) -> serde_json::Value {
389 let mut server = serde_json::Map::new();
390 server.insert(
391 "command".to_string(),
392 serde_json::Value::String(entry.command.clone()),
393 );
394 server.insert(
395 "args".to_string(),
396 serde_json::Value::Array(
397 entry
398 .args
399 .iter()
400 .cloned()
401 .map(serde_json::Value::String)
402 .collect(),
403 ),
404 );
405 if !entry.env.is_empty() {
406 server.insert(
407 "env".to_string(),
408 serde_json::Value::Object(
409 entry
410 .env
411 .iter()
412 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
413 .collect(),
414 ),
415 );
416 }
417 server.insert(
418 "description".to_string(),
419 serde_json::Value::String(entry_description(entry).to_string()),
420 );
421
422 serde_json::Value::Object(server)
423}
424
425fn toml_server_config(entry: &McpServerEntry) -> toml::Value {
426 let mut server = toml::map::Map::new();
427 server.insert(
428 "command".to_string(),
429 toml::Value::String(entry.command.clone()),
430 );
431 server.insert(
432 "args".to_string(),
433 toml::Value::Array(
434 entry
435 .args
436 .iter()
437 .cloned()
438 .map(toml::Value::String)
439 .collect(),
440 ),
441 );
442 if !entry.env.is_empty() {
443 let env = entry
444 .env
445 .iter()
446 .map(|(k, v)| (k.clone(), toml::Value::String(v.clone())))
447 .collect();
448 server.insert("env".to_string(), toml::Value::Table(env));
449 }
450
451 toml::Value::Table(server)
452}
453
454fn render_snippet(format: HostFormat, entry: &McpServerEntry) -> Result<String> {
455 match format {
456 HostFormat::Json => {
457 let mut servers = serde_json::Map::new();
458 servers.insert(entry.name.clone(), json_server_config(entry));
459 let mut root = serde_json::Map::new();
460 root.insert("mcpServers".to_string(), serde_json::Value::Object(servers));
461 serde_json::to_string_pretty(&serde_json::Value::Object(root))
462 .with_context(|| "Failed to serialize JSON snippet")
463 }
464 HostFormat::Toml => {
465 let mut servers = toml::map::Map::new();
466 servers.insert(entry.name.clone(), toml_server_config(entry));
467 let mut root = toml::map::Map::new();
468 root.insert("mcp_servers".to_string(), toml::Value::Table(servers));
469 toml::to_string_pretty(&toml::Value::Table(root))
470 .with_context(|| "Failed to serialize TOML snippet")
471 }
472 }
473}
474
475pub fn generate_extended_snippet(
477 kind: ExtendedHostKind,
478 binary_path: &str,
479 config_path: &str,
480 http_port: Option<u16>,
481) -> String {
482 let Some((_, format)) = get_extended_host_config_path(kind) else {
483 return String::new();
484 };
485
486 render_snippet(
487 format,
488 &build_direct_host_entry(binary_path, config_path, http_port),
489 )
490 .unwrap_or_default()
491}
492
493#[derive(Debug)]
495pub struct WriteResult {
496 pub host_name: String,
497 pub config_path: PathBuf,
498 pub backup_path: Option<PathBuf>,
499 pub created: bool,
500}
501
502fn backup_timestamp() -> String {
504 let secs = SystemTime::now()
505 .duration_since(UNIX_EPOCH)
506 .unwrap_or_default()
507 .as_secs();
508 format!("{}", secs)
509}
510
511fn create_backup(path: &Path) -> Result<PathBuf> {
513 use crate::path_utils::validate_read_path;
514
515 let safe_src = validate_read_path(path).with_context(|| {
517 format!(
518 "Cannot backup: source path validation failed for {}",
519 path.display()
520 )
521 })?;
522
523 let backup_path = PathBuf::from(format!("{}.bak.{}", safe_src.display(), backup_timestamp()));
524
525 let safe_dst = crate::path_utils::safe_copy(&safe_src, &backup_path)
527 .with_context(|| format!("Failed to create backup of {}", safe_src.display()))?;
528 Ok(safe_dst)
529}
530
531fn merge_json_config(existing_content: &str, entry: &McpServerEntry) -> Result<String> {
533 let mut config: serde_json::Value = if existing_content.trim().is_empty() {
534 serde_json::json!({})
535 } else {
536 serde_json::from_str(existing_content)
537 .with_context(|| "Failed to parse existing JSON config")?
538 };
539
540 if config.get("mcpServers").is_none() {
542 config["mcpServers"] = serde_json::json!({});
543 }
544
545 config["mcpServers"][entry.name.as_str()] = json_server_config(entry);
547
548 serde_json::to_string_pretty(&config).with_context(|| "Failed to serialize JSON config")
549}
550
551fn merge_toml_config(existing_content: &str, entry: &McpServerEntry) -> Result<String> {
553 let mut config: toml::Table = if existing_content.trim().is_empty() {
556 toml::Table::new()
557 } else {
558 existing_content
559 .parse()
560 .with_context(|| "Failed to parse existing TOML config")?
561 };
562
563 if !config.contains_key("mcp_servers") {
565 config.insert(
566 "mcp_servers".to_string(),
567 toml::Value::Table(toml::Table::new()),
568 );
569 }
570
571 if let Some(mcp_servers) = config.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
573 mcp_servers.insert(entry.name.clone(), toml_server_config(entry));
574 }
575
576 Ok(toml::to_string_pretty(&config)?)
577}
578
579fn write_host_config_entry(
580 host_name: String,
581 path: &Path,
582 format: HostFormat,
583 exists: bool,
584 entry: &McpServerEntry,
585) -> Result<WriteResult> {
586 if let Some(parent) = path.parent()
588 && !parent.exists()
589 {
590 std::fs::create_dir_all(parent)
591 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
592 }
593
594 let backup_path = if exists {
596 Some(create_backup(path)?)
597 } else {
598 None
599 };
600
601 use crate::path_utils::validate_write_path;
602
603 let existing_content = if exists {
605 let (_safe_path, content) = crate::path_utils::safe_read_to_string(&path.to_string_lossy())
606 .with_context(|| format!("Cannot read config: {}", path.display()))?;
607 content
608 } else {
609 String::new()
610 };
611
612 let new_content = match format {
614 HostFormat::Json => merge_json_config(&existing_content, entry)?,
615 HostFormat::Toml => merge_toml_config(&existing_content, entry)?,
616 };
617
618 let safe_write_path = validate_write_path(path).with_context(|| {
620 format!(
621 "Cannot write config: path validation failed for {}",
622 path.display()
623 )
624 })?;
625
626 std::fs::write(&safe_write_path, &new_content)
627 .with_context(|| format!("Failed to write config to {}", safe_write_path.display()))?;
628
629 Ok(WriteResult {
630 host_name,
631 config_path: path.to_path_buf(),
632 backup_path,
633 created: !exists,
634 })
635}
636
637pub fn write_extended_host_config(
639 kind: ExtendedHostKind,
640 binary_path: &str,
641 config_path: &str,
642 http_port: Option<u16>,
643) -> Result<WriteResult> {
644 let (path, format) =
645 get_extended_host_config_path(kind).ok_or_else(|| anyhow::anyhow!("Unknown host kind"))?;
646 let entry = build_direct_host_entry(binary_path, config_path, http_port);
647 write_host_config_entry(
648 kind.label().to_string(),
649 &path,
650 format,
651 path.exists(),
652 &entry,
653 )
654}
655
656pub fn detect_extended_hosts() -> Vec<(ExtendedHostKind, HostDetection)> {
658 let mut results = Vec::new();
659
660 for kind in [
662 HostKind::Codex,
663 HostKind::Cursor,
664 HostKind::Claude,
665 HostKind::JetBrains,
666 HostKind::VSCode,
667 ] {
668 if let Some(detection) = detect_single_host(kind) {
669 results.push((ExtendedHostKind::Standard(kind), detection));
670 }
671 }
672
673 for ext_kind in [ExtendedHostKind::ClaudeCode, ExtendedHostKind::Junie] {
675 if let Some((path, format)) = get_extended_host_config_path(ext_kind) {
676 let exists = path.exists();
677 let (has_rust_memex, servers) = if exists {
678 if let Ok(content) = std::fs::read_to_string(&path) {
679 let servers = parse_json_mcp_servers(&content);
680 let has_rmcp = servers.iter().any(matches_memex_server);
681 (has_rmcp, servers)
682 } else {
683 (false, Vec::new())
684 }
685 } else {
686 (false, Vec::new())
687 };
688
689 results.push((
690 ext_kind,
691 HostDetection {
692 kind: HostKind::Unknown,
693 path,
694 format,
695 exists,
696 has_rust_memex,
697 servers,
698 },
699 ));
700 }
701 }
702
703 results
704}
705
706pub fn generate_extended_snippet_mux(
707 kind: ExtendedHostKind,
708 proxy_command: &str,
709 sock_path: &str,
710) -> String {
711 let Some((_, format)) = get_extended_host_config_path(kind) else {
712 return String::new();
713 };
714
715 render_snippet(format, &build_mux_host_entry(proxy_command, sock_path)).unwrap_or_default()
716}
717
718pub fn write_extended_host_config_mux(
719 kind: ExtendedHostKind,
720 proxy_command: &str,
721 sock_path: &str,
722) -> Result<WriteResult> {
723 let (path, format) =
724 get_extended_host_config_path(kind).ok_or_else(|| anyhow::anyhow!("Unknown host kind"))?;
725 write_host_config_entry(
726 kind.label().to_string(),
727 &path,
728 format,
729 path.exists(),
730 &build_mux_host_entry(proxy_command, sock_path),
731 )
732}
733
734fn build_mux_service_config_toml(
735 binary_path: &str,
736 config_path: &str,
737 http_port: Option<u16>,
738 max_request_bytes: usize,
739 log_level: &str,
740) -> Result<String> {
741 let mut servers = BTreeMap::new();
742 servers.insert(
743 DEFAULT_MUX_SERVICE_NAME.to_string(),
744 MuxServiceConfig {
745 socket: DEFAULT_MUX_SOCKET_PATH.to_string(),
746 cmd: binary_path.to_string(),
747 args: direct_command_args(config_path, http_port),
748 max_active_clients: MUX_MAX_ACTIVE_CLIENTS,
749 max_request_bytes,
750 request_timeout_ms: MUX_REQUEST_TIMEOUT_MS,
751 restart_backoff_ms: MUX_RESTART_BACKOFF_MS,
752 restart_backoff_max_ms: MUX_RESTART_BACKOFF_MAX_MS,
753 max_restarts: MUX_MAX_RESTARTS,
754 lazy_start: false,
755 tray: false,
756 service_name: DEFAULT_MUX_SERVICE_NAME.to_string(),
757 log_level: log_level.to_string(),
758 status_file: DEFAULT_MUX_STATUS_PATH.to_string(),
759 },
760 );
761
762 toml::to_string_pretty(&MuxConfigFile { servers })
763 .with_context(|| "Failed to serialize mux service config")
764}
765
766pub fn write_mux_service_config(
767 binary_path: &str,
768 config_path: &str,
769 http_port: Option<u16>,
770 max_request_bytes: usize,
771 log_level: &str,
772) -> Result<WriteResult> {
773 let config_file = expand_home_path(DEFAULT_MUX_CONFIG_PATH);
774 let socket_dir = expand_home_path(DEFAULT_MUX_SOCKET_PATH)
775 .parent()
776 .map(Path::to_path_buf)
777 .ok_or_else(|| anyhow::anyhow!("Invalid mux socket path"))?;
778 let status_dir = expand_home_path(DEFAULT_MUX_STATUS_PATH)
779 .parent()
780 .map(Path::to_path_buf)
781 .ok_or_else(|| anyhow::anyhow!("Invalid mux status path"))?;
782
783 if let Some(parent) = config_file.parent()
784 && !parent.exists()
785 {
786 std::fs::create_dir_all(parent)
787 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
788 }
789 if !socket_dir.exists() {
790 std::fs::create_dir_all(&socket_dir)
791 .with_context(|| format!("Failed to create directory {}", socket_dir.display()))?;
792 }
793 if !status_dir.exists() {
794 std::fs::create_dir_all(&status_dir)
795 .with_context(|| format!("Failed to create directory {}", status_dir.display()))?;
796 }
797
798 let exists = config_file.exists();
799 let backup_path = if exists {
800 Some(create_backup(&config_file)?)
801 } else {
802 None
803 };
804
805 use crate::path_utils::validate_write_path;
806
807 let content = build_mux_service_config_toml(
808 binary_path,
809 config_path,
810 http_port,
811 max_request_bytes,
812 log_level,
813 )?;
814 let safe_write_path = validate_write_path(&config_file).with_context(|| {
815 format!(
816 "Cannot write mux service config: path validation failed for {}",
817 config_file.display()
818 )
819 })?;
820 std::fs::write(&safe_write_path, content).with_context(|| {
821 format!(
822 "Failed to write mux service config to {}",
823 safe_write_path.display()
824 )
825 })?;
826
827 Ok(WriteResult {
828 host_name: "rust-mux service".to_string(),
829 config_path: config_file,
830 backup_path,
831 created: !exists,
832 })
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn test_parse_toml_mcp_servers() {
841 let toml_content = r#"
842[mcp_servers.rust_memex]
843command = "/usr/local/bin/rust_memex"
844args = ["--db-path", "~/.rmcp/db"]
845
846[mcp_servers.other_server]
847command = "other"
848"#;
849 let servers = parse_toml_mcp_servers(toml_content);
850 assert_eq!(servers.len(), 2);
851 assert!(servers.iter().any(|s| s.name == "rust_memex"));
852 }
853
854 #[test]
855 fn test_parse_json_mcp_servers() {
856 let json_content = r#"{
857 "mcpServers": {
858 "rust_memex": {
859 "command": "/usr/local/bin/rust_memex",
860 "args": ["--db-path", "~/.rmcp/db"]
861 }
862 }
863}"#;
864 let servers = parse_json_mcp_servers(json_content);
865 assert_eq!(servers.len(), 1);
866 assert_eq!(servers[0].name, "rust_memex");
867 }
868
869 #[test]
870 fn test_matches_memex_server_accepts_canonical_binary_name() {
871 let entry = McpServerEntry {
872 name: "custom".to_string(),
873 command: "/usr/local/bin/rust-memex".to_string(),
874 args: vec!["serve".to_string()],
875 env: HashMap::new(),
876 };
877
878 assert!(matches_memex_server(&entry));
879 }
880
881 #[test]
882 fn test_generate_toml_snippet() {
883 let snippet = generate_extended_snippet(
884 ExtendedHostKind::Standard(HostKind::Codex),
885 "/usr/bin/rust-memex",
886 "~/.rmcp-servers/rust-memex/config.toml",
887 None,
888 );
889 assert!(snippet.contains("[mcp_servers.rust_memex]"));
890 assert!(snippet.contains("/usr/bin/rust-memex"));
891 assert!(snippet.contains("--config"));
892 }
893
894 #[test]
895 fn test_generate_json_snippet() {
896 let snippet = generate_extended_snippet(
897 ExtendedHostKind::Standard(HostKind::Claude),
898 "/usr/bin/rust-memex",
899 "~/.rmcp-servers/rust-memex/config.toml",
900 None,
901 );
902 assert!(snippet.contains("\"mcpServers\""));
903 assert!(snippet.contains("\"rust_memex\""));
904 assert!(snippet.contains("/usr/bin/rust-memex"));
905 assert!(snippet.contains("--config"));
906 }
907
908 #[test]
909 fn test_generate_extended_claude_code_snippet() {
910 let snippet = generate_extended_snippet(
911 ExtendedHostKind::ClaudeCode,
912 "/usr/bin/rust-memex",
913 "~/.rmcp-servers/rust-memex/config.toml",
914 None,
915 );
916 assert!(snippet.contains("\"mcpServers\""));
917 assert!(snippet.contains("\"rust_memex\""));
918 assert!(snippet.contains("/usr/bin/rust-memex"));
919 assert!(snippet.contains("--config"));
920 }
921
922 #[test]
923 fn test_generate_extended_junie_snippet() {
924 let snippet = generate_extended_snippet(
925 ExtendedHostKind::Junie,
926 "/usr/bin/rust-memex",
927 "~/.rmcp-servers/rust-memex/config.toml",
928 None,
929 );
930 assert!(snippet.contains("\"mcpServers\""));
931 assert!(snippet.contains("\"rust_memex\""));
932 assert!(snippet.contains("/usr/bin/rust-memex"));
933 assert!(snippet.contains("--config"));
934 }
935
936 #[test]
937 fn test_generate_json_snippet_includes_http_port_when_requested() {
938 let snippet = generate_extended_snippet(
939 ExtendedHostKind::Standard(HostKind::Claude),
940 "/usr/bin/rust-memex",
941 "~/.rmcp-servers/rust-memex/config.toml",
942 Some(8765),
943 );
944 assert!(snippet.contains("--http-port"));
945 assert!(snippet.contains("8765"));
946 }
947
948 #[test]
949 fn test_merge_json_config_empty() {
950 let result = merge_json_config(
951 "",
952 &build_direct_host_entry(
953 "/usr/bin/rust-memex",
954 "~/.rmcp-servers/rust-memex/config.toml",
955 None,
956 ),
957 )
958 .unwrap();
959 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
960 assert!(
961 parsed["mcpServers"]["rust_memex"]["command"]
962 .as_str()
963 .unwrap()
964 .contains("rust-memex")
965 );
966 }
967
968 #[test]
969 fn test_merge_json_config_preserves_http_port() {
970 let result = merge_json_config(
971 "",
972 &build_direct_host_entry(
973 "/usr/bin/rust-memex",
974 "~/.rmcp-servers/rust-memex/config.toml",
975 Some(8765),
976 ),
977 )
978 .unwrap();
979 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
980 let args = parsed["mcpServers"]["rust_memex"]["args"]
981 .as_array()
982 .unwrap()
983 .iter()
984 .filter_map(|value| value.as_str())
985 .collect::<Vec<_>>();
986 assert_eq!(
987 args,
988 vec![
989 "serve",
990 "--http-port",
991 "8765",
992 "--config",
993 "~/.rmcp-servers/rust-memex/config.toml"
994 ]
995 );
996 }
997
998 #[test]
999 fn test_merge_json_config_existing() {
1000 let existing = r#"{
1001 "mcpServers": {
1002 "other_server": {
1003 "command": "other",
1004 "args": []
1005 }
1006 }
1007 }"#;
1008 let result = merge_json_config(
1009 existing,
1010 &build_direct_host_entry(
1011 "/usr/bin/rust-memex",
1012 "~/.rmcp-servers/rust-memex/config.toml",
1013 None,
1014 ),
1015 )
1016 .unwrap();
1017 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1018 assert!(
1020 parsed["mcpServers"]["other_server"]["command"]
1021 .as_str()
1022 .is_some()
1023 );
1024 assert!(
1026 parsed["mcpServers"]["rust_memex"]["command"]
1027 .as_str()
1028 .unwrap()
1029 .contains("rust-memex")
1030 );
1031 }
1032
1033 #[test]
1034 fn test_merge_toml_config_empty() {
1035 let result = merge_toml_config(
1036 "",
1037 &build_direct_host_entry(
1038 "/usr/bin/rust-memex",
1039 "~/.rmcp-servers/rust-memex/config.toml",
1040 None,
1041 ),
1042 )
1043 .unwrap();
1044 assert!(result.contains("[mcp_servers.rust_memex]"));
1045 assert!(result.contains("rust-memex"));
1046 assert!(result.contains("--config"));
1047 }
1048
1049 #[test]
1050 fn test_merge_toml_config_existing() {
1051 let existing = r#"
1052[mcp_servers.other_server]
1053command = "other"
1054args = []
1055"#;
1056 let result = merge_toml_config(
1057 existing,
1058 &build_direct_host_entry(
1059 "/usr/bin/rust-memex",
1060 "~/.rmcp-servers/rust-memex/config.toml",
1061 None,
1062 ),
1063 )
1064 .unwrap();
1065 assert!(result.contains("other_server"));
1067 assert!(result.contains("rust-memex"));
1069 assert!(result.contains("--config"));
1070 }
1071
1072 #[test]
1073 fn test_generate_mux_snippet_uses_proxy_command() {
1074 let snippet = generate_extended_snippet_mux(
1075 ExtendedHostKind::Standard(HostKind::Claude),
1076 "/custom/bin/rust-mux-proxy",
1077 DEFAULT_MUX_SOCKET_PATH,
1078 );
1079 assert!(snippet.contains("/custom/bin/rust-mux-proxy"));
1080 assert!(snippet.contains("--socket"));
1081 assert!(snippet.contains(DEFAULT_MUX_SOCKET_PATH));
1082 }
1083
1084 #[test]
1085 fn test_build_mux_service_config_toml_uses_shared_daemon_shape() {
1086 let config = build_mux_service_config_toml(
1087 "/usr/bin/rust-memex",
1088 "~/.rmcp-servers/rust-memex/config.toml",
1089 Some(8765),
1090 4_194_304,
1091 "debug",
1092 )
1093 .unwrap();
1094
1095 assert!(config.contains("[servers.rust-memex]"));
1096 assert!(config.contains("socket = \"~/.rmcp-servers/rust-memex/sockets/main.sock\""));
1097 assert!(config.contains("cmd = \"/usr/bin/rust-memex\""));
1098 assert!(config.contains("--http-port"));
1099 assert!(config.contains("8765"));
1100 assert!(config.contains("status_file = \"~/.rmcp-servers/rust-memex/status/main.json\""));
1101 assert!(config.contains("service_name = \"rust-memex\""));
1102 assert!(config.contains("max_request_bytes = 4194304"));
1103 }
1104
1105 #[test]
1106 fn test_extended_host_kind_display_names() {
1107 assert_eq!(
1108 ExtendedHostKind::Standard(HostKind::Claude).label(),
1109 "Claude Desktop"
1110 );
1111 assert_eq!(ExtendedHostKind::ClaudeCode.label(), "Claude Code");
1112 assert_eq!(ExtendedHostKind::Junie.label(), "Junie");
1113 }
1114}