1mod 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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LanguageExtensionMapping {
22 pub extensions: Vec<String>,
24 pub language_id: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ServerConfig {
32 #[serde(default)]
34 pub workspace: WorkspaceConfig,
35
36 #[serde(default)]
38 pub lsp_servers: Vec<LspServerConfig>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct WorkspaceConfig {
45 #[serde(default)]
47 pub roots: Vec<PathBuf>,
48
49 #[serde(default = "default_position_encodings")]
52 pub position_encodings: Vec<String>,
53
54 #[serde(default)]
57 pub language_extensions: Vec<LanguageExtensionMapping>,
58
59 #[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 #[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 #[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 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 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#[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 #[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 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 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 Ok(Self::default())
351 }
352
353 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 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 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 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 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 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 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 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 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}