Skip to main content

mcpls_core/config/
mod.rs

1//! Configuration types and loading.
2//!
3//! This module provides configuration structures for MCPLS,
4//! including LSP server definitions and workspace settings.
5
6mod server;
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12pub use server::{DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, ServerHeuristics};
13
14use crate::error::{Error, Result};
15
16/// Maps file extensions to LSP language identifiers.
17///
18/// Used to detect the language ID for files based on their extension.
19/// Extensions are mapped to language IDs like "rust", "python", "cpp", etc.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LanguageExtensionMapping {
22    /// Array of extensions and their corresponding language ID.
23    pub extensions: Vec<String>,
24    /// Language ID to report to the LSP server.
25    pub language_id: String,
26}
27
28/// Main configuration for the MCPLS server.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ServerConfig {
32    /// Workspace configuration.
33    #[serde(default)]
34    pub workspace: WorkspaceConfig,
35
36    /// LSP server configurations.
37    #[serde(default)]
38    pub lsp_servers: Vec<LspServerConfig>,
39}
40
41/// Workspace-level configuration.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct WorkspaceConfig {
45    /// Root directories for the workspace.
46    #[serde(default)]
47    pub roots: Vec<PathBuf>,
48
49    /// Position encoding preference order.
50    /// Valid values: "utf-8", "utf-16", "utf-32"
51    #[serde(default = "default_position_encodings")]
52    pub position_encodings: Vec<String>,
53
54    /// File extension to language ID mappings.
55    /// Allows users to customize which file extensions map to which language servers.
56    #[serde(default)]
57    pub language_extensions: Vec<LanguageExtensionMapping>,
58
59    /// Maximum depth for recursive project marker search.
60    /// Controls how deeply nested projects can be detected.
61    /// Default: 10
62    #[serde(default = "default_heuristics_max_depth")]
63    pub heuristics_max_depth: usize,
64}
65
66impl Default for WorkspaceConfig {
67    fn default() -> Self {
68        Self {
69            roots: Vec::new(),
70            position_encodings: default_position_encodings(),
71            language_extensions: default_language_extensions(),
72            heuristics_max_depth: default_heuristics_max_depth(),
73        }
74    }
75}
76
77const fn default_heuristics_max_depth() -> usize {
78    DEFAULT_HEURISTICS_MAX_DEPTH
79}
80
81impl WorkspaceConfig {
82    /// Build a map of file extensions to language IDs from the configuration.
83    ///
84    /// # Returns
85    ///
86    /// A `HashMap` where keys are file extensions (without the dot) and values
87    /// are the corresponding language IDs to report to LSP servers.
88    #[must_use]
89    pub fn build_extension_map(&self) -> HashMap<String, String> {
90        let mut map = HashMap::new();
91        for mapping in &self.language_extensions {
92            for ext in &mapping.extensions {
93                map.insert(ext.clone(), mapping.language_id.clone());
94            }
95        }
96        map
97    }
98
99    /// Get the language ID for a file extension.
100    ///
101    /// # Arguments
102    ///
103    /// * `extension` - The file extension (without the dot)
104    ///
105    /// # Returns
106    ///
107    /// The language ID if found, `None` otherwise.
108    #[must_use]
109    pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
110        for mapping in &self.language_extensions {
111            if mapping.extensions.contains(&extension.to_string()) {
112                return Some(mapping.language_id.clone());
113            }
114        }
115        None
116    }
117}
118
119/// Extract a file extension from a glob-like file pattern.
120///
121/// Supports common patterns such as `**/*.rs` and `*.h`.
122/// Returns `None` for patterns without a simple trailing extension.
123fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
124    let basename = pattern.rsplit('/').next().unwrap_or(pattern);
125    if basename.starts_with('.') {
126        return None;
127    }
128
129    let (_, ext) = basename.rsplit_once('.')?;
130    if ext.is_empty() {
131        return None;
132    }
133
134    // Keep this conservative: only accept plain extension-like tokens.
135    if ext
136        .chars()
137        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
138    {
139        Some(ext.to_string())
140    } else {
141        None
142    }
143}
144
145fn default_position_encodings() -> Vec<String> {
146    vec!["utf-8".to_string(), "utf-16".to_string()]
147}
148
149/// Build default language extension mappings.
150///
151/// Returns all built-in language extensions that MCPLS recognizes by default.
152/// These mappings are used when no custom configuration is provided.
153#[allow(clippy::too_many_lines)]
154fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
155    vec![
156        LanguageExtensionMapping {
157            extensions: vec!["rs".to_string()],
158            language_id: "rust".to_string(),
159        },
160        LanguageExtensionMapping {
161            extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
162            language_id: "python".to_string(),
163        },
164        LanguageExtensionMapping {
165            extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
166            language_id: "javascript".to_string(),
167        },
168        LanguageExtensionMapping {
169            extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
170            language_id: "typescript".to_string(),
171        },
172        LanguageExtensionMapping {
173            extensions: vec!["tsx".to_string()],
174            language_id: "typescriptreact".to_string(),
175        },
176        LanguageExtensionMapping {
177            extensions: vec!["jsx".to_string()],
178            language_id: "javascriptreact".to_string(),
179        },
180        LanguageExtensionMapping {
181            extensions: vec!["go".to_string()],
182            language_id: "go".to_string(),
183        },
184        LanguageExtensionMapping {
185            extensions: vec!["c".to_string(), "h".to_string()],
186            language_id: "c".to_string(),
187        },
188        LanguageExtensionMapping {
189            extensions: vec![
190                "cpp".to_string(),
191                "cc".to_string(),
192                "cxx".to_string(),
193                "hpp".to_string(),
194                "hh".to_string(),
195                "hxx".to_string(),
196            ],
197            language_id: "cpp".to_string(),
198        },
199        LanguageExtensionMapping {
200            extensions: vec!["java".to_string()],
201            language_id: "java".to_string(),
202        },
203        LanguageExtensionMapping {
204            extensions: vec!["rb".to_string()],
205            language_id: "ruby".to_string(),
206        },
207        LanguageExtensionMapping {
208            extensions: vec!["php".to_string()],
209            language_id: "php".to_string(),
210        },
211        LanguageExtensionMapping {
212            extensions: vec!["swift".to_string()],
213            language_id: "swift".to_string(),
214        },
215        LanguageExtensionMapping {
216            extensions: vec!["kt".to_string(), "kts".to_string()],
217            language_id: "kotlin".to_string(),
218        },
219        LanguageExtensionMapping {
220            extensions: vec!["scala".to_string(), "sc".to_string()],
221            language_id: "scala".to_string(),
222        },
223        LanguageExtensionMapping {
224            extensions: vec!["zig".to_string()],
225            language_id: "zig".to_string(),
226        },
227        LanguageExtensionMapping {
228            extensions: vec!["lua".to_string()],
229            language_id: "lua".to_string(),
230        },
231        LanguageExtensionMapping {
232            extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
233            language_id: "shellscript".to_string(),
234        },
235        LanguageExtensionMapping {
236            extensions: vec!["json".to_string()],
237            language_id: "json".to_string(),
238        },
239        LanguageExtensionMapping {
240            extensions: vec!["toml".to_string()],
241            language_id: "toml".to_string(),
242        },
243        LanguageExtensionMapping {
244            extensions: vec!["yaml".to_string(), "yml".to_string()],
245            language_id: "yaml".to_string(),
246        },
247        LanguageExtensionMapping {
248            extensions: vec!["xml".to_string()],
249            language_id: "xml".to_string(),
250        },
251        LanguageExtensionMapping {
252            extensions: vec!["html".to_string(), "htm".to_string()],
253            language_id: "html".to_string(),
254        },
255        LanguageExtensionMapping {
256            extensions: vec!["css".to_string()],
257            language_id: "css".to_string(),
258        },
259        LanguageExtensionMapping {
260            extensions: vec!["scss".to_string()],
261            language_id: "scss".to_string(),
262        },
263        LanguageExtensionMapping {
264            extensions: vec!["less".to_string()],
265            language_id: "less".to_string(),
266        },
267        LanguageExtensionMapping {
268            extensions: vec!["md".to_string(), "markdown".to_string()],
269            language_id: "markdown".to_string(),
270        },
271        LanguageExtensionMapping {
272            extensions: vec!["cs".to_string()],
273            language_id: "csharp".to_string(),
274        },
275        LanguageExtensionMapping {
276            extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
277            language_id: "fsharp".to_string(),
278        },
279        LanguageExtensionMapping {
280            extensions: vec!["r".to_string(), "R".to_string()],
281            language_id: "r".to_string(),
282        },
283    ]
284}
285
286impl ServerConfig {
287    /// Build the effective extension map used for language detection.
288    ///
289    /// Starts with workspace mappings and overlays mappings inferred from
290    /// configured LSP server `file_patterns`.
291    #[must_use]
292    pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
293        let mut map = self.workspace.build_extension_map();
294
295        for server in &self.lsp_servers {
296            for pattern in &server.file_patterns {
297                if let Some(ext) = extract_extension_from_pattern(pattern) {
298                    map.insert(ext, server.language_id.clone());
299                }
300            }
301        }
302
303        map
304    }
305
306    /// Load configuration from the default path.
307    ///
308    /// Default paths checked in order:
309    /// 1. `$MCPLS_CONFIG` environment variable
310    /// 2. `./mcpls.toml` (current directory)
311    /// 3. `~/.config/mcpls/mcpls.toml` (Linux/macOS)
312    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
313    ///
314    /// If no configuration file exists, creates a default configuration file
315    /// in the user's config directory with all default language extensions.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if parsing an existing config fails.
320    /// If config creation fails, returns default config with graceful degradation.
321    pub fn load() -> Result<Self> {
322        if let Ok(path) = std::env::var("MCPLS_CONFIG") {
323            return Self::load_from(Path::new(&path));
324        }
325
326        let local_config = PathBuf::from("mcpls.toml");
327        if local_config.exists() {
328            return Self::load_from(&local_config);
329        }
330
331        if let Some(config_dir) = dirs::config_dir() {
332            let user_config = config_dir.join("mcpls").join("mcpls.toml");
333            if user_config.exists() {
334                return Self::load_from(&user_config);
335            }
336
337            // No config found - create default config file
338            if let Err(e) = Self::create_default_config_file(&user_config) {
339                tracing::warn!(
340                    "Failed to create default config at {}: {}. Using in-memory defaults.",
341                    user_config.display(),
342                    e
343                );
344            } else {
345                tracing::info!("Created default config at {}", user_config.display());
346            }
347        }
348
349        // Return default configuration
350        Ok(Self::default())
351    }
352
353    /// Load configuration from a specific path.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if the file doesn't exist or parsing fails.
358    pub fn load_from(path: &Path) -> Result<Self> {
359        let content = std::fs::read_to_string(path).map_err(|e| {
360            if e.kind() == std::io::ErrorKind::NotFound {
361                Error::ConfigNotFound(path.to_path_buf())
362            } else {
363                Error::Io(e)
364            }
365        })?;
366
367        let config: Self = toml::from_str(&content)?;
368        config.validate()?;
369        Ok(config)
370    }
371
372    /// Create a default configuration file with all built-in extensions.
373    ///
374    /// Creates the parent directory if it doesn't exist.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if directory or file creation fails.
379    fn create_default_config_file(path: &Path) -> Result<()> {
380        if let Some(parent) = path.parent() {
381            std::fs::create_dir_all(parent)?;
382        }
383
384        let default_config = Self::default();
385        let toml_content = toml::to_string_pretty(&default_config)?;
386        std::fs::write(path, toml_content)?;
387
388        Ok(())
389    }
390
391    /// Validate the configuration.
392    fn validate(&self) -> Result<()> {
393        for server in &self.lsp_servers {
394            if server.language_id.is_empty() {
395                return Err(Error::InvalidConfig(
396                    "language_id cannot be empty".to_string(),
397                ));
398            }
399            if server.command.is_empty() {
400                return Err(Error::InvalidConfig(format!(
401                    "command cannot be empty for language '{}'",
402                    server.language_id
403                )));
404            }
405        }
406        Ok(())
407    }
408}
409
410impl Default for ServerConfig {
411    fn default() -> Self {
412        Self {
413            workspace: WorkspaceConfig::default(),
414            lsp_servers: vec![
415                LspServerConfig::rust_analyzer(),
416                LspServerConfig::pyright(),
417                LspServerConfig::typescript(),
418                LspServerConfig::gopls(),
419                LspServerConfig::clangd(),
420                LspServerConfig::zls(),
421            ],
422        }
423    }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used)]
428mod tests {
429    use std::fs;
430
431    use tempfile::TempDir;
432
433    use super::*;
434
435    #[test]
436    fn test_default_config() {
437        let config = ServerConfig::default();
438        assert_eq!(config.lsp_servers.len(), 6);
439        assert_eq!(config.lsp_servers[0].language_id, "rust");
440        assert_eq!(config.lsp_servers[1].language_id, "python");
441        assert_eq!(config.lsp_servers[2].language_id, "typescript");
442        assert_eq!(config.lsp_servers[3].language_id, "go");
443        assert_eq!(config.lsp_servers[4].language_id, "cpp");
444        assert_eq!(config.lsp_servers[5].language_id, "zig");
445        assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
446    }
447
448    #[test]
449    fn test_default_position_encodings() {
450        let encodings = default_position_encodings();
451        assert_eq!(encodings, vec!["utf-8", "utf-16"]);
452    }
453
454    #[test]
455    fn test_load_from_valid_toml() {
456        let tmp_dir = TempDir::new().unwrap();
457        let config_path = tmp_dir.path().join("config.toml");
458
459        let toml_content = r#"
460            [workspace]
461            roots = ["/tmp/workspace"]
462            position_encodings = ["utf-8"]
463
464            [[lsp_servers]]
465            language_id = "rust"
466            command = "rust-analyzer"
467            timeout_seconds = 30
468        "#;
469
470        fs::write(&config_path, toml_content).unwrap();
471
472        let config = ServerConfig::load_from(&config_path).unwrap();
473        assert_eq!(
474            config.workspace.roots,
475            vec![PathBuf::from("/tmp/workspace")]
476        );
477        assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
478        assert_eq!(config.lsp_servers.len(), 1);
479        assert_eq!(config.lsp_servers[0].language_id, "rust");
480    }
481
482    #[test]
483    fn test_load_from_nonexistent_file() {
484        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
485        assert!(result.is_err());
486
487        if let Err(Error::ConfigNotFound(path)) = result {
488            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
489        } else {
490            panic!("Expected ConfigNotFound error");
491        }
492    }
493
494    #[test]
495    fn test_load_from_invalid_toml() {
496        let tmp_dir = TempDir::new().unwrap();
497        let config_path = tmp_dir.path().join("invalid.toml");
498
499        fs::write(&config_path, "invalid toml content {{}").unwrap();
500
501        let result = ServerConfig::load_from(&config_path);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_validate_empty_language_id() {
507        let tmp_dir = TempDir::new().unwrap();
508        let config_path = tmp_dir.path().join("config.toml");
509
510        let toml_content = r#"
511            [[lsp_servers]]
512            language_id = ""
513            command = "test"
514        "#;
515
516        fs::write(&config_path, toml_content).unwrap();
517
518        let result = ServerConfig::load_from(&config_path);
519        assert!(result.is_err());
520
521        if let Err(Error::InvalidConfig(msg)) = result {
522            assert!(msg.contains("language_id cannot be empty"));
523        } else {
524            panic!("Expected InvalidConfig error");
525        }
526    }
527
528    #[test]
529    fn test_validate_empty_command() {
530        let tmp_dir = TempDir::new().unwrap();
531        let config_path = tmp_dir.path().join("config.toml");
532
533        let toml_content = r#"
534            [[lsp_servers]]
535            language_id = "rust"
536            command = ""
537        "#;
538
539        fs::write(&config_path, toml_content).unwrap();
540
541        let result = ServerConfig::load_from(&config_path);
542        assert!(result.is_err());
543
544        if let Err(Error::InvalidConfig(msg)) = result {
545            assert!(msg.contains("command cannot be empty"));
546        } else {
547            panic!("Expected InvalidConfig error");
548        }
549    }
550
551    #[test]
552    fn test_workspace_config_defaults() {
553        let workspace = WorkspaceConfig::default();
554        assert!(workspace.roots.is_empty());
555        assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
556        assert!(!workspace.language_extensions.is_empty());
557        assert_eq!(workspace.language_extensions.len(), 30);
558        assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
559    }
560
561    #[test]
562    fn test_load_multiple_servers() {
563        let tmp_dir = TempDir::new().unwrap();
564        let config_path = tmp_dir.path().join("multi.toml");
565
566        let toml_content = r#"
567            [[lsp_servers]]
568            language_id = "rust"
569            command = "rust-analyzer"
570
571            [[lsp_servers]]
572            language_id = "python"
573            command = "pyright-langserver"
574            args = ["--stdio"]
575        "#;
576
577        fs::write(&config_path, toml_content).unwrap();
578
579        let config = ServerConfig::load_from(&config_path).unwrap();
580        assert_eq!(config.lsp_servers.len(), 2);
581        assert_eq!(config.lsp_servers[0].language_id, "rust");
582        assert_eq!(config.lsp_servers[1].language_id, "python");
583        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
584    }
585
586    #[test]
587    fn test_deny_unknown_fields() {
588        let tmp_dir = TempDir::new().unwrap();
589        let config_path = tmp_dir.path().join("unknown.toml");
590
591        let toml_content = r#"
592            unknown_field = "value"
593
594            [workspace]
595            roots = []
596        "#;
597
598        fs::write(&config_path, toml_content).unwrap();
599
600        let result = ServerConfig::load_from(&config_path);
601        assert!(result.is_err(), "Should reject unknown fields");
602    }
603
604    #[test]
605    fn test_empty_config_file() {
606        let tmp_dir = TempDir::new().unwrap();
607        let config_path = tmp_dir.path().join("empty.toml");
608
609        fs::write(&config_path, "").unwrap();
610
611        let config = ServerConfig::load_from(&config_path).unwrap();
612        assert!(config.workspace.roots.is_empty());
613        assert!(config.lsp_servers.is_empty());
614    }
615
616    #[test]
617    fn test_config_with_initialization_options() {
618        let tmp_dir = TempDir::new().unwrap();
619        let config_path = tmp_dir.path().join("init_opts.toml");
620
621        let toml_content = r#"
622            [[lsp_servers]]
623            language_id = "rust"
624            command = "rust-analyzer"
625
626            [lsp_servers.initialization_options]
627            cargo = { allFeatures = true }
628        "#;
629
630        fs::write(&config_path, toml_content).unwrap();
631
632        let config = ServerConfig::load_from(&config_path).unwrap();
633        assert!(config.lsp_servers[0].initialization_options.is_some());
634    }
635
636    #[test]
637    fn test_language_extensions_in_config() {
638        let tmp_dir = TempDir::new().unwrap();
639        let config_path = tmp_dir.path().join("extensions.toml");
640
641        let toml_content = r#"
642            [[workspace.language_extensions]]
643            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
644            language_id = "cpp"
645
646            [[workspace.language_extensions]]
647            extensions = ["nu"]
648            language_id = "nushell"
649
650            [[workspace.language_extensions]]
651            extensions = ["py", "pyw", "pyi"]
652            language_id = "python"
653        "#;
654
655        fs::write(&config_path, toml_content).unwrap();
656
657        let config = ServerConfig::load_from(&config_path).unwrap();
658        assert_eq!(config.workspace.language_extensions.len(), 3);
659
660        // Check C++ extensions
661        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
662        assert_eq!(
663            config.workspace.language_extensions[0].extensions,
664            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
665        );
666
667        // Check Nushell extension
668        assert_eq!(
669            config.workspace.language_extensions[1].language_id,
670            "nushell"
671        );
672        assert_eq!(
673            config.workspace.language_extensions[1].extensions,
674            vec!["nu"]
675        );
676    }
677
678    #[test]
679    fn test_build_extension_map() {
680        let workspace = WorkspaceConfig {
681            roots: vec![],
682            position_encodings: vec![],
683            language_extensions: vec![
684                LanguageExtensionMapping {
685                    extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
686                    language_id: "cpp".to_string(),
687                },
688                LanguageExtensionMapping {
689                    extensions: vec!["nu".to_string()],
690                    language_id: "nushell".to_string(),
691                },
692            ],
693            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
694        };
695
696        let map = workspace.build_extension_map();
697        assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
698        assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
699        assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
700        assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
701        assert_eq!(map.get("unknown"), None);
702    }
703
704    #[test]
705    fn test_extract_extension_from_pattern_empty_string() {
706        assert_eq!(extract_extension_from_pattern(""), None);
707    }
708
709    #[test]
710    fn test_extract_extension_from_pattern_without_dot() {
711        assert_eq!(extract_extension_from_pattern("**/*"), None);
712    }
713
714    #[test]
715    fn test_extract_extension_from_pattern_dotfile() {
716        assert_eq!(extract_extension_from_pattern(".gitignore"), None);
717    }
718
719    #[test]
720    fn test_extract_extension_from_pattern_multi_dot_extension() {
721        assert_eq!(
722            extract_extension_from_pattern("foo.tar.gz"),
723            Some("gz".to_string())
724        );
725    }
726
727    #[test]
728    fn test_build_effective_extension_map_overrides_with_file_patterns() {
729        let config = ServerConfig {
730            workspace: WorkspaceConfig::default(),
731            lsp_servers: vec![LspServerConfig {
732                language_id: "cpp".to_string(),
733                command: "clangd".to_string(),
734                args: vec![],
735                env: HashMap::new(),
736                file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
737                initialization_options: None,
738                timeout_seconds: 30,
739                heuristics: None,
740            }],
741        };
742
743        let map = config.build_effective_extension_map();
744        assert_eq!(map.get("c"), Some(&"cpp".to_string()));
745        assert_eq!(map.get("h"), Some(&"cpp".to_string()));
746    }
747
748    #[test]
749    fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
750        let config = ServerConfig {
751            workspace: WorkspaceConfig::default(),
752            lsp_servers: vec![LspServerConfig {
753                language_id: "cpp".to_string(),
754                command: "clangd".to_string(),
755                args: vec![],
756                env: HashMap::new(),
757                file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
758                initialization_options: None,
759                timeout_seconds: 30,
760                heuristics: None,
761            }],
762        };
763
764        let map = config.build_effective_extension_map();
765        // Default C/C++ mappings remain unchanged when patterns cannot be parsed.
766        assert_eq!(map.get("h"), Some(&"c".to_string()));
767    }
768
769    #[test]
770    fn test_get_language_for_extension() {
771        let workspace = WorkspaceConfig {
772            roots: vec![],
773            position_encodings: vec![],
774            language_extensions: vec![
775                LanguageExtensionMapping {
776                    extensions: vec!["hpp".to_string(), "hh".to_string()],
777                    language_id: "cpp".to_string(),
778                },
779                LanguageExtensionMapping {
780                    extensions: vec!["py".to_string()],
781                    language_id: "python".to_string(),
782                },
783            ],
784            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
785        };
786
787        assert_eq!(
788            workspace.get_language_for_extension("hpp"),
789            Some("cpp".to_string())
790        );
791        assert_eq!(
792            workspace.get_language_for_extension("hh"),
793            Some("cpp".to_string())
794        );
795        assert_eq!(
796            workspace.get_language_for_extension("py"),
797            Some("python".to_string())
798        );
799        assert_eq!(workspace.get_language_for_extension("unknown"), None);
800    }
801
802    #[test]
803    fn test_default_language_extensions() {
804        let workspace = WorkspaceConfig::default();
805        let map = workspace.build_extension_map();
806        assert!(!map.is_empty());
807        assert_eq!(
808            workspace.get_language_for_extension("rs"),
809            Some("rust".to_string())
810        );
811        assert_eq!(
812            workspace.get_language_for_extension("py"),
813            Some("python".to_string())
814        );
815        assert_eq!(
816            workspace.get_language_for_extension("cpp"),
817            Some("cpp".to_string())
818        );
819    }
820
821    #[test]
822    fn test_create_default_config_file() {
823        let tmp_dir = TempDir::new().unwrap();
824        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
825
826        ServerConfig::create_default_config_file(&config_path).unwrap();
827
828        assert!(config_path.exists());
829
830        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
831        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
832        assert_eq!(loaded_config.lsp_servers.len(), 6);
833        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
834    }
835
836    #[test]
837    fn test_load_returns_default_config() {
838        // When called directly, default() should return config with all language extensions
839        let config = ServerConfig::default();
840        assert_eq!(config.workspace.language_extensions.len(), 30);
841        assert_eq!(config.lsp_servers.len(), 6);
842        assert_eq!(config.lsp_servers[0].language_id, "rust");
843    }
844
845    #[test]
846    fn test_load_does_not_overwrite_existing_config() {
847        // Save original directory to restore it after the test
848        let original_dir = std::env::current_dir().unwrap();
849
850        let tmp_dir = TempDir::new().unwrap();
851        let config_path = tmp_dir.path().join("mcpls.toml");
852
853        let custom_toml = r#"
854            [workspace]
855            roots = ["/custom/path"]
856
857            [[lsp_servers]]
858            language_id = "python"
859            command = "pyright-langserver"
860        "#;
861
862        fs::write(&config_path, custom_toml).unwrap();
863
864        std::env::set_current_dir(tmp_dir.path()).unwrap();
865        let config = ServerConfig::load().unwrap();
866
867        assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
868        assert_eq!(config.lsp_servers.len(), 1);
869        assert_eq!(config.lsp_servers[0].language_id, "python");
870
871        // Restore original directory to avoid affecting other tests
872        std::env::set_current_dir(original_dir).unwrap();
873    }
874
875    #[test]
876    fn test_config_file_creation_with_proper_structure() {
877        let tmp_dir = TempDir::new().unwrap();
878        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
879
880        ServerConfig::create_default_config_file(&config_path).unwrap();
881
882        let content = fs::read_to_string(&config_path).unwrap();
883
884        assert!(content.contains("[workspace]"));
885        assert!(content.contains("[[workspace.language_extensions]]"));
886        assert!(content.contains("[[lsp_servers]]"));
887        assert!(content.contains("language_id = \"rust\""));
888        assert!(content.contains("extensions = [\"rs\"]"));
889    }
890
891    #[test]
892    fn test_heuristics_max_depth_default() {
893        let config = WorkspaceConfig::default();
894        assert_eq!(config.heuristics_max_depth, 10);
895    }
896
897    #[test]
898    fn test_heuristics_max_depth_from_config() {
899        let tmp_dir = TempDir::new().unwrap();
900        let config_path = tmp_dir.path().join("depth.toml");
901
902        let toml_content = r"
903            [workspace]
904            heuristics_max_depth = 5
905        ";
906
907        fs::write(&config_path, toml_content).unwrap();
908
909        let config = ServerConfig::load_from(&config_path).unwrap();
910        assert_eq!(config.workspace.heuristics_max_depth, 5);
911    }
912
913    #[test]
914    fn test_heuristics_max_depth_uses_default_when_not_specified() {
915        let tmp_dir = TempDir::new().unwrap();
916        let config_path = tmp_dir.path().join("no_depth.toml");
917
918        let toml_content = r"
919            [workspace]
920            roots = []
921        ";
922
923        fs::write(&config_path, toml_content).unwrap();
924
925        let config = ServerConfig::load_from(&config_path).unwrap();
926        assert_eq!(
927            config.workspace.heuristics_max_depth,
928            DEFAULT_HEURISTICS_MAX_DEPTH
929        );
930    }
931}