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 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub tool_prefix: Option<ToolPrefix>,
126}
127
128pub const MAX_MCP_TITLE_BYTES: usize = 128;
133
134pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
138
139pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
146
147pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
191pub struct ToolPrefix(String);
192
193impl ToolPrefix {
194 #[must_use]
196 pub fn as_str(&self) -> &str {
197 &self.0
198 }
199}
200
201impl std::fmt::Display for ToolPrefix {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.write_str(&self.0)
204 }
205}
206
207impl std::str::FromStr for ToolPrefix {
208 type Err = String;
209
210 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
211 validate_tool_prefix(s)?;
212 Ok(Self(s.to_string()))
213 }
214}
215
216impl<'de> Deserialize<'de> for ToolPrefix {
217 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
218 where
219 D: serde::Deserializer<'de>,
220 {
221 let value = String::deserialize(deserializer)?;
222 value.parse().map_err(serde::de::Error::custom)
223 }
224}
225
226fn validate_tool_prefix(value: &str) -> std::result::Result<(), String> {
230 if value.trim().is_empty() {
231 return Err(
232 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
233 use unprefixed tool names)"
234 .to_string(),
235 );
236 }
237 let len = value.len();
238 if len > MAX_MCP_TOOL_PREFIX_BYTES {
239 return Err(format!(
240 "mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} \
241 given)"
242 ));
243 }
244 if let Some(bad) = value
245 .chars()
246 .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
247 {
248 return Err(format!(
249 "mcp.tool_prefix contains an invalid character {bad:?} (allowed: ASCII letters, \
255 digits, '_', and '-')"
256 ));
257 }
258 let first = value.chars().next().unwrap_or_default();
263 let last = value.chars().next_back().unwrap_or_default();
264 if !first.is_ascii_alphanumeric() {
265 return Err(format!(
266 "mcp.tool_prefix cannot start with '{first}' (must start with an ASCII letter or \
267 digit)"
268 ));
269 }
270 if !last.is_ascii_alphanumeric() {
271 return Err(format!(
272 "mcp.tool_prefix cannot end with '{last}' (the '_' separator between the prefix \
273 and each tool name is inserted automatically by mcpls -- remove the trailing \
274 separator character)"
275 ));
276 }
277 Ok(())
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct WorkspaceConfig {
284 #[serde(default)]
286 pub roots: Vec<PathBuf>,
287
288 #[serde(default = "default_position_encodings")]
297 pub position_encodings: Vec<String>,
298
299 #[serde(default)]
302 pub language_extensions: Vec<LanguageExtensionMapping>,
303
304 #[serde(default = "default_heuristics_max_depth")]
308 pub heuristics_max_depth: usize,
309
310 #[serde(default = "default_max_documents")]
320 pub max_documents: usize,
321
322 #[serde(default = "default_max_file_size")]
327 pub max_file_size: u64,
328}
329
330impl Default for WorkspaceConfig {
331 fn default() -> Self {
332 Self {
333 roots: Vec::new(),
334 position_encodings: default_position_encodings(),
335 language_extensions: default_language_extensions(),
336 heuristics_max_depth: default_heuristics_max_depth(),
337 max_documents: default_max_documents(),
338 max_file_size: default_max_file_size(),
339 }
340 }
341}
342
343const fn default_heuristics_max_depth() -> usize {
344 DEFAULT_HEURISTICS_MAX_DEPTH
345}
346
347const fn default_max_documents() -> usize {
348 DEFAULT_MAX_DOCUMENTS
349}
350
351const fn default_max_file_size() -> u64 {
352 DEFAULT_MAX_FILE_SIZE
353}
354
355impl WorkspaceConfig {
356 #[must_use]
363 pub fn build_extension_map(&self) -> HashMap<String, String> {
364 let mut map = HashMap::new();
365 for mapping in &self.language_extensions {
366 for ext in &mapping.extensions {
367 map.insert(ext.clone(), mapping.language_id.clone());
368 }
369 }
370 map
371 }
372
373 #[must_use]
383 pub fn language_for_extension(&self, extension: &str) -> Option<String> {
384 for mapping in &self.language_extensions {
385 if mapping.extensions.contains(&extension.to_string()) {
386 return Some(mapping.language_id.clone());
387 }
388 }
389 None
390 }
391
392 #[must_use]
395 pub const fn resource_limits(&self) -> ResourceLimits {
396 ResourceLimits {
397 max_documents: self.max_documents,
398 max_file_size: self.max_file_size,
399 }
400 }
401}
402
403fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
408 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
409 if basename.starts_with('.') {
410 return None;
411 }
412
413 let (_, ext) = basename.rsplit_once('.')?;
414 if ext.is_empty() {
415 return None;
416 }
417
418 if ext
420 .chars()
421 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
422 {
423 Some(ext.to_string())
424 } else {
425 None
426 }
427}
428
429fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
430 react_variant_language_id(server_language_id, extension)
431 .unwrap_or(server_language_id)
432 .to_string()
433}
434
435pub(crate) fn default_position_encodings() -> Vec<String> {
452 vec!["utf-8".to_string(), "utf-16".to_string()]
453}
454
455pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
465 match value {
466 "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
467 "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
468 "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
469 _ => None,
470 }
471}
472
473#[allow(clippy::too_many_lines)]
478fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
479 vec![
480 LanguageExtensionMapping {
481 extensions: vec!["rs".to_string()],
482 language_id: "rust".to_string(),
483 },
484 LanguageExtensionMapping {
485 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
486 language_id: "python".to_string(),
487 },
488 LanguageExtensionMapping {
489 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
490 language_id: "javascript".to_string(),
491 },
492 LanguageExtensionMapping {
493 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
494 language_id: "typescript".to_string(),
495 },
496 LanguageExtensionMapping {
497 extensions: vec!["tsx".to_string()],
498 language_id: "typescriptreact".to_string(),
499 },
500 LanguageExtensionMapping {
501 extensions: vec!["jsx".to_string()],
502 language_id: "javascriptreact".to_string(),
503 },
504 LanguageExtensionMapping {
505 extensions: vec!["go".to_string()],
506 language_id: "go".to_string(),
507 },
508 LanguageExtensionMapping {
509 extensions: vec!["c".to_string(), "h".to_string()],
510 language_id: "c".to_string(),
511 },
512 LanguageExtensionMapping {
513 extensions: vec![
514 "cpp".to_string(),
515 "cc".to_string(),
516 "cxx".to_string(),
517 "hpp".to_string(),
518 "hh".to_string(),
519 "hxx".to_string(),
520 ],
521 language_id: "cpp".to_string(),
522 },
523 LanguageExtensionMapping {
524 extensions: vec!["java".to_string()],
525 language_id: "java".to_string(),
526 },
527 LanguageExtensionMapping {
528 extensions: vec!["rb".to_string()],
529 language_id: "ruby".to_string(),
530 },
531 LanguageExtensionMapping {
532 extensions: vec!["php".to_string()],
533 language_id: "php".to_string(),
534 },
535 LanguageExtensionMapping {
536 extensions: vec!["swift".to_string()],
537 language_id: "swift".to_string(),
538 },
539 LanguageExtensionMapping {
540 extensions: vec!["kt".to_string(), "kts".to_string()],
541 language_id: "kotlin".to_string(),
542 },
543 LanguageExtensionMapping {
544 extensions: vec!["scala".to_string(), "sc".to_string()],
545 language_id: "scala".to_string(),
546 },
547 LanguageExtensionMapping {
548 extensions: vec!["zig".to_string()],
549 language_id: "zig".to_string(),
550 },
551 LanguageExtensionMapping {
552 extensions: vec!["lua".to_string()],
553 language_id: "lua".to_string(),
554 },
555 LanguageExtensionMapping {
556 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
557 language_id: "shellscript".to_string(),
558 },
559 LanguageExtensionMapping {
560 extensions: vec!["json".to_string()],
561 language_id: "json".to_string(),
562 },
563 LanguageExtensionMapping {
564 extensions: vec!["toml".to_string()],
565 language_id: "toml".to_string(),
566 },
567 LanguageExtensionMapping {
568 extensions: vec!["yaml".to_string(), "yml".to_string()],
569 language_id: "yaml".to_string(),
570 },
571 LanguageExtensionMapping {
572 extensions: vec!["xml".to_string()],
573 language_id: "xml".to_string(),
574 },
575 LanguageExtensionMapping {
576 extensions: vec!["html".to_string(), "htm".to_string()],
577 language_id: "html".to_string(),
578 },
579 LanguageExtensionMapping {
580 extensions: vec!["css".to_string()],
581 language_id: "css".to_string(),
582 },
583 LanguageExtensionMapping {
584 extensions: vec!["scss".to_string()],
585 language_id: "scss".to_string(),
586 },
587 LanguageExtensionMapping {
588 extensions: vec!["less".to_string()],
589 language_id: "less".to_string(),
590 },
591 LanguageExtensionMapping {
592 extensions: vec!["md".to_string(), "markdown".to_string()],
593 language_id: "markdown".to_string(),
594 },
595 LanguageExtensionMapping {
596 extensions: vec!["cs".to_string()],
597 language_id: "csharp".to_string(),
598 },
599 LanguageExtensionMapping {
600 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
601 language_id: "fsharp".to_string(),
602 },
603 LanguageExtensionMapping {
604 extensions: vec!["r".to_string(), "R".to_string()],
605 language_id: "r".to_string(),
606 },
607 ]
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625pub enum ProjectConfigTrust {
626 Untrusted,
629 Trusted,
631}
632
633const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
656
657#[derive(Clone, Copy, Debug, PartialEq, Eq)]
660enum RelativeRootBase {
661 ConfigDir,
667 Cwd,
672}
673
674impl ServerConfig {
675 #[must_use]
680 pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
681 let mut map = self.workspace.build_extension_map();
682
683 for server in &self.lsp_servers {
684 for pattern in &server.file_patterns {
685 if let Some(ext) = extract_extension_from_pattern(pattern) {
686 let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
687 map.insert(ext, language_id);
688 }
689 }
690 }
691
692 map
693 }
694
695 pub fn load() -> Result<Self> {
720 Self::load_with_trust(ProjectConfigTrust::Untrusted)
721 }
722
723 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
754 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
762 return Self::load_from(Path::new(&path));
763 }
764
765 let mut project_config_ignored = false;
766
767 let local_config = PathBuf::from("mcpls.toml");
768 if local_config.exists() {
769 match trust {
770 ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
771 ProjectConfigTrust::Untrusted => {
772 project_config_ignored = true;
773 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
774 std::env::current_dir()
775 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
776 });
777 tracing::warn!(
778 "ignoring untrusted project-local config at {}; pass \
779 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
780 load it",
781 display_path.display()
782 );
783 }
784 }
785 }
786
787 if let Some(config_dir) = dirs::config_dir() {
788 let user_config = config_dir.join("mcpls").join("mcpls.toml");
789 if user_config.exists() {
790 let mut config =
799 Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
800 config.project_config_ignored = project_config_ignored;
801 return Ok(config);
802 }
803
804 if let Err(e) = Self::create_default_config_file(&user_config) {
806 tracing::warn!(
807 "Failed to create default config at {}: {}. Using in-memory defaults.",
808 user_config.display(),
809 e
810 );
811 } else {
812 tracing::info!("Created default config at {}", user_config.display());
813 }
814 }
815
816 Ok(Self {
818 project_config_ignored,
819 ..Self::default()
820 })
821 }
822
823 pub fn load_from(path: &Path) -> Result<Self> {
835 Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
836 }
837
838 fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
847 let file = std::fs::File::open(path).map_err(|e| {
848 if e.kind() == std::io::ErrorKind::NotFound {
849 Error::ConfigNotFound(path.to_path_buf())
850 } else {
851 Error::Io(e)
852 }
853 })?;
854
855 let mut buf = Vec::new();
859 file.take(MAX_CONFIG_FILE_BYTES + 1)
860 .read_to_end(&mut buf)
861 .map_err(Error::Io)?;
862 if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
863 return Err(Error::FileSizeLimitExceeded {
864 size: buf.len() as u64,
865 max: MAX_CONFIG_FILE_BYTES,
866 });
867 }
868 let content = String::from_utf8(buf)
869 .map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
870
871 let mut config: Self = toml::from_str(&content)?;
872 config.validate()?;
873
874 if !config.workspace.roots.is_empty() {
875 config.workspace.roots = if config.workspace.roots.iter().any(|root| root.is_relative())
876 {
877 let absolute_config_path = if path.is_absolute() {
885 path.to_path_buf()
886 } else {
887 std::env::current_dir().map_err(Error::Io)?.join(path)
888 };
889 let config_dir = absolute_config_path.parent().ok_or_else(|| {
890 Error::InvalidConfig(format!(
891 "configuration path has no parent directory: {}",
892 absolute_config_path.display()
893 ))
894 })?;
895
896 let base_dir = match relative_root_base {
897 RelativeRootBase::ConfigDir => {
898 dunce::canonicalize(config_dir).map_err(|source| {
899 Error::InvalidConfig(format!(
900 "configuration directory '{}' could not be canonicalized: {source}",
901 config_dir.display()
902 ))
903 })?
904 }
905 RelativeRootBase::Cwd => std::env::current_dir().map_err(Error::Io)?,
906 };
907 crate::resolve_workspace_roots(&config.workspace.roots, &base_dir)?
908 } else {
909 crate::canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
913 };
914 }
915
916 Ok(config)
917 }
918
919 fn create_default_config_file(path: &Path) -> Result<()> {
927 if let Some(parent) = path.parent() {
928 std::fs::create_dir_all(parent)?;
929 }
930
931 let default_config = Self::default();
932 let toml_content = toml::to_string_pretty(&default_config)?;
933 std::fs::write(path, toml_content)?;
934
935 Ok(())
936 }
937
938 pub fn validate(&self) -> Result<()> {
972 self.validate_mcp()?;
973
974 if self.workspace.position_encodings.is_empty() {
975 return Err(Error::InvalidConfig(
976 "workspace.position_encodings cannot be empty".to_string(),
977 ));
978 }
979 for encoding in &self.workspace.position_encodings {
980 if parse_position_encoding(encoding).is_none() {
981 return Err(Error::InvalidConfig(format!(
982 "invalid workspace.position_encodings value '{encoding}'; expected one of \
983 \"utf-8\", \"utf-16\", \"utf-32\""
984 )));
985 }
986 }
987 if self
993 .workspace
994 .roots
995 .iter()
996 .any(|root| root.as_os_str().is_empty())
997 {
998 return Err(Error::InvalidConfig(
999 "workspace.roots entries cannot be empty".to_string(),
1000 ));
1001 }
1002
1003 let mut seen_names: HashMap<&str, &str> = HashMap::new();
1004 for server in &self.lsp_servers {
1005 if server.language_id.is_empty() {
1006 return Err(Error::InvalidConfig(
1007 "language_id cannot be empty".to_string(),
1008 ));
1009 }
1010 if server.command.is_empty() {
1011 return Err(Error::InvalidConfig(format!(
1012 "command cannot be empty for language '{}'",
1013 server.language_id
1014 )));
1015 }
1016 if server.timeout_seconds == 0 {
1017 return Err(Error::InvalidConfig(format!(
1018 "timeout_seconds cannot be 0 for language '{}'",
1019 server.language_id
1020 )));
1021 }
1022 if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
1023 return Err(Error::InvalidConfig(format!(
1024 "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
1025 server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1026 )));
1027 }
1028 if server.request_timeout_seconds == 0 {
1029 return Err(Error::InvalidConfig(format!(
1030 "request_timeout_seconds cannot be 0 for language '{}'",
1031 server.language_id
1032 )));
1033 }
1034 if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
1035 return Err(Error::InvalidConfig(format!(
1036 "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
1037 language '{}'",
1038 server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1039 )));
1040 }
1041 if let Some(name) = &server.name {
1042 if name.is_empty() {
1043 return Err(Error::InvalidConfig(format!(
1044 "name cannot be empty for language '{}' (omit `name` to default to \
1045 the language id)",
1046 server.language_id
1047 )));
1048 }
1049 if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
1050 tracing::warn!(
1056 "duplicate explicit server name '{name}' in config (language ids: \
1057 '{prev_language}', '{}'); this is only an error if both entries are \
1058 applicable in the same workspace",
1059 server.language_id
1060 );
1061 }
1062 }
1063 if let Some(handles) = &server.handles {
1064 if handles.is_empty() {
1065 return Err(Error::InvalidConfig(format!(
1066 "handles cannot be empty for language '{}' (omit `handles` for a \
1067 catch-all server)",
1068 server.language_id
1069 )));
1070 }
1071 let mut seen_tools = HashSet::new();
1072 for tool in handles {
1073 if !seen_tools.insert(*tool) {
1074 return Err(Error::InvalidConfig(format!(
1075 "duplicate tool '{tool}' in `handles` for language '{}'",
1076 server.language_id
1077 )));
1078 }
1079 }
1080 }
1081 }
1082 Ok(())
1083 }
1084
1085 fn validate_mcp(&self) -> Result<()> {
1090 validate_mcp_field(self.mcp.title.as_deref(), "mcp.title", MAX_MCP_TITLE_BYTES)?;
1091 validate_mcp_field(
1092 self.mcp.description.as_deref(),
1093 "mcp.description",
1094 MAX_MCP_DESCRIPTION_BYTES,
1095 )?;
1096 validate_mcp_field(
1097 self.mcp.instructions.as_deref(),
1098 "mcp.instructions",
1099 MAX_MCP_INSTRUCTIONS_BYTES,
1100 )
1101 }
1102}
1103
1104fn validate_mcp_field(value: Option<&str>, field: &str, max_bytes: usize) -> Result<()> {
1110 let Some(value) = value else {
1111 return Ok(());
1112 };
1113 if value.trim().is_empty() {
1114 return Err(Error::InvalidConfig(format!(
1120 "{field} cannot be empty (omit `{}` from the `[mcp]` section to use the built-in default)",
1121 field.rsplit('.').next().unwrap_or(field)
1122 )));
1123 }
1124 let len = value.len();
1125 if len > max_bytes {
1126 return Err(Error::InvalidConfig(format!(
1127 "{field} exceeds the maximum of {max_bytes} bytes ({len} given)"
1128 )));
1129 }
1130 Ok(())
1131}
1132
1133impl Default for ServerConfig {
1134 fn default() -> Self {
1135 Self {
1136 mcp: McpConfig::default(),
1137 workspace: WorkspaceConfig::default(),
1138 lsp_servers: vec![
1139 LspServerConfig::rust_analyzer(),
1140 LspServerConfig::pyright(),
1141 LspServerConfig::typescript(),
1142 LspServerConfig::gopls(),
1143 LspServerConfig::clangd(),
1144 LspServerConfig::zls(),
1145 ],
1146 project_config_ignored: false,
1147 }
1148 }
1149}
1150
1151#[cfg(test)]
1152#[allow(clippy::unwrap_used)]
1153mod tests {
1154 use std::fs;
1155
1156 use tempfile::TempDir;
1157
1158 use super::*;
1159
1160 fn toml_path_literal(path: &Path) -> String {
1161 toml::Value::String(path.to_string_lossy().into_owned()).to_string()
1162 }
1163
1164 #[test]
1165 fn test_default_config() {
1166 let config = ServerConfig::default();
1167 assert_eq!(config.lsp_servers.len(), 6);
1168 assert_eq!(config.lsp_servers[0].language_id, "rust");
1169 assert_eq!(config.lsp_servers[1].language_id, "python");
1170 assert_eq!(config.lsp_servers[2].language_id, "typescript");
1171 assert_eq!(config.lsp_servers[3].language_id, "go");
1172 assert_eq!(config.lsp_servers[4].language_id, "cpp");
1173 assert_eq!(config.lsp_servers[5].language_id, "zig");
1174 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
1175 }
1176
1177 #[test]
1178 fn test_default_position_encodings() {
1179 let encodings = default_position_encodings();
1180 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
1181 }
1182
1183 #[test]
1184 fn test_load_from_valid_toml() {
1185 let tmp_dir = TempDir::new().unwrap();
1186 let config_path = tmp_dir.path().join("config.toml");
1187 let workspace_root = tmp_dir.path().join("workspace");
1188 fs::create_dir(&workspace_root).unwrap();
1189 let workspace_root_literal = toml_path_literal(&workspace_root);
1190
1191 let toml_content = format!(
1192 r#"
1193 [workspace]
1194 roots = [{workspace_root_literal}]
1195 position_encodings = ["utf-8"]
1196
1197 [[lsp_servers]]
1198 language_id = "rust"
1199 command = "rust-analyzer"
1200 timeout_seconds = 30
1201 "#
1202 );
1203
1204 fs::write(&config_path, &toml_content).unwrap();
1205
1206 let config = ServerConfig::load_from(&config_path).unwrap();
1207 assert_eq!(
1208 config.workspace.roots,
1209 vec![dunce::canonicalize(workspace_root).unwrap()]
1210 );
1211 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
1212 assert_eq!(config.lsp_servers.len(), 1);
1213 assert_eq!(config.lsp_servers[0].language_id, "rust");
1214 }
1215
1216 #[test]
1217 fn test_load_from_resolves_relative_roots_against_config_directory() {
1218 let tmp_dir = TempDir::new().unwrap();
1219 let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1220 let config_dir = project_root.join(".agents");
1221 fs::create_dir(&config_dir).unwrap();
1222 let config_path = config_dir.join("mcpls.toml");
1223 fs::write(
1224 &config_path,
1225 r#"
1226 [workspace]
1227 roots = [".", ".."]
1228 "#,
1229 )
1230 .unwrap();
1231
1232 let config = ServerConfig::load_from(&config_path).unwrap();
1233
1234 assert_eq!(config.workspace.roots, vec![config_dir, project_root]);
1235 assert!(config.workspace.roots.iter().all(|root| root.is_absolute()));
1236 }
1237
1238 #[test]
1248 fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1249 let config_tmp_dir = TempDir::new().unwrap();
1250 let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1251 let config_path = config_dir.join("mcpls.toml");
1252 fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1253
1254 let cwd_tmp_dir = TempDir::new().unwrap();
1255 let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1256 let expected_root = cwd.join("relative-root");
1257 fs::create_dir(&expected_root).unwrap();
1258
1259 let config = {
1260 let _guard = CwdGuard::enter(&cwd);
1261 ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1262 };
1263
1264 assert_eq!(config.workspace.roots, vec![expected_root]);
1265 }
1266
1267 #[test]
1268 fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1269 let tmp_dir = TempDir::new().unwrap();
1270 let config_path = tmp_dir.path().join("mcpls.toml");
1271 fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1272
1273 let err = ServerConfig::load_from(&config_path).unwrap_err();
1274
1275 let Error::InvalidConfig(message) = err else {
1276 panic!("expected InvalidConfig, got {err:?}");
1277 };
1278 assert!(message.contains("workspace root 'missing'"));
1279 let config_dir = dunce::canonicalize(tmp_dir.path()).unwrap();
1280 assert!(message.contains(&config_dir.display().to_string()));
1281 }
1282
1283 #[test]
1284 fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1285 let tmp_dir = TempDir::new().unwrap();
1288 let config_path = tmp_dir.path().join("config.toml");
1289
1290 let toml_content = r#"
1291 [[lsp_servers]]
1292 language_id = "rust"
1293 command = "rust-analyzer"
1294 timeout_seconds = 30
1295 "#;
1296
1297 fs::write(&config_path, toml_content).unwrap();
1298
1299 let config = ServerConfig::load_from(&config_path).unwrap();
1300 assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
1301 }
1302
1303 #[test]
1304 fn test_validate_rejects_zero_timeout_seconds() {
1305 let tmp_dir = TempDir::new().unwrap();
1306 let config_path = tmp_dir.path().join("config.toml");
1307
1308 let toml_content = r#"
1309 [[lsp_servers]]
1310 language_id = "rust"
1311 command = "rust-analyzer"
1312 timeout_seconds = 0
1313 "#;
1314
1315 fs::write(&config_path, toml_content).unwrap();
1316
1317 let result = ServerConfig::load_from(&config_path);
1318 if let Err(Error::InvalidConfig(msg)) = result {
1319 assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
1324 } else {
1325 panic!("Expected InvalidConfig error, got {result:?}");
1326 }
1327 }
1328
1329 #[test]
1330 fn test_validate_rejects_zero_request_timeout_seconds() {
1331 let tmp_dir = TempDir::new().unwrap();
1332 let config_path = tmp_dir.path().join("config.toml");
1333
1334 let toml_content = r#"
1335 [[lsp_servers]]
1336 language_id = "rust"
1337 command = "rust-analyzer"
1338 request_timeout_seconds = 0
1339 "#;
1340
1341 fs::write(&config_path, toml_content).unwrap();
1342
1343 let result = ServerConfig::load_from(&config_path);
1344 if let Err(Error::InvalidConfig(msg)) = result {
1345 assert_eq!(
1346 msg,
1347 "request_timeout_seconds cannot be 0 for language 'rust'"
1348 );
1349 } else {
1350 panic!("Expected InvalidConfig error, got {result:?}");
1351 }
1352 }
1353
1354 #[test]
1355 fn test_validate_rejects_request_timeout_seconds_above_max() {
1356 let tmp_dir = TempDir::new().unwrap();
1357 let config_path = tmp_dir.path().join("config.toml");
1358
1359 let toml_content = format!(
1360 r#"
1361 [[lsp_servers]]
1362 language_id = "rust"
1363 command = "rust-analyzer"
1364 request_timeout_seconds = {}
1365 "#,
1366 MAX_TIMEOUT_SECONDS + 1
1367 );
1368
1369 fs::write(&config_path, toml_content).unwrap();
1370
1371 let result = ServerConfig::load_from(&config_path);
1372 if let Err(Error::InvalidConfig(msg)) = result {
1373 assert!(msg.contains("request_timeout_seconds"));
1374 assert!(msg.contains("exceeds the maximum"));
1375 } else {
1376 panic!("Expected InvalidConfig error, got {result:?}");
1377 }
1378 }
1379
1380 #[test]
1381 fn test_validate_accepts_request_timeout_seconds_at_max() {
1382 let tmp_dir = TempDir::new().unwrap();
1383 let config_path = tmp_dir.path().join("config.toml");
1384
1385 let toml_content = format!(
1386 r#"
1387 [[lsp_servers]]
1388 language_id = "rust"
1389 command = "rust-analyzer"
1390 request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1391 "#
1392 );
1393
1394 fs::write(&config_path, toml_content).unwrap();
1395
1396 let result = ServerConfig::load_from(&config_path);
1397 assert!(result.is_ok(), "expected Ok, got {result:?}");
1398 }
1399
1400 #[test]
1401 fn test_validate_rejects_timeout_seconds_above_max() {
1402 let tmp_dir = TempDir::new().unwrap();
1403 let config_path = tmp_dir.path().join("config.toml");
1404
1405 let toml_content = format!(
1406 r#"
1407 [[lsp_servers]]
1408 language_id = "rust"
1409 command = "rust-analyzer"
1410 timeout_seconds = {}
1411 "#,
1412 MAX_TIMEOUT_SECONDS + 1
1413 );
1414
1415 fs::write(&config_path, toml_content).unwrap();
1416
1417 let result = ServerConfig::load_from(&config_path);
1418 if let Err(Error::InvalidConfig(msg)) = result {
1419 assert!(msg.contains("timeout_seconds"));
1420 assert!(msg.contains("exceeds the maximum"));
1421 } else {
1422 panic!("Expected InvalidConfig error, got {result:?}");
1423 }
1424 }
1425
1426 #[test]
1427 fn test_validate_accepts_timeout_seconds_at_max() {
1428 let tmp_dir = TempDir::new().unwrap();
1429 let config_path = tmp_dir.path().join("config.toml");
1430
1431 let toml_content = format!(
1432 r#"
1433 [[lsp_servers]]
1434 language_id = "rust"
1435 command = "rust-analyzer"
1436 timeout_seconds = {MAX_TIMEOUT_SECONDS}
1437 "#
1438 );
1439
1440 fs::write(&config_path, toml_content).unwrap();
1441
1442 let result = ServerConfig::load_from(&config_path);
1443 assert!(result.is_ok(), "expected Ok, got {result:?}");
1444 }
1445
1446 #[test]
1447 fn test_load_from_nonexistent_file() {
1448 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
1449 assert!(result.is_err());
1450
1451 if let Err(Error::ConfigNotFound(path)) = result {
1452 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
1453 } else {
1454 panic!("Expected ConfigNotFound error");
1455 }
1456 }
1457
1458 #[test]
1459 fn test_load_from_invalid_toml() {
1460 let tmp_dir = TempDir::new().unwrap();
1461 let config_path = tmp_dir.path().join("invalid.toml");
1462
1463 fs::write(&config_path, "invalid toml content {{}").unwrap();
1464
1465 let result = ServerConfig::load_from(&config_path);
1466 assert!(result.is_err());
1467 }
1468
1469 #[test]
1473 fn test_load_from_rejects_oversized_file() {
1474 let tmp_dir = TempDir::new().unwrap();
1475 let config_path = tmp_dir.path().join("oversized.toml");
1476
1477 let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1480 fs::write(&config_path, &oversized).unwrap();
1481
1482 let result = ServerConfig::load_from(&config_path);
1483 assert!(matches!(
1484 result,
1485 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1486 ));
1487 }
1488
1489 #[test]
1490 fn test_load_from_accepts_file_at_exact_size_cap() {
1491 let tmp_dir = TempDir::new().unwrap();
1492 let config_path = tmp_dir.path().join("exact.toml");
1493
1494 let mut toml_content = "[workspace]\n# ".to_string();
1497 toml_content.push_str(
1498 &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1499 );
1500 assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1501 fs::write(&config_path, &toml_content).unwrap();
1502
1503 let result = ServerConfig::load_from(&config_path);
1504 assert!(result.is_ok(), "expected Ok, got {result:?}");
1505 }
1506
1507 #[cfg(unix)]
1514 #[test]
1515 fn test_load_from_rejects_infinite_special_file() {
1516 let path = Path::new("/dev/zero");
1517 assert_eq!(
1518 fs::metadata(path).unwrap().len(),
1519 0,
1520 "test assumption: /dev/zero must report zero length"
1521 );
1522
1523 let result = ServerConfig::load_from(path);
1524 assert!(matches!(
1525 result,
1526 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1527 ));
1528 }
1529
1530 #[test]
1531 fn test_validate_empty_language_id() {
1532 let tmp_dir = TempDir::new().unwrap();
1533 let config_path = tmp_dir.path().join("config.toml");
1534
1535 let toml_content = r#"
1536 [[lsp_servers]]
1537 language_id = ""
1538 command = "test"
1539 "#;
1540
1541 fs::write(&config_path, toml_content).unwrap();
1542
1543 let result = ServerConfig::load_from(&config_path);
1544 assert!(result.is_err());
1545
1546 if let Err(Error::InvalidConfig(msg)) = result {
1547 assert!(msg.contains("language_id cannot be empty"));
1548 } else {
1549 panic!("Expected InvalidConfig error");
1550 }
1551 }
1552
1553 #[test]
1554 fn test_validate_empty_command() {
1555 let tmp_dir = TempDir::new().unwrap();
1556 let config_path = tmp_dir.path().join("config.toml");
1557
1558 let toml_content = r#"
1559 [[lsp_servers]]
1560 language_id = "rust"
1561 command = ""
1562 "#;
1563
1564 fs::write(&config_path, toml_content).unwrap();
1565
1566 let result = ServerConfig::load_from(&config_path);
1567 assert!(result.is_err());
1568
1569 if let Err(Error::InvalidConfig(msg)) = result {
1570 assert!(msg.contains("command cannot be empty"));
1571 } else {
1572 panic!("Expected InvalidConfig error");
1573 }
1574 }
1575
1576 #[test]
1577 fn test_validate_empty_name() {
1578 let tmp_dir = TempDir::new().unwrap();
1579 let config_path = tmp_dir.path().join("config.toml");
1580
1581 let toml_content = r#"
1582 [[lsp_servers]]
1583 name = ""
1584 language_id = "python"
1585 command = "pyright-langserver"
1586 "#;
1587
1588 fs::write(&config_path, toml_content).unwrap();
1589
1590 let result = ServerConfig::load_from(&config_path);
1591 assert!(result.is_err());
1592
1593 if let Err(Error::InvalidConfig(msg)) = result {
1594 assert!(msg.contains("name cannot be empty"));
1595 } else {
1596 panic!("Expected InvalidConfig error");
1597 }
1598 }
1599
1600 #[test]
1601 fn test_validate_empty_handles() {
1602 let tmp_dir = TempDir::new().unwrap();
1603 let config_path = tmp_dir.path().join("config.toml");
1604
1605 let toml_content = r#"
1606 [[lsp_servers]]
1607 language_id = "python"
1608 command = "pylsp"
1609 handles = []
1610 "#;
1611
1612 fs::write(&config_path, toml_content).unwrap();
1613
1614 let result = ServerConfig::load_from(&config_path);
1615 assert!(result.is_err());
1616
1617 if let Err(Error::InvalidConfig(msg)) = result {
1618 assert!(msg.contains("handles cannot be empty"));
1619 } else {
1620 panic!("Expected InvalidConfig error");
1621 }
1622 }
1623
1624 #[test]
1625 fn test_validate_duplicate_tool_in_handles() {
1626 let tmp_dir = TempDir::new().unwrap();
1627 let config_path = tmp_dir.path().join("config.toml");
1628
1629 let toml_content = r#"
1630 [[lsp_servers]]
1631 language_id = "python"
1632 command = "pylsp"
1633 handles = ["diagnostics", "diagnostics"]
1634 "#;
1635
1636 fs::write(&config_path, toml_content).unwrap();
1637
1638 let result = ServerConfig::load_from(&config_path);
1639 assert!(result.is_err());
1640
1641 if let Err(Error::InvalidConfig(msg)) = result {
1642 assert!(msg.contains("duplicate tool"));
1643 assert!(msg.contains("diagnostics"));
1644 } else {
1645 panic!("Expected InvalidConfig error");
1646 }
1647 }
1648
1649 #[test]
1650 fn test_validate_rejects_empty_position_encodings() {
1651 let tmp_dir = TempDir::new().unwrap();
1652 let config_path = tmp_dir.path().join("config.toml");
1653
1654 let toml_content = r"
1655 [workspace]
1656 position_encodings = []
1657 ";
1658
1659 fs::write(&config_path, toml_content).unwrap();
1660
1661 let result = ServerConfig::load_from(&config_path);
1662 if let Err(Error::InvalidConfig(msg)) = result {
1663 assert_eq!(msg, "workspace.position_encodings cannot be empty");
1664 } else {
1665 panic!("Expected InvalidConfig error, got {result:?}");
1666 }
1667 }
1668
1669 #[test]
1674 fn test_validate_rejects_empty_workspace_root_entry() {
1675 let tmp_dir = TempDir::new().unwrap();
1676 let config_path = tmp_dir.path().join("config.toml");
1677
1678 let toml_content = r#"
1679 [workspace]
1680 roots = [""]
1681 "#;
1682
1683 fs::write(&config_path, toml_content).unwrap();
1684
1685 let result = ServerConfig::load_from(&config_path);
1686 if let Err(Error::InvalidConfig(msg)) = result {
1687 assert_eq!(msg, "workspace.roots entries cannot be empty");
1688 } else {
1689 panic!("Expected InvalidConfig error, got {result:?}");
1690 }
1691 }
1692
1693 #[test]
1694 fn test_validate_rejects_unrecognized_position_encoding() {
1695 let tmp_dir = TempDir::new().unwrap();
1696 let config_path = tmp_dir.path().join("config.toml");
1697
1698 let toml_content = r#"
1699 [workspace]
1700 position_encodings = ["utf-8", "utf-7"]
1701 "#;
1702
1703 fs::write(&config_path, toml_content).unwrap();
1704
1705 let result = ServerConfig::load_from(&config_path);
1706 if let Err(Error::InvalidConfig(msg)) = result {
1707 assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1708 } else {
1709 panic!("Expected InvalidConfig error, got {result:?}");
1710 }
1711 }
1712
1713 #[test]
1714 fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1715 assert_eq!(
1716 parse_position_encoding("utf-8"),
1717 Some(lsp_types::PositionEncodingKind::UTF8)
1718 );
1719 assert_eq!(
1720 parse_position_encoding("utf-16"),
1721 Some(lsp_types::PositionEncodingKind::UTF16)
1722 );
1723 assert_eq!(
1724 parse_position_encoding("utf-32"),
1725 Some(lsp_types::PositionEncodingKind::UTF32)
1726 );
1727 assert_eq!(parse_position_encoding("utf-7"), None);
1728 }
1729
1730 #[test]
1731 fn test_validate_duplicate_name_warns_but_loads() {
1732 let tmp_dir = TempDir::new().unwrap();
1737 let config_path = tmp_dir.path().join("config.toml");
1738
1739 let toml_content = r#"
1740 [[lsp_servers]]
1741 name = "dup"
1742 language_id = "python"
1743 command = "pyright-langserver"
1744
1745 [[lsp_servers]]
1746 name = "dup"
1747 language_id = "typescript"
1748 command = "typescript-language-server"
1749 "#;
1750
1751 fs::write(&config_path, toml_content).unwrap();
1752
1753 let result = ServerConfig::load_from(&config_path);
1754 assert!(result.is_ok(), "duplicate name must only warn at load time");
1755 }
1756
1757 #[test]
1758 fn test_workspace_config_defaults() {
1759 let workspace = WorkspaceConfig::default();
1760 assert!(workspace.roots.is_empty());
1761 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1762 assert!(!workspace.language_extensions.is_empty());
1763 assert_eq!(workspace.language_extensions.len(), 30);
1764 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1765 }
1766
1767 #[test]
1768 fn test_load_multiple_servers() {
1769 let tmp_dir = TempDir::new().unwrap();
1770 let config_path = tmp_dir.path().join("multi.toml");
1771
1772 let toml_content = r#"
1773 [[lsp_servers]]
1774 language_id = "rust"
1775 command = "rust-analyzer"
1776
1777 [[lsp_servers]]
1778 language_id = "python"
1779 command = "pyright-langserver"
1780 args = ["--stdio"]
1781 "#;
1782
1783 fs::write(&config_path, toml_content).unwrap();
1784
1785 let config = ServerConfig::load_from(&config_path).unwrap();
1786 assert_eq!(config.lsp_servers.len(), 2);
1787 assert_eq!(config.lsp_servers[0].language_id, "rust");
1788 assert_eq!(config.lsp_servers[1].language_id, "python");
1789 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1790 }
1791
1792 #[test]
1793 fn test_deny_unknown_fields() {
1794 let tmp_dir = TempDir::new().unwrap();
1795 let config_path = tmp_dir.path().join("unknown.toml");
1796
1797 let toml_content = r#"
1798 unknown_field = "value"
1799
1800 [workspace]
1801 roots = []
1802 "#;
1803
1804 fs::write(&config_path, toml_content).unwrap();
1805
1806 let result = ServerConfig::load_from(&config_path);
1807 assert!(result.is_err(), "Should reject unknown fields");
1808 }
1809
1810 #[test]
1811 fn test_empty_config_file() {
1812 let tmp_dir = TempDir::new().unwrap();
1813 let config_path = tmp_dir.path().join("empty.toml");
1814
1815 fs::write(&config_path, "").unwrap();
1816
1817 let config = ServerConfig::load_from(&config_path).unwrap();
1818 assert!(config.workspace.roots.is_empty());
1819 assert!(config.lsp_servers.is_empty());
1820 }
1821
1822 #[test]
1823 fn test_config_with_initialization_options() {
1824 let tmp_dir = TempDir::new().unwrap();
1825 let config_path = tmp_dir.path().join("init_opts.toml");
1826
1827 let toml_content = r#"
1828 [[lsp_servers]]
1829 language_id = "rust"
1830 command = "rust-analyzer"
1831
1832 [lsp_servers.initialization_options]
1833 cargo = { allFeatures = true }
1834 "#;
1835
1836 fs::write(&config_path, toml_content).unwrap();
1837
1838 let config = ServerConfig::load_from(&config_path).unwrap();
1839 assert!(config.lsp_servers[0].initialization_options.is_some());
1840 }
1841
1842 #[test]
1843 fn test_language_extensions_in_config() {
1844 let tmp_dir = TempDir::new().unwrap();
1845 let config_path = tmp_dir.path().join("extensions.toml");
1846
1847 let toml_content = r#"
1848 [[workspace.language_extensions]]
1849 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1850 language_id = "cpp"
1851
1852 [[workspace.language_extensions]]
1853 extensions = ["nu"]
1854 language_id = "nushell"
1855
1856 [[workspace.language_extensions]]
1857 extensions = ["py", "pyw", "pyi"]
1858 language_id = "python"
1859 "#;
1860
1861 fs::write(&config_path, toml_content).unwrap();
1862
1863 let config = ServerConfig::load_from(&config_path).unwrap();
1864 assert_eq!(config.workspace.language_extensions.len(), 3);
1865
1866 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
1868 assert_eq!(
1869 config.workspace.language_extensions[0].extensions,
1870 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1871 );
1872
1873 assert_eq!(
1875 config.workspace.language_extensions[1].language_id,
1876 "nushell"
1877 );
1878 assert_eq!(
1879 config.workspace.language_extensions[1].extensions,
1880 vec!["nu"]
1881 );
1882 }
1883
1884 #[test]
1885 fn test_build_extension_map() {
1886 let workspace = WorkspaceConfig {
1887 roots: vec![],
1888 position_encodings: vec![],
1889 language_extensions: vec![
1890 LanguageExtensionMapping {
1891 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
1892 language_id: "cpp".to_string(),
1893 },
1894 LanguageExtensionMapping {
1895 extensions: vec!["nu".to_string()],
1896 language_id: "nushell".to_string(),
1897 },
1898 ],
1899 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1900 max_documents: DEFAULT_MAX_DOCUMENTS,
1901 max_file_size: DEFAULT_MAX_FILE_SIZE,
1902 };
1903
1904 let map = workspace.build_extension_map();
1905 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
1906 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
1907 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
1908 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
1909 assert_eq!(map.get("unknown"), None);
1910 }
1911
1912 #[test]
1913 fn test_extract_extension_from_pattern_empty_string() {
1914 assert_eq!(extract_extension_from_pattern(""), None);
1915 }
1916
1917 #[test]
1918 fn test_extract_extension_from_pattern_without_dot() {
1919 assert_eq!(extract_extension_from_pattern("**/*"), None);
1920 }
1921
1922 #[test]
1923 fn test_extract_extension_from_pattern_dotfile() {
1924 assert_eq!(extract_extension_from_pattern(".gitignore"), None);
1925 }
1926
1927 #[test]
1928 fn test_extract_extension_from_pattern_multi_dot_extension() {
1929 assert_eq!(
1930 extract_extension_from_pattern("foo.tar.gz"),
1931 Some("gz".to_string())
1932 );
1933 }
1934
1935 #[test]
1936 fn test_build_effective_extension_map_overrides_with_file_patterns() {
1937 let config = ServerConfig {
1938 mcp: McpConfig::default(),
1939 workspace: WorkspaceConfig::default(),
1940 lsp_servers: vec![LspServerConfig {
1941 language_id: "cpp".to_string(),
1942 command: "clangd".to_string(),
1943 args: vec![],
1944 env: HashMap::new(),
1945 file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
1946 initialization_options: None,
1947 timeout_seconds: 30,
1948 request_timeout_seconds: 30,
1949 heuristics: None,
1950 name: None,
1951 handles: None,
1952 }],
1953 project_config_ignored: false,
1954 };
1955
1956 let map = config.build_effective_extension_map();
1957 assert_eq!(map.get("c"), Some(&"cpp".to_string()));
1958 assert_eq!(map.get("h"), Some(&"cpp".to_string()));
1959 }
1960
1961 #[test]
1962 fn test_build_effective_extension_map_derives_tsx_language_id() {
1963 let config = ServerConfig {
1964 mcp: McpConfig::default(),
1965 workspace: WorkspaceConfig::default(),
1966 lsp_servers: vec![LspServerConfig {
1967 language_id: "typescript".to_string(),
1968 command: "tsgo".to_string(),
1969 args: vec!["--lsp".to_string(), "--stdio".to_string()],
1970 env: HashMap::new(),
1971 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
1972 initialization_options: None,
1973 timeout_seconds: 30,
1974 request_timeout_seconds: 30,
1975 heuristics: None,
1976 name: None,
1977 handles: None,
1978 }],
1979 project_config_ignored: false,
1980 };
1981
1982 let map = config.build_effective_extension_map();
1983 assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1984 assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1985 }
1986
1987 #[test]
1988 fn test_build_effective_extension_map_derives_jsx_language_id() {
1989 let config = ServerConfig {
1990 mcp: McpConfig::default(),
1991 workspace: WorkspaceConfig::default(),
1992 lsp_servers: vec![LspServerConfig {
1993 language_id: "javascript".to_string(),
1994 command: "typescript-language-server".to_string(),
1995 args: vec!["--stdio".to_string()],
1996 env: HashMap::new(),
1997 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1998 initialization_options: None,
1999 timeout_seconds: 30,
2000 request_timeout_seconds: 30,
2001 heuristics: None,
2002 name: None,
2003 handles: None,
2004 }],
2005 project_config_ignored: false,
2006 };
2007
2008 let map = config.build_effective_extension_map();
2009 assert_eq!(map.get("js"), Some(&"javascript".to_string()));
2010 assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
2011 }
2012
2013 #[test]
2014 fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
2015 let config = ServerConfig {
2016 mcp: McpConfig::default(),
2017 workspace: WorkspaceConfig::default(),
2018 lsp_servers: vec![LspServerConfig {
2019 language_id: "cpp".to_string(),
2020 command: "clangd".to_string(),
2021 args: vec![],
2022 env: HashMap::new(),
2023 file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
2024 initialization_options: None,
2025 timeout_seconds: 30,
2026 request_timeout_seconds: 30,
2027 heuristics: None,
2028 name: None,
2029 handles: None,
2030 }],
2031 project_config_ignored: false,
2032 };
2033
2034 let map = config.build_effective_extension_map();
2035 assert_eq!(map.get("h"), Some(&"c".to_string()));
2037 }
2038
2039 #[test]
2040 fn test_language_for_extension() {
2041 let workspace = WorkspaceConfig {
2042 roots: vec![],
2043 position_encodings: vec![],
2044 language_extensions: vec![
2045 LanguageExtensionMapping {
2046 extensions: vec!["hpp".to_string(), "hh".to_string()],
2047 language_id: "cpp".to_string(),
2048 },
2049 LanguageExtensionMapping {
2050 extensions: vec!["py".to_string()],
2051 language_id: "python".to_string(),
2052 },
2053 ],
2054 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
2055 max_documents: DEFAULT_MAX_DOCUMENTS,
2056 max_file_size: DEFAULT_MAX_FILE_SIZE,
2057 };
2058
2059 assert_eq!(
2060 workspace.language_for_extension("hpp"),
2061 Some("cpp".to_string())
2062 );
2063 assert_eq!(
2064 workspace.language_for_extension("hh"),
2065 Some("cpp".to_string())
2066 );
2067 assert_eq!(
2068 workspace.language_for_extension("py"),
2069 Some("python".to_string())
2070 );
2071 assert_eq!(workspace.language_for_extension("unknown"), None);
2072 }
2073
2074 #[test]
2075 fn test_default_language_extensions() {
2076 let workspace = WorkspaceConfig::default();
2077 let map = workspace.build_extension_map();
2078 assert!(!map.is_empty());
2079 assert_eq!(
2080 workspace.language_for_extension("rs"),
2081 Some("rust".to_string())
2082 );
2083 assert_eq!(
2084 workspace.language_for_extension("py"),
2085 Some("python".to_string())
2086 );
2087 assert_eq!(
2088 workspace.language_for_extension("cpp"),
2089 Some("cpp".to_string())
2090 );
2091 }
2092
2093 #[test]
2094 fn test_create_default_config_file() {
2095 let tmp_dir = TempDir::new().unwrap();
2096 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
2097
2098 ServerConfig::create_default_config_file(&config_path).unwrap();
2099
2100 assert!(config_path.exists());
2101
2102 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
2103 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
2104 assert_eq!(loaded_config.lsp_servers.len(), 6);
2105 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
2106 }
2107
2108 #[test]
2109 fn test_load_returns_default_config() {
2110 let config = ServerConfig::default();
2112 assert_eq!(config.workspace.language_extensions.len(), 30);
2113 assert_eq!(config.lsp_servers.len(), 6);
2114 assert_eq!(config.lsp_servers[0].language_id, "rust");
2115 }
2116
2117 use crate::test_support::CwdGuard;
2125
2126 fn assert_mcpls_config_env_unset() {
2139 assert!(
2140 std::env::var_os("MCPLS_CONFIG").is_none(),
2141 "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2142 load_with_trust returns before consulting CWD when it's set"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_load_ignores_untrusted_project_local_config() {
2148 let tmp_dir = TempDir::new().unwrap();
2156 let config_path = tmp_dir.path().join("mcpls.toml");
2157
2158 let custom_toml = r#"
2164 [workspace]
2165 roots = ["/should-never-load-attacker-path"]
2166
2167 [[lsp_servers]]
2168 language_id = "definitely-not-a-real-language-marker"
2169 command = "rm"
2170 args = ["-rf", "/"]
2171 "#;
2172
2173 fs::write(&config_path, custom_toml).unwrap();
2174
2175 let config = {
2176 let _guard = CwdGuard::enter(tmp_dir.path());
2177 ServerConfig::load().unwrap()
2178 };
2179
2180 assert!(
2181 !config
2182 .workspace
2183 .roots
2184 .contains(&PathBuf::from("/should-never-load-attacker-path"))
2185 );
2186 assert!(
2187 !config
2188 .lsp_servers
2189 .iter()
2190 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2191 );
2192 }
2193
2194 #[test]
2195 fn test_load_with_trust_loads_trusted_project_local_config() {
2196 let tmp_dir = TempDir::new().unwrap();
2197 let config_path = tmp_dir.path().join("mcpls.toml");
2198 let custom_root = tmp_dir.path().join("custom");
2199 fs::create_dir(&custom_root).unwrap();
2200 let custom_root_literal = toml_path_literal(&custom_root);
2201
2202 let custom_toml = format!(
2203 r#"
2204 [workspace]
2205 roots = [{custom_root_literal}]
2206
2207 [[lsp_servers]]
2208 language_id = "python"
2209 command = "pyright-langserver"
2210 "#
2211 );
2212
2213 fs::write(&config_path, &custom_toml).unwrap();
2214
2215 let config = {
2216 let _guard = CwdGuard::enter(tmp_dir.path());
2217 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2218 };
2219
2220 assert_eq!(
2221 config.workspace.roots,
2222 vec![dunce::canonicalize(custom_root).unwrap()]
2223 );
2224 assert_eq!(config.lsp_servers.len(), 1);
2225 assert_eq!(config.lsp_servers[0].language_id, "python");
2226 }
2227
2228 #[test]
2229 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2230 let tmp_dir = TempDir::new().unwrap();
2231 let config_path = tmp_dir.path().join("mcpls.toml");
2232
2233 let custom_toml = r#"
2234 [workspace]
2235 roots = ["/attacker/controlled"]
2236 heuristics_max_depth = 999999
2237
2238 [[lsp_servers]]
2239 language_id = "evil"
2240 command = "rm"
2241 args = ["-rf", "/"]
2242 "#;
2243
2244 fs::write(&config_path, custom_toml).unwrap();
2245
2246 let config = {
2247 let _guard = CwdGuard::enter(tmp_dir.path());
2248 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2249 };
2250
2251 assert!(
2252 !config
2253 .workspace
2254 .roots
2255 .contains(&PathBuf::from("/attacker/controlled"))
2256 );
2257 assert_ne!(config.workspace.heuristics_max_depth, 999_999);
2258 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2259 }
2260
2261 #[test]
2262 fn test_load_with_trust_sets_project_config_ignored_flag() {
2263 assert_mcpls_config_env_unset();
2264
2265 let tmp_dir = TempDir::new().unwrap();
2266 let config_path = tmp_dir.path().join("mcpls.toml");
2267 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2268
2269 let config = {
2270 let _guard = CwdGuard::enter(tmp_dir.path());
2271 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2272 };
2273 assert!(config.project_config_ignored);
2274
2275 let tmp_dir = TempDir::new().unwrap();
2276 let config_path = tmp_dir.path().join("mcpls.toml");
2277 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2278
2279 let config = {
2280 let _guard = CwdGuard::enter(tmp_dir.path());
2281 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2282 };
2283 assert!(!config.project_config_ignored);
2284 }
2285
2286 #[test]
2287 fn test_load_no_local_config_leaves_flag_unset() {
2288 assert_mcpls_config_env_unset();
2289
2290 let tmp_dir = TempDir::new().unwrap();
2291
2292 let config = {
2293 let _guard = CwdGuard::enter(tmp_dir.path());
2294 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2295 };
2296 assert!(!config.project_config_ignored);
2297 }
2298
2299 #[test]
2300 fn test_config_file_creation_with_proper_structure() {
2301 let tmp_dir = TempDir::new().unwrap();
2302 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2303
2304 ServerConfig::create_default_config_file(&config_path).unwrap();
2305
2306 let content = fs::read_to_string(&config_path).unwrap();
2307
2308 assert!(content.contains("[mcp]"));
2309 assert!(content.contains("[workspace]"));
2310 assert!(content.contains("[[workspace.language_extensions]]"));
2311 assert!(content.contains("[[lsp_servers]]"));
2312 assert!(content.contains("language_id = \"rust\""));
2313 assert!(content.contains("extensions = [\"rs\"]"));
2314 }
2315
2316 #[test]
2317 fn test_heuristics_max_depth_default() {
2318 let config = WorkspaceConfig::default();
2319 assert_eq!(config.heuristics_max_depth, 10);
2320 }
2321
2322 #[test]
2323 fn test_heuristics_max_depth_from_config() {
2324 let tmp_dir = TempDir::new().unwrap();
2325 let config_path = tmp_dir.path().join("depth.toml");
2326
2327 let toml_content = r"
2328 [workspace]
2329 heuristics_max_depth = 5
2330 ";
2331
2332 fs::write(&config_path, toml_content).unwrap();
2333
2334 let config = ServerConfig::load_from(&config_path).unwrap();
2335 assert_eq!(config.workspace.heuristics_max_depth, 5);
2336 }
2337
2338 #[test]
2339 fn test_heuristics_max_depth_uses_default_when_not_specified() {
2340 let tmp_dir = TempDir::new().unwrap();
2341 let config_path = tmp_dir.path().join("no_depth.toml");
2342
2343 let toml_content = r"
2344 [workspace]
2345 roots = []
2346 ";
2347
2348 fs::write(&config_path, toml_content).unwrap();
2349
2350 let config = ServerConfig::load_from(&config_path).unwrap();
2351 assert_eq!(
2352 config.workspace.heuristics_max_depth,
2353 DEFAULT_HEURISTICS_MAX_DEPTH
2354 );
2355 }
2356
2357 #[test]
2358 fn test_max_documents_default() {
2359 let config = WorkspaceConfig::default();
2360 assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
2361 }
2362
2363 #[test]
2364 fn test_max_file_size_default() {
2365 let config = WorkspaceConfig::default();
2366 assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
2367 }
2368
2369 #[test]
2370 fn test_max_documents_from_config() {
2371 let tmp_dir = TempDir::new().unwrap();
2372 let config_path = tmp_dir.path().join("limits.toml");
2373
2374 let toml_content = r"
2375 [workspace]
2376 max_documents = 500
2377 ";
2378
2379 fs::write(&config_path, toml_content).unwrap();
2380
2381 let config = ServerConfig::load_from(&config_path).unwrap();
2382 assert_eq!(config.workspace.max_documents, 500);
2383 }
2384
2385 #[test]
2386 fn test_max_file_size_from_config() {
2387 let tmp_dir = TempDir::new().unwrap();
2388 let config_path = tmp_dir.path().join("limits.toml");
2389
2390 let toml_content = r"
2391 [workspace]
2392 max_file_size = 20971520
2393 ";
2394
2395 fs::write(&config_path, toml_content).unwrap();
2396
2397 let config = ServerConfig::load_from(&config_path).unwrap();
2398 assert_eq!(config.workspace.max_file_size, 20_971_520);
2399 }
2400
2401 #[test]
2402 fn test_max_documents_uses_default_when_not_specified() {
2403 let tmp_dir = TempDir::new().unwrap();
2404 let config_path = tmp_dir.path().join("no_limits.toml");
2405
2406 let toml_content = r"
2407 [workspace]
2408 roots = []
2409 ";
2410
2411 fs::write(&config_path, toml_content).unwrap();
2412
2413 let config = ServerConfig::load_from(&config_path).unwrap();
2414 assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
2415 assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
2416 }
2417
2418 #[test]
2422 fn test_max_file_size_zero_means_unlimited() {
2423 let tmp_dir = TempDir::new().unwrap();
2424 let config_path = tmp_dir.path().join("unlimited.toml");
2425
2426 let toml_content = r"
2427 [workspace]
2428 max_file_size = 0
2429 ";
2430
2431 fs::write(&config_path, toml_content).unwrap();
2432
2433 let config = ServerConfig::load_from(&config_path).unwrap();
2434 assert_eq!(config.workspace.max_file_size, 0);
2435 assert_eq!(config.workspace.resource_limits().max_file_size, 0);
2436 }
2437
2438 #[test]
2441 fn test_max_documents_rejects_negative_value() {
2442 let tmp_dir = TempDir::new().unwrap();
2443 let config_path = tmp_dir.path().join("negative_max_documents.toml");
2444
2445 let toml_content = r"
2446 [workspace]
2447 max_documents = -1
2448 ";
2449
2450 fs::write(&config_path, toml_content).unwrap();
2451
2452 let result = ServerConfig::load_from(&config_path);
2453 if let Err(Error::TomlDe(e)) = &result {
2458 let msg = e.to_string();
2459 assert!(
2460 msg.contains("-1") && msg.contains("usize"),
2461 "expected a type-mismatch message naming the offending value and the \
2462 expected type, got: {msg}"
2463 );
2464 } else {
2465 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2466 }
2467 }
2468
2469 #[test]
2472 fn test_max_file_size_rejects_string_value() {
2473 let tmp_dir = TempDir::new().unwrap();
2474 let config_path = tmp_dir.path().join("string_max_file_size.toml");
2475
2476 let toml_content = r#"
2477 [workspace]
2478 max_file_size = "10MB"
2479 "#;
2480
2481 fs::write(&config_path, toml_content).unwrap();
2482
2483 let result = ServerConfig::load_from(&config_path);
2484 if let Err(Error::TomlDe(e)) = &result {
2485 let msg = e.to_string();
2486 assert!(
2487 msg.contains("10MB") && msg.contains("u64"),
2488 "expected a type-mismatch message naming the offending value and the \
2489 expected type, got: {msg}"
2490 );
2491 } else {
2492 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2493 }
2494 }
2495
2496 #[test]
2497 fn test_workspace_config_resource_limits_maps_fields() {
2498 let workspace = WorkspaceConfig {
2499 max_documents: 250,
2500 max_file_size: 0,
2501 ..WorkspaceConfig::default()
2502 };
2503
2504 let limits = workspace.resource_limits();
2505 assert_eq!(limits.max_documents, 250);
2506 assert_eq!(limits.max_file_size, 0);
2507 }
2508
2509 #[test]
2510 fn test_workspace_config_toml_round_trip() {
2511 let original = WorkspaceConfig {
2512 roots: vec![PathBuf::from("/tmp/round-trip")],
2513 position_encodings: vec!["utf-8".to_string()],
2514 language_extensions: vec![LanguageExtensionMapping {
2515 extensions: vec!["nu".to_string()],
2516 language_id: "nushell".to_string(),
2517 }],
2518 heuristics_max_depth: 5,
2519 max_documents: 500,
2520 max_file_size: 0,
2521 };
2522
2523 let toml_content = toml::to_string_pretty(&original).unwrap();
2524 let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2525
2526 assert_eq!(round_tripped.roots, original.roots);
2527 assert_eq!(
2528 round_tripped.position_encodings,
2529 original.position_encodings
2530 );
2531 assert_eq!(
2532 round_tripped.language_extensions.len(),
2533 original.language_extensions.len()
2534 );
2535 assert_eq!(
2536 round_tripped.language_extensions[0].extensions,
2537 original.language_extensions[0].extensions
2538 );
2539 assert_eq!(
2540 round_tripped.language_extensions[0].language_id,
2541 original.language_extensions[0].language_id
2542 );
2543 assert_eq!(
2544 round_tripped.heuristics_max_depth,
2545 original.heuristics_max_depth
2546 );
2547 assert_eq!(round_tripped.max_documents, original.max_documents);
2548 assert_eq!(round_tripped.max_file_size, original.max_file_size);
2549 }
2550
2551 #[test]
2552 fn test_mcp_config_parses_from_toml_section() {
2553 let tmp_dir = TempDir::new().unwrap();
2554 let config_path = tmp_dir.path().join("config.toml");
2555
2556 let toml_content = r#"
2557 [mcp]
2558 title = "Custom Title"
2559 description = "Custom description"
2560 instructions = "Custom instructions."
2561 "#;
2562
2563 fs::write(&config_path, toml_content).unwrap();
2564
2565 let config = ServerConfig::load_from(&config_path).unwrap();
2566 assert_eq!(config.mcp.title.as_deref(), Some("Custom Title"));
2567 assert_eq!(
2568 config.mcp.description.as_deref(),
2569 Some("Custom description")
2570 );
2571 assert_eq!(
2572 config.mcp.instructions.as_deref(),
2573 Some("Custom instructions.")
2574 );
2575 }
2576
2577 #[test]
2578 fn test_mcp_config_defaults_to_none_when_section_absent() {
2579 let tmp_dir = TempDir::new().unwrap();
2580 let config_path = tmp_dir.path().join("config.toml");
2581
2582 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2583
2584 let config = ServerConfig::load_from(&config_path).unwrap();
2585 assert_eq!(config.mcp.title, None);
2586 assert_eq!(config.mcp.description, None);
2587 assert_eq!(config.mcp.instructions, None);
2588 }
2589
2590 #[test]
2591 fn test_mcp_config_rejects_unknown_field() {
2592 let tmp_dir = TempDir::new().unwrap();
2593 let config_path = tmp_dir.path().join("config.toml");
2594
2595 fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
2596
2597 let result = ServerConfig::load_from(&config_path);
2598 assert!(matches!(result, Err(Error::TomlDe(_))));
2599 }
2600
2601 #[test]
2602 fn test_validate_rejects_empty_mcp_title() {
2603 let tmp_dir = TempDir::new().unwrap();
2604 let config_path = tmp_dir.path().join("config.toml");
2605
2606 fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
2607
2608 let result = ServerConfig::load_from(&config_path);
2609 if let Err(Error::InvalidConfig(msg)) = result {
2610 assert_eq!(
2611 msg,
2612 "mcp.title cannot be empty (omit `title` from the `[mcp]` section to use the \
2613 built-in default)"
2614 );
2615 } else {
2616 panic!("Expected InvalidConfig error, got {result:?}");
2617 }
2618 }
2619
2620 #[test]
2623 fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
2624 let tmp_dir = TempDir::new().unwrap();
2625 let config_path = tmp_dir.path().join("config.toml");
2626
2627 fs::write(&config_path, "[mcp]\ntitle = \" \"\n").unwrap();
2628
2629 let result = ServerConfig::load_from(&config_path);
2630 if let Err(Error::InvalidConfig(msg)) = result {
2631 assert!(msg.contains("cannot be empty"));
2632 } else {
2633 panic!("Expected InvalidConfig error, got {result:?}");
2634 }
2635 }
2636
2637 #[test]
2638 fn test_validate_rejects_empty_mcp_description() {
2639 let tmp_dir = TempDir::new().unwrap();
2640 let config_path = tmp_dir.path().join("config.toml");
2641
2642 fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
2643
2644 let result = ServerConfig::load_from(&config_path);
2645 if let Err(Error::InvalidConfig(msg)) = result {
2646 assert_eq!(
2647 msg,
2648 "mcp.description cannot be empty (omit `description` from the `[mcp]` section \
2649 to use the built-in default)"
2650 );
2651 } else {
2652 panic!("Expected InvalidConfig error, got {result:?}");
2653 }
2654 }
2655
2656 #[test]
2657 fn test_validate_rejects_empty_mcp_instructions() {
2658 let tmp_dir = TempDir::new().unwrap();
2659 let config_path = tmp_dir.path().join("config.toml");
2660
2661 fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
2662
2663 let result = ServerConfig::load_from(&config_path);
2664 if let Err(Error::InvalidConfig(msg)) = result {
2665 assert_eq!(
2666 msg,
2667 "mcp.instructions cannot be empty (omit `instructions` from the `[mcp]` \
2668 section to use the built-in default)"
2669 );
2670 } else {
2671 panic!("Expected InvalidConfig error, got {result:?}");
2672 }
2673 }
2674
2675 #[test]
2676 fn test_validate_rejects_over_length_mcp_title() {
2677 let tmp_dir = TempDir::new().unwrap();
2678 let config_path = tmp_dir.path().join("config.toml");
2679
2680 let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
2681 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2682
2683 let result = ServerConfig::load_from(&config_path);
2684 if let Err(Error::InvalidConfig(msg)) = result {
2685 assert_eq!(
2686 msg,
2687 format!(
2688 "mcp.title exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
2689 MAX_MCP_TITLE_BYTES + 1
2690 )
2691 );
2692 } else {
2693 panic!("Expected InvalidConfig error, got {result:?}");
2694 }
2695 }
2696
2697 #[test]
2698 fn test_validate_accepts_mcp_title_at_exact_cap() {
2699 let tmp_dir = TempDir::new().unwrap();
2700 let config_path = tmp_dir.path().join("config.toml");
2701
2702 let title = "a".repeat(MAX_MCP_TITLE_BYTES);
2703 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2704
2705 let result = ServerConfig::load_from(&config_path);
2706 assert!(result.is_ok(), "expected Ok, got {result:?}");
2707 }
2708
2709 #[test]
2710 fn test_validate_rejects_over_length_mcp_description() {
2711 let tmp_dir = TempDir::new().unwrap();
2712 let config_path = tmp_dir.path().join("config.toml");
2713
2714 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
2715 fs::write(
2716 &config_path,
2717 format!("[mcp]\ndescription = \"{description}\"\n"),
2718 )
2719 .unwrap();
2720
2721 let result = ServerConfig::load_from(&config_path);
2722 if let Err(Error::InvalidConfig(msg)) = result {
2723 assert!(msg.contains("mcp.description exceeds the maximum"));
2724 assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
2725 } else {
2726 panic!("Expected InvalidConfig error, got {result:?}");
2727 }
2728 }
2729
2730 #[test]
2731 fn test_validate_accepts_mcp_description_at_exact_cap() {
2732 let tmp_dir = TempDir::new().unwrap();
2733 let config_path = tmp_dir.path().join("config.toml");
2734
2735 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
2736 fs::write(
2737 &config_path,
2738 format!("[mcp]\ndescription = \"{description}\"\n"),
2739 )
2740 .unwrap();
2741
2742 let result = ServerConfig::load_from(&config_path);
2743 assert!(result.is_ok(), "expected Ok, got {result:?}");
2744 }
2745
2746 #[test]
2751 fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
2752 let tmp_dir = TempDir::new().unwrap();
2753 let config_path = tmp_dir.path().join("config.toml");
2754
2755 let title = "é".repeat(65);
2756 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
2757 assert_eq!(title.chars().count(), 65);
2758 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2759
2760 let result = ServerConfig::load_from(&config_path);
2761 if let Err(Error::InvalidConfig(msg)) = result {
2762 assert!(msg.contains("mcp.title exceeds the maximum"));
2763 } else {
2764 panic!("Expected InvalidConfig error, got {result:?}");
2765 }
2766 }
2767
2768 #[test]
2769 fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
2770 let tmp_dir = TempDir::new().unwrap();
2771 let config_path = tmp_dir.path().join("config.toml");
2772
2773 let title = "é".repeat(64);
2774 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
2775 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2776
2777 let result = ServerConfig::load_from(&config_path);
2778 assert!(result.is_ok(), "expected Ok, got {result:?}");
2779 }
2780
2781 #[test]
2782 fn test_validate_rejects_over_length_mcp_instructions() {
2783 let tmp_dir = TempDir::new().unwrap();
2784 let config_path = tmp_dir.path().join("config.toml");
2785
2786 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
2787 fs::write(
2788 &config_path,
2789 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2790 )
2791 .unwrap();
2792
2793 let result = ServerConfig::load_from(&config_path);
2794 if let Err(Error::InvalidConfig(msg)) = result {
2795 assert!(msg.contains("mcp.instructions exceeds the maximum"));
2796 assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
2797 } else {
2798 panic!("Expected InvalidConfig error, got {result:?}");
2799 }
2800 }
2801
2802 #[test]
2803 fn test_validate_accepts_mcp_instructions_at_exact_cap() {
2804 let tmp_dir = TempDir::new().unwrap();
2805 let config_path = tmp_dir.path().join("config.toml");
2806
2807 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
2808 fs::write(
2809 &config_path,
2810 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2811 )
2812 .unwrap();
2813
2814 let result = ServerConfig::load_from(&config_path);
2815 assert!(result.is_ok(), "expected Ok, got {result:?}");
2816 }
2817
2818 #[test]
2823 fn test_tool_prefix_accepts_valid_and_round_trips() {
2824 let tmp_dir = TempDir::new().unwrap();
2825 let config_path = tmp_dir.path().join("config.toml");
2826 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
2827
2828 let config = ServerConfig::load_from(&config_path).unwrap();
2829 assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
2830
2831 let serialized = toml::to_string_pretty(&config).unwrap();
2832 let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
2833 assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
2834 }
2835
2836 #[test]
2837 fn test_tool_prefix_accepts_digits_and_mixed_case() {
2838 let prefix: ToolPrefix = "Optics2".parse().unwrap();
2839 assert_eq!(prefix.as_str(), "Optics2");
2840 }
2841
2842 #[test]
2843 fn test_tool_prefix_accepts_single_alphanumeric_char() {
2844 assert!("x".parse::<ToolPrefix>().is_ok());
2845 assert!("9".parse::<ToolPrefix>().is_ok());
2846 }
2847
2848 #[test]
2849 fn test_tool_prefix_rejects_empty() {
2850 let err = "".parse::<ToolPrefix>().unwrap_err();
2851 assert_eq!(
2852 err,
2853 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
2854 use unprefixed tool names)"
2855 );
2856 }
2857
2858 #[test]
2859 fn test_tool_prefix_rejects_whitespace_only() {
2860 let err = " ".parse::<ToolPrefix>().unwrap_err();
2861 assert!(err.contains("cannot be empty"));
2862 }
2863
2864 #[test]
2865 fn test_tool_prefix_rejects_leading_separator() {
2866 for bad in ["_optics", "-optics"] {
2867 let err = bad.parse::<ToolPrefix>().unwrap_err();
2868 assert!(
2869 err.contains("cannot start with"),
2870 "for input {bad:?}: {err}"
2871 );
2872 }
2873 }
2874
2875 #[test]
2876 fn test_tool_prefix_rejects_trailing_separator() {
2877 for bad in ["optics_", "optics-"] {
2878 let err = bad.parse::<ToolPrefix>().unwrap_err();
2879 assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
2880 assert!(err.contains("inserted automatically"));
2881 }
2882 }
2883
2884 #[test]
2885 fn test_tool_prefix_rejects_dot() {
2886 let err = "op.tics".parse::<ToolPrefix>().unwrap_err();
2887 assert!(err.contains("invalid character '.'"));
2888 }
2889
2890 #[test]
2891 fn test_tool_prefix_rejects_space() {
2892 let err = "op tics".parse::<ToolPrefix>().unwrap_err();
2893 assert!(err.contains("invalid character ' '"));
2894 }
2895
2896 #[test]
2897 fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
2898 let err = "optiсs".parse::<ToolPrefix>().unwrap_err();
2899 assert!(err.contains("invalid character 'с'"), "{err}");
2900 }
2901
2902 #[test]
2903 fn test_tool_prefix_rejects_over_length() {
2904 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
2905 let err = prefix.parse::<ToolPrefix>().unwrap_err();
2906 assert!(err.contains(&format!(
2907 "exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes"
2908 )));
2909 }
2910
2911 #[test]
2912 fn test_tool_prefix_accepts_exact_length_cap() {
2913 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
2914 assert!(prefix.parse::<ToolPrefix>().is_ok());
2915 }
2916
2917 #[test]
2918 fn test_tool_prefix_from_str_shares_config_validator() {
2919 assert!("optics_".parse::<ToolPrefix>().is_err());
2924 let tmp_dir = TempDir::new().unwrap();
2925 let config_path = tmp_dir.path().join("config.toml");
2926 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
2927 assert!(matches!(
2928 ServerConfig::load_from(&config_path),
2929 Err(Error::TomlDe(_))
2930 ));
2931 }
2932
2933 #[test]
2940 fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
2941 let tmp_dir = TempDir::new().unwrap();
2942 let config_path = tmp_dir.path().join("config.toml");
2943 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
2944
2945 let result = ServerConfig::load_from(&config_path);
2946 let Err(Error::TomlDe(err)) = result else {
2947 panic!("Expected TomlDe error, got {result:?}");
2948 };
2949 let msg = err.to_string();
2950 assert!(msg.contains("mcp.tool_prefix"), "{msg}");
2951 assert!(msg.contains("cannot end with"), "{msg}");
2952 assert!(msg.contains("line 2"), "{msg}");
2959 }
2960}