ricecoder_external_lsp/registry/
config.rs

1//! Configuration loading from YAML files
2
3use crate::error::{ExternalLspError, Result};
4use crate::types::LspServerRegistry;
5use std::path::Path;
6use tracing::{debug, info};
7
8/// Loads LSP server configurations from YAML files
9pub struct ConfigLoader;
10
11impl ConfigLoader {
12    /// Load configuration from a YAML file
13    pub fn load_from_file(path: &Path) -> Result<LspServerRegistry> {
14        debug!("Loading LSP configuration from: {:?}", path);
15
16        let content = std::fs::read_to_string(path).map_err(|e| {
17            ExternalLspError::ConfigError(format!("Failed to read config file: {}", e))
18        })?;
19
20        Self::load_from_string(&content)
21    }
22
23    /// Load configuration from a YAML string
24    pub fn load_from_string(content: &str) -> Result<LspServerRegistry> {
25        let registry: LspServerRegistry = serde_yaml::from_str(content).map_err(|e| {
26            ExternalLspError::ConfigError(format!("Failed to parse YAML: {}", e))
27        })?;
28
29        // Validate configuration
30        Self::validate(&registry)?;
31
32        info!("Successfully loaded LSP configuration with {} languages", registry.servers.len());
33
34        Ok(registry)
35    }
36
37    /// Validate configuration schema
38    fn validate(registry: &LspServerRegistry) -> Result<()> {
39        for (language, configs) in &registry.servers {
40            if configs.is_empty() {
41                return Err(ExternalLspError::InvalidConfiguration(format!(
42                    "Language '{}' has no server configurations",
43                    language
44                )));
45            }
46
47            for (idx, config) in configs.iter().enumerate() {
48                if config.language != *language {
49                    return Err(ExternalLspError::InvalidConfiguration(format!(
50                        "Server {} for language '{}' has mismatched language field: '{}'",
51                        idx, language, config.language
52                    )));
53                }
54
55                if config.executable.is_empty() {
56                    return Err(ExternalLspError::InvalidConfiguration(format!(
57                        "Server {} for language '{}' has empty executable",
58                        idx, language
59                    )));
60                }
61
62                if config.extensions.is_empty() {
63                    return Err(ExternalLspError::InvalidConfiguration(format!(
64                        "Server {} for language '{}' has no file extensions",
65                        idx, language
66                    )));
67                }
68
69                if config.timeout_ms == 0 {
70                    return Err(ExternalLspError::InvalidConfiguration(format!(
71                        "Server {} for language '{}' has invalid timeout_ms: 0",
72                        idx, language
73                    )));
74                }
75            }
76        }
77
78        Ok(())
79    }
80
81    /// Merge configurations with hierarchy: Runtime → Project → User → Built-in
82    pub fn merge_configs(
83        runtime: Option<LspServerRegistry>,
84        project: Option<LspServerRegistry>,
85        user: Option<LspServerRegistry>,
86        builtin: LspServerRegistry,
87    ) -> Result<LspServerRegistry> {
88        let mut result = builtin;
89
90        // Apply user config
91        if let Some(user_config) = user {
92            Self::merge_into(&mut result, user_config)?;
93        }
94
95        // Apply project config
96        if let Some(project_config) = project {
97            Self::merge_into(&mut result, project_config)?;
98        }
99
100        // Apply runtime config
101        if let Some(runtime_config) = runtime {
102            Self::merge_into(&mut result, runtime_config)?;
103        }
104
105        Ok(result)
106    }
107
108    /// Merge one registry into another
109    fn merge_into(target: &mut LspServerRegistry, source: LspServerRegistry) -> Result<()> {
110        // Merge servers
111        for (language, configs) in source.servers {
112            target.servers.insert(language, configs);
113        }
114
115        // Merge global settings (source overrides target)
116        target.global = source.global;
117
118        Ok(())
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_load_valid_config() {
128        let yaml = r#"
129global:
130  max_processes: 5
131  default_timeout_ms: 5000
132  enable_fallback: true
133  health_check_interval_ms: 30000
134
135servers:
136  rust:
137    - language: rust
138      extensions: [".rs"]
139      executable: rust-analyzer
140      args: []
141      env: {}
142      enabled: true
143      timeout_ms: 10000
144      max_restarts: 3
145      idle_timeout_ms: 300000
146"#;
147
148        let result = ConfigLoader::load_from_string(yaml);
149        assert!(result.is_ok());
150
151        let registry = result.unwrap();
152        assert_eq!(registry.servers.len(), 1);
153        assert!(registry.servers.contains_key("rust"));
154    }
155
156    #[test]
157    fn test_load_invalid_yaml() {
158        let yaml = "invalid: [yaml";
159        let result = ConfigLoader::load_from_string(yaml);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn test_validate_empty_executable() {
165        let yaml = r#"
166global:
167  max_processes: 5
168  default_timeout_ms: 5000
169  enable_fallback: true
170  health_check_interval_ms: 30000
171
172servers:
173  rust:
174    - language: rust
175      extensions: [".rs"]
176      executable: ""
177      args: []
178      env: {}
179      enabled: true
180      timeout_ms: 10000
181      max_restarts: 3
182      idle_timeout_ms: 300000
183"#;
184
185        let result = ConfigLoader::load_from_string(yaml);
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_validate_no_extensions() {
191        let yaml = r#"
192global:
193  max_processes: 5
194  default_timeout_ms: 5000
195  enable_fallback: true
196  health_check_interval_ms: 30000
197
198servers:
199  rust:
200    - language: rust
201      extensions: []
202      executable: rust-analyzer
203      args: []
204      env: {}
205      enabled: true
206      timeout_ms: 10000
207      max_restarts: 3
208      idle_timeout_ms: 300000
209"#;
210
211        let result = ConfigLoader::load_from_string(yaml);
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn test_merge_configs() {
217        let builtin_yaml = r#"
218global:
219  max_processes: 5
220  default_timeout_ms: 5000
221  enable_fallback: true
222  health_check_interval_ms: 30000
223
224servers:
225  rust:
226    - language: rust
227      extensions: [".rs"]
228      executable: rust-analyzer
229      args: []
230      env: {}
231      enabled: true
232      timeout_ms: 10000
233      max_restarts: 3
234      idle_timeout_ms: 300000
235"#;
236
237        let user_yaml = r#"
238global:
239  max_processes: 10
240  default_timeout_ms: 10000
241  enable_fallback: true
242  health_check_interval_ms: 30000
243
244servers:
245  python:
246    - language: python
247      extensions: [".py"]
248      executable: pylsp
249      args: []
250      env: {}
251      enabled: true
252      timeout_ms: 5000
253      max_restarts: 3
254      idle_timeout_ms: 300000
255"#;
256
257        let builtin = ConfigLoader::load_from_string(builtin_yaml).unwrap();
258        let user = ConfigLoader::load_from_string(user_yaml).ok();
259
260        let result = ConfigLoader::merge_configs(None, None, user, builtin).unwrap();
261
262        assert_eq!(result.servers.len(), 2);
263        assert!(result.servers.contains_key("rust"));
264        assert!(result.servers.contains_key("python"));
265        assert_eq!(result.global.max_processes, 10);
266    }
267}