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
119fn default_position_encodings() -> Vec<String> {
120    vec!["utf-8".to_string(), "utf-16".to_string()]
121}
122
123/// Build default language extension mappings.
124///
125/// Returns all built-in language extensions that MCPLS recognizes by default.
126/// These mappings are used when no custom configuration is provided.
127#[allow(clippy::too_many_lines)]
128fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
129    vec![
130        LanguageExtensionMapping {
131            extensions: vec!["rs".to_string()],
132            language_id: "rust".to_string(),
133        },
134        LanguageExtensionMapping {
135            extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
136            language_id: "python".to_string(),
137        },
138        LanguageExtensionMapping {
139            extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
140            language_id: "javascript".to_string(),
141        },
142        LanguageExtensionMapping {
143            extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
144            language_id: "typescript".to_string(),
145        },
146        LanguageExtensionMapping {
147            extensions: vec!["tsx".to_string()],
148            language_id: "typescriptreact".to_string(),
149        },
150        LanguageExtensionMapping {
151            extensions: vec!["jsx".to_string()],
152            language_id: "javascriptreact".to_string(),
153        },
154        LanguageExtensionMapping {
155            extensions: vec!["go".to_string()],
156            language_id: "go".to_string(),
157        },
158        LanguageExtensionMapping {
159            extensions: vec!["c".to_string(), "h".to_string()],
160            language_id: "c".to_string(),
161        },
162        LanguageExtensionMapping {
163            extensions: vec![
164                "cpp".to_string(),
165                "cc".to_string(),
166                "cxx".to_string(),
167                "hpp".to_string(),
168                "hh".to_string(),
169                "hxx".to_string(),
170            ],
171            language_id: "cpp".to_string(),
172        },
173        LanguageExtensionMapping {
174            extensions: vec!["java".to_string()],
175            language_id: "java".to_string(),
176        },
177        LanguageExtensionMapping {
178            extensions: vec!["rb".to_string()],
179            language_id: "ruby".to_string(),
180        },
181        LanguageExtensionMapping {
182            extensions: vec!["php".to_string()],
183            language_id: "php".to_string(),
184        },
185        LanguageExtensionMapping {
186            extensions: vec!["swift".to_string()],
187            language_id: "swift".to_string(),
188        },
189        LanguageExtensionMapping {
190            extensions: vec!["kt".to_string(), "kts".to_string()],
191            language_id: "kotlin".to_string(),
192        },
193        LanguageExtensionMapping {
194            extensions: vec!["scala".to_string(), "sc".to_string()],
195            language_id: "scala".to_string(),
196        },
197        LanguageExtensionMapping {
198            extensions: vec!["zig".to_string()],
199            language_id: "zig".to_string(),
200        },
201        LanguageExtensionMapping {
202            extensions: vec!["lua".to_string()],
203            language_id: "lua".to_string(),
204        },
205        LanguageExtensionMapping {
206            extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
207            language_id: "shellscript".to_string(),
208        },
209        LanguageExtensionMapping {
210            extensions: vec!["json".to_string()],
211            language_id: "json".to_string(),
212        },
213        LanguageExtensionMapping {
214            extensions: vec!["toml".to_string()],
215            language_id: "toml".to_string(),
216        },
217        LanguageExtensionMapping {
218            extensions: vec!["yaml".to_string(), "yml".to_string()],
219            language_id: "yaml".to_string(),
220        },
221        LanguageExtensionMapping {
222            extensions: vec!["xml".to_string()],
223            language_id: "xml".to_string(),
224        },
225        LanguageExtensionMapping {
226            extensions: vec!["html".to_string(), "htm".to_string()],
227            language_id: "html".to_string(),
228        },
229        LanguageExtensionMapping {
230            extensions: vec!["css".to_string()],
231            language_id: "css".to_string(),
232        },
233        LanguageExtensionMapping {
234            extensions: vec!["scss".to_string()],
235            language_id: "scss".to_string(),
236        },
237        LanguageExtensionMapping {
238            extensions: vec!["less".to_string()],
239            language_id: "less".to_string(),
240        },
241        LanguageExtensionMapping {
242            extensions: vec!["md".to_string(), "markdown".to_string()],
243            language_id: "markdown".to_string(),
244        },
245        LanguageExtensionMapping {
246            extensions: vec!["cs".to_string()],
247            language_id: "csharp".to_string(),
248        },
249        LanguageExtensionMapping {
250            extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
251            language_id: "fsharp".to_string(),
252        },
253        LanguageExtensionMapping {
254            extensions: vec!["r".to_string(), "R".to_string()],
255            language_id: "r".to_string(),
256        },
257    ]
258}
259
260impl ServerConfig {
261    /// Load configuration from the default path.
262    ///
263    /// Default paths checked in order:
264    /// 1. `$MCPLS_CONFIG` environment variable
265    /// 2. `./mcpls.toml` (current directory)
266    /// 3. `~/.config/mcpls/mcpls.toml` (Linux/macOS)
267    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
268    ///
269    /// If no configuration file exists, creates a default configuration file
270    /// in the user's config directory with all default language extensions.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if parsing an existing config fails.
275    /// If config creation fails, returns default config with graceful degradation.
276    pub fn load() -> Result<Self> {
277        if let Ok(path) = std::env::var("MCPLS_CONFIG") {
278            return Self::load_from(Path::new(&path));
279        }
280
281        let local_config = PathBuf::from("mcpls.toml");
282        if local_config.exists() {
283            return Self::load_from(&local_config);
284        }
285
286        if let Some(config_dir) = dirs::config_dir() {
287            let user_config = config_dir.join("mcpls").join("mcpls.toml");
288            if user_config.exists() {
289                return Self::load_from(&user_config);
290            }
291
292            // No config found - create default config file
293            if let Err(e) = Self::create_default_config_file(&user_config) {
294                tracing::warn!(
295                    "Failed to create default config at {}: {}. Using in-memory defaults.",
296                    user_config.display(),
297                    e
298                );
299            } else {
300                tracing::info!("Created default config at {}", user_config.display());
301            }
302        }
303
304        // Return default configuration
305        Ok(Self::default())
306    }
307
308    /// Load configuration from a specific path.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if the file doesn't exist or parsing fails.
313    pub fn load_from(path: &Path) -> Result<Self> {
314        let content = std::fs::read_to_string(path).map_err(|e| {
315            if e.kind() == std::io::ErrorKind::NotFound {
316                Error::ConfigNotFound(path.to_path_buf())
317            } else {
318                Error::Io(e)
319            }
320        })?;
321
322        let config: Self = toml::from_str(&content)?;
323        config.validate()?;
324        Ok(config)
325    }
326
327    /// Create a default configuration file with all built-in extensions.
328    ///
329    /// Creates the parent directory if it doesn't exist.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if directory or file creation fails.
334    fn create_default_config_file(path: &Path) -> Result<()> {
335        if let Some(parent) = path.parent() {
336            std::fs::create_dir_all(parent)?;
337        }
338
339        let default_config = Self::default();
340        let toml_content = toml::to_string_pretty(&default_config)?;
341        std::fs::write(path, toml_content)?;
342
343        Ok(())
344    }
345
346    /// Validate the configuration.
347    fn validate(&self) -> Result<()> {
348        for server in &self.lsp_servers {
349            if server.language_id.is_empty() {
350                return Err(Error::InvalidConfig(
351                    "language_id cannot be empty".to_string(),
352                ));
353            }
354            if server.command.is_empty() {
355                return Err(Error::InvalidConfig(format!(
356                    "command cannot be empty for language '{}'",
357                    server.language_id
358                )));
359            }
360        }
361        Ok(())
362    }
363}
364
365impl Default for ServerConfig {
366    fn default() -> Self {
367        Self {
368            workspace: WorkspaceConfig::default(),
369            lsp_servers: vec![
370                LspServerConfig::rust_analyzer(),
371                LspServerConfig::pyright(),
372                LspServerConfig::typescript(),
373                LspServerConfig::gopls(),
374                LspServerConfig::clangd(),
375                LspServerConfig::zls(),
376            ],
377        }
378    }
379}
380
381#[cfg(test)]
382#[allow(clippy::unwrap_used)]
383mod tests {
384    use std::fs;
385
386    use tempfile::TempDir;
387
388    use super::*;
389
390    #[test]
391    fn test_default_config() {
392        let config = ServerConfig::default();
393        assert_eq!(config.lsp_servers.len(), 6);
394        assert_eq!(config.lsp_servers[0].language_id, "rust");
395        assert_eq!(config.lsp_servers[1].language_id, "python");
396        assert_eq!(config.lsp_servers[2].language_id, "typescript");
397        assert_eq!(config.lsp_servers[3].language_id, "go");
398        assert_eq!(config.lsp_servers[4].language_id, "cpp");
399        assert_eq!(config.lsp_servers[5].language_id, "zig");
400        assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
401    }
402
403    #[test]
404    fn test_default_position_encodings() {
405        let encodings = default_position_encodings();
406        assert_eq!(encodings, vec!["utf-8", "utf-16"]);
407    }
408
409    #[test]
410    fn test_load_from_valid_toml() {
411        let tmp_dir = TempDir::new().unwrap();
412        let config_path = tmp_dir.path().join("config.toml");
413
414        let toml_content = r#"
415            [workspace]
416            roots = ["/tmp/workspace"]
417            position_encodings = ["utf-8"]
418
419            [[lsp_servers]]
420            language_id = "rust"
421            command = "rust-analyzer"
422            timeout_seconds = 30
423        "#;
424
425        fs::write(&config_path, toml_content).unwrap();
426
427        let config = ServerConfig::load_from(&config_path).unwrap();
428        assert_eq!(
429            config.workspace.roots,
430            vec![PathBuf::from("/tmp/workspace")]
431        );
432        assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
433        assert_eq!(config.lsp_servers.len(), 1);
434        assert_eq!(config.lsp_servers[0].language_id, "rust");
435    }
436
437    #[test]
438    fn test_load_from_nonexistent_file() {
439        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
440        assert!(result.is_err());
441
442        if let Err(Error::ConfigNotFound(path)) = result {
443            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
444        } else {
445            panic!("Expected ConfigNotFound error");
446        }
447    }
448
449    #[test]
450    fn test_load_from_invalid_toml() {
451        let tmp_dir = TempDir::new().unwrap();
452        let config_path = tmp_dir.path().join("invalid.toml");
453
454        fs::write(&config_path, "invalid toml content {{}").unwrap();
455
456        let result = ServerConfig::load_from(&config_path);
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn test_validate_empty_language_id() {
462        let tmp_dir = TempDir::new().unwrap();
463        let config_path = tmp_dir.path().join("config.toml");
464
465        let toml_content = r#"
466            [[lsp_servers]]
467            language_id = ""
468            command = "test"
469        "#;
470
471        fs::write(&config_path, toml_content).unwrap();
472
473        let result = ServerConfig::load_from(&config_path);
474        assert!(result.is_err());
475
476        if let Err(Error::InvalidConfig(msg)) = result {
477            assert!(msg.contains("language_id cannot be empty"));
478        } else {
479            panic!("Expected InvalidConfig error");
480        }
481    }
482
483    #[test]
484    fn test_validate_empty_command() {
485        let tmp_dir = TempDir::new().unwrap();
486        let config_path = tmp_dir.path().join("config.toml");
487
488        let toml_content = r#"
489            [[lsp_servers]]
490            language_id = "rust"
491            command = ""
492        "#;
493
494        fs::write(&config_path, toml_content).unwrap();
495
496        let result = ServerConfig::load_from(&config_path);
497        assert!(result.is_err());
498
499        if let Err(Error::InvalidConfig(msg)) = result {
500            assert!(msg.contains("command cannot be empty"));
501        } else {
502            panic!("Expected InvalidConfig error");
503        }
504    }
505
506    #[test]
507    fn test_workspace_config_defaults() {
508        let workspace = WorkspaceConfig::default();
509        assert!(workspace.roots.is_empty());
510        assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
511        assert!(!workspace.language_extensions.is_empty());
512        assert_eq!(workspace.language_extensions.len(), 30);
513        assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
514    }
515
516    #[test]
517    fn test_load_multiple_servers() {
518        let tmp_dir = TempDir::new().unwrap();
519        let config_path = tmp_dir.path().join("multi.toml");
520
521        let toml_content = r#"
522            [[lsp_servers]]
523            language_id = "rust"
524            command = "rust-analyzer"
525
526            [[lsp_servers]]
527            language_id = "python"
528            command = "pyright-langserver"
529            args = ["--stdio"]
530        "#;
531
532        fs::write(&config_path, toml_content).unwrap();
533
534        let config = ServerConfig::load_from(&config_path).unwrap();
535        assert_eq!(config.lsp_servers.len(), 2);
536        assert_eq!(config.lsp_servers[0].language_id, "rust");
537        assert_eq!(config.lsp_servers[1].language_id, "python");
538        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
539    }
540
541    #[test]
542    fn test_deny_unknown_fields() {
543        let tmp_dir = TempDir::new().unwrap();
544        let config_path = tmp_dir.path().join("unknown.toml");
545
546        let toml_content = r#"
547            unknown_field = "value"
548
549            [workspace]
550            roots = []
551        "#;
552
553        fs::write(&config_path, toml_content).unwrap();
554
555        let result = ServerConfig::load_from(&config_path);
556        assert!(result.is_err(), "Should reject unknown fields");
557    }
558
559    #[test]
560    fn test_empty_config_file() {
561        let tmp_dir = TempDir::new().unwrap();
562        let config_path = tmp_dir.path().join("empty.toml");
563
564        fs::write(&config_path, "").unwrap();
565
566        let config = ServerConfig::load_from(&config_path).unwrap();
567        assert!(config.workspace.roots.is_empty());
568        assert!(config.lsp_servers.is_empty());
569    }
570
571    #[test]
572    fn test_config_with_initialization_options() {
573        let tmp_dir = TempDir::new().unwrap();
574        let config_path = tmp_dir.path().join("init_opts.toml");
575
576        let toml_content = r#"
577            [[lsp_servers]]
578            language_id = "rust"
579            command = "rust-analyzer"
580
581            [lsp_servers.initialization_options]
582            cargo = { allFeatures = true }
583        "#;
584
585        fs::write(&config_path, toml_content).unwrap();
586
587        let config = ServerConfig::load_from(&config_path).unwrap();
588        assert!(config.lsp_servers[0].initialization_options.is_some());
589    }
590
591    #[test]
592    fn test_language_extensions_in_config() {
593        let tmp_dir = TempDir::new().unwrap();
594        let config_path = tmp_dir.path().join("extensions.toml");
595
596        let toml_content = r#"
597            [[workspace.language_extensions]]
598            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
599            language_id = "cpp"
600
601            [[workspace.language_extensions]]
602            extensions = ["nu"]
603            language_id = "nushell"
604
605            [[workspace.language_extensions]]
606            extensions = ["py", "pyw", "pyi"]
607            language_id = "python"
608        "#;
609
610        fs::write(&config_path, toml_content).unwrap();
611
612        let config = ServerConfig::load_from(&config_path).unwrap();
613        assert_eq!(config.workspace.language_extensions.len(), 3);
614
615        // Check C++ extensions
616        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
617        assert_eq!(
618            config.workspace.language_extensions[0].extensions,
619            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
620        );
621
622        // Check Nushell extension
623        assert_eq!(
624            config.workspace.language_extensions[1].language_id,
625            "nushell"
626        );
627        assert_eq!(
628            config.workspace.language_extensions[1].extensions,
629            vec!["nu"]
630        );
631    }
632
633    #[test]
634    fn test_build_extension_map() {
635        let workspace = WorkspaceConfig {
636            roots: vec![],
637            position_encodings: vec![],
638            language_extensions: vec![
639                LanguageExtensionMapping {
640                    extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
641                    language_id: "cpp".to_string(),
642                },
643                LanguageExtensionMapping {
644                    extensions: vec!["nu".to_string()],
645                    language_id: "nushell".to_string(),
646                },
647            ],
648            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
649        };
650
651        let map = workspace.build_extension_map();
652        assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
653        assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
654        assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
655        assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
656        assert_eq!(map.get("unknown"), None);
657    }
658
659    #[test]
660    fn test_get_language_for_extension() {
661        let workspace = WorkspaceConfig {
662            roots: vec![],
663            position_encodings: vec![],
664            language_extensions: vec![
665                LanguageExtensionMapping {
666                    extensions: vec!["hpp".to_string(), "hh".to_string()],
667                    language_id: "cpp".to_string(),
668                },
669                LanguageExtensionMapping {
670                    extensions: vec!["py".to_string()],
671                    language_id: "python".to_string(),
672                },
673            ],
674            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
675        };
676
677        assert_eq!(
678            workspace.get_language_for_extension("hpp"),
679            Some("cpp".to_string())
680        );
681        assert_eq!(
682            workspace.get_language_for_extension("hh"),
683            Some("cpp".to_string())
684        );
685        assert_eq!(
686            workspace.get_language_for_extension("py"),
687            Some("python".to_string())
688        );
689        assert_eq!(workspace.get_language_for_extension("unknown"), None);
690    }
691
692    #[test]
693    fn test_default_language_extensions() {
694        let workspace = WorkspaceConfig::default();
695        let map = workspace.build_extension_map();
696        assert!(!map.is_empty());
697        assert_eq!(
698            workspace.get_language_for_extension("rs"),
699            Some("rust".to_string())
700        );
701        assert_eq!(
702            workspace.get_language_for_extension("py"),
703            Some("python".to_string())
704        );
705        assert_eq!(
706            workspace.get_language_for_extension("cpp"),
707            Some("cpp".to_string())
708        );
709    }
710
711    #[test]
712    fn test_create_default_config_file() {
713        let tmp_dir = TempDir::new().unwrap();
714        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
715
716        ServerConfig::create_default_config_file(&config_path).unwrap();
717
718        assert!(config_path.exists());
719
720        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
721        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
722        assert_eq!(loaded_config.lsp_servers.len(), 6);
723        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
724    }
725
726    #[test]
727    fn test_load_returns_default_config() {
728        // When called directly, default() should return config with all language extensions
729        let config = ServerConfig::default();
730        assert_eq!(config.workspace.language_extensions.len(), 30);
731        assert_eq!(config.lsp_servers.len(), 6);
732        assert_eq!(config.lsp_servers[0].language_id, "rust");
733    }
734
735    #[test]
736    fn test_load_does_not_overwrite_existing_config() {
737        // Save original directory to restore it after the test
738        let original_dir = std::env::current_dir().unwrap();
739
740        let tmp_dir = TempDir::new().unwrap();
741        let config_path = tmp_dir.path().join("mcpls.toml");
742
743        let custom_toml = r#"
744            [workspace]
745            roots = ["/custom/path"]
746
747            [[lsp_servers]]
748            language_id = "python"
749            command = "pyright-langserver"
750        "#;
751
752        fs::write(&config_path, custom_toml).unwrap();
753
754        std::env::set_current_dir(tmp_dir.path()).unwrap();
755        let config = ServerConfig::load().unwrap();
756
757        assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
758        assert_eq!(config.lsp_servers.len(), 1);
759        assert_eq!(config.lsp_servers[0].language_id, "python");
760
761        // Restore original directory to avoid affecting other tests
762        std::env::set_current_dir(original_dir).unwrap();
763    }
764
765    #[test]
766    fn test_config_file_creation_with_proper_structure() {
767        let tmp_dir = TempDir::new().unwrap();
768        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
769
770        ServerConfig::create_default_config_file(&config_path).unwrap();
771
772        let content = fs::read_to_string(&config_path).unwrap();
773
774        assert!(content.contains("[workspace]"));
775        assert!(content.contains("[[workspace.language_extensions]]"));
776        assert!(content.contains("[[lsp_servers]]"));
777        assert!(content.contains("language_id = \"rust\""));
778        assert!(content.contains("extensions = [\"rs\"]"));
779    }
780
781    #[test]
782    fn test_heuristics_max_depth_default() {
783        let config = WorkspaceConfig::default();
784        assert_eq!(config.heuristics_max_depth, 10);
785    }
786
787    #[test]
788    fn test_heuristics_max_depth_from_config() {
789        let tmp_dir = TempDir::new().unwrap();
790        let config_path = tmp_dir.path().join("depth.toml");
791
792        let toml_content = r"
793            [workspace]
794            heuristics_max_depth = 5
795        ";
796
797        fs::write(&config_path, toml_content).unwrap();
798
799        let config = ServerConfig::load_from(&config_path).unwrap();
800        assert_eq!(config.workspace.heuristics_max_depth, 5);
801    }
802
803    #[test]
804    fn test_heuristics_max_depth_uses_default_when_not_specified() {
805        let tmp_dir = TempDir::new().unwrap();
806        let config_path = tmp_dir.path().join("no_depth.toml");
807
808        let toml_content = r"
809            [workspace]
810            roots = []
811        ";
812
813        fs::write(&config_path, toml_content).unwrap();
814
815        let config = ServerConfig::load_from(&config_path).unwrap();
816        assert_eq!(
817            config.workspace.heuristics_max_depth,
818            DEFAULT_HEURISTICS_MAX_DEPTH
819        );
820    }
821}