Skip to main content

rust_memex/tui/
host_detection.rs

1//! Host detection module for MCP server configurations.
2//!
3//! Scans known locations for MCP host configurations (Codex, Cursor, Claude Desktop, JetBrains).
4//! Also provides config writing functionality for the wizard.
5
6use 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// =============================================================================
14// MCP SERVER ENTRIES
15// =============================================================================
16
17#[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/// Extended host kind that includes hosts not in rmcp-common
116/// (ClaudeCode and Junie are specific to rust-memex wizard)
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ExtendedHostKind {
119    /// Standard hosts from rmcp-common
120    Standard(HostKind),
121    /// Claude Code CLI (~/.claude.json)
122    ClaudeCode,
123    /// Junie AI (~/.junie/mcp.json)
124    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            // JetBrains uses a common MCP config location
169            #[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
194/// Get config path for extended host kinds (including ClaudeCode and Junie)
195pub 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    // toml 1.0: `Value` parser expects a single value, not a document.
209    // Parse the document as `Table` (alias for `Map<String, Value>`).
210    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
328/// Detect all known MCP host configurations.
329pub 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
475/// Generate a config snippet for an extended host kind.
476pub 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/// Result of writing a host config
494#[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
502/// Generate a backup timestamp
503fn 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
511/// Create a backup of an existing config file
512fn create_backup(path: &Path) -> Result<PathBuf> {
513    use crate::path_utils::validate_read_path;
514
515    // Validate source path is safe to read
516    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    // Atomic validated copy: validates both paths and copies in one step
526    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
531/// Merge the rust_memex host entry into existing JSON config.
532fn 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    // Ensure mcpServers object exists
541    if config.get("mcpServers").is_none() {
542        config["mcpServers"] = serde_json::json!({});
543    }
544
545    // Add or update rust_memex entry
546    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
551/// Merge the rust_memex host entry into existing TOML config.
552fn merge_toml_config(existing_content: &str, entry: &McpServerEntry) -> Result<String> {
553    // toml 1.0: parse the document as `Table`. `Value` parser only accepts a
554    // single value, not a full document.
555    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    // Ensure mcp_servers table exists
564    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    // Add or update rust_memex entry
572    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    // Ensure parent directory exists
587    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    // Create backup if file exists
595    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    // Read existing content or use empty string
604    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    // Merge config based on format
613    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    // Validate path before writing
619    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
637/// Write config for an extended host kind (including ClaudeCode and Junie)
638pub 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
656/// Detect all extended hosts (including ClaudeCode and Junie)
657pub fn detect_extended_hosts() -> Vec<(ExtendedHostKind, HostDetection)> {
658    let mut results = Vec::new();
659
660    // Standard hosts
661    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    // Extended hosts (ClaudeCode, Junie)
674    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        // Should preserve existing server
1019        assert!(
1020            parsed["mcpServers"]["other_server"]["command"]
1021                .as_str()
1022                .is_some()
1023        );
1024        // Should add rust_memex
1025        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        // Should preserve existing server
1066        assert!(result.contains("other_server"));
1067        // Should add rust_memex
1068        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}