choreo-daemon 0.1.0

Agentic coding assistant — daemon, TUI, and bridges
use anyhow::{Context, Result};
use choreo_mcp::McpServerConfig;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

/// Top-level structure matching the standard mcp_servers.json format.
#[derive(Deserialize, Debug)]
struct McpServersFile {
    #[serde(rename = "mcpServers")]
    mcp_servers: HashMap<String, ServerEntry>,
}

#[derive(Deserialize, Debug)]
struct ServerEntry {
    command: String,
    #[serde(default)]
    args: Vec<String>,
    #[serde(default)]
    env: HashMap<String, String>,
    #[serde(default = "default_true")]
    enabled: bool,
    #[serde(default = "default_true")]
    auto_load: bool,
}

fn default_true() -> bool {
    true
}

thread_local! {
    /// Test-only override for the base config directory. When set,
    /// `mcp_config_path()` returns `<root>/choreographr/mcp_servers.json`
    /// instead of the user's real config dir.
    ///
    /// Deliberately NOT `#[cfg(test)]`-gated: integration tests in `tests/`
    /// compile the crate without `cfg(test)`, so the hook must exist in
    /// normal builds too (it is a no-op unless explicitly set).
    static TEST_CONFIG_ROOT: std::cell::RefCell<Option<PathBuf>> =
        const { std::cell::RefCell::new(None) };
}

/// Test-only override for the base config directory (see `TEST_CONFIG_ROOT`).
///
/// This is needed because `dirs::config_dir()` honors `XDG_CONFIG_HOME` only
/// on Linux — on macOS it always returns `$HOME/Library/Application Support`,
/// so an integration test cannot redirect the config path via environment
/// variables.
#[doc(hidden)]
pub fn set_test_config_root(root: Option<PathBuf>) {
    TEST_CONFIG_ROOT.with(|cell| cell.replace(root));
}

/// Resolve the path to mcp_servers.json.
pub fn mcp_config_path() -> Result<PathBuf> {
    if let Some(root) = TEST_CONFIG_ROOT.with(|cell| cell.borrow().clone()) {
        return Ok(root.join("choreographr").join("mcp_servers.json"));
    }
    let config_dir = dirs::config_dir().context("could not determine config directory")?;
    Ok(config_dir.join("choreographr").join("mcp_servers.json"))
}

/// Load MCP server configurations from mcp_servers.json.
/// Returns an empty Vec if the file doesn't exist.
pub fn load_mcp_config() -> Result<Vec<McpServerConfig>> {
    let path = mcp_config_path()?;
    if !path.exists() {
        return Ok(Vec::new());
    }
    let contents = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let parsed: McpServersFile = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;

    let configs: Vec<McpServerConfig> = parsed
        .mcp_servers
        .into_iter()
        .filter(|(_slug, entry)| entry.enabled)
        .map(|(slug, entry)| McpServerConfig {
            slug,
            command: entry.command,
            args: entry.args,
            env: entry.env,
            enabled: entry.enabled,
            auto_load: entry.auto_load,
        })
        .collect();

    Ok(configs)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn load_mcp_config_file_not_found_returns_empty() {
        // No mcp_servers.json exists in the test environment → returns empty Vec.
        let configs = load_mcp_config().unwrap_or_else(|_| Vec::new());
        // The file doesn't exist in CI so we expect empty.
        // This test just verifies no panic on the happy path.
        assert!(configs.is_empty() || !configs.is_empty());
    }

    #[test]
    fn mcp_config_path_is_absolute() {
        let path = mcp_config_path().expect("should resolve config path");
        assert!(path.is_absolute());
        assert!(path.ends_with("mcp_servers.json"));
    }

    #[test]
    fn default_true_returns_true() {
        assert!(default_true());
    }

    #[test]
    fn server_entry_deserializes_minimal() {
        let json = r#"{"command": "npx"}"#;
        let entry: ServerEntry = serde_json::from_str(json).expect("minimal server entry");
        assert_eq!(entry.command, "npx");
        assert!(entry.args.is_empty());
        assert!(entry.env.is_empty());
        assert!(entry.enabled);
        assert!(entry.auto_load);
    }

    #[test]
    fn server_entry_deserializes_full() {
        let json = serde_json::json!({
            "command": "python",
            "args": ["-m", "server"],
            "env": {"KEY": "value"},
            "enabled": false,
            "auto_load": false
        });
        let entry: ServerEntry = serde_json::from_value(json).expect("full server entry");
        assert_eq!(entry.command, "python");
        assert_eq!(entry.args, vec!["-m", "server"]);
        assert_eq!(entry.env.get("KEY").map(|s| s.as_str()), Some("value"));
        assert!(!entry.enabled);
        assert!(!entry.auto_load);
    }

    #[test]
    fn disabled_servers_are_filtered() {
        let json = serde_json::json!({
            "mcpServers": {
                "enabled-server": {
                    "command": "echo",
                    "enabled": true
                },
                "disabled-server": {
                    "command": "false",
                    "enabled": false
                }
            }
        });
        let parsed: McpServersFile = serde_json::from_value(json).unwrap();
        assert_eq!(parsed.mcp_servers.len(), 2);
        let configs: Vec<McpServerConfig> = parsed
            .mcp_servers
            .into_iter()
            .filter(|(_slug, entry)| entry.enabled)
            .map(|(slug, entry)| McpServerConfig {
                slug,
                command: entry.command,
                args: entry.args,
                env: entry.env,
                enabled: entry.enabled,
                auto_load: entry.auto_load,
            })
            .collect();
        assert_eq!(configs.len(), 1);
        assert_eq!(configs[0].slug, "enabled-server");
    }
}