1mod language;
7mod routing;
8mod server;
9
10use std::collections::{HashMap, HashSet};
11use std::io::Read;
12use std::path::{Path, PathBuf};
13
14pub use language::{base_language_id, react_variant_language_id};
15pub use routing::{NoServerReason, ServerId, ToolKind, ToolRouter};
16use serde::{Deserialize, Serialize};
17pub use server::{
18 DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, MAX_TIMEOUT_SECONDS, ServerHeuristics,
19};
20
21use crate::bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE, ResourceLimits};
22use crate::error::{Error, Result};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct LanguageExtensionMapping {
30 pub extensions: Vec<String>,
32 pub language_id: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ServerConfig {
40 #[serde(default)]
42 pub workspace: WorkspaceConfig,
43
44 #[serde(default)]
46 pub lsp_servers: Vec<LspServerConfig>,
47
48 #[serde(skip)]
58 pub project_config_ignored: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct WorkspaceConfig {
65 #[serde(default)]
67 pub roots: Vec<PathBuf>,
68
69 #[serde(default = "default_position_encodings")]
78 pub position_encodings: Vec<String>,
79
80 #[serde(default)]
83 pub language_extensions: Vec<LanguageExtensionMapping>,
84
85 #[serde(default = "default_heuristics_max_depth")]
89 pub heuristics_max_depth: usize,
90
91 #[serde(default = "default_max_documents")]
101 pub max_documents: usize,
102
103 #[serde(default = "default_max_file_size")]
108 pub max_file_size: u64,
109}
110
111impl Default for WorkspaceConfig {
112 fn default() -> Self {
113 Self {
114 roots: Vec::new(),
115 position_encodings: default_position_encodings(),
116 language_extensions: default_language_extensions(),
117 heuristics_max_depth: default_heuristics_max_depth(),
118 max_documents: default_max_documents(),
119 max_file_size: default_max_file_size(),
120 }
121 }
122}
123
124const fn default_heuristics_max_depth() -> usize {
125 DEFAULT_HEURISTICS_MAX_DEPTH
126}
127
128const fn default_max_documents() -> usize {
129 DEFAULT_MAX_DOCUMENTS
130}
131
132const fn default_max_file_size() -> u64 {
133 DEFAULT_MAX_FILE_SIZE
134}
135
136impl WorkspaceConfig {
137 #[must_use]
144 pub fn build_extension_map(&self) -> HashMap<String, String> {
145 let mut map = HashMap::new();
146 for mapping in &self.language_extensions {
147 for ext in &mapping.extensions {
148 map.insert(ext.clone(), mapping.language_id.clone());
149 }
150 }
151 map
152 }
153
154 #[must_use]
164 pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
165 for mapping in &self.language_extensions {
166 if mapping.extensions.contains(&extension.to_string()) {
167 return Some(mapping.language_id.clone());
168 }
169 }
170 None
171 }
172
173 #[must_use]
176 pub const fn resource_limits(&self) -> ResourceLimits {
177 ResourceLimits {
178 max_documents: self.max_documents,
179 max_file_size: self.max_file_size,
180 }
181 }
182}
183
184fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
189 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
190 if basename.starts_with('.') {
191 return None;
192 }
193
194 let (_, ext) = basename.rsplit_once('.')?;
195 if ext.is_empty() {
196 return None;
197 }
198
199 if ext
201 .chars()
202 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
203 {
204 Some(ext.to_string())
205 } else {
206 None
207 }
208}
209
210fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
211 react_variant_language_id(server_language_id, extension)
212 .unwrap_or(server_language_id)
213 .to_string()
214}
215
216pub(crate) fn default_position_encodings() -> Vec<String> {
233 vec!["utf-8".to_string(), "utf-16".to_string()]
234}
235
236pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
246 match value {
247 "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
248 "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
249 "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
250 _ => None,
251 }
252}
253
254#[allow(clippy::too_many_lines)]
259fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
260 vec![
261 LanguageExtensionMapping {
262 extensions: vec!["rs".to_string()],
263 language_id: "rust".to_string(),
264 },
265 LanguageExtensionMapping {
266 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
267 language_id: "python".to_string(),
268 },
269 LanguageExtensionMapping {
270 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
271 language_id: "javascript".to_string(),
272 },
273 LanguageExtensionMapping {
274 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
275 language_id: "typescript".to_string(),
276 },
277 LanguageExtensionMapping {
278 extensions: vec!["tsx".to_string()],
279 language_id: "typescriptreact".to_string(),
280 },
281 LanguageExtensionMapping {
282 extensions: vec!["jsx".to_string()],
283 language_id: "javascriptreact".to_string(),
284 },
285 LanguageExtensionMapping {
286 extensions: vec!["go".to_string()],
287 language_id: "go".to_string(),
288 },
289 LanguageExtensionMapping {
290 extensions: vec!["c".to_string(), "h".to_string()],
291 language_id: "c".to_string(),
292 },
293 LanguageExtensionMapping {
294 extensions: vec![
295 "cpp".to_string(),
296 "cc".to_string(),
297 "cxx".to_string(),
298 "hpp".to_string(),
299 "hh".to_string(),
300 "hxx".to_string(),
301 ],
302 language_id: "cpp".to_string(),
303 },
304 LanguageExtensionMapping {
305 extensions: vec!["java".to_string()],
306 language_id: "java".to_string(),
307 },
308 LanguageExtensionMapping {
309 extensions: vec!["rb".to_string()],
310 language_id: "ruby".to_string(),
311 },
312 LanguageExtensionMapping {
313 extensions: vec!["php".to_string()],
314 language_id: "php".to_string(),
315 },
316 LanguageExtensionMapping {
317 extensions: vec!["swift".to_string()],
318 language_id: "swift".to_string(),
319 },
320 LanguageExtensionMapping {
321 extensions: vec!["kt".to_string(), "kts".to_string()],
322 language_id: "kotlin".to_string(),
323 },
324 LanguageExtensionMapping {
325 extensions: vec!["scala".to_string(), "sc".to_string()],
326 language_id: "scala".to_string(),
327 },
328 LanguageExtensionMapping {
329 extensions: vec!["zig".to_string()],
330 language_id: "zig".to_string(),
331 },
332 LanguageExtensionMapping {
333 extensions: vec!["lua".to_string()],
334 language_id: "lua".to_string(),
335 },
336 LanguageExtensionMapping {
337 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
338 language_id: "shellscript".to_string(),
339 },
340 LanguageExtensionMapping {
341 extensions: vec!["json".to_string()],
342 language_id: "json".to_string(),
343 },
344 LanguageExtensionMapping {
345 extensions: vec!["toml".to_string()],
346 language_id: "toml".to_string(),
347 },
348 LanguageExtensionMapping {
349 extensions: vec!["yaml".to_string(), "yml".to_string()],
350 language_id: "yaml".to_string(),
351 },
352 LanguageExtensionMapping {
353 extensions: vec!["xml".to_string()],
354 language_id: "xml".to_string(),
355 },
356 LanguageExtensionMapping {
357 extensions: vec!["html".to_string(), "htm".to_string()],
358 language_id: "html".to_string(),
359 },
360 LanguageExtensionMapping {
361 extensions: vec!["css".to_string()],
362 language_id: "css".to_string(),
363 },
364 LanguageExtensionMapping {
365 extensions: vec!["scss".to_string()],
366 language_id: "scss".to_string(),
367 },
368 LanguageExtensionMapping {
369 extensions: vec!["less".to_string()],
370 language_id: "less".to_string(),
371 },
372 LanguageExtensionMapping {
373 extensions: vec!["md".to_string(), "markdown".to_string()],
374 language_id: "markdown".to_string(),
375 },
376 LanguageExtensionMapping {
377 extensions: vec!["cs".to_string()],
378 language_id: "csharp".to_string(),
379 },
380 LanguageExtensionMapping {
381 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
382 language_id: "fsharp".to_string(),
383 },
384 LanguageExtensionMapping {
385 extensions: vec!["r".to_string(), "R".to_string()],
386 language_id: "r".to_string(),
387 },
388 ]
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ProjectConfigTrust {
407 Untrusted,
410 Trusted,
412}
413
414const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
437
438impl ServerConfig {
439 #[must_use]
444 pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
445 let mut map = self.workspace.build_extension_map();
446
447 for server in &self.lsp_servers {
448 for pattern in &server.file_patterns {
449 if let Some(ext) = extract_extension_from_pattern(pattern) {
450 let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
451 map.insert(ext, language_id);
452 }
453 }
454 }
455
456 map
457 }
458
459 pub fn load() -> Result<Self> {
484 Self::load_with_trust(ProjectConfigTrust::Untrusted)
485 }
486
487 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
510 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
518 return Self::load_from(Path::new(&path));
519 }
520
521 let mut project_config_ignored = false;
522
523 let local_config = PathBuf::from("mcpls.toml");
524 if local_config.exists() {
525 match trust {
526 ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
527 ProjectConfigTrust::Untrusted => {
528 project_config_ignored = true;
529 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
530 std::env::current_dir()
531 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
532 });
533 tracing::warn!(
534 "ignoring untrusted project-local config at {}; pass \
535 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
536 load it",
537 display_path.display()
538 );
539 }
540 }
541 }
542
543 if let Some(config_dir) = dirs::config_dir() {
544 let user_config = config_dir.join("mcpls").join("mcpls.toml");
545 if user_config.exists() {
546 let mut config = Self::load_from(&user_config)?;
547 config.project_config_ignored = project_config_ignored;
548 return Ok(config);
549 }
550
551 if let Err(e) = Self::create_default_config_file(&user_config) {
553 tracing::warn!(
554 "Failed to create default config at {}: {}. Using in-memory defaults.",
555 user_config.display(),
556 e
557 );
558 } else {
559 tracing::info!("Created default config at {}", user_config.display());
560 }
561 }
562
563 Ok(Self {
565 project_config_ignored,
566 ..Self::default()
567 })
568 }
569
570 pub fn load_from(path: &Path) -> Result<Self> {
577 let file = std::fs::File::open(path).map_err(|e| {
578 if e.kind() == std::io::ErrorKind::NotFound {
579 Error::ConfigNotFound(path.to_path_buf())
580 } else {
581 Error::Io(e)
582 }
583 })?;
584
585 let mut buf = Vec::new();
589 file.take(MAX_CONFIG_FILE_BYTES + 1)
590 .read_to_end(&mut buf)
591 .map_err(Error::Io)?;
592 if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
593 return Err(Error::FileSizeLimitExceeded {
594 size: buf.len() as u64,
595 max: MAX_CONFIG_FILE_BYTES,
596 });
597 }
598 let content = String::from_utf8(buf)
599 .map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
600
601 let config: Self = toml::from_str(&content)?;
602 config.validate()?;
603 Ok(config)
604 }
605
606 fn create_default_config_file(path: &Path) -> Result<()> {
614 if let Some(parent) = path.parent() {
615 std::fs::create_dir_all(parent)?;
616 }
617
618 let default_config = Self::default();
619 let toml_content = toml::to_string_pretty(&default_config)?;
620 std::fs::write(path, toml_content)?;
621
622 Ok(())
623 }
624
625 pub fn validate(&self) -> Result<()> {
659 if self.workspace.position_encodings.is_empty() {
660 return Err(Error::InvalidConfig(
661 "workspace.position_encodings cannot be empty".to_string(),
662 ));
663 }
664 for encoding in &self.workspace.position_encodings {
665 if parse_position_encoding(encoding).is_none() {
666 return Err(Error::InvalidConfig(format!(
667 "invalid workspace.position_encodings value '{encoding}'; expected one of \
668 \"utf-8\", \"utf-16\", \"utf-32\""
669 )));
670 }
671 }
672
673 let mut seen_names: HashMap<&str, &str> = HashMap::new();
674 for server in &self.lsp_servers {
675 if server.language_id.is_empty() {
676 return Err(Error::InvalidConfig(
677 "language_id cannot be empty".to_string(),
678 ));
679 }
680 if server.command.is_empty() {
681 return Err(Error::InvalidConfig(format!(
682 "command cannot be empty for language '{}'",
683 server.language_id
684 )));
685 }
686 if server.timeout_seconds == 0 {
687 return Err(Error::InvalidConfig(format!(
688 "timeout_seconds cannot be 0 for language '{}'",
689 server.language_id
690 )));
691 }
692 if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
693 return Err(Error::InvalidConfig(format!(
694 "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
695 server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
696 )));
697 }
698 if server.request_timeout_seconds == 0 {
699 return Err(Error::InvalidConfig(format!(
700 "request_timeout_seconds cannot be 0 for language '{}'",
701 server.language_id
702 )));
703 }
704 if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
705 return Err(Error::InvalidConfig(format!(
706 "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
707 language '{}'",
708 server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
709 )));
710 }
711 if let Some(name) = &server.name {
712 if name.is_empty() {
713 return Err(Error::InvalidConfig(format!(
714 "name cannot be empty for language '{}' (omit `name` to default to \
715 the language id)",
716 server.language_id
717 )));
718 }
719 if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
720 tracing::warn!(
726 "duplicate explicit server name '{name}' in config (language ids: \
727 '{prev_language}', '{}'); this is only an error if both entries are \
728 applicable in the same workspace",
729 server.language_id
730 );
731 }
732 }
733 if let Some(handles) = &server.handles {
734 if handles.is_empty() {
735 return Err(Error::InvalidConfig(format!(
736 "handles cannot be empty for language '{}' (omit `handles` for a \
737 catch-all server)",
738 server.language_id
739 )));
740 }
741 let mut seen_tools = HashSet::new();
742 for tool in handles {
743 if !seen_tools.insert(*tool) {
744 return Err(Error::InvalidConfig(format!(
745 "duplicate tool '{tool}' in `handles` for language '{}'",
746 server.language_id
747 )));
748 }
749 }
750 }
751 }
752 Ok(())
753 }
754}
755
756impl Default for ServerConfig {
757 fn default() -> Self {
758 Self {
759 workspace: WorkspaceConfig::default(),
760 lsp_servers: vec![
761 LspServerConfig::rust_analyzer(),
762 LspServerConfig::pyright(),
763 LspServerConfig::typescript(),
764 LspServerConfig::gopls(),
765 LspServerConfig::clangd(),
766 LspServerConfig::zls(),
767 ],
768 project_config_ignored: false,
769 }
770 }
771}
772
773#[cfg(test)]
774#[allow(clippy::unwrap_used)]
775mod tests {
776 use std::fs;
777
778 use tempfile::TempDir;
779
780 use super::*;
781
782 #[test]
783 fn test_default_config() {
784 let config = ServerConfig::default();
785 assert_eq!(config.lsp_servers.len(), 6);
786 assert_eq!(config.lsp_servers[0].language_id, "rust");
787 assert_eq!(config.lsp_servers[1].language_id, "python");
788 assert_eq!(config.lsp_servers[2].language_id, "typescript");
789 assert_eq!(config.lsp_servers[3].language_id, "go");
790 assert_eq!(config.lsp_servers[4].language_id, "cpp");
791 assert_eq!(config.lsp_servers[5].language_id, "zig");
792 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
793 }
794
795 #[test]
796 fn test_default_position_encodings() {
797 let encodings = default_position_encodings();
798 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
799 }
800
801 #[test]
802 fn test_load_from_valid_toml() {
803 let tmp_dir = TempDir::new().unwrap();
804 let config_path = tmp_dir.path().join("config.toml");
805
806 let toml_content = r#"
807 [workspace]
808 roots = ["/tmp/workspace"]
809 position_encodings = ["utf-8"]
810
811 [[lsp_servers]]
812 language_id = "rust"
813 command = "rust-analyzer"
814 timeout_seconds = 30
815 "#;
816
817 fs::write(&config_path, toml_content).unwrap();
818
819 let config = ServerConfig::load_from(&config_path).unwrap();
820 assert_eq!(
821 config.workspace.roots,
822 vec![PathBuf::from("/tmp/workspace")]
823 );
824 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
825 assert_eq!(config.lsp_servers.len(), 1);
826 assert_eq!(config.lsp_servers[0].language_id, "rust");
827 }
828
829 #[test]
830 fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
831 let tmp_dir = TempDir::new().unwrap();
834 let config_path = tmp_dir.path().join("config.toml");
835
836 let toml_content = r#"
837 [[lsp_servers]]
838 language_id = "rust"
839 command = "rust-analyzer"
840 timeout_seconds = 30
841 "#;
842
843 fs::write(&config_path, toml_content).unwrap();
844
845 let config = ServerConfig::load_from(&config_path).unwrap();
846 assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
847 }
848
849 #[test]
850 fn test_validate_rejects_zero_timeout_seconds() {
851 let tmp_dir = TempDir::new().unwrap();
852 let config_path = tmp_dir.path().join("config.toml");
853
854 let toml_content = r#"
855 [[lsp_servers]]
856 language_id = "rust"
857 command = "rust-analyzer"
858 timeout_seconds = 0
859 "#;
860
861 fs::write(&config_path, toml_content).unwrap();
862
863 let result = ServerConfig::load_from(&config_path);
864 if let Err(Error::InvalidConfig(msg)) = result {
865 assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
870 } else {
871 panic!("Expected InvalidConfig error, got {result:?}");
872 }
873 }
874
875 #[test]
876 fn test_validate_rejects_zero_request_timeout_seconds() {
877 let tmp_dir = TempDir::new().unwrap();
878 let config_path = tmp_dir.path().join("config.toml");
879
880 let toml_content = r#"
881 [[lsp_servers]]
882 language_id = "rust"
883 command = "rust-analyzer"
884 request_timeout_seconds = 0
885 "#;
886
887 fs::write(&config_path, toml_content).unwrap();
888
889 let result = ServerConfig::load_from(&config_path);
890 if let Err(Error::InvalidConfig(msg)) = result {
891 assert_eq!(
892 msg,
893 "request_timeout_seconds cannot be 0 for language 'rust'"
894 );
895 } else {
896 panic!("Expected InvalidConfig error, got {result:?}");
897 }
898 }
899
900 #[test]
901 fn test_validate_rejects_request_timeout_seconds_above_max() {
902 let tmp_dir = TempDir::new().unwrap();
903 let config_path = tmp_dir.path().join("config.toml");
904
905 let toml_content = format!(
906 r#"
907 [[lsp_servers]]
908 language_id = "rust"
909 command = "rust-analyzer"
910 request_timeout_seconds = {}
911 "#,
912 MAX_TIMEOUT_SECONDS + 1
913 );
914
915 fs::write(&config_path, toml_content).unwrap();
916
917 let result = ServerConfig::load_from(&config_path);
918 if let Err(Error::InvalidConfig(msg)) = result {
919 assert!(msg.contains("request_timeout_seconds"));
920 assert!(msg.contains("exceeds the maximum"));
921 } else {
922 panic!("Expected InvalidConfig error, got {result:?}");
923 }
924 }
925
926 #[test]
927 fn test_validate_accepts_request_timeout_seconds_at_max() {
928 let tmp_dir = TempDir::new().unwrap();
929 let config_path = tmp_dir.path().join("config.toml");
930
931 let toml_content = format!(
932 r#"
933 [[lsp_servers]]
934 language_id = "rust"
935 command = "rust-analyzer"
936 request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
937 "#
938 );
939
940 fs::write(&config_path, toml_content).unwrap();
941
942 let result = ServerConfig::load_from(&config_path);
943 assert!(result.is_ok(), "expected Ok, got {result:?}");
944 }
945
946 #[test]
947 fn test_validate_rejects_timeout_seconds_above_max() {
948 let tmp_dir = TempDir::new().unwrap();
949 let config_path = tmp_dir.path().join("config.toml");
950
951 let toml_content = format!(
952 r#"
953 [[lsp_servers]]
954 language_id = "rust"
955 command = "rust-analyzer"
956 timeout_seconds = {}
957 "#,
958 MAX_TIMEOUT_SECONDS + 1
959 );
960
961 fs::write(&config_path, toml_content).unwrap();
962
963 let result = ServerConfig::load_from(&config_path);
964 if let Err(Error::InvalidConfig(msg)) = result {
965 assert!(msg.contains("timeout_seconds"));
966 assert!(msg.contains("exceeds the maximum"));
967 } else {
968 panic!("Expected InvalidConfig error, got {result:?}");
969 }
970 }
971
972 #[test]
973 fn test_validate_accepts_timeout_seconds_at_max() {
974 let tmp_dir = TempDir::new().unwrap();
975 let config_path = tmp_dir.path().join("config.toml");
976
977 let toml_content = format!(
978 r#"
979 [[lsp_servers]]
980 language_id = "rust"
981 command = "rust-analyzer"
982 timeout_seconds = {MAX_TIMEOUT_SECONDS}
983 "#
984 );
985
986 fs::write(&config_path, toml_content).unwrap();
987
988 let result = ServerConfig::load_from(&config_path);
989 assert!(result.is_ok(), "expected Ok, got {result:?}");
990 }
991
992 #[test]
993 fn test_load_from_nonexistent_file() {
994 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
995 assert!(result.is_err());
996
997 if let Err(Error::ConfigNotFound(path)) = result {
998 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
999 } else {
1000 panic!("Expected ConfigNotFound error");
1001 }
1002 }
1003
1004 #[test]
1005 fn test_load_from_invalid_toml() {
1006 let tmp_dir = TempDir::new().unwrap();
1007 let config_path = tmp_dir.path().join("invalid.toml");
1008
1009 fs::write(&config_path, "invalid toml content {{}").unwrap();
1010
1011 let result = ServerConfig::load_from(&config_path);
1012 assert!(result.is_err());
1013 }
1014
1015 #[test]
1019 fn test_load_from_rejects_oversized_file() {
1020 let tmp_dir = TempDir::new().unwrap();
1021 let config_path = tmp_dir.path().join("oversized.toml");
1022
1023 let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1026 fs::write(&config_path, &oversized).unwrap();
1027
1028 let result = ServerConfig::load_from(&config_path);
1029 assert!(matches!(
1030 result,
1031 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1032 ));
1033 }
1034
1035 #[test]
1036 fn test_load_from_accepts_file_at_exact_size_cap() {
1037 let tmp_dir = TempDir::new().unwrap();
1038 let config_path = tmp_dir.path().join("exact.toml");
1039
1040 let mut toml_content = "[workspace]\n# ".to_string();
1043 toml_content.push_str(
1044 &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1045 );
1046 assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1047 fs::write(&config_path, &toml_content).unwrap();
1048
1049 let result = ServerConfig::load_from(&config_path);
1050 assert!(result.is_ok(), "expected Ok, got {result:?}");
1051 }
1052
1053 #[cfg(unix)]
1060 #[test]
1061 fn test_load_from_rejects_infinite_special_file() {
1062 let path = Path::new("/dev/zero");
1063 assert_eq!(
1064 fs::metadata(path).unwrap().len(),
1065 0,
1066 "test assumption: /dev/zero must report zero length"
1067 );
1068
1069 let result = ServerConfig::load_from(path);
1070 assert!(matches!(
1071 result,
1072 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1073 ));
1074 }
1075
1076 #[test]
1077 fn test_validate_empty_language_id() {
1078 let tmp_dir = TempDir::new().unwrap();
1079 let config_path = tmp_dir.path().join("config.toml");
1080
1081 let toml_content = r#"
1082 [[lsp_servers]]
1083 language_id = ""
1084 command = "test"
1085 "#;
1086
1087 fs::write(&config_path, toml_content).unwrap();
1088
1089 let result = ServerConfig::load_from(&config_path);
1090 assert!(result.is_err());
1091
1092 if let Err(Error::InvalidConfig(msg)) = result {
1093 assert!(msg.contains("language_id cannot be empty"));
1094 } else {
1095 panic!("Expected InvalidConfig error");
1096 }
1097 }
1098
1099 #[test]
1100 fn test_validate_empty_command() {
1101 let tmp_dir = TempDir::new().unwrap();
1102 let config_path = tmp_dir.path().join("config.toml");
1103
1104 let toml_content = r#"
1105 [[lsp_servers]]
1106 language_id = "rust"
1107 command = ""
1108 "#;
1109
1110 fs::write(&config_path, toml_content).unwrap();
1111
1112 let result = ServerConfig::load_from(&config_path);
1113 assert!(result.is_err());
1114
1115 if let Err(Error::InvalidConfig(msg)) = result {
1116 assert!(msg.contains("command cannot be empty"));
1117 } else {
1118 panic!("Expected InvalidConfig error");
1119 }
1120 }
1121
1122 #[test]
1123 fn test_validate_empty_name() {
1124 let tmp_dir = TempDir::new().unwrap();
1125 let config_path = tmp_dir.path().join("config.toml");
1126
1127 let toml_content = r#"
1128 [[lsp_servers]]
1129 name = ""
1130 language_id = "python"
1131 command = "pyright-langserver"
1132 "#;
1133
1134 fs::write(&config_path, toml_content).unwrap();
1135
1136 let result = ServerConfig::load_from(&config_path);
1137 assert!(result.is_err());
1138
1139 if let Err(Error::InvalidConfig(msg)) = result {
1140 assert!(msg.contains("name cannot be empty"));
1141 } else {
1142 panic!("Expected InvalidConfig error");
1143 }
1144 }
1145
1146 #[test]
1147 fn test_validate_empty_handles() {
1148 let tmp_dir = TempDir::new().unwrap();
1149 let config_path = tmp_dir.path().join("config.toml");
1150
1151 let toml_content = r#"
1152 [[lsp_servers]]
1153 language_id = "python"
1154 command = "pylsp"
1155 handles = []
1156 "#;
1157
1158 fs::write(&config_path, toml_content).unwrap();
1159
1160 let result = ServerConfig::load_from(&config_path);
1161 assert!(result.is_err());
1162
1163 if let Err(Error::InvalidConfig(msg)) = result {
1164 assert!(msg.contains("handles cannot be empty"));
1165 } else {
1166 panic!("Expected InvalidConfig error");
1167 }
1168 }
1169
1170 #[test]
1171 fn test_validate_duplicate_tool_in_handles() {
1172 let tmp_dir = TempDir::new().unwrap();
1173 let config_path = tmp_dir.path().join("config.toml");
1174
1175 let toml_content = r#"
1176 [[lsp_servers]]
1177 language_id = "python"
1178 command = "pylsp"
1179 handles = ["diagnostics", "diagnostics"]
1180 "#;
1181
1182 fs::write(&config_path, toml_content).unwrap();
1183
1184 let result = ServerConfig::load_from(&config_path);
1185 assert!(result.is_err());
1186
1187 if let Err(Error::InvalidConfig(msg)) = result {
1188 assert!(msg.contains("duplicate tool"));
1189 assert!(msg.contains("diagnostics"));
1190 } else {
1191 panic!("Expected InvalidConfig error");
1192 }
1193 }
1194
1195 #[test]
1196 fn test_validate_rejects_empty_position_encodings() {
1197 let tmp_dir = TempDir::new().unwrap();
1198 let config_path = tmp_dir.path().join("config.toml");
1199
1200 let toml_content = r"
1201 [workspace]
1202 position_encodings = []
1203 ";
1204
1205 fs::write(&config_path, toml_content).unwrap();
1206
1207 let result = ServerConfig::load_from(&config_path);
1208 if let Err(Error::InvalidConfig(msg)) = result {
1209 assert_eq!(msg, "workspace.position_encodings cannot be empty");
1210 } else {
1211 panic!("Expected InvalidConfig error, got {result:?}");
1212 }
1213 }
1214
1215 #[test]
1216 fn test_validate_rejects_unrecognized_position_encoding() {
1217 let tmp_dir = TempDir::new().unwrap();
1218 let config_path = tmp_dir.path().join("config.toml");
1219
1220 let toml_content = r#"
1221 [workspace]
1222 position_encodings = ["utf-8", "utf-7"]
1223 "#;
1224
1225 fs::write(&config_path, toml_content).unwrap();
1226
1227 let result = ServerConfig::load_from(&config_path);
1228 if let Err(Error::InvalidConfig(msg)) = result {
1229 assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1230 } else {
1231 panic!("Expected InvalidConfig error, got {result:?}");
1232 }
1233 }
1234
1235 #[test]
1236 fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1237 assert_eq!(
1238 parse_position_encoding("utf-8"),
1239 Some(lsp_types::PositionEncodingKind::UTF8)
1240 );
1241 assert_eq!(
1242 parse_position_encoding("utf-16"),
1243 Some(lsp_types::PositionEncodingKind::UTF16)
1244 );
1245 assert_eq!(
1246 parse_position_encoding("utf-32"),
1247 Some(lsp_types::PositionEncodingKind::UTF32)
1248 );
1249 assert_eq!(parse_position_encoding("utf-7"), None);
1250 }
1251
1252 #[test]
1253 fn test_validate_duplicate_name_warns_but_loads() {
1254 let tmp_dir = TempDir::new().unwrap();
1259 let config_path = tmp_dir.path().join("config.toml");
1260
1261 let toml_content = r#"
1262 [[lsp_servers]]
1263 name = "dup"
1264 language_id = "python"
1265 command = "pyright-langserver"
1266
1267 [[lsp_servers]]
1268 name = "dup"
1269 language_id = "typescript"
1270 command = "typescript-language-server"
1271 "#;
1272
1273 fs::write(&config_path, toml_content).unwrap();
1274
1275 let result = ServerConfig::load_from(&config_path);
1276 assert!(result.is_ok(), "duplicate name must only warn at load time");
1277 }
1278
1279 #[test]
1280 fn test_workspace_config_defaults() {
1281 let workspace = WorkspaceConfig::default();
1282 assert!(workspace.roots.is_empty());
1283 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1284 assert!(!workspace.language_extensions.is_empty());
1285 assert_eq!(workspace.language_extensions.len(), 30);
1286 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1287 }
1288
1289 #[test]
1290 fn test_load_multiple_servers() {
1291 let tmp_dir = TempDir::new().unwrap();
1292 let config_path = tmp_dir.path().join("multi.toml");
1293
1294 let toml_content = r#"
1295 [[lsp_servers]]
1296 language_id = "rust"
1297 command = "rust-analyzer"
1298
1299 [[lsp_servers]]
1300 language_id = "python"
1301 command = "pyright-langserver"
1302 args = ["--stdio"]
1303 "#;
1304
1305 fs::write(&config_path, toml_content).unwrap();
1306
1307 let config = ServerConfig::load_from(&config_path).unwrap();
1308 assert_eq!(config.lsp_servers.len(), 2);
1309 assert_eq!(config.lsp_servers[0].language_id, "rust");
1310 assert_eq!(config.lsp_servers[1].language_id, "python");
1311 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1312 }
1313
1314 #[test]
1315 fn test_deny_unknown_fields() {
1316 let tmp_dir = TempDir::new().unwrap();
1317 let config_path = tmp_dir.path().join("unknown.toml");
1318
1319 let toml_content = r#"
1320 unknown_field = "value"
1321
1322 [workspace]
1323 roots = []
1324 "#;
1325
1326 fs::write(&config_path, toml_content).unwrap();
1327
1328 let result = ServerConfig::load_from(&config_path);
1329 assert!(result.is_err(), "Should reject unknown fields");
1330 }
1331
1332 #[test]
1333 fn test_empty_config_file() {
1334 let tmp_dir = TempDir::new().unwrap();
1335 let config_path = tmp_dir.path().join("empty.toml");
1336
1337 fs::write(&config_path, "").unwrap();
1338
1339 let config = ServerConfig::load_from(&config_path).unwrap();
1340 assert!(config.workspace.roots.is_empty());
1341 assert!(config.lsp_servers.is_empty());
1342 }
1343
1344 #[test]
1345 fn test_config_with_initialization_options() {
1346 let tmp_dir = TempDir::new().unwrap();
1347 let config_path = tmp_dir.path().join("init_opts.toml");
1348
1349 let toml_content = r#"
1350 [[lsp_servers]]
1351 language_id = "rust"
1352 command = "rust-analyzer"
1353
1354 [lsp_servers.initialization_options]
1355 cargo = { allFeatures = true }
1356 "#;
1357
1358 fs::write(&config_path, toml_content).unwrap();
1359
1360 let config = ServerConfig::load_from(&config_path).unwrap();
1361 assert!(config.lsp_servers[0].initialization_options.is_some());
1362 }
1363
1364 #[test]
1365 fn test_language_extensions_in_config() {
1366 let tmp_dir = TempDir::new().unwrap();
1367 let config_path = tmp_dir.path().join("extensions.toml");
1368
1369 let toml_content = r#"
1370 [[workspace.language_extensions]]
1371 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1372 language_id = "cpp"
1373
1374 [[workspace.language_extensions]]
1375 extensions = ["nu"]
1376 language_id = "nushell"
1377
1378 [[workspace.language_extensions]]
1379 extensions = ["py", "pyw", "pyi"]
1380 language_id = "python"
1381 "#;
1382
1383 fs::write(&config_path, toml_content).unwrap();
1384
1385 let config = ServerConfig::load_from(&config_path).unwrap();
1386 assert_eq!(config.workspace.language_extensions.len(), 3);
1387
1388 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
1390 assert_eq!(
1391 config.workspace.language_extensions[0].extensions,
1392 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1393 );
1394
1395 assert_eq!(
1397 config.workspace.language_extensions[1].language_id,
1398 "nushell"
1399 );
1400 assert_eq!(
1401 config.workspace.language_extensions[1].extensions,
1402 vec!["nu"]
1403 );
1404 }
1405
1406 #[test]
1407 fn test_build_extension_map() {
1408 let workspace = WorkspaceConfig {
1409 roots: vec![],
1410 position_encodings: vec![],
1411 language_extensions: vec![
1412 LanguageExtensionMapping {
1413 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
1414 language_id: "cpp".to_string(),
1415 },
1416 LanguageExtensionMapping {
1417 extensions: vec!["nu".to_string()],
1418 language_id: "nushell".to_string(),
1419 },
1420 ],
1421 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1422 max_documents: DEFAULT_MAX_DOCUMENTS,
1423 max_file_size: DEFAULT_MAX_FILE_SIZE,
1424 };
1425
1426 let map = workspace.build_extension_map();
1427 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
1428 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
1429 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
1430 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
1431 assert_eq!(map.get("unknown"), None);
1432 }
1433
1434 #[test]
1435 fn test_extract_extension_from_pattern_empty_string() {
1436 assert_eq!(extract_extension_from_pattern(""), None);
1437 }
1438
1439 #[test]
1440 fn test_extract_extension_from_pattern_without_dot() {
1441 assert_eq!(extract_extension_from_pattern("**/*"), None);
1442 }
1443
1444 #[test]
1445 fn test_extract_extension_from_pattern_dotfile() {
1446 assert_eq!(extract_extension_from_pattern(".gitignore"), None);
1447 }
1448
1449 #[test]
1450 fn test_extract_extension_from_pattern_multi_dot_extension() {
1451 assert_eq!(
1452 extract_extension_from_pattern("foo.tar.gz"),
1453 Some("gz".to_string())
1454 );
1455 }
1456
1457 #[test]
1458 fn test_build_effective_extension_map_overrides_with_file_patterns() {
1459 let config = ServerConfig {
1460 workspace: WorkspaceConfig::default(),
1461 lsp_servers: vec![LspServerConfig {
1462 language_id: "cpp".to_string(),
1463 command: "clangd".to_string(),
1464 args: vec![],
1465 env: HashMap::new(),
1466 file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
1467 initialization_options: None,
1468 timeout_seconds: 30,
1469 request_timeout_seconds: 30,
1470 heuristics: None,
1471 name: None,
1472 handles: None,
1473 }],
1474 project_config_ignored: false,
1475 };
1476
1477 let map = config.build_effective_extension_map();
1478 assert_eq!(map.get("c"), Some(&"cpp".to_string()));
1479 assert_eq!(map.get("h"), Some(&"cpp".to_string()));
1480 }
1481
1482 #[test]
1483 fn test_build_effective_extension_map_derives_tsx_language_id() {
1484 let config = ServerConfig {
1485 workspace: WorkspaceConfig::default(),
1486 lsp_servers: vec![LspServerConfig {
1487 language_id: "typescript".to_string(),
1488 command: "tsgo".to_string(),
1489 args: vec!["--lsp".to_string(), "--stdio".to_string()],
1490 env: HashMap::new(),
1491 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
1492 initialization_options: None,
1493 timeout_seconds: 30,
1494 request_timeout_seconds: 30,
1495 heuristics: None,
1496 name: None,
1497 handles: None,
1498 }],
1499 project_config_ignored: false,
1500 };
1501
1502 let map = config.build_effective_extension_map();
1503 assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1504 assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1505 }
1506
1507 #[test]
1508 fn test_build_effective_extension_map_derives_jsx_language_id() {
1509 let config = ServerConfig {
1510 workspace: WorkspaceConfig::default(),
1511 lsp_servers: vec![LspServerConfig {
1512 language_id: "javascript".to_string(),
1513 command: "typescript-language-server".to_string(),
1514 args: vec!["--stdio".to_string()],
1515 env: HashMap::new(),
1516 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1517 initialization_options: None,
1518 timeout_seconds: 30,
1519 request_timeout_seconds: 30,
1520 heuristics: None,
1521 name: None,
1522 handles: None,
1523 }],
1524 project_config_ignored: false,
1525 };
1526
1527 let map = config.build_effective_extension_map();
1528 assert_eq!(map.get("js"), Some(&"javascript".to_string()));
1529 assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
1530 }
1531
1532 #[test]
1533 fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
1534 let config = ServerConfig {
1535 workspace: WorkspaceConfig::default(),
1536 lsp_servers: vec![LspServerConfig {
1537 language_id: "cpp".to_string(),
1538 command: "clangd".to_string(),
1539 args: vec![],
1540 env: HashMap::new(),
1541 file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
1542 initialization_options: None,
1543 timeout_seconds: 30,
1544 request_timeout_seconds: 30,
1545 heuristics: None,
1546 name: None,
1547 handles: None,
1548 }],
1549 project_config_ignored: false,
1550 };
1551
1552 let map = config.build_effective_extension_map();
1553 assert_eq!(map.get("h"), Some(&"c".to_string()));
1555 }
1556
1557 #[test]
1558 fn test_get_language_for_extension() {
1559 let workspace = WorkspaceConfig {
1560 roots: vec![],
1561 position_encodings: vec![],
1562 language_extensions: vec![
1563 LanguageExtensionMapping {
1564 extensions: vec!["hpp".to_string(), "hh".to_string()],
1565 language_id: "cpp".to_string(),
1566 },
1567 LanguageExtensionMapping {
1568 extensions: vec!["py".to_string()],
1569 language_id: "python".to_string(),
1570 },
1571 ],
1572 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1573 max_documents: DEFAULT_MAX_DOCUMENTS,
1574 max_file_size: DEFAULT_MAX_FILE_SIZE,
1575 };
1576
1577 assert_eq!(
1578 workspace.get_language_for_extension("hpp"),
1579 Some("cpp".to_string())
1580 );
1581 assert_eq!(
1582 workspace.get_language_for_extension("hh"),
1583 Some("cpp".to_string())
1584 );
1585 assert_eq!(
1586 workspace.get_language_for_extension("py"),
1587 Some("python".to_string())
1588 );
1589 assert_eq!(workspace.get_language_for_extension("unknown"), None);
1590 }
1591
1592 #[test]
1593 fn test_default_language_extensions() {
1594 let workspace = WorkspaceConfig::default();
1595 let map = workspace.build_extension_map();
1596 assert!(!map.is_empty());
1597 assert_eq!(
1598 workspace.get_language_for_extension("rs"),
1599 Some("rust".to_string())
1600 );
1601 assert_eq!(
1602 workspace.get_language_for_extension("py"),
1603 Some("python".to_string())
1604 );
1605 assert_eq!(
1606 workspace.get_language_for_extension("cpp"),
1607 Some("cpp".to_string())
1608 );
1609 }
1610
1611 #[test]
1612 fn test_create_default_config_file() {
1613 let tmp_dir = TempDir::new().unwrap();
1614 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
1615
1616 ServerConfig::create_default_config_file(&config_path).unwrap();
1617
1618 assert!(config_path.exists());
1619
1620 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
1621 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
1622 assert_eq!(loaded_config.lsp_servers.len(), 6);
1623 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
1624 }
1625
1626 #[test]
1627 fn test_load_returns_default_config() {
1628 let config = ServerConfig::default();
1630 assert_eq!(config.workspace.language_extensions.len(), 30);
1631 assert_eq!(config.lsp_servers.len(), 6);
1632 assert_eq!(config.lsp_servers[0].language_id, "rust");
1633 }
1634
1635 static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1644
1645 struct CwdGuard {
1649 _lock: std::sync::MutexGuard<'static, ()>,
1650 original_dir: PathBuf,
1651 }
1652
1653 impl CwdGuard {
1654 fn enter(dir: &Path) -> Self {
1655 let lock = CWD_LOCK
1656 .lock()
1657 .unwrap_or_else(std::sync::PoisonError::into_inner);
1658 let original_dir = std::env::current_dir().unwrap();
1659 std::env::set_current_dir(dir).unwrap();
1660 Self {
1661 _lock: lock,
1662 original_dir,
1663 }
1664 }
1665 }
1666
1667 impl Drop for CwdGuard {
1668 fn drop(&mut self) {
1669 let restored = std::env::set_current_dir(&self.original_dir);
1670 if !std::thread::panicking() {
1678 #[allow(clippy::expect_used)]
1679 restored.expect("CwdGuard failed to restore original working directory");
1680 }
1681 }
1682 }
1683
1684 #[test]
1685 fn test_cwd_guard_restores_cwd_on_panic() {
1686 let original_dir = std::env::current_dir().unwrap();
1687 let tmp_dir = TempDir::new().unwrap();
1688
1689 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1690 let _guard = CwdGuard::enter(tmp_dir.path());
1691 panic!("boom");
1692 }));
1693
1694 assert!(result.is_err());
1695 assert_eq!(std::env::current_dir().unwrap(), original_dir);
1696 }
1697
1698 fn assert_mcpls_config_env_unset() {
1711 assert!(
1712 std::env::var_os("MCPLS_CONFIG").is_none(),
1713 "this test requires MCPLS_CONFIG to be unset in the test environment, since \
1714 load_with_trust returns before consulting CWD when it's set"
1715 );
1716 }
1717
1718 #[test]
1719 fn test_load_ignores_untrusted_project_local_config() {
1720 let tmp_dir = TempDir::new().unwrap();
1728 let config_path = tmp_dir.path().join("mcpls.toml");
1729
1730 let custom_toml = r#"
1736 [workspace]
1737 roots = ["/should-never-load-attacker-path"]
1738
1739 [[lsp_servers]]
1740 language_id = "definitely-not-a-real-language-marker"
1741 command = "rm"
1742 args = ["-rf", "/"]
1743 "#;
1744
1745 fs::write(&config_path, custom_toml).unwrap();
1746
1747 let config = {
1748 let _guard = CwdGuard::enter(tmp_dir.path());
1749 ServerConfig::load().unwrap()
1750 };
1751
1752 assert!(
1753 !config
1754 .workspace
1755 .roots
1756 .contains(&PathBuf::from("/should-never-load-attacker-path"))
1757 );
1758 assert!(
1759 !config
1760 .lsp_servers
1761 .iter()
1762 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
1763 );
1764 }
1765
1766 #[test]
1767 fn test_load_with_trust_loads_trusted_project_local_config() {
1768 let tmp_dir = TempDir::new().unwrap();
1769 let config_path = tmp_dir.path().join("mcpls.toml");
1770
1771 let custom_toml = r#"
1772 [workspace]
1773 roots = ["/custom/path"]
1774
1775 [[lsp_servers]]
1776 language_id = "python"
1777 command = "pyright-langserver"
1778 "#;
1779
1780 fs::write(&config_path, custom_toml).unwrap();
1781
1782 let config = {
1783 let _guard = CwdGuard::enter(tmp_dir.path());
1784 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
1785 };
1786
1787 assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
1788 assert_eq!(config.lsp_servers.len(), 1);
1789 assert_eq!(config.lsp_servers[0].language_id, "python");
1790 }
1791
1792 #[test]
1793 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
1794 let tmp_dir = TempDir::new().unwrap();
1795 let config_path = tmp_dir.path().join("mcpls.toml");
1796
1797 let custom_toml = r#"
1798 [workspace]
1799 roots = ["/attacker/controlled"]
1800 heuristics_max_depth = 999999
1801
1802 [[lsp_servers]]
1803 language_id = "evil"
1804 command = "rm"
1805 args = ["-rf", "/"]
1806 "#;
1807
1808 fs::write(&config_path, custom_toml).unwrap();
1809
1810 let config = {
1811 let _guard = CwdGuard::enter(tmp_dir.path());
1812 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1813 };
1814
1815 assert!(
1816 !config
1817 .workspace
1818 .roots
1819 .contains(&PathBuf::from("/attacker/controlled"))
1820 );
1821 assert_ne!(config.workspace.heuristics_max_depth, 999_999);
1822 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
1823 }
1824
1825 #[test]
1826 fn test_load_with_trust_sets_project_config_ignored_flag() {
1827 assert_mcpls_config_env_unset();
1828
1829 let tmp_dir = TempDir::new().unwrap();
1830 let config_path = tmp_dir.path().join("mcpls.toml");
1831 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
1832
1833 let config = {
1834 let _guard = CwdGuard::enter(tmp_dir.path());
1835 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1836 };
1837 assert!(config.project_config_ignored);
1838
1839 let tmp_dir = TempDir::new().unwrap();
1840 let config_path = tmp_dir.path().join("mcpls.toml");
1841 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
1842
1843 let config = {
1844 let _guard = CwdGuard::enter(tmp_dir.path());
1845 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
1846 };
1847 assert!(!config.project_config_ignored);
1848 }
1849
1850 #[test]
1851 fn test_load_no_local_config_leaves_flag_unset() {
1852 assert_mcpls_config_env_unset();
1853
1854 let tmp_dir = TempDir::new().unwrap();
1855
1856 let config = {
1857 let _guard = CwdGuard::enter(tmp_dir.path());
1858 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1859 };
1860 assert!(!config.project_config_ignored);
1861 }
1862
1863 #[test]
1864 fn test_config_file_creation_with_proper_structure() {
1865 let tmp_dir = TempDir::new().unwrap();
1866 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
1867
1868 ServerConfig::create_default_config_file(&config_path).unwrap();
1869
1870 let content = fs::read_to_string(&config_path).unwrap();
1871
1872 assert!(content.contains("[workspace]"));
1873 assert!(content.contains("[[workspace.language_extensions]]"));
1874 assert!(content.contains("[[lsp_servers]]"));
1875 assert!(content.contains("language_id = \"rust\""));
1876 assert!(content.contains("extensions = [\"rs\"]"));
1877 }
1878
1879 #[test]
1880 fn test_heuristics_max_depth_default() {
1881 let config = WorkspaceConfig::default();
1882 assert_eq!(config.heuristics_max_depth, 10);
1883 }
1884
1885 #[test]
1886 fn test_heuristics_max_depth_from_config() {
1887 let tmp_dir = TempDir::new().unwrap();
1888 let config_path = tmp_dir.path().join("depth.toml");
1889
1890 let toml_content = r"
1891 [workspace]
1892 heuristics_max_depth = 5
1893 ";
1894
1895 fs::write(&config_path, toml_content).unwrap();
1896
1897 let config = ServerConfig::load_from(&config_path).unwrap();
1898 assert_eq!(config.workspace.heuristics_max_depth, 5);
1899 }
1900
1901 #[test]
1902 fn test_heuristics_max_depth_uses_default_when_not_specified() {
1903 let tmp_dir = TempDir::new().unwrap();
1904 let config_path = tmp_dir.path().join("no_depth.toml");
1905
1906 let toml_content = r"
1907 [workspace]
1908 roots = []
1909 ";
1910
1911 fs::write(&config_path, toml_content).unwrap();
1912
1913 let config = ServerConfig::load_from(&config_path).unwrap();
1914 assert_eq!(
1915 config.workspace.heuristics_max_depth,
1916 DEFAULT_HEURISTICS_MAX_DEPTH
1917 );
1918 }
1919
1920 #[test]
1921 fn test_max_documents_default() {
1922 let config = WorkspaceConfig::default();
1923 assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
1924 }
1925
1926 #[test]
1927 fn test_max_file_size_default() {
1928 let config = WorkspaceConfig::default();
1929 assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
1930 }
1931
1932 #[test]
1933 fn test_max_documents_from_config() {
1934 let tmp_dir = TempDir::new().unwrap();
1935 let config_path = tmp_dir.path().join("limits.toml");
1936
1937 let toml_content = r"
1938 [workspace]
1939 max_documents = 500
1940 ";
1941
1942 fs::write(&config_path, toml_content).unwrap();
1943
1944 let config = ServerConfig::load_from(&config_path).unwrap();
1945 assert_eq!(config.workspace.max_documents, 500);
1946 }
1947
1948 #[test]
1949 fn test_max_file_size_from_config() {
1950 let tmp_dir = TempDir::new().unwrap();
1951 let config_path = tmp_dir.path().join("limits.toml");
1952
1953 let toml_content = r"
1954 [workspace]
1955 max_file_size = 20971520
1956 ";
1957
1958 fs::write(&config_path, toml_content).unwrap();
1959
1960 let config = ServerConfig::load_from(&config_path).unwrap();
1961 assert_eq!(config.workspace.max_file_size, 20_971_520);
1962 }
1963
1964 #[test]
1965 fn test_max_documents_uses_default_when_not_specified() {
1966 let tmp_dir = TempDir::new().unwrap();
1967 let config_path = tmp_dir.path().join("no_limits.toml");
1968
1969 let toml_content = r"
1970 [workspace]
1971 roots = []
1972 ";
1973
1974 fs::write(&config_path, toml_content).unwrap();
1975
1976 let config = ServerConfig::load_from(&config_path).unwrap();
1977 assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
1978 assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
1979 }
1980
1981 #[test]
1985 fn test_max_file_size_zero_means_unlimited() {
1986 let tmp_dir = TempDir::new().unwrap();
1987 let config_path = tmp_dir.path().join("unlimited.toml");
1988
1989 let toml_content = r"
1990 [workspace]
1991 max_file_size = 0
1992 ";
1993
1994 fs::write(&config_path, toml_content).unwrap();
1995
1996 let config = ServerConfig::load_from(&config_path).unwrap();
1997 assert_eq!(config.workspace.max_file_size, 0);
1998 assert_eq!(config.workspace.resource_limits().max_file_size, 0);
1999 }
2000
2001 #[test]
2002 fn test_workspace_config_resource_limits_maps_fields() {
2003 let workspace = WorkspaceConfig {
2004 max_documents: 250,
2005 max_file_size: 0,
2006 ..WorkspaceConfig::default()
2007 };
2008
2009 let limits = workspace.resource_limits();
2010 assert_eq!(limits.max_documents, 250);
2011 assert_eq!(limits.max_file_size, 0);
2012 }
2013
2014 #[test]
2015 fn test_workspace_config_toml_round_trip() {
2016 let original = WorkspaceConfig {
2017 roots: vec![PathBuf::from("/tmp/round-trip")],
2018 position_encodings: vec!["utf-8".to_string()],
2019 language_extensions: vec![LanguageExtensionMapping {
2020 extensions: vec!["nu".to_string()],
2021 language_id: "nushell".to_string(),
2022 }],
2023 heuristics_max_depth: 5,
2024 max_documents: 500,
2025 max_file_size: 0,
2026 };
2027
2028 let toml_content = toml::to_string_pretty(&original).unwrap();
2029 let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2030
2031 assert_eq!(round_tripped.roots, original.roots);
2032 assert_eq!(
2033 round_tripped.position_encodings,
2034 original.position_encodings
2035 );
2036 assert_eq!(
2037 round_tripped.language_extensions.len(),
2038 original.language_extensions.len()
2039 );
2040 assert_eq!(
2041 round_tripped.language_extensions[0].extensions,
2042 original.language_extensions[0].extensions
2043 );
2044 assert_eq!(
2045 round_tripped.language_extensions[0].language_id,
2046 original.language_extensions[0].language_id
2047 );
2048 assert_eq!(
2049 round_tripped.heuristics_max_depth,
2050 original.heuristics_max_depth
2051 );
2052 assert_eq!(round_tripped.max_documents, original.max_documents);
2053 assert_eq!(round_tripped.max_file_size, original.max_file_size);
2054 }
2055}