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    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
326/// Detect all known MCP host configurations.
327pub 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
473/// Generate a config snippet for an extended host kind.
474pub 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/// Result of writing a host config
492#[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
500/// Generate a backup timestamp
501fn 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
509/// Create a backup of an existing config file
510fn create_backup(path: &Path) -> Result<PathBuf> {
511    use crate::path_utils::validate_read_path;
512
513    // Validate source path is safe to read
514    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    // Atomic validated copy: validates both paths and copies in one step
524    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
529/// Merge the rust_memex host entry into existing JSON config.
530fn 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    // Ensure mcpServers object exists
539    if config.get("mcpServers").is_none() {
540        config["mcpServers"] = serde_json::json!({});
541    }
542
543    // Add or update rust_memex entry
544    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
549/// Merge the rust_memex host entry into existing TOML config.
550fn 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    // Ensure mcp_servers table exists
560    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    // Add or update rust_memex entry
569    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    // Ensure parent directory exists
584    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    // Create backup if file exists
592    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    // Read existing content or use empty string
601    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    // Merge config based on format
610    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    // Validate path before writing
616    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
634/// Write config for an extended host kind (including ClaudeCode and Junie)
635pub 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
653/// Detect all extended hosts (including ClaudeCode and Junie)
654pub fn detect_extended_hosts() -> Vec<(ExtendedHostKind, HostDetection)> {
655    let mut results = Vec::new();
656
657    // Standard hosts
658    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    // Extended hosts (ClaudeCode, Junie)
671    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        // Should preserve existing server
1016        assert!(
1017            parsed["mcpServers"]["other_server"]["command"]
1018                .as_str()
1019                .is_some()
1020        );
1021        // Should add rust_memex
1022        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        // Should preserve existing server
1063        assert!(result.contains("other_server"));
1064        // Should add rust_memex
1065        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}