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::{
22 DEFAULT_INDEXING_READY_TIMEOUT_SECS, DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE,
23 INDEXING_STALENESS_BOUND, PROGRESS_SETTLE, ResourceLimits,
24};
25use crate::error::{Error, Result};
26use crate::util::{BoundedReadOutcome, bounded_read_cap, check_bounded_utf8};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct LanguageExtensionMapping {
34 pub extensions: Vec<String>,
36 pub language_id: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ServerConfig {
44 #[serde(default)]
46 pub mcp: McpConfig,
47
48 #[serde(default)]
50 pub workspace: WorkspaceConfig,
51
52 #[serde(default)]
54 pub lsp_servers: Vec<LspServerConfig>,
55
56 #[serde(skip)]
66 pub project_config_ignored: bool,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct McpConfig {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub title: Option<String>,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub description: Option<String>,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub instructions: Option<String>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub tool_prefix: Option<ToolPrefix>,
130}
131
132pub const MAX_MCP_TITLE_BYTES: usize = 128;
137
138pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
142
143pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
150
151pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct ToolPrefix(String);
196
197impl ToolPrefix {
198 #[must_use]
200 pub fn as_str(&self) -> &str {
201 &self.0
202 }
203}
204
205impl std::fmt::Display for ToolPrefix {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 f.write_str(&self.0)
208 }
209}
210
211impl std::str::FromStr for ToolPrefix {
212 type Err = String;
213
214 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
215 validate_tool_prefix(s)?;
216 Ok(Self(s.to_string()))
217 }
218}
219
220impl<'de> Deserialize<'de> for ToolPrefix {
221 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
222 where
223 D: serde::Deserializer<'de>,
224 {
225 let value = String::deserialize(deserializer)?;
226 value.parse().map_err(serde::de::Error::custom)
227 }
228}
229
230fn validate_tool_prefix(value: &str) -> std::result::Result<(), String> {
234 if value.trim().is_empty() {
235 return Err(
236 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
237 use unprefixed tool names)"
238 .to_string(),
239 );
240 }
241 let len = value.len();
242 if len > MAX_MCP_TOOL_PREFIX_BYTES {
243 return Err(format!(
244 "mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} \
245 given)"
246 ));
247 }
248 if let Some(bad) = value
249 .chars()
250 .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
251 {
252 return Err(format!(
253 "mcp.tool_prefix contains an invalid character {bad:?} (allowed: ASCII letters, \
259 digits, '_', and '-')"
260 ));
261 }
262 let first = value.chars().next().unwrap_or_default();
267 let last = value.chars().next_back().unwrap_or_default();
268 if !first.is_ascii_alphanumeric() {
269 return Err(format!(
270 "mcp.tool_prefix cannot start with '{first}' (must start with an ASCII letter or \
271 digit)"
272 ));
273 }
274 if !last.is_ascii_alphanumeric() {
275 return Err(format!(
276 "mcp.tool_prefix cannot end with '{last}' (the '_' separator between the prefix \
277 and each tool name is inserted automatically by mcpls -- remove the trailing \
278 separator character)"
279 ));
280 }
281 Ok(())
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct WorkspaceConfig {
288 #[serde(default)]
290 pub roots: Vec<PathBuf>,
291
292 #[serde(default = "default_position_encodings")]
301 pub position_encodings: Vec<String>,
302
303 #[serde(default)]
306 pub language_extensions: Vec<LanguageExtensionMapping>,
307
308 #[serde(default = "default_heuristics_max_depth")]
312 pub heuristics_max_depth: usize,
313
314 #[serde(default = "default_max_documents")]
332 pub max_documents: usize,
333
334 #[serde(default = "default_max_file_size")]
339 pub max_file_size: u64,
340
341 #[serde(default = "default_indexing_ready_timeout_seconds")]
353 pub indexing_ready_timeout_seconds: u64,
354}
355
356impl Default for WorkspaceConfig {
357 fn default() -> Self {
358 Self {
359 roots: Vec::new(),
360 position_encodings: default_position_encodings(),
361 language_extensions: default_language_extensions(),
362 heuristics_max_depth: default_heuristics_max_depth(),
363 max_documents: default_max_documents(),
364 max_file_size: default_max_file_size(),
365 indexing_ready_timeout_seconds: default_indexing_ready_timeout_seconds(),
366 }
367 }
368}
369
370const fn default_heuristics_max_depth() -> usize {
371 DEFAULT_HEURISTICS_MAX_DEPTH
372}
373
374const fn default_max_documents() -> usize {
375 DEFAULT_MAX_DOCUMENTS
376}
377
378const fn default_max_file_size() -> u64 {
379 DEFAULT_MAX_FILE_SIZE
380}
381
382const fn default_indexing_ready_timeout_seconds() -> u64 {
383 DEFAULT_INDEXING_READY_TIMEOUT_SECS
384}
385
386impl WorkspaceConfig {
387 #[must_use]
394 pub fn build_extension_map(&self) -> HashMap<String, String> {
395 let mut map = HashMap::new();
396 for mapping in &self.language_extensions {
397 for ext in &mapping.extensions {
398 map.insert(ext.clone(), mapping.language_id.clone());
399 }
400 }
401 map
402 }
403
404 #[must_use]
414 pub fn language_for_extension(&self, extension: &str) -> Option<String> {
415 for mapping in &self.language_extensions {
416 if mapping.extensions.contains(&extension.to_string()) {
417 return Some(mapping.language_id.clone());
418 }
419 }
420 None
421 }
422
423 #[must_use]
426 pub const fn resource_limits(&self) -> ResourceLimits {
427 ResourceLimits {
428 max_documents: self.max_documents,
429 max_file_size: self.max_file_size,
430 }
431 }
432}
433
434fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
439 let basename = pattern.rsplit('/').next().unwrap_or(pattern);
440 if basename.starts_with('.') {
441 return None;
442 }
443
444 let (_, ext) = basename.rsplit_once('.')?;
445 if ext.is_empty() {
446 return None;
447 }
448
449 if ext
451 .chars()
452 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
453 {
454 Some(ext.to_string())
455 } else {
456 None
457 }
458}
459
460fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
461 react_variant_language_id(server_language_id, extension)
462 .unwrap_or(server_language_id)
463 .to_string()
464}
465
466pub(crate) fn default_position_encodings() -> Vec<String> {
483 vec!["utf-8".to_string(), "utf-16".to_string()]
484}
485
486pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
496 match value {
497 "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
498 "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
499 "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
500 _ => None,
501 }
502}
503
504#[allow(clippy::too_many_lines)]
509fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
510 vec![
511 LanguageExtensionMapping {
512 extensions: vec!["rs".to_string()],
513 language_id: "rust".to_string(),
514 },
515 LanguageExtensionMapping {
516 extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
517 language_id: "python".to_string(),
518 },
519 LanguageExtensionMapping {
520 extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
521 language_id: "javascript".to_string(),
522 },
523 LanguageExtensionMapping {
524 extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
525 language_id: "typescript".to_string(),
526 },
527 LanguageExtensionMapping {
528 extensions: vec!["tsx".to_string()],
529 language_id: "typescriptreact".to_string(),
530 },
531 LanguageExtensionMapping {
532 extensions: vec!["jsx".to_string()],
533 language_id: "javascriptreact".to_string(),
534 },
535 LanguageExtensionMapping {
536 extensions: vec!["go".to_string()],
537 language_id: "go".to_string(),
538 },
539 LanguageExtensionMapping {
540 extensions: vec!["c".to_string(), "h".to_string()],
541 language_id: "c".to_string(),
542 },
543 LanguageExtensionMapping {
544 extensions: vec![
545 "cpp".to_string(),
546 "cc".to_string(),
547 "cxx".to_string(),
548 "hpp".to_string(),
549 "hh".to_string(),
550 "hxx".to_string(),
551 ],
552 language_id: "cpp".to_string(),
553 },
554 LanguageExtensionMapping {
555 extensions: vec!["java".to_string()],
556 language_id: "java".to_string(),
557 },
558 LanguageExtensionMapping {
559 extensions: vec!["rb".to_string()],
560 language_id: "ruby".to_string(),
561 },
562 LanguageExtensionMapping {
563 extensions: vec!["php".to_string()],
564 language_id: "php".to_string(),
565 },
566 LanguageExtensionMapping {
567 extensions: vec!["swift".to_string()],
568 language_id: "swift".to_string(),
569 },
570 LanguageExtensionMapping {
571 extensions: vec!["kt".to_string(), "kts".to_string()],
572 language_id: "kotlin".to_string(),
573 },
574 LanguageExtensionMapping {
575 extensions: vec!["scala".to_string(), "sc".to_string()],
576 language_id: "scala".to_string(),
577 },
578 LanguageExtensionMapping {
579 extensions: vec!["zig".to_string()],
580 language_id: "zig".to_string(),
581 },
582 LanguageExtensionMapping {
583 extensions: vec!["lua".to_string()],
584 language_id: "lua".to_string(),
585 },
586 LanguageExtensionMapping {
587 extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
588 language_id: "shellscript".to_string(),
589 },
590 LanguageExtensionMapping {
591 extensions: vec!["json".to_string()],
592 language_id: "json".to_string(),
593 },
594 LanguageExtensionMapping {
595 extensions: vec!["toml".to_string()],
596 language_id: "toml".to_string(),
597 },
598 LanguageExtensionMapping {
599 extensions: vec!["yaml".to_string(), "yml".to_string()],
600 language_id: "yaml".to_string(),
601 },
602 LanguageExtensionMapping {
603 extensions: vec!["xml".to_string()],
604 language_id: "xml".to_string(),
605 },
606 LanguageExtensionMapping {
607 extensions: vec!["html".to_string(), "htm".to_string()],
608 language_id: "html".to_string(),
609 },
610 LanguageExtensionMapping {
611 extensions: vec!["css".to_string()],
612 language_id: "css".to_string(),
613 },
614 LanguageExtensionMapping {
615 extensions: vec!["scss".to_string()],
616 language_id: "scss".to_string(),
617 },
618 LanguageExtensionMapping {
619 extensions: vec!["less".to_string()],
620 language_id: "less".to_string(),
621 },
622 LanguageExtensionMapping {
623 extensions: vec!["md".to_string(), "markdown".to_string()],
624 language_id: "markdown".to_string(),
625 },
626 LanguageExtensionMapping {
627 extensions: vec!["cs".to_string()],
628 language_id: "csharp".to_string(),
629 },
630 LanguageExtensionMapping {
631 extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
632 language_id: "fsharp".to_string(),
633 },
634 LanguageExtensionMapping {
635 extensions: vec!["r".to_string(), "R".to_string()],
636 language_id: "r".to_string(),
637 },
638 ]
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656pub enum ProjectConfigTrust {
657 Untrusted,
660 Trusted,
662}
663
664const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
687
688#[derive(Clone, Copy, Debug, PartialEq, Eq)]
691enum RelativeRootBase {
692 ConfigDir,
698 Cwd,
703}
704
705impl ServerConfig {
706 #[must_use]
711 pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
712 let mut map = self.workspace.build_extension_map();
713
714 for server in &self.lsp_servers {
715 for pattern in &server.file_patterns {
716 if let Some(ext) = extract_extension_from_pattern(pattern) {
717 let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
718 map.insert(ext, language_id);
719 }
720 }
721 }
722
723 map
724 }
725
726 pub fn load() -> Result<Self> {
751 Self::load_with_trust(ProjectConfigTrust::Untrusted)
752 }
753
754 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
785 if let Ok(path) = std::env::var("MCPLS_CONFIG") {
793 return Self::load_from(Path::new(&path));
794 }
795
796 let mut project_config_ignored = false;
797
798 let local_config = PathBuf::from("mcpls.toml");
799 if local_config.exists() {
800 match trust {
801 ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
802 ProjectConfigTrust::Untrusted => {
803 project_config_ignored = true;
804 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
805 std::env::current_dir()
806 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
807 });
808 tracing::warn!(
809 "ignoring untrusted project-local config at {}; pass \
810 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
811 load it",
812 display_path.display()
813 );
814 }
815 }
816 }
817
818 if let Some(config_dir) = dirs::config_dir() {
819 let user_config = config_dir.join("mcpls").join("mcpls.toml");
820 if user_config.exists() {
821 let mut config =
830 Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
831 config.project_config_ignored = project_config_ignored;
832 return Ok(config);
833 }
834
835 if let Err(e) = Self::create_default_config_file(&user_config) {
837 tracing::warn!(
838 "Failed to create default config at {}: {}. Using in-memory defaults.",
839 user_config.display(),
840 e
841 );
842 } else {
843 tracing::info!("Created default config at {}", user_config.display());
844 }
845 }
846
847 Ok(Self {
849 project_config_ignored,
850 ..Self::default()
851 })
852 }
853
854 pub fn load_from(path: &Path) -> Result<Self> {
866 Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
867 }
868
869 fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
878 let file = std::fs::File::open(path).map_err(|e| {
879 if e.kind() == std::io::ErrorKind::NotFound {
880 Error::ConfigNotFound(path.to_path_buf())
881 } else {
882 Error::Io(e)
883 }
884 })?;
885
886 let mut buf = Vec::new();
890 file.take(bounded_read_cap(MAX_CONFIG_FILE_BYTES))
891 .read_to_end(&mut buf)
892 .map_err(Error::Io)?;
893 let content = match check_bounded_utf8(buf, MAX_CONFIG_FILE_BYTES) {
894 BoundedReadOutcome::Ok(s) => s,
895 BoundedReadOutcome::TooLarge { size } => {
896 return Err(Error::FileSizeLimitExceeded {
897 size,
898 max: MAX_CONFIG_FILE_BYTES,
899 });
900 }
901 BoundedReadOutcome::InvalidUtf8(e) => {
902 return Err(Error::InvalidConfig(format!(
903 "config file is not valid UTF-8: {e}"
904 )));
905 }
906 };
907
908 let mut config: Self = toml::from_str(&content)?;
909 config.validate()?;
910
911 if !config.workspace.roots.is_empty() {
912 config.workspace.roots = if config.workspace.roots.iter().any(|root| root.is_relative())
913 {
914 let absolute_config_path = if path.is_absolute() {
922 path.to_path_buf()
923 } else {
924 std::env::current_dir().map_err(Error::Io)?.join(path)
925 };
926 let config_dir = absolute_config_path.parent().ok_or_else(|| {
927 Error::InvalidConfig(format!(
928 "configuration path has no parent directory: {}",
929 absolute_config_path.display()
930 ))
931 })?;
932
933 let base_dir = match relative_root_base {
934 RelativeRootBase::ConfigDir => {
935 dunce::canonicalize(config_dir).map_err(|source| {
936 Error::InvalidConfig(format!(
937 "configuration directory '{}' could not be canonicalized: {source}",
938 config_dir.display()
939 ))
940 })?
941 }
942 RelativeRootBase::Cwd => std::env::current_dir().map_err(Error::Io)?,
943 };
944 crate::resolve_workspace_roots(&config.workspace.roots, &base_dir)?
945 } else {
946 crate::canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
950 };
951 }
952
953 Ok(config)
954 }
955
956 fn create_default_config_file(path: &Path) -> Result<()> {
964 if let Some(parent) = path.parent() {
965 std::fs::create_dir_all(parent)?;
966 }
967
968 let default_config = Self::default();
969 let toml_content = toml::to_string_pretty(&default_config)?;
970 std::fs::write(path, toml_content)?;
971
972 Ok(())
973 }
974
975 pub fn validate(&self) -> Result<()> {
1009 self.validate_mcp()?;
1010 self.validate_indexing_ready_timeout()?;
1011
1012 if self.workspace.position_encodings.is_empty() {
1013 return Err(Error::InvalidConfig(
1014 "workspace.position_encodings cannot be empty".to_string(),
1015 ));
1016 }
1017 for encoding in &self.workspace.position_encodings {
1018 if parse_position_encoding(encoding).is_none() {
1019 return Err(Error::InvalidConfig(format!(
1020 "invalid workspace.position_encodings value '{encoding}'; expected one of \
1021 \"utf-8\", \"utf-16\", \"utf-32\""
1022 )));
1023 }
1024 }
1025 if self
1031 .workspace
1032 .roots
1033 .iter()
1034 .any(|root| root.as_os_str().is_empty())
1035 {
1036 return Err(Error::InvalidConfig(
1037 "workspace.roots entries cannot be empty".to_string(),
1038 ));
1039 }
1040 let mut seen_names: HashMap<&str, &str> = HashMap::new();
1041 for server in &self.lsp_servers {
1042 if server.language_id.is_empty() {
1043 return Err(Error::InvalidConfig(
1044 "language_id cannot be empty".to_string(),
1045 ));
1046 }
1047 if server.command.is_empty() {
1048 return Err(Error::InvalidConfig(format!(
1049 "command cannot be empty for language '{}'",
1050 server.language_id
1051 )));
1052 }
1053 if server.timeout_seconds == 0 {
1054 return Err(Error::InvalidConfig(format!(
1055 "timeout_seconds cannot be 0 for language '{}'",
1056 server.language_id
1057 )));
1058 }
1059 if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
1060 return Err(Error::InvalidConfig(format!(
1061 "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
1062 server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1063 )));
1064 }
1065 if server.request_timeout_seconds == 0 {
1066 return Err(Error::InvalidConfig(format!(
1067 "request_timeout_seconds cannot be 0 for language '{}'",
1068 server.language_id
1069 )));
1070 }
1071 if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
1072 return Err(Error::InvalidConfig(format!(
1073 "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
1074 language '{}'",
1075 server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1076 )));
1077 }
1078 if let Some(name) = &server.name {
1079 if name.is_empty() {
1080 return Err(Error::InvalidConfig(format!(
1081 "name cannot be empty for language '{}' (omit `name` to default to \
1082 the language id)",
1083 server.language_id
1084 )));
1085 }
1086 if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
1087 tracing::warn!(
1093 "duplicate explicit server name '{name}' in config (language ids: \
1094 '{prev_language}', '{}'); this is only an error if both entries are \
1095 applicable in the same workspace",
1096 server.language_id
1097 );
1098 }
1099 }
1100 if let Some(handles) = &server.handles {
1101 if handles.is_empty() {
1102 return Err(Error::InvalidConfig(format!(
1103 "handles cannot be empty for language '{}' (omit `handles` for a \
1104 catch-all server)",
1105 server.language_id
1106 )));
1107 }
1108 let mut seen_tools = HashSet::new();
1109 for tool in handles {
1110 if !seen_tools.insert(*tool) {
1111 return Err(Error::InvalidConfig(format!(
1112 "duplicate tool '{tool}' in `handles` for language '{}'",
1113 server.language_id
1114 )));
1115 }
1116 }
1117 }
1118 }
1119 Ok(())
1120 }
1121
1122 fn validate_indexing_ready_timeout(&self) -> Result<()> {
1134 if self.workspace.indexing_ready_timeout_seconds <= PROGRESS_SETTLE.as_secs() {
1135 return Err(Error::InvalidConfig(format!(
1136 "workspace.indexing_ready_timeout_seconds ({}) must be greater than {} seconds",
1137 self.workspace.indexing_ready_timeout_seconds,
1138 PROGRESS_SETTLE.as_secs()
1139 )));
1140 }
1141 if self.workspace.indexing_ready_timeout_seconds >= INDEXING_STALENESS_BOUND.as_secs() {
1142 return Err(Error::InvalidConfig(format!(
1143 "workspace.indexing_ready_timeout_seconds ({}) must be less than {} seconds",
1144 self.workspace.indexing_ready_timeout_seconds,
1145 INDEXING_STALENESS_BOUND.as_secs()
1146 )));
1147 }
1148 Ok(())
1149 }
1150
1151 fn validate_mcp(&self) -> Result<()> {
1156 validate_mcp_field(self.mcp.title.as_deref(), "mcp.title", MAX_MCP_TITLE_BYTES)?;
1157 validate_mcp_field(
1158 self.mcp.description.as_deref(),
1159 "mcp.description",
1160 MAX_MCP_DESCRIPTION_BYTES,
1161 )?;
1162 validate_mcp_field(
1163 self.mcp.instructions.as_deref(),
1164 "mcp.instructions",
1165 MAX_MCP_INSTRUCTIONS_BYTES,
1166 )
1167 }
1168}
1169
1170fn validate_mcp_field(value: Option<&str>, field: &str, max_bytes: usize) -> Result<()> {
1176 let Some(value) = value else {
1177 return Ok(());
1178 };
1179 if value.trim().is_empty() {
1180 return Err(Error::InvalidConfig(format!(
1186 "{field} cannot be empty (omit `{}` from the `[mcp]` section to use the built-in default)",
1187 field.rsplit('.').next().unwrap_or(field)
1188 )));
1189 }
1190 let len = value.len();
1191 if len > max_bytes {
1192 return Err(Error::InvalidConfig(format!(
1193 "{field} exceeds the maximum of {max_bytes} bytes ({len} given)"
1194 )));
1195 }
1196 Ok(())
1197}
1198
1199impl Default for ServerConfig {
1200 fn default() -> Self {
1201 Self {
1202 mcp: McpConfig::default(),
1203 workspace: WorkspaceConfig::default(),
1204 lsp_servers: vec![
1205 LspServerConfig::rust_analyzer(),
1206 LspServerConfig::pyright(),
1207 LspServerConfig::typescript(),
1208 LspServerConfig::gopls(),
1209 LspServerConfig::clangd(),
1210 LspServerConfig::zls(),
1211 ],
1212 project_config_ignored: false,
1213 }
1214 }
1215}
1216
1217#[cfg(test)]
1218#[allow(clippy::unwrap_used)]
1219mod tests {
1220 use std::fs;
1221
1222 use tempfile::TempDir;
1223
1224 use super::*;
1225
1226 fn toml_path_literal(path: &Path) -> String {
1227 toml::Value::String(path.to_string_lossy().into_owned()).to_string()
1228 }
1229
1230 #[test]
1231 fn test_default_config() {
1232 let config = ServerConfig::default();
1233 assert_eq!(config.lsp_servers.len(), 6);
1234 assert_eq!(config.lsp_servers[0].language_id, "rust");
1235 assert_eq!(config.lsp_servers[1].language_id, "python");
1236 assert_eq!(config.lsp_servers[2].language_id, "typescript");
1237 assert_eq!(config.lsp_servers[3].language_id, "go");
1238 assert_eq!(config.lsp_servers[4].language_id, "cpp");
1239 assert_eq!(config.lsp_servers[5].language_id, "zig");
1240 assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
1241 }
1242
1243 #[test]
1244 fn test_default_position_encodings() {
1245 let encodings = default_position_encodings();
1246 assert_eq!(encodings, vec!["utf-8", "utf-16"]);
1247 }
1248
1249 #[test]
1250 fn test_load_from_valid_toml() {
1251 let tmp_dir = TempDir::new().unwrap();
1252 let config_path = tmp_dir.path().join("config.toml");
1253 let workspace_root = tmp_dir.path().join("workspace");
1254 fs::create_dir(&workspace_root).unwrap();
1255 let workspace_root_literal = toml_path_literal(&workspace_root);
1256
1257 let toml_content = format!(
1258 r#"
1259 [workspace]
1260 roots = [{workspace_root_literal}]
1261 position_encodings = ["utf-8"]
1262
1263 [[lsp_servers]]
1264 language_id = "rust"
1265 command = "rust-analyzer"
1266 timeout_seconds = 30
1267 "#
1268 );
1269
1270 fs::write(&config_path, &toml_content).unwrap();
1271
1272 let config = ServerConfig::load_from(&config_path).unwrap();
1273 assert_eq!(
1274 config.workspace.roots,
1275 vec![dunce::canonicalize(workspace_root).unwrap()]
1276 );
1277 assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
1278 assert_eq!(config.lsp_servers.len(), 1);
1279 assert_eq!(config.lsp_servers[0].language_id, "rust");
1280 }
1281
1282 #[test]
1283 fn test_load_from_resolves_relative_roots_against_config_directory() {
1284 let tmp_dir = TempDir::new().unwrap();
1285 let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1286 let config_dir = project_root.join(".agents");
1287 fs::create_dir(&config_dir).unwrap();
1288 let config_path = config_dir.join("mcpls.toml");
1289 fs::write(
1290 &config_path,
1291 r#"
1292 [workspace]
1293 roots = [".", ".."]
1294 "#,
1295 )
1296 .unwrap();
1297
1298 let config = ServerConfig::load_from(&config_path).unwrap();
1299
1300 assert_eq!(config.workspace.roots, vec![config_dir, project_root]);
1301 assert!(config.workspace.roots.iter().all(|root| root.is_absolute()));
1302 }
1303
1304 #[test]
1314 fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1315 let config_tmp_dir = TempDir::new().unwrap();
1316 let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1317 let config_path = config_dir.join("mcpls.toml");
1318 fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1319
1320 let cwd_tmp_dir = TempDir::new().unwrap();
1321 let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1322 let expected_root = cwd.join("relative-root");
1323 fs::create_dir(&expected_root).unwrap();
1324
1325 let config = {
1326 let _guard = CwdGuard::enter(&cwd);
1327 ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1328 };
1329
1330 assert_eq!(config.workspace.roots, vec![expected_root]);
1331 }
1332
1333 #[test]
1334 fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1335 let tmp_dir = TempDir::new().unwrap();
1336 let config_path = tmp_dir.path().join("mcpls.toml");
1337 fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1338
1339 let err = ServerConfig::load_from(&config_path).unwrap_err();
1340
1341 let Error::InvalidConfig(message) = err else {
1342 panic!("expected InvalidConfig, got {err:?}");
1343 };
1344 assert!(message.contains("workspace root 'missing'"));
1345 let config_dir = dunce::canonicalize(tmp_dir.path()).unwrap();
1346 assert!(message.contains(&config_dir.display().to_string()));
1347 }
1348
1349 #[test]
1350 fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1351 let tmp_dir = TempDir::new().unwrap();
1354 let config_path = tmp_dir.path().join("config.toml");
1355
1356 let toml_content = r#"
1357 [[lsp_servers]]
1358 language_id = "rust"
1359 command = "rust-analyzer"
1360 timeout_seconds = 30
1361 "#;
1362
1363 fs::write(&config_path, toml_content).unwrap();
1364
1365 let config = ServerConfig::load_from(&config_path).unwrap();
1366 assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
1367 }
1368
1369 #[test]
1370 fn test_validate_rejects_zero_timeout_seconds() {
1371 let tmp_dir = TempDir::new().unwrap();
1372 let config_path = tmp_dir.path().join("config.toml");
1373
1374 let toml_content = r#"
1375 [[lsp_servers]]
1376 language_id = "rust"
1377 command = "rust-analyzer"
1378 timeout_seconds = 0
1379 "#;
1380
1381 fs::write(&config_path, toml_content).unwrap();
1382
1383 let result = ServerConfig::load_from(&config_path);
1384 if let Err(Error::InvalidConfig(msg)) = result {
1385 assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
1390 } else {
1391 panic!("Expected InvalidConfig error, got {result:?}");
1392 }
1393 }
1394
1395 #[test]
1396 fn test_validate_rejects_zero_request_timeout_seconds() {
1397 let tmp_dir = TempDir::new().unwrap();
1398 let config_path = tmp_dir.path().join("config.toml");
1399
1400 let toml_content = r#"
1401 [[lsp_servers]]
1402 language_id = "rust"
1403 command = "rust-analyzer"
1404 request_timeout_seconds = 0
1405 "#;
1406
1407 fs::write(&config_path, toml_content).unwrap();
1408
1409 let result = ServerConfig::load_from(&config_path);
1410 if let Err(Error::InvalidConfig(msg)) = result {
1411 assert_eq!(
1412 msg,
1413 "request_timeout_seconds cannot be 0 for language 'rust'"
1414 );
1415 } else {
1416 panic!("Expected InvalidConfig error, got {result:?}");
1417 }
1418 }
1419
1420 #[test]
1421 fn test_validate_rejects_request_timeout_seconds_above_max() {
1422 let tmp_dir = TempDir::new().unwrap();
1423 let config_path = tmp_dir.path().join("config.toml");
1424
1425 let toml_content = format!(
1426 r#"
1427 [[lsp_servers]]
1428 language_id = "rust"
1429 command = "rust-analyzer"
1430 request_timeout_seconds = {}
1431 "#,
1432 MAX_TIMEOUT_SECONDS + 1
1433 );
1434
1435 fs::write(&config_path, toml_content).unwrap();
1436
1437 let result = ServerConfig::load_from(&config_path);
1438 if let Err(Error::InvalidConfig(msg)) = result {
1439 assert!(msg.contains("request_timeout_seconds"));
1440 assert!(msg.contains("exceeds the maximum"));
1441 } else {
1442 panic!("Expected InvalidConfig error, got {result:?}");
1443 }
1444 }
1445
1446 #[test]
1447 fn test_validate_accepts_request_timeout_seconds_at_max() {
1448 let tmp_dir = TempDir::new().unwrap();
1449 let config_path = tmp_dir.path().join("config.toml");
1450
1451 let toml_content = format!(
1452 r#"
1453 [[lsp_servers]]
1454 language_id = "rust"
1455 command = "rust-analyzer"
1456 request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1457 "#
1458 );
1459
1460 fs::write(&config_path, toml_content).unwrap();
1461
1462 let result = ServerConfig::load_from(&config_path);
1463 assert!(result.is_ok(), "expected Ok, got {result:?}");
1464 }
1465
1466 #[test]
1467 fn test_validate_rejects_timeout_seconds_above_max() {
1468 let tmp_dir = TempDir::new().unwrap();
1469 let config_path = tmp_dir.path().join("config.toml");
1470
1471 let toml_content = format!(
1472 r#"
1473 [[lsp_servers]]
1474 language_id = "rust"
1475 command = "rust-analyzer"
1476 timeout_seconds = {}
1477 "#,
1478 MAX_TIMEOUT_SECONDS + 1
1479 );
1480
1481 fs::write(&config_path, toml_content).unwrap();
1482
1483 let result = ServerConfig::load_from(&config_path);
1484 if let Err(Error::InvalidConfig(msg)) = result {
1485 assert!(msg.contains("timeout_seconds"));
1486 assert!(msg.contains("exceeds the maximum"));
1487 } else {
1488 panic!("Expected InvalidConfig error, got {result:?}");
1489 }
1490 }
1491
1492 #[test]
1493 fn test_validate_accepts_timeout_seconds_at_max() {
1494 let tmp_dir = TempDir::new().unwrap();
1495 let config_path = tmp_dir.path().join("config.toml");
1496
1497 let toml_content = format!(
1498 r#"
1499 [[lsp_servers]]
1500 language_id = "rust"
1501 command = "rust-analyzer"
1502 timeout_seconds = {MAX_TIMEOUT_SECONDS}
1503 "#
1504 );
1505
1506 fs::write(&config_path, toml_content).unwrap();
1507
1508 let result = ServerConfig::load_from(&config_path);
1509 assert!(result.is_ok(), "expected Ok, got {result:?}");
1510 }
1511
1512 #[test]
1513 fn test_validate_rejects_indexing_ready_timeout_seconds_at_or_below_progress_settle() {
1514 let tmp_dir = TempDir::new().unwrap();
1515 let config_path = tmp_dir.path().join("config.toml");
1516
1517 let toml_content = format!(
1518 r"
1519 [workspace]
1520 indexing_ready_timeout_seconds = {}
1521 ",
1522 PROGRESS_SETTLE.as_secs()
1523 );
1524
1525 fs::write(&config_path, toml_content).unwrap();
1526
1527 let result = ServerConfig::load_from(&config_path);
1528 if let Err(Error::InvalidConfig(msg)) = result {
1529 assert!(msg.contains("indexing_ready_timeout_seconds"));
1530 } else {
1531 panic!("Expected InvalidConfig error, got {result:?}");
1532 }
1533 }
1534
1535 #[test]
1536 fn test_validate_rejects_indexing_ready_timeout_seconds_at_or_above_staleness_bound() {
1537 let tmp_dir = TempDir::new().unwrap();
1538 let config_path = tmp_dir.path().join("config.toml");
1539
1540 let toml_content = format!(
1541 r"
1542 [workspace]
1543 indexing_ready_timeout_seconds = {}
1544 ",
1545 INDEXING_STALENESS_BOUND.as_secs()
1546 );
1547
1548 fs::write(&config_path, toml_content).unwrap();
1549
1550 let result = ServerConfig::load_from(&config_path);
1551 if let Err(Error::InvalidConfig(msg)) = result {
1552 assert!(msg.contains("indexing_ready_timeout_seconds"));
1553 } else {
1554 panic!("Expected InvalidConfig error, got {result:?}");
1555 }
1556 }
1557
1558 #[test]
1559 fn test_validate_accepts_indexing_ready_timeout_seconds_within_bounds() {
1560 let tmp_dir = TempDir::new().unwrap();
1561 let config_path = tmp_dir.path().join("config.toml");
1562
1563 fs::write(
1564 &config_path,
1565 r"
1566 [workspace]
1567 indexing_ready_timeout_seconds = 45
1568 ",
1569 )
1570 .unwrap();
1571
1572 let result = ServerConfig::load_from(&config_path);
1573 assert!(result.is_ok(), "expected Ok, got {result:?}");
1574 assert_eq!(result.unwrap().workspace.indexing_ready_timeout_seconds, 45);
1575 }
1576
1577 #[test]
1578 fn test_indexing_ready_timeout_seconds_default() {
1579 let config = ServerConfig::default();
1580 assert_eq!(
1581 config.workspace.indexing_ready_timeout_seconds,
1582 DEFAULT_INDEXING_READY_TIMEOUT_SECS
1583 );
1584 }
1585
1586 #[test]
1587 fn test_load_from_nonexistent_file() {
1588 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
1589 assert!(result.is_err());
1590
1591 if let Err(Error::ConfigNotFound(path)) = result {
1592 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
1593 } else {
1594 panic!("Expected ConfigNotFound error");
1595 }
1596 }
1597
1598 #[test]
1599 fn test_load_from_invalid_toml() {
1600 let tmp_dir = TempDir::new().unwrap();
1601 let config_path = tmp_dir.path().join("invalid.toml");
1602
1603 fs::write(&config_path, "invalid toml content {{}").unwrap();
1604
1605 let result = ServerConfig::load_from(&config_path);
1606 assert!(result.is_err());
1607 }
1608
1609 #[test]
1613 fn test_load_from_rejects_oversized_file() {
1614 let tmp_dir = TempDir::new().unwrap();
1615 let config_path = tmp_dir.path().join("oversized.toml");
1616
1617 let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1620 fs::write(&config_path, &oversized).unwrap();
1621
1622 let result = ServerConfig::load_from(&config_path);
1623 assert!(matches!(
1624 result,
1625 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1626 ));
1627 }
1628
1629 #[test]
1630 fn test_load_from_accepts_file_at_exact_size_cap() {
1631 let tmp_dir = TempDir::new().unwrap();
1632 let config_path = tmp_dir.path().join("exact.toml");
1633
1634 let mut toml_content = "[workspace]\n# ".to_string();
1637 toml_content.push_str(
1638 &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1639 );
1640 assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1641 fs::write(&config_path, &toml_content).unwrap();
1642
1643 let result = ServerConfig::load_from(&config_path);
1644 assert!(result.is_ok(), "expected Ok, got {result:?}");
1645 }
1646
1647 #[cfg(unix)]
1654 #[test]
1655 fn test_load_from_rejects_infinite_special_file() {
1656 let path = Path::new("/dev/zero");
1657 assert_eq!(
1658 fs::metadata(path).unwrap().len(),
1659 0,
1660 "test assumption: /dev/zero must report zero length"
1661 );
1662
1663 let result = ServerConfig::load_from(path);
1664 assert!(matches!(
1665 result,
1666 Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1667 ));
1668 }
1669
1670 #[test]
1671 fn test_validate_empty_language_id() {
1672 let tmp_dir = TempDir::new().unwrap();
1673 let config_path = tmp_dir.path().join("config.toml");
1674
1675 let toml_content = r#"
1676 [[lsp_servers]]
1677 language_id = ""
1678 command = "test"
1679 "#;
1680
1681 fs::write(&config_path, toml_content).unwrap();
1682
1683 let result = ServerConfig::load_from(&config_path);
1684 assert!(result.is_err());
1685
1686 if let Err(Error::InvalidConfig(msg)) = result {
1687 assert!(msg.contains("language_id cannot be empty"));
1688 } else {
1689 panic!("Expected InvalidConfig error");
1690 }
1691 }
1692
1693 #[test]
1694 fn test_validate_empty_command() {
1695 let tmp_dir = TempDir::new().unwrap();
1696 let config_path = tmp_dir.path().join("config.toml");
1697
1698 let toml_content = r#"
1699 [[lsp_servers]]
1700 language_id = "rust"
1701 command = ""
1702 "#;
1703
1704 fs::write(&config_path, toml_content).unwrap();
1705
1706 let result = ServerConfig::load_from(&config_path);
1707 assert!(result.is_err());
1708
1709 if let Err(Error::InvalidConfig(msg)) = result {
1710 assert!(msg.contains("command cannot be empty"));
1711 } else {
1712 panic!("Expected InvalidConfig error");
1713 }
1714 }
1715
1716 #[test]
1717 fn test_validate_empty_name() {
1718 let tmp_dir = TempDir::new().unwrap();
1719 let config_path = tmp_dir.path().join("config.toml");
1720
1721 let toml_content = r#"
1722 [[lsp_servers]]
1723 name = ""
1724 language_id = "python"
1725 command = "pyright-langserver"
1726 "#;
1727
1728 fs::write(&config_path, toml_content).unwrap();
1729
1730 let result = ServerConfig::load_from(&config_path);
1731 assert!(result.is_err());
1732
1733 if let Err(Error::InvalidConfig(msg)) = result {
1734 assert!(msg.contains("name cannot be empty"));
1735 } else {
1736 panic!("Expected InvalidConfig error");
1737 }
1738 }
1739
1740 #[test]
1741 fn test_validate_empty_handles() {
1742 let tmp_dir = TempDir::new().unwrap();
1743 let config_path = tmp_dir.path().join("config.toml");
1744
1745 let toml_content = r#"
1746 [[lsp_servers]]
1747 language_id = "python"
1748 command = "pylsp"
1749 handles = []
1750 "#;
1751
1752 fs::write(&config_path, toml_content).unwrap();
1753
1754 let result = ServerConfig::load_from(&config_path);
1755 assert!(result.is_err());
1756
1757 if let Err(Error::InvalidConfig(msg)) = result {
1758 assert!(msg.contains("handles cannot be empty"));
1759 } else {
1760 panic!("Expected InvalidConfig error");
1761 }
1762 }
1763
1764 #[test]
1765 fn test_validate_duplicate_tool_in_handles() {
1766 let tmp_dir = TempDir::new().unwrap();
1767 let config_path = tmp_dir.path().join("config.toml");
1768
1769 let toml_content = r#"
1770 [[lsp_servers]]
1771 language_id = "python"
1772 command = "pylsp"
1773 handles = ["diagnostics", "diagnostics"]
1774 "#;
1775
1776 fs::write(&config_path, toml_content).unwrap();
1777
1778 let result = ServerConfig::load_from(&config_path);
1779 assert!(result.is_err());
1780
1781 if let Err(Error::InvalidConfig(msg)) = result {
1782 assert!(msg.contains("duplicate tool"));
1783 assert!(msg.contains("diagnostics"));
1784 } else {
1785 panic!("Expected InvalidConfig error");
1786 }
1787 }
1788
1789 #[test]
1790 fn test_validate_rejects_empty_position_encodings() {
1791 let tmp_dir = TempDir::new().unwrap();
1792 let config_path = tmp_dir.path().join("config.toml");
1793
1794 let toml_content = r"
1795 [workspace]
1796 position_encodings = []
1797 ";
1798
1799 fs::write(&config_path, toml_content).unwrap();
1800
1801 let result = ServerConfig::load_from(&config_path);
1802 if let Err(Error::InvalidConfig(msg)) = result {
1803 assert_eq!(msg, "workspace.position_encodings cannot be empty");
1804 } else {
1805 panic!("Expected InvalidConfig error, got {result:?}");
1806 }
1807 }
1808
1809 #[test]
1814 fn test_validate_rejects_empty_workspace_root_entry() {
1815 let tmp_dir = TempDir::new().unwrap();
1816 let config_path = tmp_dir.path().join("config.toml");
1817
1818 let toml_content = r#"
1819 [workspace]
1820 roots = [""]
1821 "#;
1822
1823 fs::write(&config_path, toml_content).unwrap();
1824
1825 let result = ServerConfig::load_from(&config_path);
1826 if let Err(Error::InvalidConfig(msg)) = result {
1827 assert_eq!(msg, "workspace.roots entries cannot be empty");
1828 } else {
1829 panic!("Expected InvalidConfig error, got {result:?}");
1830 }
1831 }
1832
1833 #[test]
1834 fn test_validate_rejects_unrecognized_position_encoding() {
1835 let tmp_dir = TempDir::new().unwrap();
1836 let config_path = tmp_dir.path().join("config.toml");
1837
1838 let toml_content = r#"
1839 [workspace]
1840 position_encodings = ["utf-8", "utf-7"]
1841 "#;
1842
1843 fs::write(&config_path, toml_content).unwrap();
1844
1845 let result = ServerConfig::load_from(&config_path);
1846 if let Err(Error::InvalidConfig(msg)) = result {
1847 assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1848 } else {
1849 panic!("Expected InvalidConfig error, got {result:?}");
1850 }
1851 }
1852
1853 #[test]
1854 fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1855 assert_eq!(
1856 parse_position_encoding("utf-8"),
1857 Some(lsp_types::PositionEncodingKind::UTF8)
1858 );
1859 assert_eq!(
1860 parse_position_encoding("utf-16"),
1861 Some(lsp_types::PositionEncodingKind::UTF16)
1862 );
1863 assert_eq!(
1864 parse_position_encoding("utf-32"),
1865 Some(lsp_types::PositionEncodingKind::UTF32)
1866 );
1867 assert_eq!(parse_position_encoding("utf-7"), None);
1868 }
1869
1870 #[test]
1871 fn test_validate_duplicate_name_warns_but_loads() {
1872 let tmp_dir = TempDir::new().unwrap();
1877 let config_path = tmp_dir.path().join("config.toml");
1878
1879 let toml_content = r#"
1880 [[lsp_servers]]
1881 name = "dup"
1882 language_id = "python"
1883 command = "pyright-langserver"
1884
1885 [[lsp_servers]]
1886 name = "dup"
1887 language_id = "typescript"
1888 command = "typescript-language-server"
1889 "#;
1890
1891 fs::write(&config_path, toml_content).unwrap();
1892
1893 let result = ServerConfig::load_from(&config_path);
1894 assert!(result.is_ok(), "duplicate name must only warn at load time");
1895 }
1896
1897 #[test]
1898 fn test_workspace_config_defaults() {
1899 let workspace = WorkspaceConfig::default();
1900 assert!(workspace.roots.is_empty());
1901 assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1902 assert!(!workspace.language_extensions.is_empty());
1903 assert_eq!(workspace.language_extensions.len(), 30);
1904 assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1905 }
1906
1907 #[test]
1908 fn test_load_multiple_servers() {
1909 let tmp_dir = TempDir::new().unwrap();
1910 let config_path = tmp_dir.path().join("multi.toml");
1911
1912 let toml_content = r#"
1913 [[lsp_servers]]
1914 language_id = "rust"
1915 command = "rust-analyzer"
1916
1917 [[lsp_servers]]
1918 language_id = "python"
1919 command = "pyright-langserver"
1920 args = ["--stdio"]
1921 "#;
1922
1923 fs::write(&config_path, toml_content).unwrap();
1924
1925 let config = ServerConfig::load_from(&config_path).unwrap();
1926 assert_eq!(config.lsp_servers.len(), 2);
1927 assert_eq!(config.lsp_servers[0].language_id, "rust");
1928 assert_eq!(config.lsp_servers[1].language_id, "python");
1929 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1930 }
1931
1932 #[test]
1933 fn test_deny_unknown_fields() {
1934 let tmp_dir = TempDir::new().unwrap();
1935 let config_path = tmp_dir.path().join("unknown.toml");
1936
1937 let toml_content = r#"
1938 unknown_field = "value"
1939
1940 [workspace]
1941 roots = []
1942 "#;
1943
1944 fs::write(&config_path, toml_content).unwrap();
1945
1946 let result = ServerConfig::load_from(&config_path);
1947 assert!(result.is_err(), "Should reject unknown fields");
1948 }
1949
1950 #[test]
1951 fn test_empty_config_file() {
1952 let tmp_dir = TempDir::new().unwrap();
1953 let config_path = tmp_dir.path().join("empty.toml");
1954
1955 fs::write(&config_path, "").unwrap();
1956
1957 let config = ServerConfig::load_from(&config_path).unwrap();
1958 assert!(config.workspace.roots.is_empty());
1959 assert!(config.lsp_servers.is_empty());
1960 }
1961
1962 #[test]
1963 fn test_config_with_initialization_options() {
1964 let tmp_dir = TempDir::new().unwrap();
1965 let config_path = tmp_dir.path().join("init_opts.toml");
1966
1967 let toml_content = r#"
1968 [[lsp_servers]]
1969 language_id = "rust"
1970 command = "rust-analyzer"
1971
1972 [lsp_servers.initialization_options]
1973 cargo = { allFeatures = true }
1974 "#;
1975
1976 fs::write(&config_path, toml_content).unwrap();
1977
1978 let config = ServerConfig::load_from(&config_path).unwrap();
1979 assert!(config.lsp_servers[0].initialization_options.is_some());
1980 }
1981
1982 #[test]
1983 fn test_language_extensions_in_config() {
1984 let tmp_dir = TempDir::new().unwrap();
1985 let config_path = tmp_dir.path().join("extensions.toml");
1986
1987 let toml_content = r#"
1988 [[workspace.language_extensions]]
1989 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1990 language_id = "cpp"
1991
1992 [[workspace.language_extensions]]
1993 extensions = ["nu"]
1994 language_id = "nushell"
1995
1996 [[workspace.language_extensions]]
1997 extensions = ["py", "pyw", "pyi"]
1998 language_id = "python"
1999 "#;
2000
2001 fs::write(&config_path, toml_content).unwrap();
2002
2003 let config = ServerConfig::load_from(&config_path).unwrap();
2004 assert_eq!(config.workspace.language_extensions.len(), 3);
2005
2006 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
2008 assert_eq!(
2009 config.workspace.language_extensions[0].extensions,
2010 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
2011 );
2012
2013 assert_eq!(
2015 config.workspace.language_extensions[1].language_id,
2016 "nushell"
2017 );
2018 assert_eq!(
2019 config.workspace.language_extensions[1].extensions,
2020 vec!["nu"]
2021 );
2022 }
2023
2024 #[test]
2025 fn test_build_extension_map() {
2026 let workspace = WorkspaceConfig {
2027 roots: vec![],
2028 position_encodings: vec![],
2029 language_extensions: vec![
2030 LanguageExtensionMapping {
2031 extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
2032 language_id: "cpp".to_string(),
2033 },
2034 LanguageExtensionMapping {
2035 extensions: vec!["nu".to_string()],
2036 language_id: "nushell".to_string(),
2037 },
2038 ],
2039 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
2040 max_documents: DEFAULT_MAX_DOCUMENTS,
2041 max_file_size: DEFAULT_MAX_FILE_SIZE,
2042 indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
2043 };
2044
2045 let map = workspace.build_extension_map();
2046 assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
2047 assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
2048 assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
2049 assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
2050 assert_eq!(map.get("unknown"), None);
2051 }
2052
2053 #[test]
2054 fn test_extract_extension_from_pattern_empty_string() {
2055 assert_eq!(extract_extension_from_pattern(""), None);
2056 }
2057
2058 #[test]
2059 fn test_extract_extension_from_pattern_without_dot() {
2060 assert_eq!(extract_extension_from_pattern("**/*"), None);
2061 }
2062
2063 #[test]
2064 fn test_extract_extension_from_pattern_dotfile() {
2065 assert_eq!(extract_extension_from_pattern(".gitignore"), None);
2066 }
2067
2068 #[test]
2069 fn test_extract_extension_from_pattern_multi_dot_extension() {
2070 assert_eq!(
2071 extract_extension_from_pattern("foo.tar.gz"),
2072 Some("gz".to_string())
2073 );
2074 }
2075
2076 #[test]
2077 fn test_build_effective_extension_map_overrides_with_file_patterns() {
2078 let config = ServerConfig {
2079 mcp: McpConfig::default(),
2080 workspace: WorkspaceConfig::default(),
2081 lsp_servers: vec![LspServerConfig {
2082 language_id: "cpp".to_string(),
2083 command: "clangd".to_string(),
2084 args: vec![],
2085 env: HashMap::new(),
2086 file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
2087 initialization_options: None,
2088 timeout_seconds: 30,
2089 request_timeout_seconds: 30,
2090 heuristics: None,
2091 name: None,
2092 handles: None,
2093 indexing: crate::bridge::IndexingPolicy::Auto,
2094 }],
2095 project_config_ignored: false,
2096 };
2097
2098 let map = config.build_effective_extension_map();
2099 assert_eq!(map.get("c"), Some(&"cpp".to_string()));
2100 assert_eq!(map.get("h"), Some(&"cpp".to_string()));
2101 }
2102
2103 #[test]
2104 fn test_build_effective_extension_map_derives_tsx_language_id() {
2105 let config = ServerConfig {
2106 mcp: McpConfig::default(),
2107 workspace: WorkspaceConfig::default(),
2108 lsp_servers: vec![LspServerConfig {
2109 language_id: "typescript".to_string(),
2110 command: "tsgo".to_string(),
2111 args: vec!["--lsp".to_string(), "--stdio".to_string()],
2112 env: HashMap::new(),
2113 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
2114 initialization_options: None,
2115 timeout_seconds: 30,
2116 request_timeout_seconds: 30,
2117 heuristics: None,
2118 name: None,
2119 handles: None,
2120 indexing: crate::bridge::IndexingPolicy::Auto,
2121 }],
2122 project_config_ignored: false,
2123 };
2124
2125 let map = config.build_effective_extension_map();
2126 assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
2127 assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
2128 }
2129
2130 #[test]
2131 fn test_build_effective_extension_map_derives_jsx_language_id() {
2132 let config = ServerConfig {
2133 mcp: McpConfig::default(),
2134 workspace: WorkspaceConfig::default(),
2135 lsp_servers: vec![LspServerConfig {
2136 language_id: "javascript".to_string(),
2137 command: "typescript-language-server".to_string(),
2138 args: vec!["--stdio".to_string()],
2139 env: HashMap::new(),
2140 file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
2141 initialization_options: None,
2142 timeout_seconds: 30,
2143 request_timeout_seconds: 30,
2144 heuristics: None,
2145 name: None,
2146 handles: None,
2147 indexing: crate::bridge::IndexingPolicy::Auto,
2148 }],
2149 project_config_ignored: false,
2150 };
2151
2152 let map = config.build_effective_extension_map();
2153 assert_eq!(map.get("js"), Some(&"javascript".to_string()));
2154 assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
2155 }
2156
2157 #[test]
2158 fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
2159 let config = ServerConfig {
2160 mcp: McpConfig::default(),
2161 workspace: WorkspaceConfig::default(),
2162 lsp_servers: vec![LspServerConfig {
2163 language_id: "cpp".to_string(),
2164 command: "clangd".to_string(),
2165 args: vec![],
2166 env: HashMap::new(),
2167 file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
2168 initialization_options: None,
2169 timeout_seconds: 30,
2170 request_timeout_seconds: 30,
2171 heuristics: None,
2172 name: None,
2173 handles: None,
2174 indexing: crate::bridge::IndexingPolicy::Auto,
2175 }],
2176 project_config_ignored: false,
2177 };
2178
2179 let map = config.build_effective_extension_map();
2180 assert_eq!(map.get("h"), Some(&"c".to_string()));
2182 }
2183
2184 #[test]
2185 fn test_language_for_extension() {
2186 let workspace = WorkspaceConfig {
2187 roots: vec![],
2188 position_encodings: vec![],
2189 language_extensions: vec![
2190 LanguageExtensionMapping {
2191 extensions: vec!["hpp".to_string(), "hh".to_string()],
2192 language_id: "cpp".to_string(),
2193 },
2194 LanguageExtensionMapping {
2195 extensions: vec!["py".to_string()],
2196 language_id: "python".to_string(),
2197 },
2198 ],
2199 heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
2200 max_documents: DEFAULT_MAX_DOCUMENTS,
2201 max_file_size: DEFAULT_MAX_FILE_SIZE,
2202 indexing_ready_timeout_seconds: DEFAULT_INDEXING_READY_TIMEOUT_SECS,
2203 };
2204
2205 assert_eq!(
2206 workspace.language_for_extension("hpp"),
2207 Some("cpp".to_string())
2208 );
2209 assert_eq!(
2210 workspace.language_for_extension("hh"),
2211 Some("cpp".to_string())
2212 );
2213 assert_eq!(
2214 workspace.language_for_extension("py"),
2215 Some("python".to_string())
2216 );
2217 assert_eq!(workspace.language_for_extension("unknown"), None);
2218 }
2219
2220 #[test]
2221 fn test_default_language_extensions() {
2222 let workspace = WorkspaceConfig::default();
2223 let map = workspace.build_extension_map();
2224 assert!(!map.is_empty());
2225 assert_eq!(
2226 workspace.language_for_extension("rs"),
2227 Some("rust".to_string())
2228 );
2229 assert_eq!(
2230 workspace.language_for_extension("py"),
2231 Some("python".to_string())
2232 );
2233 assert_eq!(
2234 workspace.language_for_extension("cpp"),
2235 Some("cpp".to_string())
2236 );
2237 }
2238
2239 #[test]
2240 fn test_create_default_config_file() {
2241 let tmp_dir = TempDir::new().unwrap();
2242 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
2243
2244 ServerConfig::create_default_config_file(&config_path).unwrap();
2245
2246 assert!(config_path.exists());
2247
2248 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
2249 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
2250 assert_eq!(loaded_config.lsp_servers.len(), 6);
2251 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
2252 }
2253
2254 #[test]
2255 fn test_load_returns_default_config() {
2256 let config = ServerConfig::default();
2258 assert_eq!(config.workspace.language_extensions.len(), 30);
2259 assert_eq!(config.lsp_servers.len(), 6);
2260 assert_eq!(config.lsp_servers[0].language_id, "rust");
2261 }
2262
2263 use crate::test_support::CwdGuard;
2271
2272 fn assert_mcpls_config_env_unset() {
2285 assert!(
2286 std::env::var_os("MCPLS_CONFIG").is_none(),
2287 "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2288 load_with_trust returns before consulting CWD when it's set"
2289 );
2290 }
2291
2292 #[test]
2293 fn test_load_ignores_untrusted_project_local_config() {
2294 let tmp_dir = TempDir::new().unwrap();
2302 let config_path = tmp_dir.path().join("mcpls.toml");
2303
2304 let custom_toml = r#"
2310 [workspace]
2311 roots = ["/should-never-load-attacker-path"]
2312
2313 [[lsp_servers]]
2314 language_id = "definitely-not-a-real-language-marker"
2315 command = "rm"
2316 args = ["-rf", "/"]
2317 "#;
2318
2319 fs::write(&config_path, custom_toml).unwrap();
2320
2321 let config = {
2322 let _guard = CwdGuard::enter(tmp_dir.path());
2323 ServerConfig::load().unwrap()
2324 };
2325
2326 assert!(
2327 !config
2328 .workspace
2329 .roots
2330 .contains(&PathBuf::from("/should-never-load-attacker-path"))
2331 );
2332 assert!(
2333 !config
2334 .lsp_servers
2335 .iter()
2336 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2337 );
2338 }
2339
2340 #[test]
2341 fn test_load_with_trust_loads_trusted_project_local_config() {
2342 let tmp_dir = TempDir::new().unwrap();
2343 let config_path = tmp_dir.path().join("mcpls.toml");
2344 let custom_root = tmp_dir.path().join("custom");
2345 fs::create_dir(&custom_root).unwrap();
2346 let custom_root_literal = toml_path_literal(&custom_root);
2347
2348 let custom_toml = format!(
2349 r#"
2350 [workspace]
2351 roots = [{custom_root_literal}]
2352
2353 [[lsp_servers]]
2354 language_id = "python"
2355 command = "pyright-langserver"
2356 "#
2357 );
2358
2359 fs::write(&config_path, &custom_toml).unwrap();
2360
2361 let config = {
2362 let _guard = CwdGuard::enter(tmp_dir.path());
2363 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2364 };
2365
2366 assert_eq!(
2367 config.workspace.roots,
2368 vec![dunce::canonicalize(custom_root).unwrap()]
2369 );
2370 assert_eq!(config.lsp_servers.len(), 1);
2371 assert_eq!(config.lsp_servers[0].language_id, "python");
2372 }
2373
2374 #[test]
2375 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2376 let tmp_dir = TempDir::new().unwrap();
2377 let config_path = tmp_dir.path().join("mcpls.toml");
2378
2379 let custom_toml = r#"
2380 [workspace]
2381 roots = ["/attacker/controlled"]
2382 heuristics_max_depth = 999999
2383
2384 [[lsp_servers]]
2385 language_id = "evil"
2386 command = "rm"
2387 args = ["-rf", "/"]
2388 "#;
2389
2390 fs::write(&config_path, custom_toml).unwrap();
2391
2392 let config = {
2393 let _guard = CwdGuard::enter(tmp_dir.path());
2394 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2395 };
2396
2397 assert!(
2398 !config
2399 .workspace
2400 .roots
2401 .contains(&PathBuf::from("/attacker/controlled"))
2402 );
2403 assert_ne!(config.workspace.heuristics_max_depth, 999_999);
2404 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2405 }
2406
2407 #[test]
2408 fn test_load_with_trust_sets_project_config_ignored_flag() {
2409 assert_mcpls_config_env_unset();
2410
2411 let tmp_dir = TempDir::new().unwrap();
2412 let config_path = tmp_dir.path().join("mcpls.toml");
2413 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2414
2415 let config = {
2416 let _guard = CwdGuard::enter(tmp_dir.path());
2417 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2418 };
2419 assert!(config.project_config_ignored);
2420
2421 let tmp_dir = TempDir::new().unwrap();
2422 let config_path = tmp_dir.path().join("mcpls.toml");
2423 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2424
2425 let config = {
2426 let _guard = CwdGuard::enter(tmp_dir.path());
2427 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2428 };
2429 assert!(!config.project_config_ignored);
2430 }
2431
2432 #[test]
2433 fn test_load_no_local_config_leaves_flag_unset() {
2434 assert_mcpls_config_env_unset();
2435
2436 let tmp_dir = TempDir::new().unwrap();
2437
2438 let config = {
2439 let _guard = CwdGuard::enter(tmp_dir.path());
2440 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2441 };
2442 assert!(!config.project_config_ignored);
2443 }
2444
2445 #[test]
2446 fn test_config_file_creation_with_proper_structure() {
2447 let tmp_dir = TempDir::new().unwrap();
2448 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2449
2450 ServerConfig::create_default_config_file(&config_path).unwrap();
2451
2452 let content = fs::read_to_string(&config_path).unwrap();
2453
2454 assert!(content.contains("[mcp]"));
2455 assert!(content.contains("[workspace]"));
2456 assert!(content.contains("[[workspace.language_extensions]]"));
2457 assert!(content.contains("[[lsp_servers]]"));
2458 assert!(content.contains("language_id = \"rust\""));
2459 assert!(content.contains("extensions = [\"rs\"]"));
2460 }
2461
2462 #[test]
2463 fn test_heuristics_max_depth_default() {
2464 let config = WorkspaceConfig::default();
2465 assert_eq!(config.heuristics_max_depth, 10);
2466 }
2467
2468 #[test]
2469 fn test_heuristics_max_depth_from_config() {
2470 let tmp_dir = TempDir::new().unwrap();
2471 let config_path = tmp_dir.path().join("depth.toml");
2472
2473 let toml_content = r"
2474 [workspace]
2475 heuristics_max_depth = 5
2476 ";
2477
2478 fs::write(&config_path, toml_content).unwrap();
2479
2480 let config = ServerConfig::load_from(&config_path).unwrap();
2481 assert_eq!(config.workspace.heuristics_max_depth, 5);
2482 }
2483
2484 #[test]
2485 fn test_heuristics_max_depth_uses_default_when_not_specified() {
2486 let tmp_dir = TempDir::new().unwrap();
2487 let config_path = tmp_dir.path().join("no_depth.toml");
2488
2489 let toml_content = r"
2490 [workspace]
2491 roots = []
2492 ";
2493
2494 fs::write(&config_path, toml_content).unwrap();
2495
2496 let config = ServerConfig::load_from(&config_path).unwrap();
2497 assert_eq!(
2498 config.workspace.heuristics_max_depth,
2499 DEFAULT_HEURISTICS_MAX_DEPTH
2500 );
2501 }
2502
2503 #[test]
2504 fn test_max_documents_default() {
2505 let config = WorkspaceConfig::default();
2506 assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
2507 }
2508
2509 #[test]
2510 fn test_max_file_size_default() {
2511 let config = WorkspaceConfig::default();
2512 assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
2513 }
2514
2515 #[test]
2516 fn test_max_documents_from_config() {
2517 let tmp_dir = TempDir::new().unwrap();
2518 let config_path = tmp_dir.path().join("limits.toml");
2519
2520 let toml_content = r"
2521 [workspace]
2522 max_documents = 500
2523 ";
2524
2525 fs::write(&config_path, toml_content).unwrap();
2526
2527 let config = ServerConfig::load_from(&config_path).unwrap();
2528 assert_eq!(config.workspace.max_documents, 500);
2529 }
2530
2531 #[test]
2532 fn test_max_file_size_from_config() {
2533 let tmp_dir = TempDir::new().unwrap();
2534 let config_path = tmp_dir.path().join("limits.toml");
2535
2536 let toml_content = r"
2537 [workspace]
2538 max_file_size = 20971520
2539 ";
2540
2541 fs::write(&config_path, toml_content).unwrap();
2542
2543 let config = ServerConfig::load_from(&config_path).unwrap();
2544 assert_eq!(config.workspace.max_file_size, 20_971_520);
2545 }
2546
2547 #[test]
2548 fn test_max_documents_uses_default_when_not_specified() {
2549 let tmp_dir = TempDir::new().unwrap();
2550 let config_path = tmp_dir.path().join("no_limits.toml");
2551
2552 let toml_content = r"
2553 [workspace]
2554 roots = []
2555 ";
2556
2557 fs::write(&config_path, toml_content).unwrap();
2558
2559 let config = ServerConfig::load_from(&config_path).unwrap();
2560 assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
2561 assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
2562 }
2563
2564 #[test]
2568 fn test_max_file_size_zero_means_unlimited() {
2569 let tmp_dir = TempDir::new().unwrap();
2570 let config_path = tmp_dir.path().join("unlimited.toml");
2571
2572 let toml_content = r"
2573 [workspace]
2574 max_file_size = 0
2575 ";
2576
2577 fs::write(&config_path, toml_content).unwrap();
2578
2579 let config = ServerConfig::load_from(&config_path).unwrap();
2580 assert_eq!(config.workspace.max_file_size, 0);
2581 assert_eq!(config.workspace.resource_limits().max_file_size, 0);
2582 }
2583
2584 #[test]
2587 fn test_max_documents_rejects_negative_value() {
2588 let tmp_dir = TempDir::new().unwrap();
2589 let config_path = tmp_dir.path().join("negative_max_documents.toml");
2590
2591 let toml_content = r"
2592 [workspace]
2593 max_documents = -1
2594 ";
2595
2596 fs::write(&config_path, toml_content).unwrap();
2597
2598 let result = ServerConfig::load_from(&config_path);
2599 if let Err(Error::TomlDe(e)) = &result {
2604 let msg = e.to_string();
2605 assert!(
2606 msg.contains("-1") && msg.contains("usize"),
2607 "expected a type-mismatch message naming the offending value and the \
2608 expected type, got: {msg}"
2609 );
2610 } else {
2611 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2612 }
2613 }
2614
2615 #[test]
2618 fn test_max_file_size_rejects_string_value() {
2619 let tmp_dir = TempDir::new().unwrap();
2620 let config_path = tmp_dir.path().join("string_max_file_size.toml");
2621
2622 let toml_content = r#"
2623 [workspace]
2624 max_file_size = "10MB"
2625 "#;
2626
2627 fs::write(&config_path, toml_content).unwrap();
2628
2629 let result = ServerConfig::load_from(&config_path);
2630 if let Err(Error::TomlDe(e)) = &result {
2631 let msg = e.to_string();
2632 assert!(
2633 msg.contains("10MB") && msg.contains("u64"),
2634 "expected a type-mismatch message naming the offending value and the \
2635 expected type, got: {msg}"
2636 );
2637 } else {
2638 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2639 }
2640 }
2641
2642 #[test]
2643 fn test_workspace_config_resource_limits_maps_fields() {
2644 let workspace = WorkspaceConfig {
2645 max_documents: 250,
2646 max_file_size: 0,
2647 ..WorkspaceConfig::default()
2648 };
2649
2650 let limits = workspace.resource_limits();
2651 assert_eq!(limits.max_documents, 250);
2652 assert_eq!(limits.max_file_size, 0);
2653 }
2654
2655 #[test]
2656 fn test_workspace_config_toml_round_trip() {
2657 let original = WorkspaceConfig {
2658 roots: vec![PathBuf::from("/tmp/round-trip")],
2659 position_encodings: vec!["utf-8".to_string()],
2660 language_extensions: vec![LanguageExtensionMapping {
2661 extensions: vec!["nu".to_string()],
2662 language_id: "nushell".to_string(),
2663 }],
2664 heuristics_max_depth: 5,
2665 max_documents: 500,
2666 max_file_size: 0,
2667 indexing_ready_timeout_seconds: 45,
2668 };
2669
2670 let toml_content = toml::to_string_pretty(&original).unwrap();
2671 let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2672
2673 assert_eq!(round_tripped.roots, original.roots);
2674 assert_eq!(
2675 round_tripped.position_encodings,
2676 original.position_encodings
2677 );
2678 assert_eq!(
2679 round_tripped.language_extensions.len(),
2680 original.language_extensions.len()
2681 );
2682 assert_eq!(
2683 round_tripped.language_extensions[0].extensions,
2684 original.language_extensions[0].extensions
2685 );
2686 assert_eq!(
2687 round_tripped.language_extensions[0].language_id,
2688 original.language_extensions[0].language_id
2689 );
2690 assert_eq!(
2691 round_tripped.heuristics_max_depth,
2692 original.heuristics_max_depth
2693 );
2694 assert_eq!(round_tripped.max_documents, original.max_documents);
2695 assert_eq!(round_tripped.max_file_size, original.max_file_size);
2696 assert_eq!(
2697 round_tripped.indexing_ready_timeout_seconds,
2698 original.indexing_ready_timeout_seconds
2699 );
2700 }
2701
2702 #[test]
2703 fn test_mcp_config_parses_from_toml_section() {
2704 let tmp_dir = TempDir::new().unwrap();
2705 let config_path = tmp_dir.path().join("config.toml");
2706
2707 let toml_content = r#"
2708 [mcp]
2709 title = "Custom Title"
2710 description = "Custom description"
2711 instructions = "Custom instructions."
2712 "#;
2713
2714 fs::write(&config_path, toml_content).unwrap();
2715
2716 let config = ServerConfig::load_from(&config_path).unwrap();
2717 assert_eq!(config.mcp.title.as_deref(), Some("Custom Title"));
2718 assert_eq!(
2719 config.mcp.description.as_deref(),
2720 Some("Custom description")
2721 );
2722 assert_eq!(
2723 config.mcp.instructions.as_deref(),
2724 Some("Custom instructions.")
2725 );
2726 }
2727
2728 #[test]
2729 fn test_mcp_config_defaults_to_none_when_section_absent() {
2730 let tmp_dir = TempDir::new().unwrap();
2731 let config_path = tmp_dir.path().join("config.toml");
2732
2733 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2734
2735 let config = ServerConfig::load_from(&config_path).unwrap();
2736 assert_eq!(config.mcp.title, None);
2737 assert_eq!(config.mcp.description, None);
2738 assert_eq!(config.mcp.instructions, None);
2739 }
2740
2741 #[test]
2742 fn test_mcp_config_rejects_unknown_field() {
2743 let tmp_dir = TempDir::new().unwrap();
2744 let config_path = tmp_dir.path().join("config.toml");
2745
2746 fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
2747
2748 let result = ServerConfig::load_from(&config_path);
2749 assert!(matches!(result, Err(Error::TomlDe(_))));
2750 }
2751
2752 #[test]
2753 fn test_validate_rejects_empty_mcp_title() {
2754 let tmp_dir = TempDir::new().unwrap();
2755 let config_path = tmp_dir.path().join("config.toml");
2756
2757 fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
2758
2759 let result = ServerConfig::load_from(&config_path);
2760 if let Err(Error::InvalidConfig(msg)) = result {
2761 assert_eq!(
2762 msg,
2763 "mcp.title cannot be empty (omit `title` from the `[mcp]` section to use the \
2764 built-in default)"
2765 );
2766 } else {
2767 panic!("Expected InvalidConfig error, got {result:?}");
2768 }
2769 }
2770
2771 #[test]
2774 fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
2775 let tmp_dir = TempDir::new().unwrap();
2776 let config_path = tmp_dir.path().join("config.toml");
2777
2778 fs::write(&config_path, "[mcp]\ntitle = \" \"\n").unwrap();
2779
2780 let result = ServerConfig::load_from(&config_path);
2781 if let Err(Error::InvalidConfig(msg)) = result {
2782 assert!(msg.contains("cannot be empty"));
2783 } else {
2784 panic!("Expected InvalidConfig error, got {result:?}");
2785 }
2786 }
2787
2788 #[test]
2789 fn test_validate_rejects_empty_mcp_description() {
2790 let tmp_dir = TempDir::new().unwrap();
2791 let config_path = tmp_dir.path().join("config.toml");
2792
2793 fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
2794
2795 let result = ServerConfig::load_from(&config_path);
2796 if let Err(Error::InvalidConfig(msg)) = result {
2797 assert_eq!(
2798 msg,
2799 "mcp.description cannot be empty (omit `description` from the `[mcp]` section \
2800 to use the built-in default)"
2801 );
2802 } else {
2803 panic!("Expected InvalidConfig error, got {result:?}");
2804 }
2805 }
2806
2807 #[test]
2808 fn test_validate_rejects_empty_mcp_instructions() {
2809 let tmp_dir = TempDir::new().unwrap();
2810 let config_path = tmp_dir.path().join("config.toml");
2811
2812 fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
2813
2814 let result = ServerConfig::load_from(&config_path);
2815 if let Err(Error::InvalidConfig(msg)) = result {
2816 assert_eq!(
2817 msg,
2818 "mcp.instructions cannot be empty (omit `instructions` from the `[mcp]` \
2819 section to use the built-in default)"
2820 );
2821 } else {
2822 panic!("Expected InvalidConfig error, got {result:?}");
2823 }
2824 }
2825
2826 #[test]
2827 fn test_validate_rejects_over_length_mcp_title() {
2828 let tmp_dir = TempDir::new().unwrap();
2829 let config_path = tmp_dir.path().join("config.toml");
2830
2831 let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
2832 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2833
2834 let result = ServerConfig::load_from(&config_path);
2835 if let Err(Error::InvalidConfig(msg)) = result {
2836 assert_eq!(
2837 msg,
2838 format!(
2839 "mcp.title exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
2840 MAX_MCP_TITLE_BYTES + 1
2841 )
2842 );
2843 } else {
2844 panic!("Expected InvalidConfig error, got {result:?}");
2845 }
2846 }
2847
2848 #[test]
2849 fn test_validate_accepts_mcp_title_at_exact_cap() {
2850 let tmp_dir = TempDir::new().unwrap();
2851 let config_path = tmp_dir.path().join("config.toml");
2852
2853 let title = "a".repeat(MAX_MCP_TITLE_BYTES);
2854 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2855
2856 let result = ServerConfig::load_from(&config_path);
2857 assert!(result.is_ok(), "expected Ok, got {result:?}");
2858 }
2859
2860 #[test]
2861 fn test_validate_rejects_over_length_mcp_description() {
2862 let tmp_dir = TempDir::new().unwrap();
2863 let config_path = tmp_dir.path().join("config.toml");
2864
2865 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
2866 fs::write(
2867 &config_path,
2868 format!("[mcp]\ndescription = \"{description}\"\n"),
2869 )
2870 .unwrap();
2871
2872 let result = ServerConfig::load_from(&config_path);
2873 if let Err(Error::InvalidConfig(msg)) = result {
2874 assert!(msg.contains("mcp.description exceeds the maximum"));
2875 assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
2876 } else {
2877 panic!("Expected InvalidConfig error, got {result:?}");
2878 }
2879 }
2880
2881 #[test]
2882 fn test_validate_accepts_mcp_description_at_exact_cap() {
2883 let tmp_dir = TempDir::new().unwrap();
2884 let config_path = tmp_dir.path().join("config.toml");
2885
2886 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
2887 fs::write(
2888 &config_path,
2889 format!("[mcp]\ndescription = \"{description}\"\n"),
2890 )
2891 .unwrap();
2892
2893 let result = ServerConfig::load_from(&config_path);
2894 assert!(result.is_ok(), "expected Ok, got {result:?}");
2895 }
2896
2897 #[test]
2902 fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
2903 let tmp_dir = TempDir::new().unwrap();
2904 let config_path = tmp_dir.path().join("config.toml");
2905
2906 let title = "é".repeat(65);
2907 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
2908 assert_eq!(title.chars().count(), 65);
2909 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2910
2911 let result = ServerConfig::load_from(&config_path);
2912 if let Err(Error::InvalidConfig(msg)) = result {
2913 assert!(msg.contains("mcp.title exceeds the maximum"));
2914 } else {
2915 panic!("Expected InvalidConfig error, got {result:?}");
2916 }
2917 }
2918
2919 #[test]
2920 fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
2921 let tmp_dir = TempDir::new().unwrap();
2922 let config_path = tmp_dir.path().join("config.toml");
2923
2924 let title = "é".repeat(64);
2925 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
2926 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2927
2928 let result = ServerConfig::load_from(&config_path);
2929 assert!(result.is_ok(), "expected Ok, got {result:?}");
2930 }
2931
2932 #[test]
2933 fn test_validate_rejects_over_length_mcp_instructions() {
2934 let tmp_dir = TempDir::new().unwrap();
2935 let config_path = tmp_dir.path().join("config.toml");
2936
2937 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
2938 fs::write(
2939 &config_path,
2940 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2941 )
2942 .unwrap();
2943
2944 let result = ServerConfig::load_from(&config_path);
2945 if let Err(Error::InvalidConfig(msg)) = result {
2946 assert!(msg.contains("mcp.instructions exceeds the maximum"));
2947 assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
2948 } else {
2949 panic!("Expected InvalidConfig error, got {result:?}");
2950 }
2951 }
2952
2953 #[test]
2954 fn test_validate_accepts_mcp_instructions_at_exact_cap() {
2955 let tmp_dir = TempDir::new().unwrap();
2956 let config_path = tmp_dir.path().join("config.toml");
2957
2958 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
2959 fs::write(
2960 &config_path,
2961 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2962 )
2963 .unwrap();
2964
2965 let result = ServerConfig::load_from(&config_path);
2966 assert!(result.is_ok(), "expected Ok, got {result:?}");
2967 }
2968
2969 #[test]
2974 fn test_tool_prefix_accepts_valid_and_round_trips() {
2975 let tmp_dir = TempDir::new().unwrap();
2976 let config_path = tmp_dir.path().join("config.toml");
2977 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
2978
2979 let config = ServerConfig::load_from(&config_path).unwrap();
2980 assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
2981
2982 let serialized = toml::to_string_pretty(&config).unwrap();
2983 let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
2984 assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
2985 }
2986
2987 #[test]
2988 fn test_tool_prefix_accepts_digits_and_mixed_case() {
2989 let prefix: ToolPrefix = "Optics2".parse().unwrap();
2990 assert_eq!(prefix.as_str(), "Optics2");
2991 }
2992
2993 #[test]
2994 fn test_tool_prefix_accepts_single_alphanumeric_char() {
2995 assert!("x".parse::<ToolPrefix>().is_ok());
2996 assert!("9".parse::<ToolPrefix>().is_ok());
2997 }
2998
2999 #[test]
3000 fn test_tool_prefix_rejects_empty() {
3001 let err = "".parse::<ToolPrefix>().unwrap_err();
3002 assert_eq!(
3003 err,
3004 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
3005 use unprefixed tool names)"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_tool_prefix_rejects_whitespace_only() {
3011 let err = " ".parse::<ToolPrefix>().unwrap_err();
3012 assert!(err.contains("cannot be empty"));
3013 }
3014
3015 #[test]
3016 fn test_tool_prefix_rejects_leading_separator() {
3017 for bad in ["_optics", "-optics"] {
3018 let err = bad.parse::<ToolPrefix>().unwrap_err();
3019 assert!(
3020 err.contains("cannot start with"),
3021 "for input {bad:?}: {err}"
3022 );
3023 }
3024 }
3025
3026 #[test]
3027 fn test_tool_prefix_rejects_trailing_separator() {
3028 for bad in ["optics_", "optics-"] {
3029 let err = bad.parse::<ToolPrefix>().unwrap_err();
3030 assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
3031 assert!(err.contains("inserted automatically"));
3032 }
3033 }
3034
3035 #[test]
3036 fn test_tool_prefix_rejects_dot() {
3037 let err = "op.tics".parse::<ToolPrefix>().unwrap_err();
3038 assert!(err.contains("invalid character '.'"));
3039 }
3040
3041 #[test]
3042 fn test_tool_prefix_rejects_space() {
3043 let err = "op tics".parse::<ToolPrefix>().unwrap_err();
3044 assert!(err.contains("invalid character ' '"));
3045 }
3046
3047 #[test]
3048 fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
3049 let err = "optiсs".parse::<ToolPrefix>().unwrap_err();
3050 assert!(err.contains("invalid character 'с'"), "{err}");
3051 }
3052
3053 #[test]
3054 fn test_tool_prefix_rejects_over_length() {
3055 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
3056 let err = prefix.parse::<ToolPrefix>().unwrap_err();
3057 assert!(err.contains(&format!(
3058 "exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes"
3059 )));
3060 }
3061
3062 #[test]
3063 fn test_tool_prefix_accepts_exact_length_cap() {
3064 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
3065 assert!(prefix.parse::<ToolPrefix>().is_ok());
3066 }
3067
3068 #[test]
3069 fn test_tool_prefix_from_str_shares_config_validator() {
3070 assert!("optics_".parse::<ToolPrefix>().is_err());
3075 let tmp_dir = TempDir::new().unwrap();
3076 let config_path = tmp_dir.path().join("config.toml");
3077 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3078 assert!(matches!(
3079 ServerConfig::load_from(&config_path),
3080 Err(Error::TomlDe(_))
3081 ));
3082 }
3083
3084 #[test]
3091 fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
3092 let tmp_dir = TempDir::new().unwrap();
3093 let config_path = tmp_dir.path().join("config.toml");
3094 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3095
3096 let result = ServerConfig::load_from(&config_path);
3097 let Err(Error::TomlDe(err)) = result else {
3098 panic!("Expected TomlDe error, got {result:?}");
3099 };
3100 let msg = err.to_string();
3101 assert!(msg.contains("mcp.tool_prefix"), "{msg}");
3102 assert!(msg.contains("cannot end with"), "{msg}");
3103 assert!(msg.contains("line 2"), "{msg}");
3110 }
3111}