Skip to main content

nms_copilot/mcp/
config.rs

1//! Configuration for the NMS Copilot MCP server.
2//!
3//! Reads from `~/.nms-copilot/config.toml`, sharing the same config
4//! file as the REPL. Only the `[logging]` section is used by the
5//! MCP server; other sections are ignored.
6
7use std::path::Path;
8
9use serde::Deserialize;
10
11/// MCP server configuration.
12///
13/// Extracts only the sections relevant to the MCP server from the
14/// shared `~/.nms-copilot/config.toml`.
15#[derive(Debug, Deserialize, Default)]
16#[serde(default)]
17pub struct McpConfig {
18    /// Logging configuration (twyg options).
19    pub logging: LoggingConfig,
20}
21
22/// Logging configuration wrapping twyg options.
23#[derive(Debug, Deserialize)]
24#[serde(default)]
25pub struct LoggingConfig {
26    /// Log level: "trace", "debug", "info", "warn", "error".
27    pub level: String,
28    /// Enable colored output.
29    pub coloured: bool,
30    /// Output destination: "stdout" or "stderr".
31    pub output: String,
32    /// Include caller info in log messages.
33    pub report_caller: bool,
34}
35
36impl Default for LoggingConfig {
37    fn default() -> Self {
38        Self {
39            level: "info".into(),
40            coloured: true,
41            output: "stderr".into(),
42            report_caller: false,
43        }
44    }
45}
46
47impl LoggingConfig {
48    /// Convert to twyg::Opts for logger initialization.
49    pub fn to_twyg_opts(&self) -> twyg::Opts {
50        let output = match self.output.as_str() {
51            "stdout" => twyg::Output::Stdout,
52            _ => twyg::Output::Stderr,
53        };
54        let level = match self.level.as_str() {
55            "trace" => twyg::LogLevel::Trace,
56            "debug" => twyg::LogLevel::Debug,
57            "warn" => twyg::LogLevel::Warn,
58            "error" => twyg::LogLevel::Error,
59            _ => twyg::LogLevel::Info,
60        };
61        let colors = twyg::Colors {
62            timestamp: Some(twyg::Color::hi_black()),
63            ..Default::default()
64        };
65        twyg::OptsBuilder::new()
66            .coloured(self.coloured)
67            .output(output)
68            .level(level)
69            .report_caller(self.report_caller)
70            .timestamp_format(twyg::TSFormat::Simple)
71            .colors(colors)
72            .build()
73            .unwrap_or_default()
74    }
75}
76
77impl McpConfig {
78    /// Load config from `~/.nms-copilot/config.toml`.
79    ///
80    /// Returns defaults if the file doesn't exist.
81    pub fn load() -> Self {
82        let path = dirs::home_dir()
83            .map(|h| h.join(".nms-copilot/config.toml"))
84            .unwrap_or_default();
85        Self::load_from(&path)
86    }
87
88    /// Load config from a specific path.
89    pub fn load_from(path: &Path) -> Self {
90        if !path.exists() {
91            return Self::default();
92        }
93        match std::fs::read_to_string(path) {
94            Ok(content) => toml::from_str(&content).unwrap_or_default(),
95            Err(_) => Self::default(),
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_default_config() {
106        let config = McpConfig::default();
107        assert_eq!(config.logging.level, "info");
108        assert!(config.logging.coloured);
109        assert_eq!(config.logging.output, "stderr");
110        assert!(!config.logging.report_caller);
111    }
112
113    #[test]
114    fn test_parse_logging_config() {
115        let toml = r#"
116            [logging]
117            level = "debug"
118            coloured = false
119            output = "stdout"
120            report_caller = true
121        "#;
122        let config: McpConfig = toml::from_str(toml).unwrap();
123        assert_eq!(config.logging.level, "debug");
124        assert!(!config.logging.coloured);
125        assert_eq!(config.logging.output, "stdout");
126        assert!(config.logging.report_caller);
127    }
128
129    #[test]
130    fn test_parse_empty_config() {
131        let config: McpConfig = toml::from_str("").unwrap();
132        assert_eq!(config.logging.level, "info");
133    }
134
135    #[test]
136    fn test_to_twyg_opts() {
137        let config = LoggingConfig::default();
138        let opts = config.to_twyg_opts();
139        // Just verify it doesn't panic and produces valid opts
140        assert!(!format!("{:?}", opts).is_empty());
141    }
142
143    #[test]
144    fn test_load_nonexistent_returns_default() {
145        let config = McpConfig::load_from(std::path::Path::new("/nonexistent/config.toml"));
146        assert_eq!(config.logging.level, "info");
147    }
148
149    #[test]
150    fn test_unknown_sections_ignored() {
151        let toml = r#"
152            [save]
153            path = "/tmp/save"
154
155            [display]
156            emoji_glyphs = false
157
158            [logging]
159            level = "warn"
160        "#;
161        let config: McpConfig = toml::from_str(toml).unwrap();
162        assert_eq!(config.logging.level, "warn");
163    }
164}