1mod language;
7mod routing;
8mod server;
9
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13pub use language::{base_language_id, react_variant_language_id};
14pub use routing::{ServerId, ToolKind, ToolRouter};
15use serde::{Deserialize, Serialize};
16pub use server::{DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, ServerHeuristics};
17
18use crate::error::{Error, Result};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LanguageExtensionMapping {
26 pub extensions: Vec<String>,
28 pub language_id: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct ServerConfig {
36 #[serde(default)]
38 pub workspace: WorkspaceConfig,
39
40 #[serde(default)]
42 pub lsp_servers: Vec<LspServerConfig>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct WorkspaceConfig {
49 #[serde(default)]
51 pub roots: Vec<PathBuf>,
52
53 #[serde(default = "default_position_encodings")]
56 pub position_encodings: Vec<String>,
57
58 #[serde(default)]
61 pub language_extensions: Vec<LanguageExtensionMapping>,
62
63 #[serde(default = "default_heuristics_max_depth")]
67 pub heuristics_max_depth: usize,
68}
69
70impl Default for WorkspaceConfig {
71 fn default() -> Self {
72 Self {
73 roots: Vec::new(),
74 position_encodings: default_position_encodings(),
75 language_extensions: default_language_extensions(),
76 heuristics_max_depth: default_heuristics_max_depth(),
77 }
78 }
79}
80
81const fn default_heuristics_max_depth() -> usize {
82 DEFAULT_HEURISTICS_MAX_DEPTH
83}
84
85impl WorkspaceConfig {
86 #[must_use]
93 pub fn build_extension_map(&self) -> HashMap<String, String> {
94 let mut map = HashMap::new();
95 for mapping in &self.language_extensions {
96 for ext in &mapping.extensions {
97 map.insert(ext.clone(), mapping.language_id.clone());
98 }
99 }
100 map
101 }
102
103 #[must_use]
113 pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
114 for mapping in &self.language_extensions {
115 if mapping.extensions.contains(&extension.to_string()) {
116 return Some(mapping.language_id.clone());
117 }
118 }
119 None
120 }
121}
122
123fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
128 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
129 if basename.starts_with('.') {
130 return None;
131 }
132
133 let (_, ext) = basename.rsplit_once('.')?;
134 if ext.is_empty() {
135 return None;
136 }
137
138 if ext
140 .chars()
141 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
142 {
143 Some(ext.to_string())
144 } else {
145 None
146 }
147}
148
149fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
150 react_variant_language_id(server_language_id, extension)
151 .unwrap_or(server_language_id)
152 .to_string()
153}
154
155fn default_position_encodings() -> Vec<String> {
156 vec!["utf-8".to_string(), "utf-16".to_string()]
157}
158
159#[allow(clippy::too_many_lines)]
164fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
165 vec![
166 LanguageExtensionMapping {
167 extensions: vec!["rs".to_string()],
168 language_id: "rust".to_string(),
169 },
170 LanguageExtensionMapping {
171 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
172 language_id: "python".to_string(),
173 },
174 LanguageExtensionMapping {
175 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
176 language_id: "javascript".to_string(),
177 },
178 LanguageExtensionMapping {
179 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
180 language_id: "typescript".to_string(),
181 },
182 LanguageExtensionMapping {
183 extensions: vec!["tsx".to_string()],
184 language_id: "typescriptreact".to_string(),
185 },
186 LanguageExtensionMapping {
187 extensions: vec!["jsx".to_string()],
188 language_id: "javascriptreact".to_string(),
189 },
190 LanguageExtensionMapping {
191 extensions: vec!["go".to_string()],
192 language_id: "go".to_string(),
193 },
194 LanguageExtensionMapping {
195 extensions: vec!["c".to_string(), "h".to_string()],
196 language_id: "c".to_string(),
197 },
198 LanguageExtensionMapping {
199 extensions: vec![
200 "cpp".to_string(),
201 "cc".to_string(),
202 "cxx".to_string(),
203 "hpp".to_string(),
204 "hh".to_string(),
205 "hxx".to_string(),
206 ],
207 language_id: "cpp".to_string(),
208 },
209 LanguageExtensionMapping {
210 extensions: vec!["java".to_string()],
211 language_id: "java".to_string(),
212 },
213 LanguageExtensionMapping {
214 extensions: vec!["rb".to_string()],
215 language_id: "ruby".to_string(),
216 },
217 LanguageExtensionMapping {
218 extensions: vec!["php".to_string()],
219 language_id: "php".to_string(),
220 },
221 LanguageExtensionMapping {
222 extensions: vec!["swift".to_string()],
223 language_id: "swift".to_string(),
224 },
225 LanguageExtensionMapping {
226 extensions: vec!["kt".to_string(), "kts".to_string()],
227 language_id: "kotlin".to_string(),
228 },
229 LanguageExtensionMapping {
230 extensions: vec!["scala".to_string(), "sc".to_string()],
231 language_id: "scala".to_string(),
232 },
233 LanguageExtensionMapping {
234 extensions: vec!["zig".to_string()],
235 language_id: "zig".to_string(),
236 },
237 LanguageExtensionMapping {
238 extensions: vec!["lua".to_string()],
239 language_id: "lua".to_string(),
240 },
241 LanguageExtensionMapping {
242 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
243 language_id: "shellscript".to_string(),
244 },
245 LanguageExtensionMapping {
246 extensions: vec!["json".to_string()],
247 language_id: "json".to_string(),
248 },
249 LanguageExtensionMapping {
250 extensions: vec!["toml".to_string()],
251 language_id: "toml".to_string(),
252 },
253 LanguageExtensionMapping {
254 extensions: vec!["yaml".to_string(), "yml".to_string()],
255 language_id: "yaml".to_string(),
256 },
257 LanguageExtensionMapping {
258 extensions: vec!["xml".to_string()],
259 language_id: "xml".to_string(),
260 },
261 LanguageExtensionMapping {
262 extensions: vec!["html".to_string(), "htm".to_string()],
263 language_id: "html".to_string(),
264 },
265 LanguageExtensionMapping {
266 extensions: vec!["css".to_string()],
267 language_id: "css".to_string(),
268 },
269 LanguageExtensionMapping {
270 extensions: vec!["scss".to_string()],
271 language_id: "scss".to_string(),
272 },
273 LanguageExtensionMapping {
274 extensions: vec!["less".to_string()],
275 language_id: "less".to_string(),
276 },
277 LanguageExtensionMapping {
278 extensions: vec!["md".to_string(), "markdown".to_string()],
279 language_id: "markdown".to_string(),
280 },
281 LanguageExtensionMapping {
282 extensions: vec!["cs".to_string()],
283 language_id: "csharp".to_string(),
284 },
285 LanguageExtensionMapping {
286 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
287 language_id: "fsharp".to_string(),
288 },
289 LanguageExtensionMapping {
290 extensions: vec!["r".to_string(), "R".to_string()],
291 language_id: "r".to_string(),
292 },
293 ]
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum ProjectConfigTrust {
312 Untrusted,
315 Trusted,
317}
318
319impl ServerConfig {
320 #[must_use]
325 pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
326 let mut map = self.workspace.build_extension_map();
327
328 for server in &self.lsp_servers {
329 for pattern in &server.file_patterns {
330 if let Some(ext) = extract_extension_from_pattern(pattern) {
331 let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
332 map.insert(ext, language_id);
333 }
334 }
335 }
336
337 map
338 }
339
340 pub fn load() -> Result<Self> {
363 Self::load_with_trust(ProjectConfigTrust::Untrusted)
364 }
365
366 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
385 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
393 return Self::load_from(Path::new(&path));
394 }
395
396 let local_config = PathBuf::from("mcpls.toml");
397 if local_config.exists() {
398 match trust {
399 ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
400 ProjectConfigTrust::Untrusted => {
401 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
402 std::env::current_dir()
403 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
404 });
405 tracing::warn!(
406 "ignoring untrusted project-local config at {}; pass \
407 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
408 load it",
409 display_path.display()
410 );
411 }
412 }
413 }
414
415 if let Some(config_dir) = dirs::config_dir() {
416 let user_config = config_dir.join("mcpls").join("mcpls.toml");
417 if user_config.exists() {
418 return Self::load_from(&user_config);
419 }
420
421 if let Err(e) = Self::create_default_config_file(&user_config) {
423 tracing::warn!(
424 "Failed to create default config at {}: {}. Using in-memory defaults.",
425 user_config.display(),
426 e
427 );
428 } else {
429 tracing::info!("Created default config at {}", user_config.display());
430 }
431 }
432
433 Ok(Self::default())
435 }
436
437 pub fn load_from(path: &Path) -> Result<Self> {
443 let content = std::fs::read_to_string(path).map_err(|e| {
444 if e.kind() == std::io::ErrorKind::NotFound {
445 Error::ConfigNotFound(path.to_path_buf())
446 } else {
447 Error::Io(e)
448 }
449 })?;
450
451 let config: Self = toml::from_str(&content)?;
452 config.validate()?;
453 Ok(config)
454 }
455
456 fn create_default_config_file(path: &Path) -> Result<()> {
464 if let Some(parent) = path.parent() {
465 std::fs::create_dir_all(parent)?;
466 }
467
468 let default_config = Self::default();
469 let toml_content = toml::to_string_pretty(&default_config)?;
470 std::fs::write(path, toml_content)?;
471
472 Ok(())
473 }
474
475 fn validate(&self) -> Result<()> {
486 let mut seen_names: HashMap<&str, &str> = HashMap::new();
487 for server in &self.lsp_servers {
488 if server.language_id.is_empty() {
489 return Err(Error::InvalidConfig(
490 "language_id cannot be empty".to_string(),
491 ));
492 }
493 if server.command.is_empty() {
494 return Err(Error::InvalidConfig(format!(
495 "command cannot be empty for language '{}'",
496 server.language_id
497 )));
498 }
499 if let Some(name) = &server.name {
500 if name.is_empty() {
501 return Err(Error::InvalidConfig(format!(
502 "name cannot be empty for language '{}' (omit `name` to default to \
503 the language id)",
504 server.language_id
505 )));
506 }
507 if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
508 tracing::warn!(
514 "duplicate explicit server name '{name}' in config (language ids: \
515 '{prev_language}', '{}'); this is only an error if both entries are \
516 applicable in the same workspace",
517 server.language_id
518 );
519 }
520 }
521 if let Some(handles) = &server.handles {
522 if handles.is_empty() {
523 return Err(Error::InvalidConfig(format!(
524 "handles cannot be empty for language '{}' (omit `handles` for a \
525 catch-all server)",
526 server.language_id
527 )));
528 }
529 let mut seen_tools = HashSet::new();
530 for tool in handles {
531 if !seen_tools.insert(*tool) {
532 return Err(Error::InvalidConfig(format!(
533 "duplicate tool '{tool}' in `handles` for language '{}'",
534 server.language_id
535 )));
536 }
537 }
538 }
539 }
540 Ok(())
541 }
542}
543
544impl Default for ServerConfig {
545 fn default() -> Self {
546 Self {
547 workspace: WorkspaceConfig::default(),
548 lsp_servers: vec![
549 LspServerConfig::rust_analyzer(),
550 LspServerConfig::pyright(),
551 LspServerConfig::typescript(),
552 LspServerConfig::gopls(),
553 LspServerConfig::clangd(),
554 LspServerConfig::zls(),
555 ],
556 }
557 }
558}
559
560#[cfg(test)]
561#[allow(clippy::unwrap_used)]
562mod tests {
563 use std::fs;
564
565 use tempfile::TempDir;
566
567 use super::*;
568
569 #[test]
570 fn test_default_config() {
571 let config = ServerConfig::default();
572 assert_eq!(config.lsp_servers.len(), 6);
573 assert_eq!(config.lsp_servers[0].language_id, "rust");
574 assert_eq!(config.lsp_servers[1].language_id, "python");
575 assert_eq!(config.lsp_servers[2].language_id, "typescript");
576 assert_eq!(config.lsp_servers[3].language_id, "go");
577 assert_eq!(config.lsp_servers[4].language_id, "cpp");
578 assert_eq!(config.lsp_servers[5].language_id, "zig");
579 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
580 }
581
582 #[test]
583 fn test_default_position_encodings() {
584 let encodings = default_position_encodings();
585 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
586 }
587
588 #[test]
589 fn test_load_from_valid_toml() {
590 let tmp_dir = TempDir::new().unwrap();
591 let config_path = tmp_dir.path().join("config.toml");
592
593 let toml_content = r#"
594 [workspace]
595 roots = ["/tmp/workspace"]
596 position_encodings = ["utf-8"]
597
598 [[lsp_servers]]
599 language_id = "rust"
600 command = "rust-analyzer"
601 timeout_seconds = 30
602 "#;
603
604 fs::write(&config_path, toml_content).unwrap();
605
606 let config = ServerConfig::load_from(&config_path).unwrap();
607 assert_eq!(
608 config.workspace.roots,
609 vec![PathBuf::from("/tmp/workspace")]
610 );
611 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
612 assert_eq!(config.lsp_servers.len(), 1);
613 assert_eq!(config.lsp_servers[0].language_id, "rust");
614 }
615
616 #[test]
617 fn test_load_from_nonexistent_file() {
618 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
619 assert!(result.is_err());
620
621 if let Err(Error::ConfigNotFound(path)) = result {
622 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
623 } else {
624 panic!("Expected ConfigNotFound error");
625 }
626 }
627
628 #[test]
629 fn test_load_from_invalid_toml() {
630 let tmp_dir = TempDir::new().unwrap();
631 let config_path = tmp_dir.path().join("invalid.toml");
632
633 fs::write(&config_path, "invalid toml content {{}").unwrap();
634
635 let result = ServerConfig::load_from(&config_path);
636 assert!(result.is_err());
637 }
638
639 #[test]
640 fn test_validate_empty_language_id() {
641 let tmp_dir = TempDir::new().unwrap();
642 let config_path = tmp_dir.path().join("config.toml");
643
644 let toml_content = r#"
645 [[lsp_servers]]
646 language_id = ""
647 command = "test"
648 "#;
649
650 fs::write(&config_path, toml_content).unwrap();
651
652 let result = ServerConfig::load_from(&config_path);
653 assert!(result.is_err());
654
655 if let Err(Error::InvalidConfig(msg)) = result {
656 assert!(msg.contains("language_id cannot be empty"));
657 } else {
658 panic!("Expected InvalidConfig error");
659 }
660 }
661
662 #[test]
663 fn test_validate_empty_command() {
664 let tmp_dir = TempDir::new().unwrap();
665 let config_path = tmp_dir.path().join("config.toml");
666
667 let toml_content = r#"
668 [[lsp_servers]]
669 language_id = "rust"
670 command = ""
671 "#;
672
673 fs::write(&config_path, toml_content).unwrap();
674
675 let result = ServerConfig::load_from(&config_path);
676 assert!(result.is_err());
677
678 if let Err(Error::InvalidConfig(msg)) = result {
679 assert!(msg.contains("command cannot be empty"));
680 } else {
681 panic!("Expected InvalidConfig error");
682 }
683 }
684
685 #[test]
686 fn test_validate_empty_name() {
687 let tmp_dir = TempDir::new().unwrap();
688 let config_path = tmp_dir.path().join("config.toml");
689
690 let toml_content = r#"
691 [[lsp_servers]]
692 name = ""
693 language_id = "python"
694 command = "pyright-langserver"
695 "#;
696
697 fs::write(&config_path, toml_content).unwrap();
698
699 let result = ServerConfig::load_from(&config_path);
700 assert!(result.is_err());
701
702 if let Err(Error::InvalidConfig(msg)) = result {
703 assert!(msg.contains("name cannot be empty"));
704 } else {
705 panic!("Expected InvalidConfig error");
706 }
707 }
708
709 #[test]
710 fn test_validate_empty_handles() {
711 let tmp_dir = TempDir::new().unwrap();
712 let config_path = tmp_dir.path().join("config.toml");
713
714 let toml_content = r#"
715 [[lsp_servers]]
716 language_id = "python"
717 command = "pylsp"
718 handles = []
719 "#;
720
721 fs::write(&config_path, toml_content).unwrap();
722
723 let result = ServerConfig::load_from(&config_path);
724 assert!(result.is_err());
725
726 if let Err(Error::InvalidConfig(msg)) = result {
727 assert!(msg.contains("handles cannot be empty"));
728 } else {
729 panic!("Expected InvalidConfig error");
730 }
731 }
732
733 #[test]
734 fn test_validate_duplicate_tool_in_handles() {
735 let tmp_dir = TempDir::new().unwrap();
736 let config_path = tmp_dir.path().join("config.toml");
737
738 let toml_content = r#"
739 [[lsp_servers]]
740 language_id = "python"
741 command = "pylsp"
742 handles = ["diagnostics", "diagnostics"]
743 "#;
744
745 fs::write(&config_path, toml_content).unwrap();
746
747 let result = ServerConfig::load_from(&config_path);
748 assert!(result.is_err());
749
750 if let Err(Error::InvalidConfig(msg)) = result {
751 assert!(msg.contains("duplicate tool"));
752 assert!(msg.contains("diagnostics"));
753 } else {
754 panic!("Expected InvalidConfig error");
755 }
756 }
757
758 #[test]
759 fn test_validate_duplicate_name_warns_but_loads() {
760 let tmp_dir = TempDir::new().unwrap();
765 let config_path = tmp_dir.path().join("config.toml");
766
767 let toml_content = r#"
768 [[lsp_servers]]
769 name = "dup"
770 language_id = "python"
771 command = "pyright-langserver"
772
773 [[lsp_servers]]
774 name = "dup"
775 language_id = "typescript"
776 command = "typescript-language-server"
777 "#;
778
779 fs::write(&config_path, toml_content).unwrap();
780
781 let result = ServerConfig::load_from(&config_path);
782 assert!(result.is_ok(), "duplicate name must only warn at load time");
783 }
784
785 #[test]
786 fn test_workspace_config_defaults() {
787 let workspace = WorkspaceConfig::default();
788 assert!(workspace.roots.is_empty());
789 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
790 assert!(!workspace.language_extensions.is_empty());
791 assert_eq!(workspace.language_extensions.len(), 30);
792 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
793 }
794
795 #[test]
796 fn test_load_multiple_servers() {
797 let tmp_dir = TempDir::new().unwrap();
798 let config_path = tmp_dir.path().join("multi.toml");
799
800 let toml_content = r#"
801 [[lsp_servers]]
802 language_id = "rust"
803 command = "rust-analyzer"
804
805 [[lsp_servers]]
806 language_id = "python"
807 command = "pyright-langserver"
808 args = ["--stdio"]
809 "#;
810
811 fs::write(&config_path, toml_content).unwrap();
812
813 let config = ServerConfig::load_from(&config_path).unwrap();
814 assert_eq!(config.lsp_servers.len(), 2);
815 assert_eq!(config.lsp_servers[0].language_id, "rust");
816 assert_eq!(config.lsp_servers[1].language_id, "python");
817 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
818 }
819
820 #[test]
821 fn test_deny_unknown_fields() {
822 let tmp_dir = TempDir::new().unwrap();
823 let config_path = tmp_dir.path().join("unknown.toml");
824
825 let toml_content = r#"
826 unknown_field = "value"
827
828 [workspace]
829 roots = []
830 "#;
831
832 fs::write(&config_path, toml_content).unwrap();
833
834 let result = ServerConfig::load_from(&config_path);
835 assert!(result.is_err(), "Should reject unknown fields");
836 }
837
838 #[test]
839 fn test_empty_config_file() {
840 let tmp_dir = TempDir::new().unwrap();
841 let config_path = tmp_dir.path().join("empty.toml");
842
843 fs::write(&config_path, "").unwrap();
844
845 let config = ServerConfig::load_from(&config_path).unwrap();
846 assert!(config.workspace.roots.is_empty());
847 assert!(config.lsp_servers.is_empty());
848 }
849
850 #[test]
851 fn test_config_with_initialization_options() {
852 let tmp_dir = TempDir::new().unwrap();
853 let config_path = tmp_dir.path().join("init_opts.toml");
854
855 let toml_content = r#"
856 [[lsp_servers]]
857 language_id = "rust"
858 command = "rust-analyzer"
859
860 [lsp_servers.initialization_options]
861 cargo = { allFeatures = true }
862 "#;
863
864 fs::write(&config_path, toml_content).unwrap();
865
866 let config = ServerConfig::load_from(&config_path).unwrap();
867 assert!(config.lsp_servers[0].initialization_options.is_some());
868 }
869
870 #[test]
871 fn test_language_extensions_in_config() {
872 let tmp_dir = TempDir::new().unwrap();
873 let config_path = tmp_dir.path().join("extensions.toml");
874
875 let toml_content = r#"
876 [[workspace.language_extensions]]
877 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
878 language_id = "cpp"
879
880 [[workspace.language_extensions]]
881 extensions = ["nu"]
882 language_id = "nushell"
883
884 [[workspace.language_extensions]]
885 extensions = ["py", "pyw", "pyi"]
886 language_id = "python"
887 "#;
888
889 fs::write(&config_path, toml_content).unwrap();
890
891 let config = ServerConfig::load_from(&config_path).unwrap();
892 assert_eq!(config.workspace.language_extensions.len(), 3);
893
894 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
896 assert_eq!(
897 config.workspace.language_extensions[0].extensions,
898 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
899 );
900
901 assert_eq!(
903 config.workspace.language_extensions[1].language_id,
904 "nushell"
905 );
906 assert_eq!(
907 config.workspace.language_extensions[1].extensions,
908 vec!["nu"]
909 );
910 }
911
912 #[test]
913 fn test_build_extension_map() {
914 let workspace = WorkspaceConfig {
915 roots: vec![],
916 position_encodings: vec![],
917 language_extensions: vec![
918 LanguageExtensionMapping {
919 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
920 language_id: "cpp".to_string(),
921 },
922 LanguageExtensionMapping {
923 extensions: vec!["nu".to_string()],
924 language_id: "nushell".to_string(),
925 },
926 ],
927 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
928 };
929
930 let map = workspace.build_extension_map();
931 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
932 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
933 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
934 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
935 assert_eq!(map.get("unknown"), None);
936 }
937
938 #[test]
939 fn test_extract_extension_from_pattern_empty_string() {
940 assert_eq!(extract_extension_from_pattern(""), None);
941 }
942
943 #[test]
944 fn test_extract_extension_from_pattern_without_dot() {
945 assert_eq!(extract_extension_from_pattern("**/*"), None);
946 }
947
948 #[test]
949 fn test_extract_extension_from_pattern_dotfile() {
950 assert_eq!(extract_extension_from_pattern(".gitignore"), None);
951 }
952
953 #[test]
954 fn test_extract_extension_from_pattern_multi_dot_extension() {
955 assert_eq!(
956 extract_extension_from_pattern("foo.tar.gz"),
957 Some("gz".to_string())
958 );
959 }
960
961 #[test]
962 fn test_build_effective_extension_map_overrides_with_file_patterns() {
963 let config = ServerConfig {
964 workspace: WorkspaceConfig::default(),
965 lsp_servers: vec![LspServerConfig {
966 language_id: "cpp".to_string(),
967 command: "clangd".to_string(),
968 args: vec![],
969 env: HashMap::new(),
970 file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
971 initialization_options: None,
972 timeout_seconds: 30,
973 heuristics: None,
974 name: None,
975 handles: None,
976 }],
977 };
978
979 let map = config.build_effective_extension_map();
980 assert_eq!(map.get("c"), Some(&"cpp".to_string()));
981 assert_eq!(map.get("h"), Some(&"cpp".to_string()));
982 }
983
984 #[test]
985 fn test_build_effective_extension_map_derives_tsx_language_id() {
986 let config = ServerConfig {
987 workspace: WorkspaceConfig::default(),
988 lsp_servers: vec![LspServerConfig {
989 language_id: "typescript".to_string(),
990 command: "tsgo".to_string(),
991 args: vec!["--lsp".to_string(), "--stdio".to_string()],
992 env: HashMap::new(),
993 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
994 initialization_options: None,
995 timeout_seconds: 30,
996 heuristics: None,
997 name: None,
998 handles: None,
999 }],
1000 };
1001
1002 let map = config.build_effective_extension_map();
1003 assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1004 assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1005 }
1006
1007 #[test]
1008 fn test_build_effective_extension_map_derives_jsx_language_id() {
1009 let config = ServerConfig {
1010 workspace: WorkspaceConfig::default(),
1011 lsp_servers: vec![LspServerConfig {
1012 language_id: "javascript".to_string(),
1013 command: "typescript-language-server".to_string(),
1014 args: vec!["--stdio".to_string()],
1015 env: HashMap::new(),
1016 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1017 initialization_options: None,
1018 timeout_seconds: 30,
1019 heuristics: None,
1020 name: None,
1021 handles: None,
1022 }],
1023 };
1024
1025 let map = config.build_effective_extension_map();
1026 assert_eq!(map.get("js"), Some(&"javascript".to_string()));
1027 assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
1028 }
1029
1030 #[test]
1031 fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
1032 let config = ServerConfig {
1033 workspace: WorkspaceConfig::default(),
1034 lsp_servers: vec![LspServerConfig {
1035 language_id: "cpp".to_string(),
1036 command: "clangd".to_string(),
1037 args: vec![],
1038 env: HashMap::new(),
1039 file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
1040 initialization_options: None,
1041 timeout_seconds: 30,
1042 heuristics: None,
1043 name: None,
1044 handles: None,
1045 }],
1046 };
1047
1048 let map = config.build_effective_extension_map();
1049 assert_eq!(map.get("h"), Some(&"c".to_string()));
1051 }
1052
1053 #[test]
1054 fn test_get_language_for_extension() {
1055 let workspace = WorkspaceConfig {
1056 roots: vec![],
1057 position_encodings: vec![],
1058 language_extensions: vec![
1059 LanguageExtensionMapping {
1060 extensions: vec!["hpp".to_string(), "hh".to_string()],
1061 language_id: "cpp".to_string(),
1062 },
1063 LanguageExtensionMapping {
1064 extensions: vec!["py".to_string()],
1065 language_id: "python".to_string(),
1066 },
1067 ],
1068 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1069 };
1070
1071 assert_eq!(
1072 workspace.get_language_for_extension("hpp"),
1073 Some("cpp".to_string())
1074 );
1075 assert_eq!(
1076 workspace.get_language_for_extension("hh"),
1077 Some("cpp".to_string())
1078 );
1079 assert_eq!(
1080 workspace.get_language_for_extension("py"),
1081 Some("python".to_string())
1082 );
1083 assert_eq!(workspace.get_language_for_extension("unknown"), None);
1084 }
1085
1086 #[test]
1087 fn test_default_language_extensions() {
1088 let workspace = WorkspaceConfig::default();
1089 let map = workspace.build_extension_map();
1090 assert!(!map.is_empty());
1091 assert_eq!(
1092 workspace.get_language_for_extension("rs"),
1093 Some("rust".to_string())
1094 );
1095 assert_eq!(
1096 workspace.get_language_for_extension("py"),
1097 Some("python".to_string())
1098 );
1099 assert_eq!(
1100 workspace.get_language_for_extension("cpp"),
1101 Some("cpp".to_string())
1102 );
1103 }
1104
1105 #[test]
1106 fn test_create_default_config_file() {
1107 let tmp_dir = TempDir::new().unwrap();
1108 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
1109
1110 ServerConfig::create_default_config_file(&config_path).unwrap();
1111
1112 assert!(config_path.exists());
1113
1114 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
1115 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
1116 assert_eq!(loaded_config.lsp_servers.len(), 6);
1117 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
1118 }
1119
1120 #[test]
1121 fn test_load_returns_default_config() {
1122 let config = ServerConfig::default();
1124 assert_eq!(config.workspace.language_extensions.len(), 30);
1125 assert_eq!(config.lsp_servers.len(), 6);
1126 assert_eq!(config.lsp_servers[0].language_id, "rust");
1127 }
1128
1129 static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1135
1136 #[test]
1137 fn test_load_ignores_untrusted_project_local_config() {
1138 let _guard = CWD_LOCK.lock().unwrap();
1146 let original_dir = std::env::current_dir().unwrap();
1147
1148 let tmp_dir = TempDir::new().unwrap();
1149 let config_path = tmp_dir.path().join("mcpls.toml");
1150
1151 let custom_toml = r#"
1157 [workspace]
1158 roots = ["/should-never-load-attacker-path"]
1159
1160 [[lsp_servers]]
1161 language_id = "definitely-not-a-real-language-marker"
1162 command = "rm"
1163 args = ["-rf", "/"]
1164 "#;
1165
1166 fs::write(&config_path, custom_toml).unwrap();
1167
1168 std::env::set_current_dir(tmp_dir.path()).unwrap();
1169 let config = ServerConfig::load().unwrap();
1170 std::env::set_current_dir(&original_dir).unwrap();
1171
1172 assert!(
1173 !config
1174 .workspace
1175 .roots
1176 .contains(&PathBuf::from("/should-never-load-attacker-path"))
1177 );
1178 assert!(
1179 !config
1180 .lsp_servers
1181 .iter()
1182 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
1183 );
1184 }
1185
1186 #[test]
1187 fn test_load_with_trust_loads_trusted_project_local_config() {
1188 let _guard = CWD_LOCK.lock().unwrap();
1189 let original_dir = std::env::current_dir().unwrap();
1190
1191 let tmp_dir = TempDir::new().unwrap();
1192 let config_path = tmp_dir.path().join("mcpls.toml");
1193
1194 let custom_toml = r#"
1195 [workspace]
1196 roots = ["/custom/path"]
1197
1198 [[lsp_servers]]
1199 language_id = "python"
1200 command = "pyright-langserver"
1201 "#;
1202
1203 fs::write(&config_path, custom_toml).unwrap();
1204
1205 std::env::set_current_dir(tmp_dir.path()).unwrap();
1206 let config = ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap();
1207 std::env::set_current_dir(&original_dir).unwrap();
1208
1209 assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
1210 assert_eq!(config.lsp_servers.len(), 1);
1211 assert_eq!(config.lsp_servers[0].language_id, "python");
1212 }
1213
1214 #[test]
1215 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
1216 let _guard = CWD_LOCK.lock().unwrap();
1217 let original_dir = std::env::current_dir().unwrap();
1218
1219 let tmp_dir = TempDir::new().unwrap();
1220 let config_path = tmp_dir.path().join("mcpls.toml");
1221
1222 let custom_toml = r#"
1223 [workspace]
1224 roots = ["/attacker/controlled"]
1225 heuristics_max_depth = 999999
1226
1227 [[lsp_servers]]
1228 language_id = "evil"
1229 command = "rm"
1230 args = ["-rf", "/"]
1231 "#;
1232
1233 fs::write(&config_path, custom_toml).unwrap();
1234
1235 std::env::set_current_dir(tmp_dir.path()).unwrap();
1236 let config = ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap();
1237 std::env::set_current_dir(&original_dir).unwrap();
1238
1239 assert!(
1240 !config
1241 .workspace
1242 .roots
1243 .contains(&PathBuf::from("/attacker/controlled"))
1244 );
1245 assert_ne!(config.workspace.heuristics_max_depth, 999_999);
1246 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
1247 }
1248
1249 #[test]
1250 fn test_config_file_creation_with_proper_structure() {
1251 let tmp_dir = TempDir::new().unwrap();
1252 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
1253
1254 ServerConfig::create_default_config_file(&config_path).unwrap();
1255
1256 let content = fs::read_to_string(&config_path).unwrap();
1257
1258 assert!(content.contains("[workspace]"));
1259 assert!(content.contains("[[workspace.language_extensions]]"));
1260 assert!(content.contains("[[lsp_servers]]"));
1261 assert!(content.contains("language_id = \"rust\""));
1262 assert!(content.contains("extensions = [\"rs\"]"));
1263 }
1264
1265 #[test]
1266 fn test_heuristics_max_depth_default() {
1267 let config = WorkspaceConfig::default();
1268 assert_eq!(config.heuristics_max_depth, 10);
1269 }
1270
1271 #[test]
1272 fn test_heuristics_max_depth_from_config() {
1273 let tmp_dir = TempDir::new().unwrap();
1274 let config_path = tmp_dir.path().join("depth.toml");
1275
1276 let toml_content = r"
1277 [workspace]
1278 heuristics_max_depth = 5
1279 ";
1280
1281 fs::write(&config_path, toml_content).unwrap();
1282
1283 let config = ServerConfig::load_from(&config_path).unwrap();
1284 assert_eq!(config.workspace.heuristics_max_depth, 5);
1285 }
1286
1287 #[test]
1288 fn test_heuristics_max_depth_uses_default_when_not_specified() {
1289 let tmp_dir = TempDir::new().unwrap();
1290 let config_path = tmp_dir.path().join("no_depth.toml");
1291
1292 let toml_content = r"
1293 [workspace]
1294 roots = []
1295 ";
1296
1297 fs::write(&config_path, toml_content).unwrap();
1298
1299 let config = ServerConfig::load_from(&config_path).unwrap();
1300 assert_eq!(
1301 config.workspace.heuristics_max_depth,
1302 DEFAULT_HEURISTICS_MAX_DEPTH
1303 );
1304 }
1305}