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 mcp: McpConfig,
43
44 #[serde(default)]
46 pub workspace: WorkspaceConfig,
47
48 #[serde(default)]
50 pub lsp_servers: Vec<LspServerConfig>,
51
52 #[serde(skip)]
62 pub project_config_ignored: bool,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct McpConfig {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub title: Option<String>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub description: Option<String>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub instructions: Option<String>,
119}
120
121pub const MAX_MCP_TITLE_BYTES: usize = 128;
126
127pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
131
132pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct WorkspaceConfig {
144 #[serde(default)]
146 pub roots: Vec<PathBuf>,
147
148 #[serde(default = "default_position_encodings")]
157 pub position_encodings: Vec<String>,
158
159 #[serde(default)]
162 pub language_extensions: Vec<LanguageExtensionMapping>,
163
164 #[serde(default = "default_heuristics_max_depth")]
168 pub heuristics_max_depth: usize,
169
170 #[serde(default = "default_max_documents")]
180 pub max_documents: usize,
181
182 #[serde(default = "default_max_file_size")]
187 pub max_file_size: u64,
188}
189
190impl Default for WorkspaceConfig {
191 fn default() -> Self {
192 Self {
193 roots: Vec::new(),
194 position_encodings: default_position_encodings(),
195 language_extensions: default_language_extensions(),
196 heuristics_max_depth: default_heuristics_max_depth(),
197 max_documents: default_max_documents(),
198 max_file_size: default_max_file_size(),
199 }
200 }
201}
202
203const fn default_heuristics_max_depth() -> usize {
204 DEFAULT_HEURISTICS_MAX_DEPTH
205}
206
207const fn default_max_documents() -> usize {
208 DEFAULT_MAX_DOCUMENTS
209}
210
211const fn default_max_file_size() -> u64 {
212 DEFAULT_MAX_FILE_SIZE
213}
214
215impl WorkspaceConfig {
216 #[must_use]
223 pub fn build_extension_map(&self) -> HashMap<String, String> {
224 let mut map = HashMap::new();
225 for mapping in &self.language_extensions {
226 for ext in &mapping.extensions {
227 map.insert(ext.clone(), mapping.language_id.clone());
228 }
229 }
230 map
231 }
232
233 #[must_use]
243 pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
244 for mapping in &self.language_extensions {
245 if mapping.extensions.contains(&extension.to_string()) {
246 return Some(mapping.language_id.clone());
247 }
248 }
249 None
250 }
251
252 #[must_use]
255 pub const fn resource_limits(&self) -> ResourceLimits {
256 ResourceLimits {
257 max_documents: self.max_documents,
258 max_file_size: self.max_file_size,
259 }
260 }
261}
262
263fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
268 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
269 if basename.starts_with('.') {
270 return None;
271 }
272
273 let (_, ext) = basename.rsplit_once('.')?;
274 if ext.is_empty() {
275 return None;
276 }
277
278 if ext
280 .chars()
281 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
282 {
283 Some(ext.to_string())
284 } else {
285 None
286 }
287}
288
289fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
290 react_variant_language_id(server_language_id, extension)
291 .unwrap_or(server_language_id)
292 .to_string()
293}
294
295pub(crate) fn default_position_encodings() -> Vec<String> {
312 vec!["utf-8".to_string(), "utf-16".to_string()]
313}
314
315pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
325 match value {
326 "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
327 "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
328 "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
329 _ => None,
330 }
331}
332
333#[allow(clippy::too_many_lines)]
338fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
339 vec![
340 LanguageExtensionMapping {
341 extensions: vec!["rs".to_string()],
342 language_id: "rust".to_string(),
343 },
344 LanguageExtensionMapping {
345 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
346 language_id: "python".to_string(),
347 },
348 LanguageExtensionMapping {
349 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
350 language_id: "javascript".to_string(),
351 },
352 LanguageExtensionMapping {
353 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
354 language_id: "typescript".to_string(),
355 },
356 LanguageExtensionMapping {
357 extensions: vec!["tsx".to_string()],
358 language_id: "typescriptreact".to_string(),
359 },
360 LanguageExtensionMapping {
361 extensions: vec!["jsx".to_string()],
362 language_id: "javascriptreact".to_string(),
363 },
364 LanguageExtensionMapping {
365 extensions: vec!["go".to_string()],
366 language_id: "go".to_string(),
367 },
368 LanguageExtensionMapping {
369 extensions: vec!["c".to_string(), "h".to_string()],
370 language_id: "c".to_string(),
371 },
372 LanguageExtensionMapping {
373 extensions: vec![
374 "cpp".to_string(),
375 "cc".to_string(),
376 "cxx".to_string(),
377 "hpp".to_string(),
378 "hh".to_string(),
379 "hxx".to_string(),
380 ],
381 language_id: "cpp".to_string(),
382 },
383 LanguageExtensionMapping {
384 extensions: vec!["java".to_string()],
385 language_id: "java".to_string(),
386 },
387 LanguageExtensionMapping {
388 extensions: vec!["rb".to_string()],
389 language_id: "ruby".to_string(),
390 },
391 LanguageExtensionMapping {
392 extensions: vec!["php".to_string()],
393 language_id: "php".to_string(),
394 },
395 LanguageExtensionMapping {
396 extensions: vec!["swift".to_string()],
397 language_id: "swift".to_string(),
398 },
399 LanguageExtensionMapping {
400 extensions: vec!["kt".to_string(), "kts".to_string()],
401 language_id: "kotlin".to_string(),
402 },
403 LanguageExtensionMapping {
404 extensions: vec!["scala".to_string(), "sc".to_string()],
405 language_id: "scala".to_string(),
406 },
407 LanguageExtensionMapping {
408 extensions: vec!["zig".to_string()],
409 language_id: "zig".to_string(),
410 },
411 LanguageExtensionMapping {
412 extensions: vec!["lua".to_string()],
413 language_id: "lua".to_string(),
414 },
415 LanguageExtensionMapping {
416 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
417 language_id: "shellscript".to_string(),
418 },
419 LanguageExtensionMapping {
420 extensions: vec!["json".to_string()],
421 language_id: "json".to_string(),
422 },
423 LanguageExtensionMapping {
424 extensions: vec!["toml".to_string()],
425 language_id: "toml".to_string(),
426 },
427 LanguageExtensionMapping {
428 extensions: vec!["yaml".to_string(), "yml".to_string()],
429 language_id: "yaml".to_string(),
430 },
431 LanguageExtensionMapping {
432 extensions: vec!["xml".to_string()],
433 language_id: "xml".to_string(),
434 },
435 LanguageExtensionMapping {
436 extensions: vec!["html".to_string(), "htm".to_string()],
437 language_id: "html".to_string(),
438 },
439 LanguageExtensionMapping {
440 extensions: vec!["css".to_string()],
441 language_id: "css".to_string(),
442 },
443 LanguageExtensionMapping {
444 extensions: vec!["scss".to_string()],
445 language_id: "scss".to_string(),
446 },
447 LanguageExtensionMapping {
448 extensions: vec!["less".to_string()],
449 language_id: "less".to_string(),
450 },
451 LanguageExtensionMapping {
452 extensions: vec!["md".to_string(), "markdown".to_string()],
453 language_id: "markdown".to_string(),
454 },
455 LanguageExtensionMapping {
456 extensions: vec!["cs".to_string()],
457 language_id: "csharp".to_string(),
458 },
459 LanguageExtensionMapping {
460 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
461 language_id: "fsharp".to_string(),
462 },
463 LanguageExtensionMapping {
464 extensions: vec!["r".to_string(), "R".to_string()],
465 language_id: "r".to_string(),
466 },
467 ]
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485pub enum ProjectConfigTrust {
486 Untrusted,
489 Trusted,
491}
492
493const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
516
517#[derive(Clone, Copy, Debug, PartialEq, Eq)]
520enum RelativeRootBase {
521 ConfigDir,
527 Cwd,
532}
533
534impl ServerConfig {
535 #[must_use]
540 pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
541 let mut map = self.workspace.build_extension_map();
542
543 for server in &self.lsp_servers {
544 for pattern in &server.file_patterns {
545 if let Some(ext) = extract_extension_from_pattern(pattern) {
546 let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
547 map.insert(ext, language_id);
548 }
549 }
550 }
551
552 map
553 }
554
555 pub fn load() -> Result<Self> {
580 Self::load_with_trust(ProjectConfigTrust::Untrusted)
581 }
582
583 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
614 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
622 return Self::load_from(Path::new(&path));
623 }
624
625 let mut project_config_ignored = false;
626
627 let local_config = PathBuf::from("mcpls.toml");
628 if local_config.exists() {
629 match trust {
630 ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
631 ProjectConfigTrust::Untrusted => {
632 project_config_ignored = true;
633 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
634 std::env::current_dir()
635 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
636 });
637 tracing::warn!(
638 "ignoring untrusted project-local config at {}; pass \
639 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
640 load it",
641 display_path.display()
642 );
643 }
644 }
645 }
646
647 if let Some(config_dir) = dirs::config_dir() {
648 let user_config = config_dir.join("mcpls").join("mcpls.toml");
649 if user_config.exists() {
650 let mut config =
659 Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
660 config.project_config_ignored = project_config_ignored;
661 return Ok(config);
662 }
663
664 if let Err(e) = Self::create_default_config_file(&user_config) {
666 tracing::warn!(
667 "Failed to create default config at {}: {}. Using in-memory defaults.",
668 user_config.display(),
669 e
670 );
671 } else {
672 tracing::info!("Created default config at {}", user_config.display());
673 }
674 }
675
676 Ok(Self {
678 project_config_ignored,
679 ..Self::default()
680 })
681 }
682
683 pub fn load_from(path: &Path) -> Result<Self> {
695 Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
696 }
697
698 fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
707 let file = std::fs::File::open(path).map_err(|e| {
708 if e.kind() == std::io::ErrorKind::NotFound {
709 Error::ConfigNotFound(path.to_path_buf())
710 } else {
711 Error::Io(e)
712 }
713 })?;
714
715 let mut buf = Vec::new();
719 file.take(MAX_CONFIG_FILE_BYTES + 1)
720 .read_to_end(&mut buf)
721 .map_err(Error::Io)?;
722 if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
723 return Err(Error::FileSizeLimitExceeded {
724 size: buf.len() as u64,
725 max: MAX_CONFIG_FILE_BYTES,
726 });
727 }
728 let content = String::from_utf8(buf)
729 .map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
730
731 let mut config: Self = toml::from_str(&content)?;
732 config.validate()?;
733
734 if !config.workspace.roots.is_empty() {
735 config.workspace.roots = if config.workspace.roots.iter().any(|root| root.is_relative())
736 {
737 let absolute_config_path = if path.is_absolute() {
745 path.to_path_buf()
746 } else {
747 std::env::current_dir().map_err(Error::Io)?.join(path)
748 };
749 let config_dir = absolute_config_path.parent().ok_or_else(|| {
750 Error::InvalidConfig(format!(
751 "configuration path has no parent directory: {}",
752 absolute_config_path.display()
753 ))
754 })?;
755
756 let base_dir = match relative_root_base {
757 RelativeRootBase::ConfigDir => {
758 dunce::canonicalize(config_dir).map_err(|source| {
759 Error::InvalidConfig(format!(
760 "configuration directory '{}' could not be canonicalized: {source}",
761 config_dir.display()
762 ))
763 })?
764 }
765 RelativeRootBase::Cwd => std::env::current_dir().map_err(Error::Io)?,
766 };
767 crate::resolve_workspace_roots(&config.workspace.roots, &base_dir)?
768 } else {
769 crate::canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
773 };
774 }
775
776 Ok(config)
777 }
778
779 fn create_default_config_file(path: &Path) -> Result<()> {
787 if let Some(parent) = path.parent() {
788 std::fs::create_dir_all(parent)?;
789 }
790
791 let default_config = Self::default();
792 let toml_content = toml::to_string_pretty(&default_config)?;
793 std::fs::write(path, toml_content)?;
794
795 Ok(())
796 }
797
798 pub fn validate(&self) -> Result<()> {
832 self.validate_mcp()?;
833
834 if self.workspace.position_encodings.is_empty() {
835 return Err(Error::InvalidConfig(
836 "workspace.position_encodings cannot be empty".to_string(),
837 ));
838 }
839 for encoding in &self.workspace.position_encodings {
840 if parse_position_encoding(encoding).is_none() {
841 return Err(Error::InvalidConfig(format!(
842 "invalid workspace.position_encodings value '{encoding}'; expected one of \
843 \"utf-8\", \"utf-16\", \"utf-32\""
844 )));
845 }
846 }
847 if self
853 .workspace
854 .roots
855 .iter()
856 .any(|root| root.as_os_str().is_empty())
857 {
858 return Err(Error::InvalidConfig(
859 "workspace.roots entries cannot be empty".to_string(),
860 ));
861 }
862
863 let mut seen_names: HashMap<&str, &str> = HashMap::new();
864 for server in &self.lsp_servers {
865 if server.language_id.is_empty() {
866 return Err(Error::InvalidConfig(
867 "language_id cannot be empty".to_string(),
868 ));
869 }
870 if server.command.is_empty() {
871 return Err(Error::InvalidConfig(format!(
872 "command cannot be empty for language '{}'",
873 server.language_id
874 )));
875 }
876 if server.timeout_seconds == 0 {
877 return Err(Error::InvalidConfig(format!(
878 "timeout_seconds cannot be 0 for language '{}'",
879 server.language_id
880 )));
881 }
882 if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
883 return Err(Error::InvalidConfig(format!(
884 "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
885 server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
886 )));
887 }
888 if server.request_timeout_seconds == 0 {
889 return Err(Error::InvalidConfig(format!(
890 "request_timeout_seconds cannot be 0 for language '{}'",
891 server.language_id
892 )));
893 }
894 if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
895 return Err(Error::InvalidConfig(format!(
896 "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
897 language '{}'",
898 server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
899 )));
900 }
901 if let Some(name) = &server.name {
902 if name.is_empty() {
903 return Err(Error::InvalidConfig(format!(
904 "name cannot be empty for language '{}' (omit `name` to default to \
905 the language id)",
906 server.language_id
907 )));
908 }
909 if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
910 tracing::warn!(
916 "duplicate explicit server name '{name}' in config (language ids: \
917 '{prev_language}', '{}'); this is only an error if both entries are \
918 applicable in the same workspace",
919 server.language_id
920 );
921 }
922 }
923 if let Some(handles) = &server.handles {
924 if handles.is_empty() {
925 return Err(Error::InvalidConfig(format!(
926 "handles cannot be empty for language '{}' (omit `handles` for a \
927 catch-all server)",
928 server.language_id
929 )));
930 }
931 let mut seen_tools = HashSet::new();
932 for tool in handles {
933 if !seen_tools.insert(*tool) {
934 return Err(Error::InvalidConfig(format!(
935 "duplicate tool '{tool}' in `handles` for language '{}'",
936 server.language_id
937 )));
938 }
939 }
940 }
941 }
942 Ok(())
943 }
944
945 fn validate_mcp(&self) -> Result<()> {
950 validate_mcp_field(self.mcp.title.as_deref(), "mcp.title", MAX_MCP_TITLE_BYTES)?;
951 validate_mcp_field(
952 self.mcp.description.as_deref(),
953 "mcp.description",
954 MAX_MCP_DESCRIPTION_BYTES,
955 )?;
956 validate_mcp_field(
957 self.mcp.instructions.as_deref(),
958 "mcp.instructions",
959 MAX_MCP_INSTRUCTIONS_BYTES,
960 )
961 }
962}
963
964fn validate_mcp_field(value: Option<&str>, field: &str, max_bytes: usize) -> Result<()> {
970 let Some(value) = value else {
971 return Ok(());
972 };
973 if value.trim().is_empty() {
974 return Err(Error::InvalidConfig(format!(
980 "{field} cannot be empty (omit `{}` from the `[mcp]` section to use the built-in default)",
981 field.rsplit('.').next().unwrap_or(field)
982 )));
983 }
984 let len = value.len();
985 if len > max_bytes {
986 return Err(Error::InvalidConfig(format!(
987 "{field} exceeds the maximum of {max_bytes} bytes ({len} given)"
988 )));
989 }
990 Ok(())
991}
992
993impl Default for ServerConfig {
994 fn default() -> Self {
995 Self {
996 mcp: McpConfig::default(),
997 workspace: WorkspaceConfig::default(),
998 lsp_servers: vec![
999 LspServerConfig::rust_analyzer(),
1000 LspServerConfig::pyright(),
1001 LspServerConfig::typescript(),
1002 LspServerConfig::gopls(),
1003 LspServerConfig::clangd(),
1004 LspServerConfig::zls(),
1005 ],
1006 project_config_ignored: false,
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012#[allow(clippy::unwrap_used)]
1013mod tests {
1014 use std::fs;
1015
1016 use tempfile::TempDir;
1017
1018 use super::*;
1019
1020 fn toml_path_literal(path: &Path) -> String {
1021 toml::Value::String(path.to_string_lossy().into_owned()).to_string()
1022 }
1023
1024 #[test]
1025 fn test_default_config() {
1026 let config = ServerConfig::default();
1027 assert_eq!(config.lsp_servers.len(), 6);
1028 assert_eq!(config.lsp_servers[0].language_id, "rust");
1029 assert_eq!(config.lsp_servers[1].language_id, "python");
1030 assert_eq!(config.lsp_servers[2].language_id, "typescript");
1031 assert_eq!(config.lsp_servers[3].language_id, "go");
1032 assert_eq!(config.lsp_servers[4].language_id, "cpp");
1033 assert_eq!(config.lsp_servers[5].language_id, "zig");
1034 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
1035 }
1036
1037 #[test]
1038 fn test_default_position_encodings() {
1039 let encodings = default_position_encodings();
1040 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
1041 }
1042
1043 #[test]
1044 fn test_load_from_valid_toml() {
1045 let tmp_dir = TempDir::new().unwrap();
1046 let config_path = tmp_dir.path().join("config.toml");
1047 let workspace_root = tmp_dir.path().join("workspace");
1048 fs::create_dir(&workspace_root).unwrap();
1049 let workspace_root_literal = toml_path_literal(&workspace_root);
1050
1051 let toml_content = format!(
1052 r#"
1053 [workspace]
1054 roots = [{workspace_root_literal}]
1055 position_encodings = ["utf-8"]
1056
1057 [[lsp_servers]]
1058 language_id = "rust"
1059 command = "rust-analyzer"
1060 timeout_seconds = 30
1061 "#
1062 );
1063
1064 fs::write(&config_path, &toml_content).unwrap();
1065
1066 let config = ServerConfig::load_from(&config_path).unwrap();
1067 assert_eq!(
1068 config.workspace.roots,
1069 vec![dunce::canonicalize(workspace_root).unwrap()]
1070 );
1071 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
1072 assert_eq!(config.lsp_servers.len(), 1);
1073 assert_eq!(config.lsp_servers[0].language_id, "rust");
1074 }
1075
1076 #[test]
1077 fn test_load_from_resolves_relative_roots_against_config_directory() {
1078 let tmp_dir = TempDir::new().unwrap();
1079 let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1080 let config_dir = project_root.join(".agents");
1081 fs::create_dir(&config_dir).unwrap();
1082 let config_path = config_dir.join("mcpls.toml");
1083 fs::write(
1084 &config_path,
1085 r#"
1086 [workspace]
1087 roots = [".", ".."]
1088 "#,
1089 )
1090 .unwrap();
1091
1092 let config = ServerConfig::load_from(&config_path).unwrap();
1093
1094 assert_eq!(config.workspace.roots, vec![config_dir, project_root]);
1095 assert!(config.workspace.roots.iter().all(|root| root.is_absolute()));
1096 }
1097
1098 #[test]
1108 fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1109 let config_tmp_dir = TempDir::new().unwrap();
1110 let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1111 let config_path = config_dir.join("mcpls.toml");
1112 fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1113
1114 let cwd_tmp_dir = TempDir::new().unwrap();
1115 let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1116 let expected_root = cwd.join("relative-root");
1117 fs::create_dir(&expected_root).unwrap();
1118
1119 let config = {
1120 let _guard = CwdGuard::enter(&cwd);
1121 ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1122 };
1123
1124 assert_eq!(config.workspace.roots, vec![expected_root]);
1125 }
1126
1127 #[test]
1128 fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1129 let tmp_dir = TempDir::new().unwrap();
1130 let config_path = tmp_dir.path().join("mcpls.toml");
1131 fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1132
1133 let err = ServerConfig::load_from(&config_path).unwrap_err();
1134
1135 let Error::InvalidConfig(message) = err else {
1136 panic!("expected InvalidConfig, got {err:?}");
1137 };
1138 assert!(message.contains("workspace root 'missing'"));
1139 let config_dir = dunce::canonicalize(tmp_dir.path()).unwrap();
1140 assert!(message.contains(&config_dir.display().to_string()));
1141 }
1142
1143 #[test]
1144 fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1145 let tmp_dir = TempDir::new().unwrap();
1148 let config_path = tmp_dir.path().join("config.toml");
1149
1150 let toml_content = r#"
1151 [[lsp_servers]]
1152 language_id = "rust"
1153 command = "rust-analyzer"
1154 timeout_seconds = 30
1155 "#;
1156
1157 fs::write(&config_path, toml_content).unwrap();
1158
1159 let config = ServerConfig::load_from(&config_path).unwrap();
1160 assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
1161 }
1162
1163 #[test]
1164 fn test_validate_rejects_zero_timeout_seconds() {
1165 let tmp_dir = TempDir::new().unwrap();
1166 let config_path = tmp_dir.path().join("config.toml");
1167
1168 let toml_content = r#"
1169 [[lsp_servers]]
1170 language_id = "rust"
1171 command = "rust-analyzer"
1172 timeout_seconds = 0
1173 "#;
1174
1175 fs::write(&config_path, toml_content).unwrap();
1176
1177 let result = ServerConfig::load_from(&config_path);
1178 if let Err(Error::InvalidConfig(msg)) = result {
1179 assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
1184 } else {
1185 panic!("Expected InvalidConfig error, got {result:?}");
1186 }
1187 }
1188
1189 #[test]
1190 fn test_validate_rejects_zero_request_timeout_seconds() {
1191 let tmp_dir = TempDir::new().unwrap();
1192 let config_path = tmp_dir.path().join("config.toml");
1193
1194 let toml_content = r#"
1195 [[lsp_servers]]
1196 language_id = "rust"
1197 command = "rust-analyzer"
1198 request_timeout_seconds = 0
1199 "#;
1200
1201 fs::write(&config_path, toml_content).unwrap();
1202
1203 let result = ServerConfig::load_from(&config_path);
1204 if let Err(Error::InvalidConfig(msg)) = result {
1205 assert_eq!(
1206 msg,
1207 "request_timeout_seconds cannot be 0 for language 'rust'"
1208 );
1209 } else {
1210 panic!("Expected InvalidConfig error, got {result:?}");
1211 }
1212 }
1213
1214 #[test]
1215 fn test_validate_rejects_request_timeout_seconds_above_max() {
1216 let tmp_dir = TempDir::new().unwrap();
1217 let config_path = tmp_dir.path().join("config.toml");
1218
1219 let toml_content = format!(
1220 r#"
1221 [[lsp_servers]]
1222 language_id = "rust"
1223 command = "rust-analyzer"
1224 request_timeout_seconds = {}
1225 "#,
1226 MAX_TIMEOUT_SECONDS + 1
1227 );
1228
1229 fs::write(&config_path, toml_content).unwrap();
1230
1231 let result = ServerConfig::load_from(&config_path);
1232 if let Err(Error::InvalidConfig(msg)) = result {
1233 assert!(msg.contains("request_timeout_seconds"));
1234 assert!(msg.contains("exceeds the maximum"));
1235 } else {
1236 panic!("Expected InvalidConfig error, got {result:?}");
1237 }
1238 }
1239
1240 #[test]
1241 fn test_validate_accepts_request_timeout_seconds_at_max() {
1242 let tmp_dir = TempDir::new().unwrap();
1243 let config_path = tmp_dir.path().join("config.toml");
1244
1245 let toml_content = format!(
1246 r#"
1247 [[lsp_servers]]
1248 language_id = "rust"
1249 command = "rust-analyzer"
1250 request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1251 "#
1252 );
1253
1254 fs::write(&config_path, toml_content).unwrap();
1255
1256 let result = ServerConfig::load_from(&config_path);
1257 assert!(result.is_ok(), "expected Ok, got {result:?}");
1258 }
1259
1260 #[test]
1261 fn test_validate_rejects_timeout_seconds_above_max() {
1262 let tmp_dir = TempDir::new().unwrap();
1263 let config_path = tmp_dir.path().join("config.toml");
1264
1265 let toml_content = format!(
1266 r#"
1267 [[lsp_servers]]
1268 language_id = "rust"
1269 command = "rust-analyzer"
1270 timeout_seconds = {}
1271 "#,
1272 MAX_TIMEOUT_SECONDS + 1
1273 );
1274
1275 fs::write(&config_path, toml_content).unwrap();
1276
1277 let result = ServerConfig::load_from(&config_path);
1278 if let Err(Error::InvalidConfig(msg)) = result {
1279 assert!(msg.contains("timeout_seconds"));
1280 assert!(msg.contains("exceeds the maximum"));
1281 } else {
1282 panic!("Expected InvalidConfig error, got {result:?}");
1283 }
1284 }
1285
1286 #[test]
1287 fn test_validate_accepts_timeout_seconds_at_max() {
1288 let tmp_dir = TempDir::new().unwrap();
1289 let config_path = tmp_dir.path().join("config.toml");
1290
1291 let toml_content = format!(
1292 r#"
1293 [[lsp_servers]]
1294 language_id = "rust"
1295 command = "rust-analyzer"
1296 timeout_seconds = {MAX_TIMEOUT_SECONDS}
1297 "#
1298 );
1299
1300 fs::write(&config_path, toml_content).unwrap();
1301
1302 let result = ServerConfig::load_from(&config_path);
1303 assert!(result.is_ok(), "expected Ok, got {result:?}");
1304 }
1305
1306 #[test]
1307 fn test_load_from_nonexistent_file() {
1308 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
1309 assert!(result.is_err());
1310
1311 if let Err(Error::ConfigNotFound(path)) = result {
1312 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
1313 } else {
1314 panic!("Expected ConfigNotFound error");
1315 }
1316 }
1317
1318 #[test]
1319 fn test_load_from_invalid_toml() {
1320 let tmp_dir = TempDir::new().unwrap();
1321 let config_path = tmp_dir.path().join("invalid.toml");
1322
1323 fs::write(&config_path, "invalid toml content {{}").unwrap();
1324
1325 let result = ServerConfig::load_from(&config_path);
1326 assert!(result.is_err());
1327 }
1328
1329 #[test]
1333 fn test_load_from_rejects_oversized_file() {
1334 let tmp_dir = TempDir::new().unwrap();
1335 let config_path = tmp_dir.path().join("oversized.toml");
1336
1337 let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1340 fs::write(&config_path, &oversized).unwrap();
1341
1342 let result = ServerConfig::load_from(&config_path);
1343 assert!(matches!(
1344 result,
1345 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1346 ));
1347 }
1348
1349 #[test]
1350 fn test_load_from_accepts_file_at_exact_size_cap() {
1351 let tmp_dir = TempDir::new().unwrap();
1352 let config_path = tmp_dir.path().join("exact.toml");
1353
1354 let mut toml_content = "[workspace]\n# ".to_string();
1357 toml_content.push_str(
1358 &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1359 );
1360 assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1361 fs::write(&config_path, &toml_content).unwrap();
1362
1363 let result = ServerConfig::load_from(&config_path);
1364 assert!(result.is_ok(), "expected Ok, got {result:?}");
1365 }
1366
1367 #[cfg(unix)]
1374 #[test]
1375 fn test_load_from_rejects_infinite_special_file() {
1376 let path = Path::new("/dev/zero");
1377 assert_eq!(
1378 fs::metadata(path).unwrap().len(),
1379 0,
1380 "test assumption: /dev/zero must report zero length"
1381 );
1382
1383 let result = ServerConfig::load_from(path);
1384 assert!(matches!(
1385 result,
1386 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1387 ));
1388 }
1389
1390 #[test]
1391 fn test_validate_empty_language_id() {
1392 let tmp_dir = TempDir::new().unwrap();
1393 let config_path = tmp_dir.path().join("config.toml");
1394
1395 let toml_content = r#"
1396 [[lsp_servers]]
1397 language_id = ""
1398 command = "test"
1399 "#;
1400
1401 fs::write(&config_path, toml_content).unwrap();
1402
1403 let result = ServerConfig::load_from(&config_path);
1404 assert!(result.is_err());
1405
1406 if let Err(Error::InvalidConfig(msg)) = result {
1407 assert!(msg.contains("language_id cannot be empty"));
1408 } else {
1409 panic!("Expected InvalidConfig error");
1410 }
1411 }
1412
1413 #[test]
1414 fn test_validate_empty_command() {
1415 let tmp_dir = TempDir::new().unwrap();
1416 let config_path = tmp_dir.path().join("config.toml");
1417
1418 let toml_content = r#"
1419 [[lsp_servers]]
1420 language_id = "rust"
1421 command = ""
1422 "#;
1423
1424 fs::write(&config_path, toml_content).unwrap();
1425
1426 let result = ServerConfig::load_from(&config_path);
1427 assert!(result.is_err());
1428
1429 if let Err(Error::InvalidConfig(msg)) = result {
1430 assert!(msg.contains("command cannot be empty"));
1431 } else {
1432 panic!("Expected InvalidConfig error");
1433 }
1434 }
1435
1436 #[test]
1437 fn test_validate_empty_name() {
1438 let tmp_dir = TempDir::new().unwrap();
1439 let config_path = tmp_dir.path().join("config.toml");
1440
1441 let toml_content = r#"
1442 [[lsp_servers]]
1443 name = ""
1444 language_id = "python"
1445 command = "pyright-langserver"
1446 "#;
1447
1448 fs::write(&config_path, toml_content).unwrap();
1449
1450 let result = ServerConfig::load_from(&config_path);
1451 assert!(result.is_err());
1452
1453 if let Err(Error::InvalidConfig(msg)) = result {
1454 assert!(msg.contains("name cannot be empty"));
1455 } else {
1456 panic!("Expected InvalidConfig error");
1457 }
1458 }
1459
1460 #[test]
1461 fn test_validate_empty_handles() {
1462 let tmp_dir = TempDir::new().unwrap();
1463 let config_path = tmp_dir.path().join("config.toml");
1464
1465 let toml_content = r#"
1466 [[lsp_servers]]
1467 language_id = "python"
1468 command = "pylsp"
1469 handles = []
1470 "#;
1471
1472 fs::write(&config_path, toml_content).unwrap();
1473
1474 let result = ServerConfig::load_from(&config_path);
1475 assert!(result.is_err());
1476
1477 if let Err(Error::InvalidConfig(msg)) = result {
1478 assert!(msg.contains("handles cannot be empty"));
1479 } else {
1480 panic!("Expected InvalidConfig error");
1481 }
1482 }
1483
1484 #[test]
1485 fn test_validate_duplicate_tool_in_handles() {
1486 let tmp_dir = TempDir::new().unwrap();
1487 let config_path = tmp_dir.path().join("config.toml");
1488
1489 let toml_content = r#"
1490 [[lsp_servers]]
1491 language_id = "python"
1492 command = "pylsp"
1493 handles = ["diagnostics", "diagnostics"]
1494 "#;
1495
1496 fs::write(&config_path, toml_content).unwrap();
1497
1498 let result = ServerConfig::load_from(&config_path);
1499 assert!(result.is_err());
1500
1501 if let Err(Error::InvalidConfig(msg)) = result {
1502 assert!(msg.contains("duplicate tool"));
1503 assert!(msg.contains("diagnostics"));
1504 } else {
1505 panic!("Expected InvalidConfig error");
1506 }
1507 }
1508
1509 #[test]
1510 fn test_validate_rejects_empty_position_encodings() {
1511 let tmp_dir = TempDir::new().unwrap();
1512 let config_path = tmp_dir.path().join("config.toml");
1513
1514 let toml_content = r"
1515 [workspace]
1516 position_encodings = []
1517 ";
1518
1519 fs::write(&config_path, toml_content).unwrap();
1520
1521 let result = ServerConfig::load_from(&config_path);
1522 if let Err(Error::InvalidConfig(msg)) = result {
1523 assert_eq!(msg, "workspace.position_encodings cannot be empty");
1524 } else {
1525 panic!("Expected InvalidConfig error, got {result:?}");
1526 }
1527 }
1528
1529 #[test]
1534 fn test_validate_rejects_empty_workspace_root_entry() {
1535 let tmp_dir = TempDir::new().unwrap();
1536 let config_path = tmp_dir.path().join("config.toml");
1537
1538 let toml_content = r#"
1539 [workspace]
1540 roots = [""]
1541 "#;
1542
1543 fs::write(&config_path, toml_content).unwrap();
1544
1545 let result = ServerConfig::load_from(&config_path);
1546 if let Err(Error::InvalidConfig(msg)) = result {
1547 assert_eq!(msg, "workspace.roots entries cannot be empty");
1548 } else {
1549 panic!("Expected InvalidConfig error, got {result:?}");
1550 }
1551 }
1552
1553 #[test]
1554 fn test_validate_rejects_unrecognized_position_encoding() {
1555 let tmp_dir = TempDir::new().unwrap();
1556 let config_path = tmp_dir.path().join("config.toml");
1557
1558 let toml_content = r#"
1559 [workspace]
1560 position_encodings = ["utf-8", "utf-7"]
1561 "#;
1562
1563 fs::write(&config_path, toml_content).unwrap();
1564
1565 let result = ServerConfig::load_from(&config_path);
1566 if let Err(Error::InvalidConfig(msg)) = result {
1567 assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1568 } else {
1569 panic!("Expected InvalidConfig error, got {result:?}");
1570 }
1571 }
1572
1573 #[test]
1574 fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1575 assert_eq!(
1576 parse_position_encoding("utf-8"),
1577 Some(lsp_types::PositionEncodingKind::UTF8)
1578 );
1579 assert_eq!(
1580 parse_position_encoding("utf-16"),
1581 Some(lsp_types::PositionEncodingKind::UTF16)
1582 );
1583 assert_eq!(
1584 parse_position_encoding("utf-32"),
1585 Some(lsp_types::PositionEncodingKind::UTF32)
1586 );
1587 assert_eq!(parse_position_encoding("utf-7"), None);
1588 }
1589
1590 #[test]
1591 fn test_validate_duplicate_name_warns_but_loads() {
1592 let tmp_dir = TempDir::new().unwrap();
1597 let config_path = tmp_dir.path().join("config.toml");
1598
1599 let toml_content = r#"
1600 [[lsp_servers]]
1601 name = "dup"
1602 language_id = "python"
1603 command = "pyright-langserver"
1604
1605 [[lsp_servers]]
1606 name = "dup"
1607 language_id = "typescript"
1608 command = "typescript-language-server"
1609 "#;
1610
1611 fs::write(&config_path, toml_content).unwrap();
1612
1613 let result = ServerConfig::load_from(&config_path);
1614 assert!(result.is_ok(), "duplicate name must only warn at load time");
1615 }
1616
1617 #[test]
1618 fn test_workspace_config_defaults() {
1619 let workspace = WorkspaceConfig::default();
1620 assert!(workspace.roots.is_empty());
1621 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1622 assert!(!workspace.language_extensions.is_empty());
1623 assert_eq!(workspace.language_extensions.len(), 30);
1624 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1625 }
1626
1627 #[test]
1628 fn test_load_multiple_servers() {
1629 let tmp_dir = TempDir::new().unwrap();
1630 let config_path = tmp_dir.path().join("multi.toml");
1631
1632 let toml_content = r#"
1633 [[lsp_servers]]
1634 language_id = "rust"
1635 command = "rust-analyzer"
1636
1637 [[lsp_servers]]
1638 language_id = "python"
1639 command = "pyright-langserver"
1640 args = ["--stdio"]
1641 "#;
1642
1643 fs::write(&config_path, toml_content).unwrap();
1644
1645 let config = ServerConfig::load_from(&config_path).unwrap();
1646 assert_eq!(config.lsp_servers.len(), 2);
1647 assert_eq!(config.lsp_servers[0].language_id, "rust");
1648 assert_eq!(config.lsp_servers[1].language_id, "python");
1649 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1650 }
1651
1652 #[test]
1653 fn test_deny_unknown_fields() {
1654 let tmp_dir = TempDir::new().unwrap();
1655 let config_path = tmp_dir.path().join("unknown.toml");
1656
1657 let toml_content = r#"
1658 unknown_field = "value"
1659
1660 [workspace]
1661 roots = []
1662 "#;
1663
1664 fs::write(&config_path, toml_content).unwrap();
1665
1666 let result = ServerConfig::load_from(&config_path);
1667 assert!(result.is_err(), "Should reject unknown fields");
1668 }
1669
1670 #[test]
1671 fn test_empty_config_file() {
1672 let tmp_dir = TempDir::new().unwrap();
1673 let config_path = tmp_dir.path().join("empty.toml");
1674
1675 fs::write(&config_path, "").unwrap();
1676
1677 let config = ServerConfig::load_from(&config_path).unwrap();
1678 assert!(config.workspace.roots.is_empty());
1679 assert!(config.lsp_servers.is_empty());
1680 }
1681
1682 #[test]
1683 fn test_config_with_initialization_options() {
1684 let tmp_dir = TempDir::new().unwrap();
1685 let config_path = tmp_dir.path().join("init_opts.toml");
1686
1687 let toml_content = r#"
1688 [[lsp_servers]]
1689 language_id = "rust"
1690 command = "rust-analyzer"
1691
1692 [lsp_servers.initialization_options]
1693 cargo = { allFeatures = true }
1694 "#;
1695
1696 fs::write(&config_path, toml_content).unwrap();
1697
1698 let config = ServerConfig::load_from(&config_path).unwrap();
1699 assert!(config.lsp_servers[0].initialization_options.is_some());
1700 }
1701
1702 #[test]
1703 fn test_language_extensions_in_config() {
1704 let tmp_dir = TempDir::new().unwrap();
1705 let config_path = tmp_dir.path().join("extensions.toml");
1706
1707 let toml_content = r#"
1708 [[workspace.language_extensions]]
1709 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1710 language_id = "cpp"
1711
1712 [[workspace.language_extensions]]
1713 extensions = ["nu"]
1714 language_id = "nushell"
1715
1716 [[workspace.language_extensions]]
1717 extensions = ["py", "pyw", "pyi"]
1718 language_id = "python"
1719 "#;
1720
1721 fs::write(&config_path, toml_content).unwrap();
1722
1723 let config = ServerConfig::load_from(&config_path).unwrap();
1724 assert_eq!(config.workspace.language_extensions.len(), 3);
1725
1726 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
1728 assert_eq!(
1729 config.workspace.language_extensions[0].extensions,
1730 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1731 );
1732
1733 assert_eq!(
1735 config.workspace.language_extensions[1].language_id,
1736 "nushell"
1737 );
1738 assert_eq!(
1739 config.workspace.language_extensions[1].extensions,
1740 vec!["nu"]
1741 );
1742 }
1743
1744 #[test]
1745 fn test_build_extension_map() {
1746 let workspace = WorkspaceConfig {
1747 roots: vec![],
1748 position_encodings: vec![],
1749 language_extensions: vec![
1750 LanguageExtensionMapping {
1751 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
1752 language_id: "cpp".to_string(),
1753 },
1754 LanguageExtensionMapping {
1755 extensions: vec!["nu".to_string()],
1756 language_id: "nushell".to_string(),
1757 },
1758 ],
1759 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1760 max_documents: DEFAULT_MAX_DOCUMENTS,
1761 max_file_size: DEFAULT_MAX_FILE_SIZE,
1762 };
1763
1764 let map = workspace.build_extension_map();
1765 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
1766 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
1767 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
1768 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
1769 assert_eq!(map.get("unknown"), None);
1770 }
1771
1772 #[test]
1773 fn test_extract_extension_from_pattern_empty_string() {
1774 assert_eq!(extract_extension_from_pattern(""), None);
1775 }
1776
1777 #[test]
1778 fn test_extract_extension_from_pattern_without_dot() {
1779 assert_eq!(extract_extension_from_pattern("**/*"), None);
1780 }
1781
1782 #[test]
1783 fn test_extract_extension_from_pattern_dotfile() {
1784 assert_eq!(extract_extension_from_pattern(".gitignore"), None);
1785 }
1786
1787 #[test]
1788 fn test_extract_extension_from_pattern_multi_dot_extension() {
1789 assert_eq!(
1790 extract_extension_from_pattern("foo.tar.gz"),
1791 Some("gz".to_string())
1792 );
1793 }
1794
1795 #[test]
1796 fn test_build_effective_extension_map_overrides_with_file_patterns() {
1797 let config = ServerConfig {
1798 mcp: McpConfig::default(),
1799 workspace: WorkspaceConfig::default(),
1800 lsp_servers: vec![LspServerConfig {
1801 language_id: "cpp".to_string(),
1802 command: "clangd".to_string(),
1803 args: vec![],
1804 env: HashMap::new(),
1805 file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
1806 initialization_options: None,
1807 timeout_seconds: 30,
1808 request_timeout_seconds: 30,
1809 heuristics: None,
1810 name: None,
1811 handles: None,
1812 }],
1813 project_config_ignored: false,
1814 };
1815
1816 let map = config.build_effective_extension_map();
1817 assert_eq!(map.get("c"), Some(&"cpp".to_string()));
1818 assert_eq!(map.get("h"), Some(&"cpp".to_string()));
1819 }
1820
1821 #[test]
1822 fn test_build_effective_extension_map_derives_tsx_language_id() {
1823 let config = ServerConfig {
1824 mcp: McpConfig::default(),
1825 workspace: WorkspaceConfig::default(),
1826 lsp_servers: vec![LspServerConfig {
1827 language_id: "typescript".to_string(),
1828 command: "tsgo".to_string(),
1829 args: vec!["--lsp".to_string(), "--stdio".to_string()],
1830 env: HashMap::new(),
1831 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
1832 initialization_options: None,
1833 timeout_seconds: 30,
1834 request_timeout_seconds: 30,
1835 heuristics: None,
1836 name: None,
1837 handles: None,
1838 }],
1839 project_config_ignored: false,
1840 };
1841
1842 let map = config.build_effective_extension_map();
1843 assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1844 assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1845 }
1846
1847 #[test]
1848 fn test_build_effective_extension_map_derives_jsx_language_id() {
1849 let config = ServerConfig {
1850 mcp: McpConfig::default(),
1851 workspace: WorkspaceConfig::default(),
1852 lsp_servers: vec![LspServerConfig {
1853 language_id: "javascript".to_string(),
1854 command: "typescript-language-server".to_string(),
1855 args: vec!["--stdio".to_string()],
1856 env: HashMap::new(),
1857 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1858 initialization_options: None,
1859 timeout_seconds: 30,
1860 request_timeout_seconds: 30,
1861 heuristics: None,
1862 name: None,
1863 handles: None,
1864 }],
1865 project_config_ignored: false,
1866 };
1867
1868 let map = config.build_effective_extension_map();
1869 assert_eq!(map.get("js"), Some(&"javascript".to_string()));
1870 assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
1871 }
1872
1873 #[test]
1874 fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
1875 let config = ServerConfig {
1876 mcp: McpConfig::default(),
1877 workspace: WorkspaceConfig::default(),
1878 lsp_servers: vec![LspServerConfig {
1879 language_id: "cpp".to_string(),
1880 command: "clangd".to_string(),
1881 args: vec![],
1882 env: HashMap::new(),
1883 file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
1884 initialization_options: None,
1885 timeout_seconds: 30,
1886 request_timeout_seconds: 30,
1887 heuristics: None,
1888 name: None,
1889 handles: None,
1890 }],
1891 project_config_ignored: false,
1892 };
1893
1894 let map = config.build_effective_extension_map();
1895 assert_eq!(map.get("h"), Some(&"c".to_string()));
1897 }
1898
1899 #[test]
1900 fn test_get_language_for_extension() {
1901 let workspace = WorkspaceConfig {
1902 roots: vec![],
1903 position_encodings: vec![],
1904 language_extensions: vec![
1905 LanguageExtensionMapping {
1906 extensions: vec!["hpp".to_string(), "hh".to_string()],
1907 language_id: "cpp".to_string(),
1908 },
1909 LanguageExtensionMapping {
1910 extensions: vec!["py".to_string()],
1911 language_id: "python".to_string(),
1912 },
1913 ],
1914 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1915 max_documents: DEFAULT_MAX_DOCUMENTS,
1916 max_file_size: DEFAULT_MAX_FILE_SIZE,
1917 };
1918
1919 assert_eq!(
1920 workspace.get_language_for_extension("hpp"),
1921 Some("cpp".to_string())
1922 );
1923 assert_eq!(
1924 workspace.get_language_for_extension("hh"),
1925 Some("cpp".to_string())
1926 );
1927 assert_eq!(
1928 workspace.get_language_for_extension("py"),
1929 Some("python".to_string())
1930 );
1931 assert_eq!(workspace.get_language_for_extension("unknown"), None);
1932 }
1933
1934 #[test]
1935 fn test_default_language_extensions() {
1936 let workspace = WorkspaceConfig::default();
1937 let map = workspace.build_extension_map();
1938 assert!(!map.is_empty());
1939 assert_eq!(
1940 workspace.get_language_for_extension("rs"),
1941 Some("rust".to_string())
1942 );
1943 assert_eq!(
1944 workspace.get_language_for_extension("py"),
1945 Some("python".to_string())
1946 );
1947 assert_eq!(
1948 workspace.get_language_for_extension("cpp"),
1949 Some("cpp".to_string())
1950 );
1951 }
1952
1953 #[test]
1954 fn test_create_default_config_file() {
1955 let tmp_dir = TempDir::new().unwrap();
1956 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
1957
1958 ServerConfig::create_default_config_file(&config_path).unwrap();
1959
1960 assert!(config_path.exists());
1961
1962 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
1963 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
1964 assert_eq!(loaded_config.lsp_servers.len(), 6);
1965 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
1966 }
1967
1968 #[test]
1969 fn test_load_returns_default_config() {
1970 let config = ServerConfig::default();
1972 assert_eq!(config.workspace.language_extensions.len(), 30);
1973 assert_eq!(config.lsp_servers.len(), 6);
1974 assert_eq!(config.lsp_servers[0].language_id, "rust");
1975 }
1976
1977 use crate::test_support::CwdGuard;
1985
1986 fn assert_mcpls_config_env_unset() {
1999 assert!(
2000 std::env::var_os("MCPLS_CONFIG").is_none(),
2001 "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2002 load_with_trust returns before consulting CWD when it's set"
2003 );
2004 }
2005
2006 #[test]
2007 fn test_load_ignores_untrusted_project_local_config() {
2008 let tmp_dir = TempDir::new().unwrap();
2016 let config_path = tmp_dir.path().join("mcpls.toml");
2017
2018 let custom_toml = r#"
2024 [workspace]
2025 roots = ["/should-never-load-attacker-path"]
2026
2027 [[lsp_servers]]
2028 language_id = "definitely-not-a-real-language-marker"
2029 command = "rm"
2030 args = ["-rf", "/"]
2031 "#;
2032
2033 fs::write(&config_path, custom_toml).unwrap();
2034
2035 let config = {
2036 let _guard = CwdGuard::enter(tmp_dir.path());
2037 ServerConfig::load().unwrap()
2038 };
2039
2040 assert!(
2041 !config
2042 .workspace
2043 .roots
2044 .contains(&PathBuf::from("/should-never-load-attacker-path"))
2045 );
2046 assert!(
2047 !config
2048 .lsp_servers
2049 .iter()
2050 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2051 );
2052 }
2053
2054 #[test]
2055 fn test_load_with_trust_loads_trusted_project_local_config() {
2056 let tmp_dir = TempDir::new().unwrap();
2057 let config_path = tmp_dir.path().join("mcpls.toml");
2058 let custom_root = tmp_dir.path().join("custom");
2059 fs::create_dir(&custom_root).unwrap();
2060 let custom_root_literal = toml_path_literal(&custom_root);
2061
2062 let custom_toml = format!(
2063 r#"
2064 [workspace]
2065 roots = [{custom_root_literal}]
2066
2067 [[lsp_servers]]
2068 language_id = "python"
2069 command = "pyright-langserver"
2070 "#
2071 );
2072
2073 fs::write(&config_path, &custom_toml).unwrap();
2074
2075 let config = {
2076 let _guard = CwdGuard::enter(tmp_dir.path());
2077 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2078 };
2079
2080 assert_eq!(
2081 config.workspace.roots,
2082 vec![dunce::canonicalize(custom_root).unwrap()]
2083 );
2084 assert_eq!(config.lsp_servers.len(), 1);
2085 assert_eq!(config.lsp_servers[0].language_id, "python");
2086 }
2087
2088 #[test]
2089 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2090 let tmp_dir = TempDir::new().unwrap();
2091 let config_path = tmp_dir.path().join("mcpls.toml");
2092
2093 let custom_toml = r#"
2094 [workspace]
2095 roots = ["/attacker/controlled"]
2096 heuristics_max_depth = 999999
2097
2098 [[lsp_servers]]
2099 language_id = "evil"
2100 command = "rm"
2101 args = ["-rf", "/"]
2102 "#;
2103
2104 fs::write(&config_path, custom_toml).unwrap();
2105
2106 let config = {
2107 let _guard = CwdGuard::enter(tmp_dir.path());
2108 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2109 };
2110
2111 assert!(
2112 !config
2113 .workspace
2114 .roots
2115 .contains(&PathBuf::from("/attacker/controlled"))
2116 );
2117 assert_ne!(config.workspace.heuristics_max_depth, 999_999);
2118 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2119 }
2120
2121 #[test]
2122 fn test_load_with_trust_sets_project_config_ignored_flag() {
2123 assert_mcpls_config_env_unset();
2124
2125 let tmp_dir = TempDir::new().unwrap();
2126 let config_path = tmp_dir.path().join("mcpls.toml");
2127 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2128
2129 let config = {
2130 let _guard = CwdGuard::enter(tmp_dir.path());
2131 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2132 };
2133 assert!(config.project_config_ignored);
2134
2135 let tmp_dir = TempDir::new().unwrap();
2136 let config_path = tmp_dir.path().join("mcpls.toml");
2137 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2138
2139 let config = {
2140 let _guard = CwdGuard::enter(tmp_dir.path());
2141 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2142 };
2143 assert!(!config.project_config_ignored);
2144 }
2145
2146 #[test]
2147 fn test_load_no_local_config_leaves_flag_unset() {
2148 assert_mcpls_config_env_unset();
2149
2150 let tmp_dir = TempDir::new().unwrap();
2151
2152 let config = {
2153 let _guard = CwdGuard::enter(tmp_dir.path());
2154 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2155 };
2156 assert!(!config.project_config_ignored);
2157 }
2158
2159 #[test]
2160 fn test_config_file_creation_with_proper_structure() {
2161 let tmp_dir = TempDir::new().unwrap();
2162 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2163
2164 ServerConfig::create_default_config_file(&config_path).unwrap();
2165
2166 let content = fs::read_to_string(&config_path).unwrap();
2167
2168 assert!(content.contains("[mcp]"));
2169 assert!(content.contains("[workspace]"));
2170 assert!(content.contains("[[workspace.language_extensions]]"));
2171 assert!(content.contains("[[lsp_servers]]"));
2172 assert!(content.contains("language_id = \"rust\""));
2173 assert!(content.contains("extensions = [\"rs\"]"));
2174 }
2175
2176 #[test]
2177 fn test_heuristics_max_depth_default() {
2178 let config = WorkspaceConfig::default();
2179 assert_eq!(config.heuristics_max_depth, 10);
2180 }
2181
2182 #[test]
2183 fn test_heuristics_max_depth_from_config() {
2184 let tmp_dir = TempDir::new().unwrap();
2185 let config_path = tmp_dir.path().join("depth.toml");
2186
2187 let toml_content = r"
2188 [workspace]
2189 heuristics_max_depth = 5
2190 ";
2191
2192 fs::write(&config_path, toml_content).unwrap();
2193
2194 let config = ServerConfig::load_from(&config_path).unwrap();
2195 assert_eq!(config.workspace.heuristics_max_depth, 5);
2196 }
2197
2198 #[test]
2199 fn test_heuristics_max_depth_uses_default_when_not_specified() {
2200 let tmp_dir = TempDir::new().unwrap();
2201 let config_path = tmp_dir.path().join("no_depth.toml");
2202
2203 let toml_content = r"
2204 [workspace]
2205 roots = []
2206 ";
2207
2208 fs::write(&config_path, toml_content).unwrap();
2209
2210 let config = ServerConfig::load_from(&config_path).unwrap();
2211 assert_eq!(
2212 config.workspace.heuristics_max_depth,
2213 DEFAULT_HEURISTICS_MAX_DEPTH
2214 );
2215 }
2216
2217 #[test]
2218 fn test_max_documents_default() {
2219 let config = WorkspaceConfig::default();
2220 assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
2221 }
2222
2223 #[test]
2224 fn test_max_file_size_default() {
2225 let config = WorkspaceConfig::default();
2226 assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
2227 }
2228
2229 #[test]
2230 fn test_max_documents_from_config() {
2231 let tmp_dir = TempDir::new().unwrap();
2232 let config_path = tmp_dir.path().join("limits.toml");
2233
2234 let toml_content = r"
2235 [workspace]
2236 max_documents = 500
2237 ";
2238
2239 fs::write(&config_path, toml_content).unwrap();
2240
2241 let config = ServerConfig::load_from(&config_path).unwrap();
2242 assert_eq!(config.workspace.max_documents, 500);
2243 }
2244
2245 #[test]
2246 fn test_max_file_size_from_config() {
2247 let tmp_dir = TempDir::new().unwrap();
2248 let config_path = tmp_dir.path().join("limits.toml");
2249
2250 let toml_content = r"
2251 [workspace]
2252 max_file_size = 20971520
2253 ";
2254
2255 fs::write(&config_path, toml_content).unwrap();
2256
2257 let config = ServerConfig::load_from(&config_path).unwrap();
2258 assert_eq!(config.workspace.max_file_size, 20_971_520);
2259 }
2260
2261 #[test]
2262 fn test_max_documents_uses_default_when_not_specified() {
2263 let tmp_dir = TempDir::new().unwrap();
2264 let config_path = tmp_dir.path().join("no_limits.toml");
2265
2266 let toml_content = r"
2267 [workspace]
2268 roots = []
2269 ";
2270
2271 fs::write(&config_path, toml_content).unwrap();
2272
2273 let config = ServerConfig::load_from(&config_path).unwrap();
2274 assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
2275 assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
2276 }
2277
2278 #[test]
2282 fn test_max_file_size_zero_means_unlimited() {
2283 let tmp_dir = TempDir::new().unwrap();
2284 let config_path = tmp_dir.path().join("unlimited.toml");
2285
2286 let toml_content = r"
2287 [workspace]
2288 max_file_size = 0
2289 ";
2290
2291 fs::write(&config_path, toml_content).unwrap();
2292
2293 let config = ServerConfig::load_from(&config_path).unwrap();
2294 assert_eq!(config.workspace.max_file_size, 0);
2295 assert_eq!(config.workspace.resource_limits().max_file_size, 0);
2296 }
2297
2298 #[test]
2299 fn test_workspace_config_resource_limits_maps_fields() {
2300 let workspace = WorkspaceConfig {
2301 max_documents: 250,
2302 max_file_size: 0,
2303 ..WorkspaceConfig::default()
2304 };
2305
2306 let limits = workspace.resource_limits();
2307 assert_eq!(limits.max_documents, 250);
2308 assert_eq!(limits.max_file_size, 0);
2309 }
2310
2311 #[test]
2312 fn test_workspace_config_toml_round_trip() {
2313 let original = WorkspaceConfig {
2314 roots: vec![PathBuf::from("/tmp/round-trip")],
2315 position_encodings: vec!["utf-8".to_string()],
2316 language_extensions: vec![LanguageExtensionMapping {
2317 extensions: vec!["nu".to_string()],
2318 language_id: "nushell".to_string(),
2319 }],
2320 heuristics_max_depth: 5,
2321 max_documents: 500,
2322 max_file_size: 0,
2323 };
2324
2325 let toml_content = toml::to_string_pretty(&original).unwrap();
2326 let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2327
2328 assert_eq!(round_tripped.roots, original.roots);
2329 assert_eq!(
2330 round_tripped.position_encodings,
2331 original.position_encodings
2332 );
2333 assert_eq!(
2334 round_tripped.language_extensions.len(),
2335 original.language_extensions.len()
2336 );
2337 assert_eq!(
2338 round_tripped.language_extensions[0].extensions,
2339 original.language_extensions[0].extensions
2340 );
2341 assert_eq!(
2342 round_tripped.language_extensions[0].language_id,
2343 original.language_extensions[0].language_id
2344 );
2345 assert_eq!(
2346 round_tripped.heuristics_max_depth,
2347 original.heuristics_max_depth
2348 );
2349 assert_eq!(round_tripped.max_documents, original.max_documents);
2350 assert_eq!(round_tripped.max_file_size, original.max_file_size);
2351 }
2352
2353 #[test]
2354 fn test_mcp_config_parses_from_toml_section() {
2355 let tmp_dir = TempDir::new().unwrap();
2356 let config_path = tmp_dir.path().join("config.toml");
2357
2358 let toml_content = r#"
2359 [mcp]
2360 title = "Custom Title"
2361 description = "Custom description"
2362 instructions = "Custom instructions."
2363 "#;
2364
2365 fs::write(&config_path, toml_content).unwrap();
2366
2367 let config = ServerConfig::load_from(&config_path).unwrap();
2368 assert_eq!(config.mcp.title.as_deref(), Some("Custom Title"));
2369 assert_eq!(
2370 config.mcp.description.as_deref(),
2371 Some("Custom description")
2372 );
2373 assert_eq!(
2374 config.mcp.instructions.as_deref(),
2375 Some("Custom instructions.")
2376 );
2377 }
2378
2379 #[test]
2380 fn test_mcp_config_defaults_to_none_when_section_absent() {
2381 let tmp_dir = TempDir::new().unwrap();
2382 let config_path = tmp_dir.path().join("config.toml");
2383
2384 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2385
2386 let config = ServerConfig::load_from(&config_path).unwrap();
2387 assert_eq!(config.mcp.title, None);
2388 assert_eq!(config.mcp.description, None);
2389 assert_eq!(config.mcp.instructions, None);
2390 }
2391
2392 #[test]
2393 fn test_mcp_config_rejects_unknown_field() {
2394 let tmp_dir = TempDir::new().unwrap();
2395 let config_path = tmp_dir.path().join("config.toml");
2396
2397 fs::write(&config_path, "[mcp]\ntool_prefix = \"x\"\n").unwrap();
2401
2402 let result = ServerConfig::load_from(&config_path);
2403 assert!(matches!(result, Err(Error::TomlDe(_))));
2404 }
2405
2406 #[test]
2407 fn test_validate_rejects_empty_mcp_title() {
2408 let tmp_dir = TempDir::new().unwrap();
2409 let config_path = tmp_dir.path().join("config.toml");
2410
2411 fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
2412
2413 let result = ServerConfig::load_from(&config_path);
2414 if let Err(Error::InvalidConfig(msg)) = result {
2415 assert_eq!(
2416 msg,
2417 "mcp.title cannot be empty (omit `title` from the `[mcp]` section to use the \
2418 built-in default)"
2419 );
2420 } else {
2421 panic!("Expected InvalidConfig error, got {result:?}");
2422 }
2423 }
2424
2425 #[test]
2428 fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
2429 let tmp_dir = TempDir::new().unwrap();
2430 let config_path = tmp_dir.path().join("config.toml");
2431
2432 fs::write(&config_path, "[mcp]\ntitle = \" \"\n").unwrap();
2433
2434 let result = ServerConfig::load_from(&config_path);
2435 if let Err(Error::InvalidConfig(msg)) = result {
2436 assert!(msg.contains("cannot be empty"));
2437 } else {
2438 panic!("Expected InvalidConfig error, got {result:?}");
2439 }
2440 }
2441
2442 #[test]
2443 fn test_validate_rejects_empty_mcp_description() {
2444 let tmp_dir = TempDir::new().unwrap();
2445 let config_path = tmp_dir.path().join("config.toml");
2446
2447 fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
2448
2449 let result = ServerConfig::load_from(&config_path);
2450 if let Err(Error::InvalidConfig(msg)) = result {
2451 assert_eq!(
2452 msg,
2453 "mcp.description cannot be empty (omit `description` from the `[mcp]` section \
2454 to use the built-in default)"
2455 );
2456 } else {
2457 panic!("Expected InvalidConfig error, got {result:?}");
2458 }
2459 }
2460
2461 #[test]
2462 fn test_validate_rejects_empty_mcp_instructions() {
2463 let tmp_dir = TempDir::new().unwrap();
2464 let config_path = tmp_dir.path().join("config.toml");
2465
2466 fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
2467
2468 let result = ServerConfig::load_from(&config_path);
2469 if let Err(Error::InvalidConfig(msg)) = result {
2470 assert_eq!(
2471 msg,
2472 "mcp.instructions cannot be empty (omit `instructions` from the `[mcp]` \
2473 section to use the built-in default)"
2474 );
2475 } else {
2476 panic!("Expected InvalidConfig error, got {result:?}");
2477 }
2478 }
2479
2480 #[test]
2481 fn test_validate_rejects_over_length_mcp_title() {
2482 let tmp_dir = TempDir::new().unwrap();
2483 let config_path = tmp_dir.path().join("config.toml");
2484
2485 let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
2486 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2487
2488 let result = ServerConfig::load_from(&config_path);
2489 if let Err(Error::InvalidConfig(msg)) = result {
2490 assert_eq!(
2491 msg,
2492 format!(
2493 "mcp.title exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
2494 MAX_MCP_TITLE_BYTES + 1
2495 )
2496 );
2497 } else {
2498 panic!("Expected InvalidConfig error, got {result:?}");
2499 }
2500 }
2501
2502 #[test]
2503 fn test_validate_accepts_mcp_title_at_exact_cap() {
2504 let tmp_dir = TempDir::new().unwrap();
2505 let config_path = tmp_dir.path().join("config.toml");
2506
2507 let title = "a".repeat(MAX_MCP_TITLE_BYTES);
2508 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2509
2510 let result = ServerConfig::load_from(&config_path);
2511 assert!(result.is_ok(), "expected Ok, got {result:?}");
2512 }
2513
2514 #[test]
2515 fn test_validate_rejects_over_length_mcp_description() {
2516 let tmp_dir = TempDir::new().unwrap();
2517 let config_path = tmp_dir.path().join("config.toml");
2518
2519 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
2520 fs::write(
2521 &config_path,
2522 format!("[mcp]\ndescription = \"{description}\"\n"),
2523 )
2524 .unwrap();
2525
2526 let result = ServerConfig::load_from(&config_path);
2527 if let Err(Error::InvalidConfig(msg)) = result {
2528 assert!(msg.contains("mcp.description exceeds the maximum"));
2529 assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
2530 } else {
2531 panic!("Expected InvalidConfig error, got {result:?}");
2532 }
2533 }
2534
2535 #[test]
2536 fn test_validate_accepts_mcp_description_at_exact_cap() {
2537 let tmp_dir = TempDir::new().unwrap();
2538 let config_path = tmp_dir.path().join("config.toml");
2539
2540 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
2541 fs::write(
2542 &config_path,
2543 format!("[mcp]\ndescription = \"{description}\"\n"),
2544 )
2545 .unwrap();
2546
2547 let result = ServerConfig::load_from(&config_path);
2548 assert!(result.is_ok(), "expected Ok, got {result:?}");
2549 }
2550
2551 #[test]
2556 fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
2557 let tmp_dir = TempDir::new().unwrap();
2558 let config_path = tmp_dir.path().join("config.toml");
2559
2560 let title = "é".repeat(65);
2561 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
2562 assert_eq!(title.chars().count(), 65);
2563 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2564
2565 let result = ServerConfig::load_from(&config_path);
2566 if let Err(Error::InvalidConfig(msg)) = result {
2567 assert!(msg.contains("mcp.title exceeds the maximum"));
2568 } else {
2569 panic!("Expected InvalidConfig error, got {result:?}");
2570 }
2571 }
2572
2573 #[test]
2574 fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
2575 let tmp_dir = TempDir::new().unwrap();
2576 let config_path = tmp_dir.path().join("config.toml");
2577
2578 let title = "é".repeat(64);
2579 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
2580 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2581
2582 let result = ServerConfig::load_from(&config_path);
2583 assert!(result.is_ok(), "expected Ok, got {result:?}");
2584 }
2585
2586 #[test]
2587 fn test_validate_rejects_over_length_mcp_instructions() {
2588 let tmp_dir = TempDir::new().unwrap();
2589 let config_path = tmp_dir.path().join("config.toml");
2590
2591 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
2592 fs::write(
2593 &config_path,
2594 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2595 )
2596 .unwrap();
2597
2598 let result = ServerConfig::load_from(&config_path);
2599 if let Err(Error::InvalidConfig(msg)) = result {
2600 assert!(msg.contains("mcp.instructions exceeds the maximum"));
2601 assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
2602 } else {
2603 panic!("Expected InvalidConfig error, got {result:?}");
2604 }
2605 }
2606
2607 #[test]
2608 fn test_validate_accepts_mcp_instructions_at_exact_cap() {
2609 let tmp_dir = TempDir::new().unwrap();
2610 let config_path = tmp_dir.path().join("config.toml");
2611
2612 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
2613 fs::write(
2614 &config_path,
2615 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2616 )
2617 .unwrap();
2618
2619 let result = ServerConfig::load_from(&config_path);
2620 assert!(result.is_ok(), "expected Ok, got {result:?}");
2621 }
2622}