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(value) = content.parse::<toml::Value>()
209 && let Some(mcp_servers) = value.get("mcp_servers").and_then(|v| v.as_table())
210 {
211 for (name, config) in mcp_servers {
212 let command = config
213 .get("command")
214 .and_then(|v| v.as_str())
215 .unwrap_or("")
216 .to_string();
217
218 let args = config
219 .get("args")
220 .and_then(|v| v.as_array())
221 .map(|arr| {
222 arr.iter()
223 .filter_map(|v| v.as_str().map(String::from))
224 .collect()
225 })
226 .unwrap_or_default();
227
228 let env = config
229 .get("env")
230 .and_then(|v| v.as_table())
231 .map(|t| {
232 t.iter()
233 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
234 .collect()
235 })
236 .unwrap_or_default();
237
238 servers.push(McpServerEntry {
239 name: name.clone(),
240 command,
241 args,
242 env,
243 });
244 }
245 }
246
247 servers
248}
249
250fn parse_json_mcp_servers(content: &str) -> Vec<McpServerEntry> {
251 let mut servers = Vec::new();
252
253 if let Ok(value) = serde_json::from_str::<serde_json::Value>(content) {
254 let mcp_servers = value.get("mcpServers").or_else(|| value.get("mcp_servers"));
255
256 if let Some(mcp_obj) = mcp_servers.and_then(|v| v.as_object()) {
257 for (name, config) in mcp_obj {
258 let command = config
259 .get("command")
260 .and_then(|v| v.as_str())
261 .unwrap_or("")
262 .to_string();
263
264 let args = config
265 .get("args")
266 .and_then(|v| v.as_array())
267 .map(|arr| {
268 arr.iter()
269 .filter_map(|v| v.as_str().map(String::from))
270 .collect()
271 })
272 .unwrap_or_default();
273
274 let env = config
275 .get("env")
276 .and_then(|v| v.as_object())
277 .map(|obj| {
278 obj.iter()
279 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
280 .collect()
281 })
282 .unwrap_or_default();
283
284 servers.push(McpServerEntry {
285 name: name.clone(),
286 command,
287 args,
288 env,
289 });
290 }
291 }
292 }
293
294 servers
295}
296
297fn detect_single_host(kind: HostKind) -> Option<HostDetection> {
298 let (path, format) = get_host_config_path(kind)?;
299 let exists = path.exists();
300
301 let (has_rust_memex, servers) = if exists {
302 if let Ok(content) = std::fs::read_to_string(&path) {
303 let servers = match format {
304 HostFormat::Toml => parse_toml_mcp_servers(&content),
305 HostFormat::Json => parse_json_mcp_servers(&content),
306 };
307 let has_rmcp = servers.iter().any(matches_memex_server);
308 (has_rmcp, servers)
309 } else {
310 (false, Vec::new())
311 }
312 } else {
313 (false, Vec::new())
314 };
315
316 Some(HostDetection {
317 kind,
318 path,
319 format,
320 exists,
321 has_rust_memex,
322 servers,
323 })
324}
325
326pub fn detect_hosts() -> Vec<HostDetection> {
328 let kinds = [
329 HostKind::Codex,
330 HostKind::Cursor,
331 HostKind::Claude,
332 HostKind::JetBrains,
333 HostKind::VSCode,
334 ];
335
336 kinds
337 .iter()
338 .filter_map(|&k| detect_single_host(k))
339 .collect()
340}
341
342fn direct_command_args(config_path: &str, http_port: Option<u16>) -> Vec<String> {
343 let mut args = vec!["serve".to_string()];
344 if let Some(port) = http_port {
345 args.push("--http-port".to_string());
346 args.push(port.to_string());
347 }
348 args.push("--config".to_string());
349 args.push(config_path.to_string());
350 args
351}
352
353fn proxy_command_args(sock_path: &str) -> Vec<String> {
354 vec!["--socket".to_string(), sock_path.to_string()]
355}
356
357fn build_server_entry(command: &str, args: Vec<String>) -> McpServerEntry {
358 McpServerEntry {
359 name: RUST_MEMEX_SERVER_NAME.to_string(),
360 command: command.to_string(),
361 args,
362 env: HashMap::new(),
363 }
364}
365
366fn build_direct_host_entry(
367 binary_path: &str,
368 config_path: &str,
369 http_port: Option<u16>,
370) -> McpServerEntry {
371 build_server_entry(binary_path, direct_command_args(config_path, http_port))
372}
373
374fn build_mux_host_entry(proxy_command: &str, sock_path: &str) -> McpServerEntry {
375 build_server_entry(proxy_command, proxy_command_args(sock_path))
376}
377
378fn entry_description(entry: &McpServerEntry) -> &'static str {
379 if entry.command.contains("rust_mux_proxy") || entry.command.contains("rust-mux-proxy") {
380 "RAG memory via shared rust-mux proxy"
381 } else {
382 "RAG memory with vector search"
383 }
384}
385
386fn json_server_config(entry: &McpServerEntry) -> serde_json::Value {
387 let mut server = serde_json::Map::new();
388 server.insert(
389 "command".to_string(),
390 serde_json::Value::String(entry.command.clone()),
391 );
392 server.insert(
393 "args".to_string(),
394 serde_json::Value::Array(
395 entry
396 .args
397 .iter()
398 .cloned()
399 .map(serde_json::Value::String)
400 .collect(),
401 ),
402 );
403 if !entry.env.is_empty() {
404 server.insert(
405 "env".to_string(),
406 serde_json::Value::Object(
407 entry
408 .env
409 .iter()
410 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
411 .collect(),
412 ),
413 );
414 }
415 server.insert(
416 "description".to_string(),
417 serde_json::Value::String(entry_description(entry).to_string()),
418 );
419
420 serde_json::Value::Object(server)
421}
422
423fn toml_server_config(entry: &McpServerEntry) -> toml::Value {
424 let mut server = toml::map::Map::new();
425 server.insert(
426 "command".to_string(),
427 toml::Value::String(entry.command.clone()),
428 );
429 server.insert(
430 "args".to_string(),
431 toml::Value::Array(
432 entry
433 .args
434 .iter()
435 .cloned()
436 .map(toml::Value::String)
437 .collect(),
438 ),
439 );
440 if !entry.env.is_empty() {
441 let env = entry
442 .env
443 .iter()
444 .map(|(k, v)| (k.clone(), toml::Value::String(v.clone())))
445 .collect();
446 server.insert("env".to_string(), toml::Value::Table(env));
447 }
448
449 toml::Value::Table(server)
450}
451
452fn render_snippet(format: HostFormat, entry: &McpServerEntry) -> Result<String> {
453 match format {
454 HostFormat::Json => {
455 let mut servers = serde_json::Map::new();
456 servers.insert(entry.name.clone(), json_server_config(entry));
457 let mut root = serde_json::Map::new();
458 root.insert("mcpServers".to_string(), serde_json::Value::Object(servers));
459 serde_json::to_string_pretty(&serde_json::Value::Object(root))
460 .with_context(|| "Failed to serialize JSON snippet")
461 }
462 HostFormat::Toml => {
463 let mut servers = toml::map::Map::new();
464 servers.insert(entry.name.clone(), toml_server_config(entry));
465 let mut root = toml::map::Map::new();
466 root.insert("mcp_servers".to_string(), toml::Value::Table(servers));
467 toml::to_string_pretty(&toml::Value::Table(root))
468 .with_context(|| "Failed to serialize TOML snippet")
469 }
470 }
471}
472
473pub fn generate_extended_snippet(
475 kind: ExtendedHostKind,
476 binary_path: &str,
477 config_path: &str,
478 http_port: Option<u16>,
479) -> String {
480 let Some((_, format)) = get_extended_host_config_path(kind) else {
481 return String::new();
482 };
483
484 render_snippet(
485 format,
486 &build_direct_host_entry(binary_path, config_path, http_port),
487 )
488 .unwrap_or_default()
489}
490
491#[derive(Debug)]
493pub struct WriteResult {
494 pub host_name: String,
495 pub config_path: PathBuf,
496 pub backup_path: Option<PathBuf>,
497 pub created: bool,
498}
499
500fn backup_timestamp() -> String {
502 let secs = SystemTime::now()
503 .duration_since(UNIX_EPOCH)
504 .unwrap_or_default()
505 .as_secs();
506 format!("{}", secs)
507}
508
509fn create_backup(path: &Path) -> Result<PathBuf> {
511 use crate::path_utils::validate_read_path;
512
513 let safe_src = validate_read_path(path).with_context(|| {
515 format!(
516 "Cannot backup: source path validation failed for {}",
517 path.display()
518 )
519 })?;
520
521 let backup_path = PathBuf::from(format!("{}.bak.{}", safe_src.display(), backup_timestamp()));
522
523 let safe_dst = crate::path_utils::safe_copy(&safe_src, &backup_path)
525 .with_context(|| format!("Failed to create backup of {}", safe_src.display()))?;
526 Ok(safe_dst)
527}
528
529fn merge_json_config(existing_content: &str, entry: &McpServerEntry) -> Result<String> {
531 let mut config: serde_json::Value = if existing_content.trim().is_empty() {
532 serde_json::json!({})
533 } else {
534 serde_json::from_str(existing_content)
535 .with_context(|| "Failed to parse existing JSON config")?
536 };
537
538 if config.get("mcpServers").is_none() {
540 config["mcpServers"] = serde_json::json!({});
541 }
542
543 config["mcpServers"][entry.name.as_str()] = json_server_config(entry);
545
546 serde_json::to_string_pretty(&config).with_context(|| "Failed to serialize JSON config")
547}
548
549fn merge_toml_config(existing_content: &str, entry: &McpServerEntry) -> Result<String> {
551 let mut config: toml::Value = if existing_content.trim().is_empty() {
552 toml::Value::Table(toml::map::Map::new())
553 } else {
554 existing_content
555 .parse()
556 .with_context(|| "Failed to parse existing TOML config")?
557 };
558
559 let table = config.as_table_mut().expect("root must be a table");
561 if !table.contains_key("mcp_servers") {
562 table.insert(
563 "mcp_servers".to_string(),
564 toml::Value::Table(toml::map::Map::new()),
565 );
566 }
567
568 if let Some(mcp_servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
570 mcp_servers.insert(entry.name.clone(), toml_server_config(entry));
571 }
572
573 Ok(toml::to_string_pretty(&config)?)
574}
575
576fn write_host_config_entry(
577 host_name: String,
578 path: &Path,
579 format: HostFormat,
580 exists: bool,
581 entry: &McpServerEntry,
582) -> Result<WriteResult> {
583 if let Some(parent) = path.parent()
585 && !parent.exists()
586 {
587 std::fs::create_dir_all(parent)
588 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
589 }
590
591 let backup_path = if exists {
593 Some(create_backup(path)?)
594 } else {
595 None
596 };
597
598 use crate::path_utils::validate_write_path;
599
600 let existing_content = if exists {
602 let (_safe_path, content) = crate::path_utils::safe_read_to_string(&path.to_string_lossy())
603 .with_context(|| format!("Cannot read config: {}", path.display()))?;
604 content
605 } else {
606 String::new()
607 };
608
609 let new_content = match format {
611 HostFormat::Json => merge_json_config(&existing_content, entry)?,
612 HostFormat::Toml => merge_toml_config(&existing_content, entry)?,
613 };
614
615 let safe_write_path = validate_write_path(path).with_context(|| {
617 format!(
618 "Cannot write config: path validation failed for {}",
619 path.display()
620 )
621 })?;
622
623 std::fs::write(&safe_write_path, &new_content)
624 .with_context(|| format!("Failed to write config to {}", safe_write_path.display()))?;
625
626 Ok(WriteResult {
627 host_name,
628 config_path: path.to_path_buf(),
629 backup_path,
630 created: !exists,
631 })
632}
633
634pub fn write_extended_host_config(
636 kind: ExtendedHostKind,
637 binary_path: &str,
638 config_path: &str,
639 http_port: Option<u16>,
640) -> Result<WriteResult> {
641 let (path, format) =
642 get_extended_host_config_path(kind).ok_or_else(|| anyhow::anyhow!("Unknown host kind"))?;
643 let entry = build_direct_host_entry(binary_path, config_path, http_port);
644 write_host_config_entry(
645 kind.label().to_string(),
646 &path,
647 format,
648 path.exists(),
649 &entry,
650 )
651}
652
653pub fn detect_extended_hosts() -> Vec<(ExtendedHostKind, HostDetection)> {
655 let mut results = Vec::new();
656
657 for kind in [
659 HostKind::Codex,
660 HostKind::Cursor,
661 HostKind::Claude,
662 HostKind::JetBrains,
663 HostKind::VSCode,
664 ] {
665 if let Some(detection) = detect_single_host(kind) {
666 results.push((ExtendedHostKind::Standard(kind), detection));
667 }
668 }
669
670 for ext_kind in [ExtendedHostKind::ClaudeCode, ExtendedHostKind::Junie] {
672 if let Some((path, format)) = get_extended_host_config_path(ext_kind) {
673 let exists = path.exists();
674 let (has_rust_memex, servers) = if exists {
675 if let Ok(content) = std::fs::read_to_string(&path) {
676 let servers = parse_json_mcp_servers(&content);
677 let has_rmcp = servers.iter().any(matches_memex_server);
678 (has_rmcp, servers)
679 } else {
680 (false, Vec::new())
681 }
682 } else {
683 (false, Vec::new())
684 };
685
686 results.push((
687 ext_kind,
688 HostDetection {
689 kind: HostKind::Unknown,
690 path,
691 format,
692 exists,
693 has_rust_memex,
694 servers,
695 },
696 ));
697 }
698 }
699
700 results
701}
702
703pub fn generate_extended_snippet_mux(
704 kind: ExtendedHostKind,
705 proxy_command: &str,
706 sock_path: &str,
707) -> String {
708 let Some((_, format)) = get_extended_host_config_path(kind) else {
709 return String::new();
710 };
711
712 render_snippet(format, &build_mux_host_entry(proxy_command, sock_path)).unwrap_or_default()
713}
714
715pub fn write_extended_host_config_mux(
716 kind: ExtendedHostKind,
717 proxy_command: &str,
718 sock_path: &str,
719) -> Result<WriteResult> {
720 let (path, format) =
721 get_extended_host_config_path(kind).ok_or_else(|| anyhow::anyhow!("Unknown host kind"))?;
722 write_host_config_entry(
723 kind.label().to_string(),
724 &path,
725 format,
726 path.exists(),
727 &build_mux_host_entry(proxy_command, sock_path),
728 )
729}
730
731fn build_mux_service_config_toml(
732 binary_path: &str,
733 config_path: &str,
734 http_port: Option<u16>,
735 max_request_bytes: usize,
736 log_level: &str,
737) -> Result<String> {
738 let mut servers = BTreeMap::new();
739 servers.insert(
740 DEFAULT_MUX_SERVICE_NAME.to_string(),
741 MuxServiceConfig {
742 socket: DEFAULT_MUX_SOCKET_PATH.to_string(),
743 cmd: binary_path.to_string(),
744 args: direct_command_args(config_path, http_port),
745 max_active_clients: MUX_MAX_ACTIVE_CLIENTS,
746 max_request_bytes,
747 request_timeout_ms: MUX_REQUEST_TIMEOUT_MS,
748 restart_backoff_ms: MUX_RESTART_BACKOFF_MS,
749 restart_backoff_max_ms: MUX_RESTART_BACKOFF_MAX_MS,
750 max_restarts: MUX_MAX_RESTARTS,
751 lazy_start: false,
752 tray: false,
753 service_name: DEFAULT_MUX_SERVICE_NAME.to_string(),
754 log_level: log_level.to_string(),
755 status_file: DEFAULT_MUX_STATUS_PATH.to_string(),
756 },
757 );
758
759 toml::to_string_pretty(&MuxConfigFile { servers })
760 .with_context(|| "Failed to serialize mux service config")
761}
762
763pub fn write_mux_service_config(
764 binary_path: &str,
765 config_path: &str,
766 http_port: Option<u16>,
767 max_request_bytes: usize,
768 log_level: &str,
769) -> Result<WriteResult> {
770 let config_file = expand_home_path(DEFAULT_MUX_CONFIG_PATH);
771 let socket_dir = expand_home_path(DEFAULT_MUX_SOCKET_PATH)
772 .parent()
773 .map(Path::to_path_buf)
774 .ok_or_else(|| anyhow::anyhow!("Invalid mux socket path"))?;
775 let status_dir = expand_home_path(DEFAULT_MUX_STATUS_PATH)
776 .parent()
777 .map(Path::to_path_buf)
778 .ok_or_else(|| anyhow::anyhow!("Invalid mux status path"))?;
779
780 if let Some(parent) = config_file.parent()
781 && !parent.exists()
782 {
783 std::fs::create_dir_all(parent)
784 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
785 }
786 if !socket_dir.exists() {
787 std::fs::create_dir_all(&socket_dir)
788 .with_context(|| format!("Failed to create directory {}", socket_dir.display()))?;
789 }
790 if !status_dir.exists() {
791 std::fs::create_dir_all(&status_dir)
792 .with_context(|| format!("Failed to create directory {}", status_dir.display()))?;
793 }
794
795 let exists = config_file.exists();
796 let backup_path = if exists {
797 Some(create_backup(&config_file)?)
798 } else {
799 None
800 };
801
802 use crate::path_utils::validate_write_path;
803
804 let content = build_mux_service_config_toml(
805 binary_path,
806 config_path,
807 http_port,
808 max_request_bytes,
809 log_level,
810 )?;
811 let safe_write_path = validate_write_path(&config_file).with_context(|| {
812 format!(
813 "Cannot write mux service config: path validation failed for {}",
814 config_file.display()
815 )
816 })?;
817 std::fs::write(&safe_write_path, content).with_context(|| {
818 format!(
819 "Failed to write mux service config to {}",
820 safe_write_path.display()
821 )
822 })?;
823
824 Ok(WriteResult {
825 host_name: "rust-mux service".to_string(),
826 config_path: config_file,
827 backup_path,
828 created: !exists,
829 })
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 #[test]
837 fn test_parse_toml_mcp_servers() {
838 let toml_content = r#"
839[mcp_servers.rust_memex]
840command = "/usr/local/bin/rust_memex"
841args = ["--db-path", "~/.rmcp/db"]
842
843[mcp_servers.other_server]
844command = "other"
845"#;
846 let servers = parse_toml_mcp_servers(toml_content);
847 assert_eq!(servers.len(), 2);
848 assert!(servers.iter().any(|s| s.name == "rust_memex"));
849 }
850
851 #[test]
852 fn test_parse_json_mcp_servers() {
853 let json_content = r#"{
854 "mcpServers": {
855 "rust_memex": {
856 "command": "/usr/local/bin/rust_memex",
857 "args": ["--db-path", "~/.rmcp/db"]
858 }
859 }
860}"#;
861 let servers = parse_json_mcp_servers(json_content);
862 assert_eq!(servers.len(), 1);
863 assert_eq!(servers[0].name, "rust_memex");
864 }
865
866 #[test]
867 fn test_matches_memex_server_accepts_canonical_binary_name() {
868 let entry = McpServerEntry {
869 name: "custom".to_string(),
870 command: "/usr/local/bin/rust-memex".to_string(),
871 args: vec!["serve".to_string()],
872 env: HashMap::new(),
873 };
874
875 assert!(matches_memex_server(&entry));
876 }
877
878 #[test]
879 fn test_generate_toml_snippet() {
880 let snippet = generate_extended_snippet(
881 ExtendedHostKind::Standard(HostKind::Codex),
882 "/usr/bin/rust-memex",
883 "~/.rmcp-servers/rust-memex/config.toml",
884 None,
885 );
886 assert!(snippet.contains("[mcp_servers.rust_memex]"));
887 assert!(snippet.contains("/usr/bin/rust-memex"));
888 assert!(snippet.contains("--config"));
889 }
890
891 #[test]
892 fn test_generate_json_snippet() {
893 let snippet = generate_extended_snippet(
894 ExtendedHostKind::Standard(HostKind::Claude),
895 "/usr/bin/rust-memex",
896 "~/.rmcp-servers/rust-memex/config.toml",
897 None,
898 );
899 assert!(snippet.contains("\"mcpServers\""));
900 assert!(snippet.contains("\"rust_memex\""));
901 assert!(snippet.contains("/usr/bin/rust-memex"));
902 assert!(snippet.contains("--config"));
903 }
904
905 #[test]
906 fn test_generate_extended_claude_code_snippet() {
907 let snippet = generate_extended_snippet(
908 ExtendedHostKind::ClaudeCode,
909 "/usr/bin/rust-memex",
910 "~/.rmcp-servers/rust-memex/config.toml",
911 None,
912 );
913 assert!(snippet.contains("\"mcpServers\""));
914 assert!(snippet.contains("\"rust_memex\""));
915 assert!(snippet.contains("/usr/bin/rust-memex"));
916 assert!(snippet.contains("--config"));
917 }
918
919 #[test]
920 fn test_generate_extended_junie_snippet() {
921 let snippet = generate_extended_snippet(
922 ExtendedHostKind::Junie,
923 "/usr/bin/rust-memex",
924 "~/.rmcp-servers/rust-memex/config.toml",
925 None,
926 );
927 assert!(snippet.contains("\"mcpServers\""));
928 assert!(snippet.contains("\"rust_memex\""));
929 assert!(snippet.contains("/usr/bin/rust-memex"));
930 assert!(snippet.contains("--config"));
931 }
932
933 #[test]
934 fn test_generate_json_snippet_includes_http_port_when_requested() {
935 let snippet = generate_extended_snippet(
936 ExtendedHostKind::Standard(HostKind::Claude),
937 "/usr/bin/rust-memex",
938 "~/.rmcp-servers/rust-memex/config.toml",
939 Some(8765),
940 );
941 assert!(snippet.contains("--http-port"));
942 assert!(snippet.contains("8765"));
943 }
944
945 #[test]
946 fn test_merge_json_config_empty() {
947 let result = merge_json_config(
948 "",
949 &build_direct_host_entry(
950 "/usr/bin/rust-memex",
951 "~/.rmcp-servers/rust-memex/config.toml",
952 None,
953 ),
954 )
955 .unwrap();
956 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
957 assert!(
958 parsed["mcpServers"]["rust_memex"]["command"]
959 .as_str()
960 .unwrap()
961 .contains("rust-memex")
962 );
963 }
964
965 #[test]
966 fn test_merge_json_config_preserves_http_port() {
967 let result = merge_json_config(
968 "",
969 &build_direct_host_entry(
970 "/usr/bin/rust-memex",
971 "~/.rmcp-servers/rust-memex/config.toml",
972 Some(8765),
973 ),
974 )
975 .unwrap();
976 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
977 let args = parsed["mcpServers"]["rust_memex"]["args"]
978 .as_array()
979 .unwrap()
980 .iter()
981 .filter_map(|value| value.as_str())
982 .collect::<Vec<_>>();
983 assert_eq!(
984 args,
985 vec![
986 "serve",
987 "--http-port",
988 "8765",
989 "--config",
990 "~/.rmcp-servers/rust-memex/config.toml"
991 ]
992 );
993 }
994
995 #[test]
996 fn test_merge_json_config_existing() {
997 let existing = r#"{
998 "mcpServers": {
999 "other_server": {
1000 "command": "other",
1001 "args": []
1002 }
1003 }
1004 }"#;
1005 let result = merge_json_config(
1006 existing,
1007 &build_direct_host_entry(
1008 "/usr/bin/rust-memex",
1009 "~/.rmcp-servers/rust-memex/config.toml",
1010 None,
1011 ),
1012 )
1013 .unwrap();
1014 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1015 assert!(
1017 parsed["mcpServers"]["other_server"]["command"]
1018 .as_str()
1019 .is_some()
1020 );
1021 assert!(
1023 parsed["mcpServers"]["rust_memex"]["command"]
1024 .as_str()
1025 .unwrap()
1026 .contains("rust-memex")
1027 );
1028 }
1029
1030 #[test]
1031 fn test_merge_toml_config_empty() {
1032 let result = merge_toml_config(
1033 "",
1034 &build_direct_host_entry(
1035 "/usr/bin/rust-memex",
1036 "~/.rmcp-servers/rust-memex/config.toml",
1037 None,
1038 ),
1039 )
1040 .unwrap();
1041 assert!(result.contains("[mcp_servers.rust_memex]"));
1042 assert!(result.contains("rust-memex"));
1043 assert!(result.contains("--config"));
1044 }
1045
1046 #[test]
1047 fn test_merge_toml_config_existing() {
1048 let existing = r#"
1049[mcp_servers.other_server]
1050command = "other"
1051args = []
1052"#;
1053 let result = merge_toml_config(
1054 existing,
1055 &build_direct_host_entry(
1056 "/usr/bin/rust-memex",
1057 "~/.rmcp-servers/rust-memex/config.toml",
1058 None,
1059 ),
1060 )
1061 .unwrap();
1062 assert!(result.contains("other_server"));
1064 assert!(result.contains("rust-memex"));
1066 assert!(result.contains("--config"));
1067 }
1068
1069 #[test]
1070 fn test_generate_mux_snippet_uses_proxy_command() {
1071 let snippet = generate_extended_snippet_mux(
1072 ExtendedHostKind::Standard(HostKind::Claude),
1073 "/custom/bin/rust-mux-proxy",
1074 DEFAULT_MUX_SOCKET_PATH,
1075 );
1076 assert!(snippet.contains("/custom/bin/rust-mux-proxy"));
1077 assert!(snippet.contains("--socket"));
1078 assert!(snippet.contains(DEFAULT_MUX_SOCKET_PATH));
1079 }
1080
1081 #[test]
1082 fn test_build_mux_service_config_toml_uses_shared_daemon_shape() {
1083 let config = build_mux_service_config_toml(
1084 "/usr/bin/rust-memex",
1085 "~/.rmcp-servers/rust-memex/config.toml",
1086 Some(8765),
1087 4_194_304,
1088 "debug",
1089 )
1090 .unwrap();
1091
1092 assert!(config.contains("[servers.rust-memex]"));
1093 assert!(config.contains("socket = \"~/.rmcp-servers/rust-memex/sockets/main.sock\""));
1094 assert!(config.contains("cmd = \"/usr/bin/rust-memex\""));
1095 assert!(config.contains("--http-port"));
1096 assert!(config.contains("8765"));
1097 assert!(config.contains("status_file = \"~/.rmcp-servers/rust-memex/status/main.json\""));
1098 assert!(config.contains("service_name = \"rust-memex\""));
1099 assert!(config.contains("max_request_bytes = 4194304"));
1100 }
1101
1102 #[test]
1103 fn test_extended_host_kind_display_names() {
1104 assert_eq!(
1105 ExtendedHostKind::Standard(HostKind::Claude).label(),
1106 "Claude Desktop"
1107 );
1108 assert_eq!(ExtendedHostKind::ClaudeCode.label(), "Claude Code");
1109 assert_eq!(ExtendedHostKind::Junie.label(), "Junie");
1110 }
1111}