1mod bounded_number;
7mod bounded_secs;
8mod command_stem;
9mod host_os;
10mod language;
11mod language_id;
12mod language_map;
13mod limits;
14mod patterns;
15mod position_encodings;
16mod routing;
17mod server;
18mod server_env;
19mod settings;
20mod text_newtype;
21mod trust;
22mod workspace_root;
23
24use std::collections::HashMap;
25use std::num::NonZeroU64;
26use std::path::{Component, Path, PathBuf};
27
28pub use bounded_secs::{BoundedSecs, IndexingReadyTimeoutSecs, InvalidSecs, TimeoutSecs};
29pub use command_stem::CommandStem;
30pub use host_os::HostOs;
31pub use language::{base_language_id, react_variant_language_id};
32pub use language_id::{InvalidLanguageId, LanguageId};
33pub use language_map::{FileKey, LanguageMap};
34pub use limits::{
35 BoundedText, DEFAULT_HEURISTICS_MAX_DEPTH, DocumentLimit, InvalidBoundedText,
36 InvalidSearchDepth, InvalidServerStartConcurrency, InvalidSizeLimit, MAX_FILE_SIZE_LIMIT,
37 MAX_HEURISTICS_DEPTH, MAX_TIMEOUT_SECONDS, SearchDepth, ServerStartConcurrency, SizeLimit,
38};
39pub use patterns::{
40 FileExtension, FileName, FilePattern, InvalidFileExtension, InvalidFileName,
41 InvalidProjectMarker, PatternTarget, ProjectMarker, UnsupportedFilePattern,
42};
43pub use position_encodings::{InvalidPositionEncodings, PositionEncodings};
44pub use routing::{
45 EntrySummary, InvalidServerId, InvalidToolSet, NoServerReason, ServerId, ServerSettlement,
46 ToolKind, ToolRouter, ToolSet,
47};
48use serde::{Deserialize, Serialize};
49pub(crate) use server::MarkerScan;
50pub use server::{
51 BuiltinServer, InvalidAutoSelection, InvalidServerCommand, LaunchCommand, LspServerConfig,
52 ServerCommand, ServerHeuristics, ServerSelection,
53};
54pub use server_env::{DuplicateEnvKey, EnvKey, InvalidEnvKey, ServerEnv};
55pub use settings::{InvalidLspSettings, LspSettings};
56use text_newtype::impl_text_newtype;
57pub(crate) use trust::login_home_dir;
58pub use trust::{ServerAllowlist, WorkspaceTrust};
59pub use workspace_root::{ConfiguredRoot, InvalidWorkspaceRoot};
60
61use crate::bridge::{ResourceLimits, WorkspaceRoots, probe_root};
62use crate::error::{ConfigError, Error, Result};
63use crate::util::{OpenRegularFileError, ReadBoundedError, RegularFile};
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct LanguageExtensionMapping {
71 pub extensions: Vec<FileExtension>,
73 pub language_id: LanguageId,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct ServerConfig {
81 #[serde(default)]
83 pub mcp: McpConfig,
84
85 #[serde(default)]
87 pub workspace: WorkspaceConfig,
88
89 #[serde(default)]
91 pub lsp_servers: Vec<LspServerConfig>,
92
93 #[serde(skip)]
103 pub project_config_status: ProjectConfigStatus,
104
105 #[serde(skip)]
111 pub workspace_trust: WorkspaceTrust,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct McpConfig {
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub title: Option<McpTitle>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub description: Option<McpDescription>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub instructions: Option<McpInstructions>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub tool_prefix: Option<ToolPrefix>,
175}
176
177pub const MAX_MCP_TITLE_BYTES: usize = 128;
181
182pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
186
187pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
194
195pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
206
207pub type McpTitle = BoundedText<MAX_MCP_TITLE_BYTES>;
209
210pub type McpDescription = BoundedText<MAX_MCP_DESCRIPTION_BYTES>;
212
213pub type McpInstructions = BoundedText<MAX_MCP_INSTRUCTIONS_BYTES>;
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(try_from = "String")]
247pub struct ToolPrefix(String);
248
249impl ToolPrefix {
250 pub fn new(value: impl Into<String>) -> std::result::Result<Self, InvalidToolPrefix> {
256 let value = value.into();
257 InvalidToolPrefix::check(&value).map_or(Ok(Self(value)), Err)
258 }
259}
260
261impl_text_newtype!(ToolPrefix, InvalidToolPrefix);
262
263#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
269pub enum InvalidToolPrefix {
270 #[error(
272 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to use \
273 unprefixed tool names)"
274 )]
275 Empty,
276 #[error(
278 "mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} given)"
279 )]
280 TooLong {
281 len: usize,
283 },
284 #[error(
286 "mcp.tool_prefix contains an invalid character {0:?} (allowed: ASCII letters, digits, \
287 '_', and '-')"
288 )]
289 InvalidChar(char),
290 #[error("mcp.tool_prefix cannot start with {0:?} (must start with an ASCII letter or digit)")]
292 LeadingSeparator(char),
293 #[error(
295 "mcp.tool_prefix cannot end with {0:?} (the '_' separator between the prefix and each \
296 tool name is inserted automatically by mcpls -- remove the trailing separator character)"
297 )]
298 TrailingSeparator(char),
299}
300
301impl InvalidToolPrefix {
302 fn check(value: &str) -> Option<Self> {
303 let (Some(first), Some(last)) = (value.chars().next(), value.chars().next_back()) else {
304 return Some(Self::Empty);
305 };
306 if value.trim().is_empty() {
307 return Some(Self::Empty);
308 }
309 if value.len() > MAX_MCP_TOOL_PREFIX_BYTES {
310 return Some(Self::TooLong { len: value.len() });
311 }
312 if let Some(bad) = value
313 .chars()
314 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
315 {
316 return Some(Self::InvalidChar(bad));
317 }
318 if !first.is_ascii_alphanumeric() {
319 return Some(Self::LeadingSeparator(first));
320 }
321 if !last.is_ascii_alphanumeric() {
322 return Some(Self::TrailingSeparator(last));
323 }
324 None
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct WorkspaceConfig {
332 #[serde(default)]
339 pub roots: Vec<ConfiguredRoot>,
340
341 #[serde(default)]
350 pub position_encodings: PositionEncodings,
351
352 #[serde(default)]
355 pub language_extensions: Vec<LanguageExtensionMapping>,
356
357 #[serde(default)]
361 pub heuristics_max_depth: SearchDepth,
362
363 #[serde(default)]
381 pub max_documents: DocumentLimit,
382
383 #[serde(default)]
390 pub max_file_size: SizeLimit,
391
392 #[serde(default)]
405 pub indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs,
406
407 #[serde(default)]
411 pub max_concurrent_server_starts: ServerStartConcurrency,
412}
413
414impl Default for WorkspaceConfig {
415 fn default() -> Self {
416 Self {
417 roots: Vec::new(),
418 position_encodings: PositionEncodings::DEFAULT,
419 language_extensions: default_language_extensions(),
420 heuristics_max_depth: SearchDepth::DEFAULT,
421 max_documents: DocumentLimit::DEFAULT,
422 max_file_size: SizeLimit::DEFAULT,
423 indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
424 max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
425 }
426 }
427}
428
429impl WorkspaceConfig {
430 #[must_use]
437 pub fn build_extension_map(&self) -> HashMap<FileExtension, LanguageId> {
438 let mut map = HashMap::new();
439 for mapping in &self.language_extensions {
440 for ext in &mapping.extensions {
441 map.insert(ext.clone(), mapping.language_id.clone());
442 }
443 }
444 map
445 }
446
447 #[must_use]
450 pub const fn resource_limits(&self) -> ResourceLimits {
451 ResourceLimits {
452 max_documents: self.max_documents,
453 max_file_size: self.max_file_size,
454 }
455 }
456}
457
458fn language_id_for_pattern_target(
459 server_language_id: &LanguageId,
460 target: &PatternTarget,
461) -> LanguageId {
462 match target {
463 PatternTarget::Extension(extension) => {
464 react_variant_language_id(server_language_id, extension.as_str())
465 .unwrap_or_else(|| server_language_id.clone())
466 }
467 PatternTarget::Name(_) => server_language_id.clone(),
468 }
469}
470
471#[expect(
476 clippy::too_many_lines,
477 reason = "a flat table of built-in extension mappings"
478)]
479fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
480 vec![
481 LanguageExtensionMapping {
482 extensions: vec![FileExtension::from_static("rs")],
483 language_id: const { LanguageId::from_static("rust") },
484 },
485 LanguageExtensionMapping {
486 extensions: vec![
487 FileExtension::from_static("py"),
488 FileExtension::from_static("pyw"),
489 FileExtension::from_static("pyi"),
490 ],
491 language_id: const { LanguageId::from_static("python") },
492 },
493 LanguageExtensionMapping {
494 extensions: vec![
495 FileExtension::from_static("js"),
496 FileExtension::from_static("mjs"),
497 FileExtension::from_static("cjs"),
498 ],
499 language_id: const { LanguageId::from_static("javascript") },
500 },
501 LanguageExtensionMapping {
502 extensions: vec![
503 FileExtension::from_static("ts"),
504 FileExtension::from_static("mts"),
505 FileExtension::from_static("cts"),
506 ],
507 language_id: const { LanguageId::from_static("typescript") },
508 },
509 LanguageExtensionMapping {
510 extensions: vec![FileExtension::from_static("tsx")],
511 language_id: const { LanguageId::from_static("typescriptreact") },
512 },
513 LanguageExtensionMapping {
514 extensions: vec![FileExtension::from_static("jsx")],
515 language_id: const { LanguageId::from_static("javascriptreact") },
516 },
517 LanguageExtensionMapping {
518 extensions: vec![FileExtension::from_static("go")],
519 language_id: const { LanguageId::from_static("go") },
520 },
521 LanguageExtensionMapping {
522 extensions: vec![
523 FileExtension::from_static("c"),
524 FileExtension::from_static("h"),
525 ],
526 language_id: const { LanguageId::from_static("c") },
527 },
528 LanguageExtensionMapping {
529 extensions: vec![
530 FileExtension::from_static("cpp"),
531 FileExtension::from_static("cc"),
532 FileExtension::from_static("cxx"),
533 FileExtension::from_static("hpp"),
534 FileExtension::from_static("hh"),
535 FileExtension::from_static("hxx"),
536 ],
537 language_id: const { LanguageId::from_static("cpp") },
538 },
539 LanguageExtensionMapping {
540 extensions: vec![FileExtension::from_static("java")],
541 language_id: const { LanguageId::from_static("java") },
542 },
543 LanguageExtensionMapping {
544 extensions: vec![FileExtension::from_static("rb")],
545 language_id: const { LanguageId::from_static("ruby") },
546 },
547 LanguageExtensionMapping {
548 extensions: vec![FileExtension::from_static("php")],
549 language_id: const { LanguageId::from_static("php") },
550 },
551 LanguageExtensionMapping {
552 extensions: vec![FileExtension::from_static("swift")],
553 language_id: const { LanguageId::from_static("swift") },
554 },
555 LanguageExtensionMapping {
556 extensions: vec![
557 FileExtension::from_static("kt"),
558 FileExtension::from_static("kts"),
559 ],
560 language_id: const { LanguageId::from_static("kotlin") },
561 },
562 LanguageExtensionMapping {
563 extensions: vec![
564 FileExtension::from_static("scala"),
565 FileExtension::from_static("sc"),
566 ],
567 language_id: const { LanguageId::from_static("scala") },
568 },
569 LanguageExtensionMapping {
570 extensions: vec![FileExtension::from_static("zig")],
571 language_id: const { LanguageId::from_static("zig") },
572 },
573 LanguageExtensionMapping {
574 extensions: vec![FileExtension::from_static("lua")],
575 language_id: const { LanguageId::from_static("lua") },
576 },
577 LanguageExtensionMapping {
578 extensions: vec![
579 FileExtension::from_static("sh"),
580 FileExtension::from_static("bash"),
581 FileExtension::from_static("zsh"),
582 ],
583 language_id: const { LanguageId::from_static("shellscript") },
584 },
585 LanguageExtensionMapping {
586 extensions: vec![FileExtension::from_static("json")],
587 language_id: const { LanguageId::from_static("json") },
588 },
589 LanguageExtensionMapping {
590 extensions: vec![FileExtension::from_static("toml")],
591 language_id: const { LanguageId::from_static("toml") },
592 },
593 LanguageExtensionMapping {
594 extensions: vec![
595 FileExtension::from_static("yaml"),
596 FileExtension::from_static("yml"),
597 ],
598 language_id: const { LanguageId::from_static("yaml") },
599 },
600 LanguageExtensionMapping {
601 extensions: vec![FileExtension::from_static("xml")],
602 language_id: const { LanguageId::from_static("xml") },
603 },
604 LanguageExtensionMapping {
605 extensions: vec![
606 FileExtension::from_static("html"),
607 FileExtension::from_static("htm"),
608 ],
609 language_id: const { LanguageId::from_static("html") },
610 },
611 LanguageExtensionMapping {
612 extensions: vec![FileExtension::from_static("css")],
613 language_id: const { LanguageId::from_static("css") },
614 },
615 LanguageExtensionMapping {
616 extensions: vec![FileExtension::from_static("scss")],
617 language_id: const { LanguageId::from_static("scss") },
618 },
619 LanguageExtensionMapping {
620 extensions: vec![FileExtension::from_static("less")],
621 language_id: const { LanguageId::from_static("less") },
622 },
623 LanguageExtensionMapping {
624 extensions: vec![
625 FileExtension::from_static("md"),
626 FileExtension::from_static("markdown"),
627 ],
628 language_id: const { LanguageId::from_static("markdown") },
629 },
630 LanguageExtensionMapping {
631 extensions: vec![FileExtension::from_static("cs")],
632 language_id: const { LanguageId::from_static("csharp") },
633 },
634 LanguageExtensionMapping {
635 extensions: vec![
636 FileExtension::from_static("fs"),
637 FileExtension::from_static("fsi"),
638 FileExtension::from_static("fsx"),
639 ],
640 language_id: const { LanguageId::from_static("fsharp") },
641 },
642 LanguageExtensionMapping {
643 extensions: vec![
644 FileExtension::from_static("r"),
645 FileExtension::from_static("R"),
646 ],
647 language_id: const { LanguageId::from_static("r") },
648 },
649 ]
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655pub enum ConfigOrigin {
656 Argument,
658 Environment,
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666enum CreateDefault {
667 Yes,
668 No,
669}
670
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum ProjectConfigTrust {
687 Untrusted,
690 Trusted,
692}
693
694#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
707pub enum ProjectConfigStatus {
708 #[default]
710 NotIgnored,
711 IgnoredUntrusted,
713}
714
715const MAX_CONFIG_FILE_BYTES: NonZeroU64 =
733 NonZeroU64::new(8 * 1024 * 1024).expect("the config file limit must be non-zero");
734
735fn rebase_relative_roots(
748 roots: &mut [ConfiguredRoot],
749 config_path: &Path,
750 base: RelativeRootBase,
751) -> Result<()> {
752 if !roots.iter().any(|root| root.as_path().is_relative()) {
753 return Ok(());
754 }
755 let config_dir = match base {
756 RelativeRootBase::Cwd => None,
757 RelativeRootBase::ConfigDir => Some(config_dir_as_given(config_path)?),
758 };
759 for root in roots.iter_mut().filter(|root| root.as_path().is_relative()) {
760 if let Some(dir) = config_dir.as_deref() {
761 let rebased = root.join_onto(dir);
762 probe_root(root.as_path(), as_base_display(dir), rebased.as_path())?;
763 *root = rebased;
764 } else {
765 let cwd = std::env::current_dir().map_err(Error::Io)?;
766 probe_root(root.as_path(), &cwd, root.join_onto(&cwd).as_path())?;
767 }
768 }
769 Ok(())
770}
771
772fn as_base_display(dir: &Path) -> &Path {
774 if dir.is_empty() { Path::new(".") } else { dir }
775}
776
777fn config_dir_as_given(config_path: &Path) -> Result<PathBuf> {
779 let drive_or_root_qualified = config_path
780 .components()
781 .next()
782 .is_some_and(|c| matches!(c, Component::Prefix(_) | Component::RootDir));
783 let path = if config_path.is_relative() && drive_or_root_qualified {
784 std::path::absolute(config_path).map_err(Error::Io)?
785 } else {
786 config_path.to_path_buf()
787 };
788 path.parent()
789 .map(Path::to_path_buf)
790 .ok_or_else(|| ConfigError::NoParentDirectory { path }.into())
791}
792
793#[derive(Clone, Copy, Debug, PartialEq, Eq)]
796enum RelativeRootBase {
797 ConfigDir,
803 Cwd,
808}
809
810impl ServerConfig {
811 #[must_use]
817 pub fn build_effective_language_map(&self) -> LanguageMap {
818 let mut map = LanguageMap::from(self.workspace.build_extension_map());
819
820 for server in &self.lsp_servers {
821 for pattern in &server.file_patterns {
822 let target = pattern.target().clone();
823 let language_id = language_id_for_pattern_target(&server.language_id, &target);
824 map.insert(target, language_id);
825 }
826 }
827
828 map
829 }
830
831 pub fn load() -> Result<Self> {
856 Self::load_with_trust(ProjectConfigTrust::Untrusted)
857 }
858
859 pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
890 Self::discover(trust, CreateDefault::Yes).map(|(config, _)| config)
891 }
892
893 pub fn load_discovered(trust: ProjectConfigTrust, workspace: &WorkspaceTrust) -> Result<Self> {
908 let create = match workspace {
909 WorkspaceTrust::Trusted => CreateDefault::Yes,
910 WorkspaceTrust::Untrusted(_) => CreateDefault::No,
911 };
912 let (mut config, source) = Self::discover(trust, create)?;
913 let source = source
914 .as_deref()
915 .map(|path| (path, ConfigOrigin::Environment));
916 config.apply_workspace_trust(workspace, source)?;
917 Ok(config)
918 }
919
920 pub fn load_explicit(
948 path: &Path,
949 origin: ConfigOrigin,
950 workspace: &WorkspaceTrust,
951 ) -> Result<Self> {
952 let mut config = Self::load_from(path)?;
953 config.apply_workspace_trust(workspace, Some((path, origin)))?;
954 Ok(config)
955 }
956
957 fn apply_workspace_trust(
958 &mut self,
959 workspace: &WorkspaceTrust,
960 source: Option<(&Path, ConfigOrigin)>,
961 ) -> Result<()> {
962 if let (WorkspaceTrust::Untrusted(_), Some((source, origin))) = (workspace, source) {
963 self.ensure_outside_workspace(source, origin)?;
964 }
965 Self::check_allowlist(workspace, &self.lsp_servers)?;
966 self.workspace_trust = workspace.clone();
967 Ok(())
968 }
969
970 fn discover(
973 trust: ProjectConfigTrust,
974 create: CreateDefault,
975 ) -> Result<(Self, Option<PathBuf>)> {
976 if let Some(path) = std::env::var_os("MCPLS_CONFIG") {
984 let path = PathBuf::from(path);
985 return Self::load_from(&path).map(|config| (config, Some(path)));
986 }
987
988 let mut project_config_status = ProjectConfigStatus::NotIgnored;
989
990 let local_config = PathBuf::from("mcpls.toml");
991 if local_config.exists() {
992 match trust {
993 ProjectConfigTrust::Trusted => {
994 return Self::load_from(&local_config)
995 .map(|config| (config, Some(local_config.clone())));
996 }
997 ProjectConfigTrust::Untrusted => {
998 project_config_status = ProjectConfigStatus::IgnoredUntrusted;
999 let display_path = local_config.canonicalize().unwrap_or_else(|_| {
1000 std::env::current_dir()
1001 .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
1002 });
1003 tracing::warn!(
1004 "ignoring untrusted project-local config at {}; pass \
1005 --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
1006 load it",
1007 display_path.display()
1008 );
1009 }
1010 }
1011 }
1012
1013 if let Some(config_dir) = dirs::config_dir() {
1014 let user_config = config_dir.join("mcpls").join("mcpls.toml");
1015 if user_config.exists() {
1016 let mut config =
1025 Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
1026 config.project_config_status = project_config_status;
1027 return Ok((config, Some(user_config)));
1028 }
1029
1030 if create == CreateDefault::Yes {
1031 if let Err(e) = Self::create_default_config_file(&user_config) {
1032 tracing::warn!(
1033 "Failed to create default config at {}: {}. Using in-memory defaults.",
1034 user_config.display(),
1035 e
1036 );
1037 } else {
1038 tracing::info!("Created default config at {}", user_config.display());
1039 }
1040 }
1041 }
1042
1043 Ok((
1045 Self {
1046 project_config_status,
1047 ..Self::default()
1048 },
1049 None,
1050 ))
1051 }
1052
1053 pub fn load_from(path: &Path) -> Result<Self> {
1069 Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
1070 }
1071
1072 fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
1081 let file = RegularFile::open(path).map_err(|e| match e {
1082 OpenRegularFileError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
1083 Error::ConfigNotFound(path.to_path_buf())
1084 }
1085 OpenRegularFileError::Io(e) => Error::Io(e),
1086 OpenRegularFileError::NotRegular => Error::NotARegularFile(path.to_path_buf()),
1087 })?;
1088 let buf = file
1089 .read_bounded(MAX_CONFIG_FILE_BYTES)
1090 .map_err(|e| match e {
1091 ReadBoundedError::TooLarge(exceeded) => ConfigError::FileTooLarge(exceeded).into(),
1092 ReadBoundedError::Io(e) => Error::Io(e),
1093 })?;
1094 let content = String::from_utf8(buf).map_err(ConfigError::NotUtf8)?;
1095
1096 let mut config: Self = toml::from_str(&content)?;
1097
1098 rebase_relative_roots(&mut config.workspace.roots, path, relative_root_base)?;
1099
1100 Ok(config)
1101 }
1102
1103 fn create_default_config_file(path: &Path) -> Result<()> {
1111 if let Some(parent) = path.parent() {
1112 std::fs::create_dir_all(parent)?;
1113 }
1114
1115 let default_config = Self::default();
1116 let toml_content = toml::to_string_pretty(&default_config)?;
1117 std::fs::write(path, toml_content)?;
1118
1119 Ok(())
1120 }
1121
1122 pub fn validate(&self) -> Result<()> {
1153 Self::check_allowlist(&self.workspace_trust, &self.lsp_servers)
1154 }
1155
1156 fn check_allowlist(trust: &WorkspaceTrust, servers: &[LspServerConfig]) -> Result<()> {
1160 let WorkspaceTrust::Untrusted(allowlist) = trust else {
1161 return Ok(());
1162 };
1163 let configured: Vec<ServerId> = servers.iter().map(LspServerConfig::id).collect();
1164 let unknown = allowlist
1165 .as_slice()
1166 .iter()
1167 .find(|id| !configured.contains(id));
1168 unknown.map_or(Ok(()), |unknown| {
1169 Err(ConfigError::UnknownAllowedServer {
1170 server: unknown.clone(),
1171 configured: configured.clone(),
1172 }
1173 .into())
1174 })
1175 }
1176
1177 pub fn ensure_outside_workspace(&self, source: &Path, origin: ConfigOrigin) -> Result<()> {
1193 let canonical = dunce::canonicalize(source).map_err(Error::Io)?;
1194 let roots = WorkspaceRoots::from_configured(&self.workspace.roots)?;
1195 let home = trust::login_home_dir();
1196 let roots = roots.untrusted_boundary(&self.workspace.roots, home.as_deref());
1197 let cwd_matters = origin == ConfigOrigin::Environment || !source.is_absolute();
1198 let cwd = cwd_matters
1199 .then(|| WorkspaceRoots::from_configured(&[]))
1200 .transpose()?
1201 .map(|cwd| cwd.checkout_scoped(home.as_deref()));
1202 if roots.contains_canonical(&canonical)
1203 || cwd.is_some_and(|cwd| cwd.contains_canonical(&canonical))
1204 {
1205 return Err(Error::ConfigInsideWorkspace { path: canonical });
1206 }
1207 Ok(())
1208 }
1209}
1210
1211impl Default for ServerConfig {
1212 fn default() -> Self {
1213 Self {
1214 mcp: McpConfig::default(),
1215 workspace: WorkspaceConfig::default(),
1216 lsp_servers: vec![
1217 LspServerConfig::rust_analyzer(),
1218 LspServerConfig::pyright(),
1219 LspServerConfig::typescript(),
1220 LspServerConfig::gopls(),
1221 LspServerConfig::clangd(),
1222 LspServerConfig::zls(),
1223 ],
1224 project_config_status: ProjectConfigStatus::NotIgnored,
1225 workspace_trust: WorkspaceTrust::default(),
1226 }
1227 }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use std::collections::HashSet;
1233 use std::{assert_matches, fs};
1234
1235 use tempfile::TempDir;
1236
1237 use super::*;
1238 #[cfg(unix)]
1239 use crate::bridge::ProcessCwd;
1240 use crate::bridge::{
1241 INDEXING_STALENESS_BOUND, PROGRESS_SETTLE, PositionEncoding, WorkspaceRoots,
1242 };
1243 #[cfg(unix)]
1244 use crate::test_lsp::client_path;
1245 use crate::test_lsp::toml_path_literal;
1246 use crate::util::SizeExceeded;
1247
1248 #[test]
1249 fn test_default_commands_match_builtin_servers() {
1250 let config = ServerConfig::default();
1251 let defaults: HashSet<&str> = config
1252 .lsp_servers
1253 .iter()
1254 .map(|s| s.command.as_str())
1255 .collect();
1256 let builtins: HashSet<&str> = BuiltinServer::ALL.iter().map(|b| b.command()).collect();
1257 assert_eq!(defaults, builtins);
1258 }
1259
1260 #[test]
1261 fn test_builtin_server_npm_table() {
1262 let npm: Vec<_> = BuiltinServer::ALL
1263 .into_iter()
1264 .filter(|b| b.is_npm_package())
1265 .collect();
1266 assert_eq!(
1267 npm,
1268 [
1269 BuiltinServer::Pyright,
1270 BuiltinServer::TypescriptLanguageServer
1271 ]
1272 );
1273 }
1274
1275 #[test]
1276 fn test_builtin_server_roundtrip_and_hint() {
1277 for b in BuiltinServer::ALL {
1278 assert_eq!(BuiltinServer::from_command(b.command()), Some(b));
1279 assert_ne!(b.install_hint(), "");
1280 }
1281 }
1282
1283 #[test]
1284 fn test_default_config() {
1285 let config = ServerConfig::default();
1286 assert_eq!(config.lsp_servers.len(), 6);
1287 assert_eq!(config.lsp_servers[0].language_id, "rust");
1288 assert_eq!(config.lsp_servers[1].language_id, "python");
1289 assert_eq!(config.lsp_servers[2].language_id, "typescript");
1290 assert_eq!(config.lsp_servers[3].language_id, "go");
1291 assert_eq!(config.lsp_servers[4].language_id, "cpp");
1292 assert_eq!(config.lsp_servers[5].language_id, "zig");
1293 assert_eq!(
1294 config.workspace.position_encodings,
1295 PositionEncodings::DEFAULT
1296 );
1297 }
1298
1299 #[test]
1300 fn test_default_position_encodings() {
1301 assert_eq!(
1302 PositionEncodings::DEFAULT.as_slice(),
1303 [PositionEncoding::Utf8, PositionEncoding::Utf16]
1304 );
1305 }
1306
1307 #[test]
1308 fn test_load_from_valid_toml() {
1309 let tmp_dir = TempDir::new().unwrap();
1310 let config_path = tmp_dir.path().join("config.toml");
1311 let workspace_root = tmp_dir.path().join("workspace");
1312 fs::create_dir(&workspace_root).unwrap();
1313 let workspace_root_literal = toml_path_literal(&workspace_root);
1314
1315 let toml_content = format!(
1316 r#"
1317 [workspace]
1318 roots = [{workspace_root_literal}]
1319 position_encodings = ["utf-8"]
1320
1321 [[lsp_servers]]
1322 language_id = "rust"
1323 command = "rust-analyzer"
1324 timeout_seconds = 30
1325 "#
1326 );
1327
1328 fs::write(&config_path, &toml_content).unwrap();
1329
1330 let config = ServerConfig::load_from(&config_path).unwrap();
1331 assert_eq!(
1332 config.workspace.roots,
1333 vec![ConfiguredRoot::new(workspace_root).unwrap()]
1334 );
1335 assert_eq!(
1336 config.workspace.position_encodings.as_slice(),
1337 [PositionEncoding::Utf8]
1338 );
1339 assert_eq!(config.lsp_servers.len(), 1);
1340 assert_eq!(config.lsp_servers[0].language_id, "rust");
1341 }
1342
1343 #[test]
1344 fn test_load_from_resolves_relative_roots_against_config_directory() {
1345 let tmp_dir = TempDir::new().unwrap();
1346 let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1347 let config_dir = project_root.join(".agents");
1348 fs::create_dir(&config_dir).unwrap();
1349 let config_path = config_dir.join("mcpls.toml");
1350 fs::write(
1351 &config_path,
1352 r#"
1353 [workspace]
1354 roots = [".", ".."]
1355 "#,
1356 )
1357 .unwrap();
1358
1359 let config = ServerConfig::load_from(&config_path).unwrap();
1360
1361 assert!(
1362 config
1363 .workspace
1364 .roots
1365 .iter()
1366 .all(|root| root.as_path().is_absolute())
1367 );
1368 let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
1369 assert_eq!(roots.canonical(), [config_dir, project_root]);
1370 }
1371
1372 #[test]
1382 fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1383 let config_tmp_dir = TempDir::new().unwrap();
1384 let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1385 let config_path = config_dir.join("mcpls.toml");
1386 fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1387
1388 let cwd_tmp_dir = TempDir::new().unwrap();
1389 let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1390 let expected_root = cwd.join("relative-root");
1391 fs::create_dir(&expected_root).unwrap();
1392
1393 let config = {
1394 let _guard = CwdGuard::enter(&cwd);
1395 ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1396 };
1397
1398 assert_eq!(
1399 config.workspace.roots,
1400 vec![ConfiguredRoot::new("relative-root").unwrap()]
1401 );
1402 let roots = {
1403 let _guard = CwdGuard::enter(&cwd);
1404 WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
1405 };
1406 assert_eq!(roots.canonical(), [expected_root]);
1407 }
1408
1409 #[test]
1410 fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1411 let tmp_dir = TempDir::new().unwrap();
1412 let config_path = tmp_dir.path().join("mcpls.toml");
1413 fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1414
1415 let err = ServerConfig::load_from(&config_path).unwrap_err();
1416
1417 let Error::Config(config_err) = err else {
1418 panic!("expected Config, got {err:?}");
1419 };
1420 let message = config_err.to_string();
1421 assert!(message.contains("workspace root 'missing'"));
1422 assert!(message.contains(&tmp_dir.path().display().to_string()));
1423 }
1424
1425 #[cfg(unix)]
1428 struct SymlinkTree {
1429 _dir: TempDir,
1430 base: PathBuf,
1431 real: PathBuf,
1432 link: PathBuf,
1433 }
1434
1435 #[cfg(unix)]
1436 fn symlink_tree() -> SymlinkTree {
1437 let dir = TempDir::new().unwrap();
1438 let base = dunce::canonicalize(dir.path()).unwrap();
1439 let real = base.join("real");
1440 fs::create_dir_all(real.join("src")).unwrap();
1441 fs::write(real.join("src/main.rs"), "fn main() {}").unwrap();
1442 let outside = base.join("outside");
1443 fs::create_dir(&outside).unwrap();
1444 fs::write(outside.join("secret.rs"), "").unwrap();
1445 std::os::unix::fs::symlink(&outside, real.join("out")).unwrap();
1446 let link = base.join("link");
1447 std::os::unix::fs::symlink(&real, &link).unwrap();
1448 SymlinkTree {
1449 _dir: dir,
1450 base,
1451 real,
1452 link,
1453 }
1454 }
1455
1456 #[cfg(unix)]
1457 fn load_roots(config_path: &Path) -> WorkspaceRoots {
1458 let config = ServerConfig::load_from(config_path).unwrap();
1459 WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
1460 }
1461
1462 #[cfg(unix)]
1465 #[test]
1466 fn test_load_from_symlinked_absolute_root_admits_configured_spelling() {
1467 let tree = symlink_tree();
1468 let config_path = tree.base.join("cfg.toml");
1469 let literal = toml_path_literal(&tree.link);
1470 fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
1471
1472 let config = ServerConfig::load_from(&config_path).unwrap();
1473 assert_eq!(
1474 config.workspace.roots,
1475 vec![ConfiguredRoot::new(tree.link.clone()).unwrap()]
1476 );
1477 let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
1478
1479 let via_link = roots
1480 .validate_blocking(&client_path(tree.link.join("src/main.rs")))
1481 .map(crate::bridge::WorkspacePath::into_path_buf);
1482 assert_eq!(via_link.unwrap(), tree.real.join("src/main.rs"));
1483 let via_real = roots
1484 .validate_blocking(&client_path(tree.real.join("src/main.rs")))
1485 .map(crate::bridge::WorkspacePath::into_path_buf);
1486 assert!(via_real.is_ok());
1487 }
1488
1489 #[cfg(unix)]
1492 #[test]
1493 fn test_load_from_symlinked_root_still_rejects_escape_symlink() {
1494 let tree = symlink_tree();
1495 let config_path = tree.base.join("cfg.toml");
1496 let literal = toml_path_literal(&tree.link);
1497 fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
1498 let roots = load_roots(&config_path);
1499
1500 for root in [&tree.link, &tree.real] {
1501 let err = roots
1502 .validate_blocking(&client_path(root.join("out/secret.rs")))
1503 .map(crate::bridge::WorkspacePath::into_path_buf)
1504 .unwrap_err();
1505 assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
1506 }
1507 }
1508
1509 #[cfg(unix)]
1512 #[test]
1513 fn test_load_from_relative_root_keeps_symlinked_config_dir_spelling() {
1514 let tree = symlink_tree();
1515 let cfg_real = tree.base.join("cfg_real");
1516 fs::create_dir(&cfg_real).unwrap();
1517 std::os::unix::fs::symlink(&tree.real, cfg_real.join("proj")).unwrap();
1518 let cfg_link = tree.base.join("cfg_link");
1519 std::os::unix::fs::symlink(&cfg_real, &cfg_link).unwrap();
1520 fs::write(
1521 cfg_real.join("mcpls.toml"),
1522 "[workspace]\nroots = [\"proj\"]\n",
1523 )
1524 .unwrap();
1525
1526 let roots = load_roots(&cfg_link.join("mcpls.toml"));
1527
1528 let admitted = roots
1529 .validate_blocking(&client_path(cfg_link.join("proj/src/main.rs")))
1530 .map(crate::bridge::WorkspacePath::into_path_buf);
1531 assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
1532 }
1533
1534 #[cfg(unix)]
1538 #[test]
1539 fn test_load_from_relative_config_path_keeps_pwd_spelling() {
1540 let tree = symlink_tree();
1541 fs::write(
1542 tree.real.join("mcpls.toml"),
1543 "[workspace]\nroots = [\".\"]\n",
1544 )
1545 .unwrap();
1546
1547 let config = {
1548 let _guard = CwdGuard::enter(&tree.real);
1549 ServerConfig::load_from(Path::new("mcpls.toml")).unwrap()
1550 };
1551 assert!(
1552 config
1553 .workspace
1554 .roots
1555 .iter()
1556 .all(|root| root.as_path().is_relative())
1557 );
1558
1559 let roots = WorkspaceRoots::from_configured_with(&config.workspace.roots, || {
1560 Ok(ProcessCwd::new(
1561 tree.real.clone(),
1562 Some(tree.link.clone().into_os_string()),
1563 ))
1564 })
1565 .unwrap();
1566
1567 let admitted = roots
1568 .validate_blocking(&client_path(tree.link.join("src/main.rs")))
1569 .map(crate::bridge::WorkspacePath::into_path_buf);
1570 assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
1571 }
1572
1573 #[cfg(unix)]
1576 #[test]
1577 fn test_load_from_absolute_config_path_does_not_read_removed_cwd() {
1578 let tree = symlink_tree();
1579 fs::create_dir(tree.base.join("proj")).unwrap();
1580 let config_path = tree.base.join("mcpls.toml");
1581 fs::write(&config_path, "[workspace]\nroots = [\"proj\"]\n").unwrap();
1582 let doomed = TempDir::new().unwrap();
1583
1584 let config = {
1585 let _guard = CwdGuard::enter(doomed.path());
1586 fs::remove_dir(doomed.path()).unwrap();
1587 ServerConfig::load_from(&config_path).unwrap()
1588 };
1589
1590 assert_eq!(
1591 config.workspace.roots,
1592 vec![ConfiguredRoot::new(tree.base.join("proj")).unwrap()]
1593 );
1594 }
1595
1596 #[cfg(unix)]
1599 #[test]
1600 fn test_load_from_parent_root_through_symlinked_config_dir_records_no_alias() {
1601 let tree = symlink_tree();
1602 let data = tree.base.join("data/a/b");
1603 fs::create_dir_all(&data).unwrap();
1604 let home = tree.base.join("home");
1605 fs::create_dir(&home).unwrap();
1606 let proj_link = home.join("proj_link");
1607 std::os::unix::fs::symlink(&data, &proj_link).unwrap();
1608 fs::write(data.join("mcpls.toml"), "[workspace]\nroots = [\"..\"]\n").unwrap();
1609 fs::write(home.join("other.rs"), "").unwrap();
1610
1611 let roots = load_roots(&proj_link.join("mcpls.toml"));
1612
1613 assert_eq!(roots.canonical(), [tree.base.join("data/a")]);
1614 let err = roots
1615 .validate_blocking(&client_path(home.join("other.rs")))
1616 .map(crate::bridge::WorkspacePath::into_path_buf)
1617 .unwrap_err();
1618 assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
1619 }
1620
1621 #[test]
1623 fn test_load_from_relative_config_path_reports_missing_root_as_written() {
1624 let tmp_dir = TempDir::new().unwrap();
1625 fs::write(
1626 tmp_dir.path().join("mcpls.toml"),
1627 "[workspace]\nroots = [\"missing\"]\n",
1628 )
1629 .unwrap();
1630
1631 let err = {
1632 let _guard = CwdGuard::enter(tmp_dir.path());
1633 ServerConfig::load_from(Path::new("mcpls.toml")).unwrap_err()
1634 };
1635
1636 let Error::Config(config_err) = err else {
1637 panic!("expected Config, got {err:?}");
1638 };
1639 let message = config_err.to_string();
1640 assert!(message.contains("workspace root 'missing'"), "{message}");
1641 }
1642
1643 #[test]
1644 fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1645 let tmp_dir = TempDir::new().unwrap();
1648 let config_path = tmp_dir.path().join("config.toml");
1649
1650 let toml_content = r#"
1651 [[lsp_servers]]
1652 language_id = "rust"
1653 command = "rust-analyzer"
1654 timeout_seconds = 30
1655 "#;
1656
1657 fs::write(&config_path, toml_content).unwrap();
1658
1659 let config = ServerConfig::load_from(&config_path).unwrap();
1660 assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), 30);
1661 }
1662
1663 #[test]
1664 fn test_validate_rejects_heuristics_max_depth_above_max() {
1665 let tmp_dir = TempDir::new().unwrap();
1666 let config_path = tmp_dir.path().join("config.toml");
1667
1668 let toml_content = format!(
1669 "[workspace]\nheuristics_max_depth = {}\n",
1670 MAX_HEURISTICS_DEPTH + 1
1671 );
1672 fs::write(&config_path, toml_content).unwrap();
1673
1674 let result = ServerConfig::load_from(&config_path);
1675 assert_matches!(
1676 result,
1677 Err(Error::TomlDe(ref e))
1678 if e.message().contains("heuristics_max_depth")
1679 && e.message().contains("exceeds the maximum")
1680 );
1681 }
1682
1683 #[test]
1684 fn test_validate_accepts_heuristics_max_depth_below_max() {
1685 for depth in [1, MAX_HEURISTICS_DEPTH - 1] {
1686 let mut config = ServerConfig::default();
1687 config.workspace.heuristics_max_depth = SearchDepth::new(depth).unwrap();
1688 assert!(config.validate().is_ok(), "depth {depth} must be accepted");
1689 }
1690 }
1691
1692 #[test]
1693 fn test_validate_max_file_size_bound() {
1694 for (size, ok) in [
1695 (0, true),
1696 (MAX_FILE_SIZE_LIMIT, true),
1697 (MAX_FILE_SIZE_LIMIT + 1, false),
1698 ] {
1699 let result =
1700 toml::from_str::<ServerConfig>(&format!("[workspace]\nmax_file_size = {size}\n"));
1701 assert_eq!(result.is_ok(), ok, "max_file_size {size}: {result:?}");
1702 if let Err(e) = result {
1703 assert!(e.message().contains("max_file_size"));
1704 }
1705 }
1706 }
1707
1708 #[test]
1709 fn test_validate_accepts_heuristics_max_depth_at_max() {
1710 let tmp_dir = TempDir::new().unwrap();
1711 let config_path = tmp_dir.path().join("config.toml");
1712
1713 let toml_content = format!("[workspace]\nheuristics_max_depth = {MAX_HEURISTICS_DEPTH}\n");
1714 fs::write(&config_path, toml_content).unwrap();
1715
1716 let config = ServerConfig::load_from(&config_path).unwrap();
1717 assert_eq!(
1718 config.workspace.heuristics_max_depth.get(),
1719 MAX_HEURISTICS_DEPTH
1720 );
1721 }
1722
1723 #[test]
1724 fn test_validate_accepts_request_timeout_seconds_at_max() {
1725 let tmp_dir = TempDir::new().unwrap();
1726 let config_path = tmp_dir.path().join("config.toml");
1727
1728 let toml_content = format!(
1729 r#"
1730 [[lsp_servers]]
1731 language_id = "rust"
1732 command = "rust-analyzer"
1733 request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1734 "#
1735 );
1736
1737 fs::write(&config_path, toml_content).unwrap();
1738
1739 let result = ServerConfig::load_from(&config_path);
1740 assert!(result.is_ok(), "expected Ok, got {result:?}");
1741 }
1742
1743 #[test]
1744 fn test_validate_accepts_timeout_seconds_at_max() {
1745 let tmp_dir = TempDir::new().unwrap();
1746 let config_path = tmp_dir.path().join("config.toml");
1747
1748 let toml_content = format!(
1749 r#"
1750 [[lsp_servers]]
1751 language_id = "rust"
1752 command = "rust-analyzer"
1753 timeout_seconds = {MAX_TIMEOUT_SECONDS}
1754 "#
1755 );
1756
1757 fs::write(&config_path, toml_content).unwrap();
1758
1759 let result = ServerConfig::load_from(&config_path);
1760 assert!(result.is_ok(), "expected Ok, got {result:?}");
1761 }
1762
1763 #[test]
1764 fn test_validate_accepts_indexing_ready_timeout_seconds_within_bounds() {
1765 let tmp_dir = TempDir::new().unwrap();
1766 let config_path = tmp_dir.path().join("config.toml");
1767
1768 fs::write(
1769 &config_path,
1770 r"
1771 [workspace]
1772 indexing_ready_timeout_seconds = 45
1773 ",
1774 )
1775 .unwrap();
1776
1777 let result = ServerConfig::load_from(&config_path);
1778 assert!(result.is_ok(), "expected Ok, got {result:?}");
1779 assert_eq!(
1780 result
1781 .unwrap()
1782 .workspace
1783 .indexing_ready_timeout_seconds
1784 .get(),
1785 45
1786 );
1787 }
1788
1789 #[test]
1790 fn test_indexing_ready_timeout_seconds_default() {
1791 let config = ServerConfig::default();
1792 assert_eq!(
1793 config.workspace.indexing_ready_timeout_seconds,
1794 IndexingReadyTimeoutSecs::DEFAULT
1795 );
1796 }
1797
1798 #[test]
1799 fn test_max_concurrent_server_starts_default_and_toml() {
1800 assert_eq!(
1801 ServerConfig::default()
1802 .workspace
1803 .max_concurrent_server_starts,
1804 ServerStartConcurrency::DEFAULT
1805 );
1806 let config: ServerConfig =
1807 toml::from_str("[workspace]\nmax_concurrent_server_starts = 3").unwrap();
1808 assert_eq!(config.workspace.max_concurrent_server_starts.get(), 3);
1809 }
1810
1811 #[test]
1812 fn test_max_concurrent_server_starts_rejects_zero() {
1813 let err = toml::from_str::<ServerConfig>("[workspace]\nmax_concurrent_server_starts = 0")
1814 .unwrap_err();
1815 assert!(err.to_string().contains("at least 1"), "{err}");
1816 }
1817
1818 fn assert_toml_rejected(toml_content: &str, needles: &[&str]) {
1821 let tmp_dir = TempDir::new().unwrap();
1822 let config_path = tmp_dir.path().join("config.toml");
1823 fs::write(&config_path, toml_content).unwrap();
1824
1825 let err = ServerConfig::load_from(&config_path).unwrap_err();
1826 let Error::TomlDe(source) = err else {
1827 panic!("expected TomlDe, got {err:?}");
1828 };
1829 let message = source.to_string();
1830 for needle in needles {
1831 assert!(message.contains(needle), "{needle:?} not in {message}");
1832 }
1833 }
1834
1835 #[test]
1836 fn test_load_rejects_out_of_range_timeouts() {
1837 for field in ["timeout_seconds", "request_timeout_seconds"] {
1838 for value in [0, MAX_TIMEOUT_SECONDS + 1] {
1839 assert_toml_rejected(
1840 &format!(
1841 "[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n{field} = {value}\n"
1842 ),
1843 &["must be between 1 and 900 seconds", &format!("got {value}")],
1844 );
1845 }
1846 }
1847 }
1848
1849 #[test]
1850 fn test_load_accepts_timeouts_at_bounds() {
1851 for value in [1, MAX_TIMEOUT_SECONDS] {
1852 let config: ServerConfig = toml::from_str(&format!(
1853 "[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n\
1854 timeout_seconds = {value}\nrequest_timeout_seconds = {value}\n"
1855 ))
1856 .unwrap();
1857 assert_eq!(config.lsp_servers[0].timeout_seconds.get(), value);
1858 assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), value);
1859 }
1860 }
1861
1862 #[test]
1863 fn test_load_rejects_indexing_ready_timeout_outside_open_interval() {
1864 for value in [
1865 PROGRESS_SETTLE.as_secs(),
1866 INDEXING_STALENESS_BOUND.as_secs(),
1867 ] {
1868 assert_toml_rejected(
1869 &format!("[workspace]\nindexing_ready_timeout_seconds = {value}\n"),
1870 &["must be between", &format!("got {value}")],
1871 );
1872 }
1873 }
1874
1875 #[test]
1876 fn test_load_accepts_indexing_ready_timeout_at_bounds() {
1877 for value in [
1878 PROGRESS_SETTLE.as_secs() + 1,
1879 INDEXING_STALENESS_BOUND.as_secs() - 1,
1880 ] {
1881 let config: ServerConfig = toml::from_str(&format!(
1882 "[workspace]\nindexing_ready_timeout_seconds = {value}\n"
1883 ))
1884 .unwrap();
1885 assert_eq!(config.workspace.indexing_ready_timeout_seconds.get(), value);
1886 }
1887 }
1888
1889 #[test]
1890 fn test_load_rejects_empty_language_id() {
1891 assert_toml_rejected(
1892 "[[lsp_servers]]\nlanguage_id = \"\"\ncommand = \"x\"\n",
1893 &["language_id cannot be blank"],
1894 );
1895 }
1896
1897 #[test]
1898 fn test_load_rejects_empty_position_encodings() {
1899 assert_toml_rejected(
1900 "[workspace]\nposition_encodings = []\n",
1901 &["position_encodings cannot be empty"],
1902 );
1903 }
1904
1905 #[test]
1906 fn test_load_rejects_unrecognized_position_encoding() {
1907 assert_toml_rejected(
1908 "[workspace]\nposition_encodings = [\"utf-8\", \"utf-7\"]\n",
1909 &["utf-7", "utf-8", "utf-16", "utf-32"],
1910 );
1911 }
1912
1913 #[test]
1914 fn test_default_config_round_trips_through_toml() {
1915 let original = ServerConfig::default();
1916 let text = toml::to_string(&original).unwrap();
1917 let reloaded: ServerConfig = toml::from_str(&text).unwrap();
1918 assert_eq!(
1919 reloaded.workspace.position_encodings,
1920 original.workspace.position_encodings
1921 );
1922 assert_eq!(
1923 reloaded.workspace.indexing_ready_timeout_seconds,
1924 original.workspace.indexing_ready_timeout_seconds
1925 );
1926 assert_eq!(reloaded.lsp_servers.len(), original.lsp_servers.len());
1927 for (a, b) in reloaded.lsp_servers.iter().zip(&original.lsp_servers) {
1928 assert_eq!(a.language_id, b.language_id);
1929 assert_eq!(a.timeout_seconds, b.timeout_seconds);
1930 assert_eq!(a.request_timeout_seconds, b.request_timeout_seconds);
1931 }
1932 }
1933
1934 #[test]
1935 fn test_default_config_keeps_typescript_selection_auto_through_toml() {
1936 let text = toml::to_string(&ServerConfig::default()).unwrap();
1937 assert_eq!(text.matches("selection = \"auto\"").count(), 1, "{text}");
1938 let reloaded: ServerConfig = toml::from_str(&text).unwrap();
1939 for server in &reloaded.lsp_servers {
1940 let expected = if server.language_id == "typescript" {
1941 ServerSelection::Auto
1942 } else {
1943 ServerSelection::Explicit
1944 };
1945 assert_eq!(
1946 server.command.selection(),
1947 expected,
1948 "{}",
1949 server.language_id
1950 );
1951 }
1952 }
1953
1954 #[test]
1955 fn test_entry_without_selection_key_is_explicit() {
1956 let config: ServerConfig = toml::from_str(
1957 r#"
1958 [[lsp_servers]]
1959 language_id = "typescript"
1960 command = "typescript-language-server"
1961 "#,
1962 )
1963 .unwrap();
1964 assert_eq!(
1965 config.lsp_servers[0].command.selection(),
1966 ServerSelection::Explicit
1967 );
1968 }
1969
1970 #[test]
1971 fn test_auto_selection_accepts_tsls_command_spellings() {
1972 for command in [
1973 "typescript-language-server",
1974 "/opt/node/bin/typescript-language-server",
1975 "typescript-language-server.cmd",
1976 ] {
1977 let mut typescript = LspServerConfig::typescript();
1978 typescript.command =
1979 LaunchCommand::auto(ServerCommand::new(command.to_string()).unwrap()).unwrap();
1980 let config = ServerConfig {
1981 lsp_servers: vec![typescript],
1982 ..ServerConfig::default()
1983 };
1984 assert!(config.validate().is_ok(), "{command}");
1985 }
1986 }
1987
1988 #[test]
1989 fn test_auto_selection_is_unrepresentable_on_other_servers() {
1990 let rust = LspServerConfig::rust_analyzer();
1991 assert!(LaunchCommand::auto(rust.command.server_command().clone()).is_err());
1992
1993 let err = toml::from_str::<ServerConfig>(
1994 r#"
1995 [[lsp_servers]]
1996 language_id = "rust"
1997 command = "rust-analyzer"
1998 selection = "auto"
1999 "#,
2000 )
2001 .unwrap_err();
2002 assert!(
2003 err.message()
2004 .contains("selection = \"auto\" is only valid for typescript-language-server"),
2005 "{err}"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_load_from_nonexistent_file() {
2011 let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
2012 assert!(result.is_err());
2013
2014 if let Err(Error::ConfigNotFound(path)) = result {
2015 assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
2016 } else {
2017 panic!("Expected ConfigNotFound error");
2018 }
2019 }
2020
2021 #[test]
2022 fn test_load_from_invalid_toml() {
2023 let tmp_dir = TempDir::new().unwrap();
2024 let config_path = tmp_dir.path().join("invalid.toml");
2025
2026 fs::write(&config_path, "invalid toml content {{}").unwrap();
2027
2028 let result = ServerConfig::load_from(&config_path);
2029 assert!(result.is_err());
2030 }
2031
2032 #[test]
2036 fn test_load_from_rejects_oversized_file() {
2037 let tmp_dir = TempDir::new().unwrap();
2038 let config_path = tmp_dir.path().join("oversized.toml");
2039
2040 let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() + 1);
2043 fs::write(&config_path, &oversized).unwrap();
2044
2045 let result = ServerConfig::load_from(&config_path);
2046 assert_matches!(
2047 result,
2048 Err(Error::Config(ConfigError::FileTooLarge(SizeExceeded { max, .. })))
2049 if max == MAX_CONFIG_FILE_BYTES
2050 );
2051 }
2052
2053 #[test]
2054 fn test_load_from_accepts_file_at_exact_size_cap() {
2055 let tmp_dir = TempDir::new().unwrap();
2056 let config_path = tmp_dir.path().join("exact.toml");
2057
2058 let mut toml_content = "[workspace]\n# ".to_string();
2061 toml_content.push_str(
2062 &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() - toml_content.len()),
2063 );
2064 assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES.get());
2065 fs::write(&config_path, &toml_content).unwrap();
2066
2067 let result = ServerConfig::load_from(&config_path);
2068 assert!(result.is_ok(), "expected Ok, got {result:?}");
2069 }
2070
2071 #[cfg(unix)]
2076 #[test]
2077 fn test_load_from_rejects_infinite_special_file() {
2078 let path = Path::new("/dev/zero");
2079 assert_eq!(
2080 fs::metadata(path).unwrap().len(),
2081 0,
2082 "test assumption: /dev/zero must report zero length"
2083 );
2084
2085 let result = ServerConfig::load_from(path);
2086 assert_matches!(result, Err(Error::NotARegularFile(ref p)) if p == path);
2087 }
2088
2089 #[cfg(unix)]
2092 #[test]
2093 fn test_load_from_rejects_fifo_without_blocking() {
2094 let tmp_dir = TempDir::new().unwrap();
2095 let fifo = tmp_dir.path().join("config.fifo");
2096 crate::test_lsp::make_fifo(&fifo);
2097
2098 let (tx, rx) = std::sync::mpsc::channel();
2099 let path = fifo.clone();
2100 std::thread::spawn(move || tx.send(ServerConfig::load_from(&path)).unwrap());
2101 let result = rx
2102 .recv_timeout(std::time::Duration::from_secs(5))
2103 .unwrap_or_else(|_| panic!("loading a FIFO config must not block"));
2104 assert_matches!(result, Err(Error::NotARegularFile(ref p)) if *p == fifo);
2105 }
2106
2107 #[test]
2108 fn test_load_from_rejects_directory_as_not_regular() {
2109 let tmp_dir = TempDir::new().unwrap();
2110 let result = ServerConfig::load_from(tmp_dir.path());
2111 #[cfg(unix)]
2112 assert_matches!(result, Err(Error::NotARegularFile(_)));
2113 #[cfg(not(unix))]
2114 assert!(result.is_err());
2115 }
2116
2117 #[test]
2118 fn test_validate_empty_command() {
2119 let tmp_dir = TempDir::new().unwrap();
2120 let config_path = tmp_dir.path().join("config.toml");
2121
2122 let toml_content = r#"
2123 [[lsp_servers]]
2124 language_id = "rust"
2125 command = ""
2126 "#;
2127
2128 fs::write(&config_path, toml_content).unwrap();
2129
2130 let result = ServerConfig::load_from(&config_path);
2131
2132 assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("command cannot be blank"));
2133 }
2134
2135 #[test]
2136 fn test_blank_server_name_is_rejected_at_load() {
2137 for name in ["", " ", "\\t"] {
2138 let toml = format!(
2139 "[[lsp_servers]]\nname = \"{name}\"\nlanguage_id = \"python\"\n\
2140 command = \"pyright-langserver\"\n"
2141 );
2142 let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
2143 assert!(
2144 err.message().contains("server id cannot be blank"),
2145 "{name:?}: {}",
2146 err.message()
2147 );
2148 }
2149 }
2150
2151 #[test]
2152 fn test_validate_empty_handles() {
2153 let tmp_dir = TempDir::new().unwrap();
2154 let config_path = tmp_dir.path().join("config.toml");
2155
2156 let toml_content = r#"
2157 [[lsp_servers]]
2158 language_id = "python"
2159 command = "pylsp"
2160 handles = []
2161 "#;
2162
2163 fs::write(&config_path, toml_content).unwrap();
2164
2165 let result = ServerConfig::load_from(&config_path);
2166
2167 assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("handles cannot be empty"));
2168 }
2169
2170 #[test]
2171 fn test_validate_duplicate_tool_in_handles() {
2172 let tmp_dir = TempDir::new().unwrap();
2173 let config_path = tmp_dir.path().join("config.toml");
2174
2175 let toml_content = r#"
2176 [[lsp_servers]]
2177 language_id = "python"
2178 command = "pylsp"
2179 handles = ["diagnostics", "diagnostics"]
2180 "#;
2181
2182 fs::write(&config_path, toml_content).unwrap();
2183
2184 let result = ServerConfig::load_from(&config_path);
2185
2186 assert_matches!(
2187 result,
2188 Err(Error::TomlDe(ref e)) if e.message().contains("duplicate tool 'diagnostics'")
2189 );
2190 }
2191
2192 #[test]
2197 fn test_load_rejects_empty_workspace_root_entry() {
2198 let tmp_dir = TempDir::new().unwrap();
2199 let config_path = tmp_dir.path().join("config.toml");
2200
2201 let toml_content = r#"
2202 [workspace]
2203 roots = [""]
2204 "#;
2205
2206 fs::write(&config_path, toml_content).unwrap();
2207
2208 let result = ServerConfig::load_from(&config_path);
2209 assert_matches!(
2210 result,
2211 Err(Error::TomlDe(ref e)) if e.message().contains("workspace.roots entries cannot be empty")
2212 );
2213 }
2214
2215 #[test]
2216 fn test_validate_duplicate_name_warns_but_loads() {
2217 let tmp_dir = TempDir::new().unwrap();
2222 let config_path = tmp_dir.path().join("config.toml");
2223
2224 let toml_content = r#"
2225 [[lsp_servers]]
2226 name = "dup"
2227 language_id = "python"
2228 command = "pyright-langserver"
2229
2230 [[lsp_servers]]
2231 name = "dup"
2232 language_id = "typescript"
2233 command = "typescript-language-server"
2234 "#;
2235
2236 fs::write(&config_path, toml_content).unwrap();
2237
2238 let result = ServerConfig::load_from(&config_path);
2239 assert!(result.is_ok(), "duplicate name must only warn at load time");
2240 }
2241
2242 #[test]
2243 fn test_workspace_config_defaults() {
2244 let workspace = WorkspaceConfig::default();
2245 assert_eq!(workspace.roots.len(), 0);
2246 assert_eq!(workspace.position_encodings, PositionEncodings::DEFAULT);
2247 assert!(!workspace.language_extensions.is_empty());
2248 assert_eq!(workspace.language_extensions.len(), 30);
2249 assert_eq!(
2250 workspace.heuristics_max_depth.get(),
2251 DEFAULT_HEURISTICS_MAX_DEPTH
2252 );
2253 }
2254
2255 #[test]
2256 fn test_load_multiple_servers() {
2257 let tmp_dir = TempDir::new().unwrap();
2258 let config_path = tmp_dir.path().join("multi.toml");
2259
2260 let toml_content = r#"
2261 [[lsp_servers]]
2262 language_id = "rust"
2263 command = "rust-analyzer"
2264
2265 [[lsp_servers]]
2266 language_id = "python"
2267 command = "pyright-langserver"
2268 args = ["--stdio"]
2269 "#;
2270
2271 fs::write(&config_path, toml_content).unwrap();
2272
2273 let config = ServerConfig::load_from(&config_path).unwrap();
2274 assert_eq!(config.lsp_servers.len(), 2);
2275 assert_eq!(config.lsp_servers[0].language_id, "rust");
2276 assert_eq!(config.lsp_servers[1].language_id, "python");
2277 assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
2278 }
2279
2280 #[test]
2281 fn test_deny_unknown_fields() {
2282 let tmp_dir = TempDir::new().unwrap();
2283 let config_path = tmp_dir.path().join("unknown.toml");
2284
2285 let toml_content = r#"
2286 unknown_field = "value"
2287
2288 [workspace]
2289 roots = []
2290 "#;
2291
2292 fs::write(&config_path, toml_content).unwrap();
2293
2294 let result = ServerConfig::load_from(&config_path);
2295 assert!(result.is_err(), "Should reject unknown fields");
2296 }
2297
2298 #[test]
2299 fn test_empty_config_file() {
2300 let tmp_dir = TempDir::new().unwrap();
2301 let config_path = tmp_dir.path().join("empty.toml");
2302
2303 fs::write(&config_path, "").unwrap();
2304
2305 let config = ServerConfig::load_from(&config_path).unwrap();
2306 assert_eq!(config.workspace.roots.len(), 0);
2307 assert!(config.lsp_servers.is_empty());
2308 }
2309
2310 #[test]
2311 fn test_config_with_initialization_options() {
2312 let tmp_dir = TempDir::new().unwrap();
2313 let config_path = tmp_dir.path().join("init_opts.toml");
2314
2315 let toml_content = r#"
2316 [[lsp_servers]]
2317 language_id = "rust"
2318 command = "rust-analyzer"
2319
2320 [lsp_servers.initialization_options]
2321 cargo = { allFeatures = true }
2322 "#;
2323
2324 fs::write(&config_path, toml_content).unwrap();
2325
2326 let config = ServerConfig::load_from(&config_path).unwrap();
2327 assert!(config.lsp_servers[0].initialization_options.is_some());
2328 }
2329
2330 #[test]
2331 fn test_language_extensions_in_config() {
2332 let tmp_dir = TempDir::new().unwrap();
2333 let config_path = tmp_dir.path().join("extensions.toml");
2334
2335 let toml_content = r#"
2336 [[workspace.language_extensions]]
2337 extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
2338 language_id = "cpp"
2339
2340 [[workspace.language_extensions]]
2341 extensions = ["nu"]
2342 language_id = "nushell"
2343
2344 [[workspace.language_extensions]]
2345 extensions = ["py", "pyw", "pyi"]
2346 language_id = "python"
2347 "#;
2348
2349 fs::write(&config_path, toml_content).unwrap();
2350
2351 let config = ServerConfig::load_from(&config_path).unwrap();
2352 assert_eq!(config.workspace.language_extensions.len(), 3);
2353
2354 assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
2356 assert_eq!(
2357 config.workspace.language_extensions[0].extensions,
2358 vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
2359 );
2360
2361 assert_eq!(
2363 config.workspace.language_extensions[1].language_id,
2364 "nushell"
2365 );
2366 assert_eq!(
2367 config.workspace.language_extensions[1].extensions,
2368 vec!["nu"]
2369 );
2370 }
2371
2372 #[test]
2373 fn test_build_extension_map() {
2374 let workspace = WorkspaceConfig {
2375 roots: vec![],
2376 position_encodings: PositionEncodings::DEFAULT,
2377 language_extensions: vec![
2378 LanguageExtensionMapping {
2379 extensions: vec![
2380 FileExtension::from_static("cpp"),
2381 FileExtension::from_static("cc"),
2382 FileExtension::from_static("cxx"),
2383 ],
2384 language_id: LanguageId::from_static("cpp"),
2385 },
2386 LanguageExtensionMapping {
2387 extensions: vec![FileExtension::from_static("nu")],
2388 language_id: LanguageId::from_static("nushell"),
2389 },
2390 ],
2391 heuristics_max_depth: SearchDepth::DEFAULT,
2392 max_documents: DocumentLimit::DEFAULT,
2393 max_file_size: SizeLimit::DEFAULT,
2394 indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
2395 max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
2396 };
2397
2398 let map = workspace.build_extension_map();
2399 assert_eq!(map.get("cpp"), Some(&LanguageId::from_static("cpp")));
2400 assert_eq!(map.get("cc"), Some(&LanguageId::from_static("cpp")));
2401 assert_eq!(map.get("cxx"), Some(&LanguageId::from_static("cpp")));
2402 assert_eq!(map.get("nu"), Some(&LanguageId::from_static("nushell")));
2403 assert_eq!(map.get("unknown"), None);
2404 }
2405
2406 #[test]
2407 fn test_build_effective_language_map_overrides_with_file_patterns() {
2408 let config = ServerConfig {
2409 mcp: McpConfig::default(),
2410 workspace: WorkspaceConfig::default(),
2411 lsp_servers: vec![LspServerConfig {
2412 language_id: LanguageId::from_static("cpp"),
2413 command: ServerCommand::from_static("clangd").into(),
2414 args: vec![],
2415 env: crate::config::ServerEnv::default(),
2416 file_patterns: vec![
2417 FilePattern::from_static("**/*.c"),
2418 FilePattern::from_static("**/*.h"),
2419 ],
2420 initialization_options: None,
2421 settings: None,
2422 timeout_seconds: TimeoutSecs::new(30).unwrap(),
2423 request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2424 heuristics: None,
2425 name: None,
2426 handles: None,
2427 indexing: crate::bridge::IndexingPolicy::Auto,
2428 }],
2429 project_config_status: ProjectConfigStatus::NotIgnored,
2430 workspace_trust: crate::config::WorkspaceTrust::default(),
2431 };
2432
2433 let map = config.build_effective_language_map();
2434 assert_eq!(map.detect(Path::new("x.c")), "cpp");
2435 assert_eq!(map.detect(Path::new("x.h")), "cpp");
2436 }
2437
2438 #[test]
2439 fn test_build_effective_language_map_derives_tsx_language_id() {
2440 let config = ServerConfig {
2441 mcp: McpConfig::default(),
2442 workspace: WorkspaceConfig::default(),
2443 lsp_servers: vec![LspServerConfig {
2444 language_id: LanguageId::from_static("typescript"),
2445 command: ServerCommand::from_static("tsgo").into(),
2446 args: vec!["--lsp".to_string(), "--stdio".to_string()],
2447 env: crate::config::ServerEnv::default(),
2448 file_patterns: vec![
2449 FilePattern::from_static("**/*.ts"),
2450 FilePattern::from_static("**/*.tsx"),
2451 ],
2452 initialization_options: None,
2453 settings: None,
2454 timeout_seconds: TimeoutSecs::new(30).unwrap(),
2455 request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2456 heuristics: None,
2457 name: None,
2458 handles: None,
2459 indexing: crate::bridge::IndexingPolicy::Auto,
2460 }],
2461 project_config_status: ProjectConfigStatus::NotIgnored,
2462 workspace_trust: crate::config::WorkspaceTrust::default(),
2463 };
2464
2465 let map = config.build_effective_language_map();
2466 assert_eq!(map.detect(Path::new("x.ts")), "typescript");
2467 assert_eq!(map.detect(Path::new("x.tsx")), "typescriptreact");
2468 }
2469
2470 #[test]
2471 fn test_build_effective_language_map_derives_jsx_language_id() {
2472 let config = ServerConfig {
2473 mcp: McpConfig::default(),
2474 workspace: WorkspaceConfig::default(),
2475 lsp_servers: vec![LspServerConfig {
2476 language_id: LanguageId::from_static("javascript"),
2477 command: ServerCommand::from_static("typescript-language-server").into(),
2478 args: vec!["--stdio".to_string()],
2479 env: crate::config::ServerEnv::default(),
2480 file_patterns: vec![
2481 FilePattern::from_static("**/*.js"),
2482 FilePattern::from_static("**/*.jsx"),
2483 ],
2484 initialization_options: None,
2485 settings: None,
2486 timeout_seconds: TimeoutSecs::new(30).unwrap(),
2487 request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2488 heuristics: None,
2489 name: None,
2490 handles: None,
2491 indexing: crate::bridge::IndexingPolicy::Auto,
2492 }],
2493 project_config_status: ProjectConfigStatus::NotIgnored,
2494 workspace_trust: crate::config::WorkspaceTrust::default(),
2495 };
2496
2497 let map = config.build_effective_language_map();
2498 assert_eq!(map.detect(Path::new("x.js")), "javascript");
2499 assert_eq!(map.detect(Path::new("x.jsx")), "javascriptreact");
2500 }
2501
2502 #[test]
2503 fn test_build_effective_language_map_maps_name_patterns() {
2504 let toml = "[[lsp_servers]]\nlanguage_id = \"make\"\ncommand = \"make-lsp\"\n\
2505 file_patterns = [\"**/Makefile\", \"GNUmakefile\", \"**/*.mk\"]\n";
2506 let config = toml::from_str::<ServerConfig>(toml).unwrap();
2507 let map = config.build_effective_language_map();
2508 assert_eq!(map.detect(Path::new("sub/Makefile")), "make");
2509 assert_eq!(map.detect(Path::new("GNUmakefile")), "make");
2510 assert_eq!(map.detect(Path::new("rules.mk")), "make");
2511 assert_eq!(map.detect(Path::new("Dockerfile")), LanguageId::PLAINTEXT);
2512 }
2513
2514 #[test]
2515 fn test_load_rejects_every_unsupported_file_pattern_form_naming_the_server() {
2516 for &pattern in patterns::UNSUPPORTED_FILE_PATTERNS {
2517 let toml = format!(
2518 "[[lsp_servers]]\nlanguage_id = \"cpp\"\ncommand = \"clangd\"\n\
2519 file_patterns = [{pattern:?}]\n"
2520 );
2521 let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
2522 let message = err.message();
2523 assert!(
2524 message.contains("lsp_servers entry 'cpp'"),
2525 "{pattern}: {message}"
2526 );
2527 assert!(
2528 message.contains(&format!("'{pattern}'")),
2529 "{pattern}: {message}"
2530 );
2531 assert!(message.contains("*.EXT"), "{pattern}: {message}");
2532 }
2533 }
2534
2535 #[test]
2536 fn test_unsupported_file_pattern_names_the_explicit_server_name() {
2537 let toml = "[[lsp_servers]]\nlanguage_id = \"python\"\ncommand = \"pylsp\"\n\
2538 name = \"mine\"\nfile_patterns = [\"**/*.{py,pyi}\"]\n";
2539 let err = toml::from_str::<ServerConfig>(toml).unwrap_err();
2540 assert!(
2541 err.message().contains("lsp_servers entry 'mine'"),
2542 "{}",
2543 err.message()
2544 );
2545 }
2546
2547 #[test]
2548 fn test_language_for_extension() {
2549 let workspace = WorkspaceConfig {
2550 roots: vec![],
2551 position_encodings: PositionEncodings::DEFAULT,
2552 language_extensions: vec![
2553 LanguageExtensionMapping {
2554 extensions: vec![
2555 FileExtension::from_static("hpp"),
2556 FileExtension::from_static("hh"),
2557 ],
2558 language_id: LanguageId::from_static("cpp"),
2559 },
2560 LanguageExtensionMapping {
2561 extensions: vec![FileExtension::from_static("py")],
2562 language_id: LanguageId::from_static("python"),
2563 },
2564 ],
2565 heuristics_max_depth: SearchDepth::DEFAULT,
2566 max_documents: DocumentLimit::DEFAULT,
2567 max_file_size: SizeLimit::DEFAULT,
2568 indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
2569 max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
2570 };
2571
2572 let map = workspace.build_extension_map();
2573 assert_eq!(
2574 map.get("hpp").cloned(),
2575 Some(LanguageId::from_static("cpp"))
2576 );
2577 assert_eq!(map.get("hh").cloned(), Some(LanguageId::from_static("cpp")));
2578 assert_eq!(
2579 map.get("py").cloned(),
2580 Some(LanguageId::from_static("python"))
2581 );
2582 assert_eq!(map.get("unknown").cloned(), None);
2583 }
2584
2585 #[test]
2586 fn test_default_language_extensions() {
2587 let workspace = WorkspaceConfig::default();
2588 let map = workspace.build_extension_map();
2589 assert!(!map.is_empty());
2590 assert_eq!(
2591 map.get("rs").cloned(),
2592 Some(LanguageId::from_static("rust"))
2593 );
2594 assert_eq!(
2595 map.get("py").cloned(),
2596 Some(LanguageId::from_static("python"))
2597 );
2598 assert_eq!(
2599 map.get("cpp").cloned(),
2600 Some(LanguageId::from_static("cpp"))
2601 );
2602 }
2603
2604 #[test]
2605 fn test_create_default_config_file() {
2606 let tmp_dir = TempDir::new().unwrap();
2607 let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
2608
2609 ServerConfig::create_default_config_file(&config_path).unwrap();
2610
2611 assert!(config_path.exists());
2612
2613 let loaded_config = ServerConfig::load_from(&config_path).unwrap();
2614 assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
2615 assert_eq!(loaded_config.lsp_servers.len(), 6);
2616 assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
2617 }
2618
2619 #[test]
2620 fn test_load_returns_default_config() {
2621 let config = ServerConfig::default();
2623 assert_eq!(config.workspace.language_extensions.len(), 30);
2624 assert_eq!(config.lsp_servers.len(), 6);
2625 assert_eq!(config.lsp_servers[0].language_id, "rust");
2626 }
2627
2628 use crate::test_support::CwdGuard;
2636
2637 fn assert_mcpls_config_env_unset() {
2650 assert!(
2651 std::env::var_os("MCPLS_CONFIG").is_none(),
2652 "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2653 load_with_trust returns before consulting CWD when it's set"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_load_ignores_untrusted_project_local_config() {
2659 let tmp_dir = TempDir::new().unwrap();
2667 let config_path = tmp_dir.path().join("mcpls.toml");
2668
2669 let custom_toml = r#"
2675 [workspace]
2676 roots = ["/should-never-load-attacker-path"]
2677
2678 [[lsp_servers]]
2679 language_id = "definitely-not-a-real-language-marker"
2680 command = "rm"
2681 args = ["-rf", "/"]
2682 "#;
2683
2684 fs::write(&config_path, custom_toml).unwrap();
2685
2686 let config = {
2687 let _guard = CwdGuard::enter(tmp_dir.path());
2688 ServerConfig::load().unwrap()
2689 };
2690
2691 assert!(
2692 !config
2693 .workspace
2694 .roots
2695 .contains(&ConfiguredRoot::new("/should-never-load-attacker-path").unwrap())
2696 );
2697 assert!(
2698 !config
2699 .lsp_servers
2700 .iter()
2701 .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2702 );
2703 }
2704
2705 #[test]
2706 fn test_load_with_trust_loads_trusted_project_local_config() {
2707 let tmp_dir = TempDir::new().unwrap();
2708 let config_path = tmp_dir.path().join("mcpls.toml");
2709 let custom_root = tmp_dir.path().join("custom");
2710 fs::create_dir(&custom_root).unwrap();
2711 let custom_root_literal = toml_path_literal(&custom_root);
2712
2713 let custom_toml = format!(
2714 r#"
2715 [workspace]
2716 roots = [{custom_root_literal}]
2717
2718 [[lsp_servers]]
2719 language_id = "python"
2720 command = "pyright-langserver"
2721 "#
2722 );
2723
2724 fs::write(&config_path, &custom_toml).unwrap();
2725
2726 let config = {
2727 let _guard = CwdGuard::enter(tmp_dir.path());
2728 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2729 };
2730
2731 assert_eq!(
2732 config.workspace.roots,
2733 vec![ConfiguredRoot::new(custom_root).unwrap()]
2734 );
2735 assert_eq!(config.lsp_servers.len(), 1);
2736 assert_eq!(config.lsp_servers[0].language_id, "python");
2737 }
2738
2739 #[test]
2740 fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2741 let tmp_dir = TempDir::new().unwrap();
2742 let config_path = tmp_dir.path().join("mcpls.toml");
2743
2744 let custom_toml = r#"
2745 [workspace]
2746 roots = ["/attacker/controlled"]
2747 heuristics_max_depth = 999999
2748
2749 [[lsp_servers]]
2750 language_id = "evil"
2751 command = "rm"
2752 args = ["-rf", "/"]
2753 "#;
2754
2755 fs::write(&config_path, custom_toml).unwrap();
2756
2757 let config = {
2758 let _guard = CwdGuard::enter(tmp_dir.path());
2759 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2760 };
2761
2762 assert!(
2763 !config
2764 .workspace
2765 .roots
2766 .contains(&ConfiguredRoot::new("/attacker/controlled").unwrap())
2767 );
2768 assert_ne!(config.workspace.heuristics_max_depth.get(), 999_999);
2769 assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2770 }
2771
2772 #[test]
2773 fn test_load_with_trust_sets_project_config_status() {
2774 assert_mcpls_config_env_unset();
2775
2776 let tmp_dir = TempDir::new().unwrap();
2777 let config_path = tmp_dir.path().join("mcpls.toml");
2778 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2779
2780 let config = {
2781 let _guard = CwdGuard::enter(tmp_dir.path());
2782 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2783 };
2784 assert_eq!(
2785 config.project_config_status,
2786 ProjectConfigStatus::IgnoredUntrusted
2787 );
2788
2789 let tmp_dir = TempDir::new().unwrap();
2790 let config_path = tmp_dir.path().join("mcpls.toml");
2791 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2792
2793 let config = {
2794 let _guard = CwdGuard::enter(tmp_dir.path());
2795 ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2796 };
2797 assert_eq!(
2798 config.project_config_status,
2799 ProjectConfigStatus::NotIgnored
2800 );
2801 }
2802
2803 #[test]
2804 fn test_load_no_local_config_leaves_flag_unset() {
2805 assert_mcpls_config_env_unset();
2806
2807 let tmp_dir = TempDir::new().unwrap();
2808
2809 let config = {
2810 let _guard = CwdGuard::enter(tmp_dir.path());
2811 ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2812 };
2813 assert_eq!(
2814 config.project_config_status,
2815 ProjectConfigStatus::NotIgnored
2816 );
2817 }
2818
2819 #[test]
2820 fn test_config_file_creation_with_proper_structure() {
2821 let tmp_dir = TempDir::new().unwrap();
2822 let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2823
2824 ServerConfig::create_default_config_file(&config_path).unwrap();
2825
2826 let content = fs::read_to_string(&config_path).unwrap();
2827
2828 assert!(content.contains("[mcp]"));
2829 assert!(content.contains("[workspace]"));
2830 assert!(content.contains("[[workspace.language_extensions]]"));
2831 assert!(content.contains("[[lsp_servers]]"));
2832 assert!(content.contains("language_id = \"rust\""));
2833 assert!(content.contains("extensions = [\"rs\"]"));
2834 }
2835
2836 #[test]
2837 fn test_heuristics_max_depth_default() {
2838 let config = WorkspaceConfig::default();
2839 assert_eq!(config.heuristics_max_depth.get(), 10);
2840 }
2841
2842 #[test]
2843 fn test_heuristics_max_depth_from_config() {
2844 let tmp_dir = TempDir::new().unwrap();
2845 let config_path = tmp_dir.path().join("depth.toml");
2846
2847 let toml_content = r"
2848 [workspace]
2849 heuristics_max_depth = 5
2850 ";
2851
2852 fs::write(&config_path, toml_content).unwrap();
2853
2854 let config = ServerConfig::load_from(&config_path).unwrap();
2855 assert_eq!(config.workspace.heuristics_max_depth.get(), 5);
2856 }
2857
2858 #[test]
2859 fn test_heuristics_max_depth_uses_default_when_not_specified() {
2860 let tmp_dir = TempDir::new().unwrap();
2861 let config_path = tmp_dir.path().join("no_depth.toml");
2862
2863 let toml_content = r"
2864 [workspace]
2865 roots = []
2866 ";
2867
2868 fs::write(&config_path, toml_content).unwrap();
2869
2870 let config = ServerConfig::load_from(&config_path).unwrap();
2871 assert_eq!(
2872 config.workspace.heuristics_max_depth.get(),
2873 DEFAULT_HEURISTICS_MAX_DEPTH
2874 );
2875 }
2876
2877 #[test]
2878 fn test_max_documents_default() {
2879 let config = WorkspaceConfig::default();
2880 assert_eq!(config.max_documents, DocumentLimit::DEFAULT);
2881 }
2882
2883 #[test]
2884 fn test_max_file_size_default() {
2885 let config = WorkspaceConfig::default();
2886 assert_eq!(config.max_file_size, SizeLimit::DEFAULT);
2887 }
2888
2889 #[test]
2890 fn test_max_documents_from_config() {
2891 let tmp_dir = TempDir::new().unwrap();
2892 let config_path = tmp_dir.path().join("limits.toml");
2893
2894 let toml_content = r"
2895 [workspace]
2896 max_documents = 500
2897 ";
2898
2899 fs::write(&config_path, toml_content).unwrap();
2900
2901 let config = ServerConfig::load_from(&config_path).unwrap();
2902 assert_eq!(config.workspace.max_documents, DocumentLimit::new(500));
2903 }
2904
2905 #[test]
2906 fn test_max_file_size_from_config() {
2907 let tmp_dir = TempDir::new().unwrap();
2908 let config_path = tmp_dir.path().join("limits.toml");
2909
2910 let toml_content = r"
2911 [workspace]
2912 max_file_size = 20971520
2913 ";
2914
2915 fs::write(&config_path, toml_content).unwrap();
2916
2917 let config = ServerConfig::load_from(&config_path).unwrap();
2918 assert_eq!(
2919 config.workspace.max_file_size,
2920 SizeLimit::from_static(20_971_520)
2921 );
2922 }
2923
2924 #[test]
2925 fn test_max_documents_uses_default_when_not_specified() {
2926 let tmp_dir = TempDir::new().unwrap();
2927 let config_path = tmp_dir.path().join("no_limits.toml");
2928
2929 let toml_content = r"
2930 [workspace]
2931 roots = []
2932 ";
2933
2934 fs::write(&config_path, toml_content).unwrap();
2935
2936 let config = ServerConfig::load_from(&config_path).unwrap();
2937 assert_eq!(config.workspace.max_documents, DocumentLimit::DEFAULT);
2938 assert_eq!(config.workspace.max_file_size, SizeLimit::DEFAULT);
2939 }
2940
2941 #[test]
2945 fn test_max_file_size_zero_means_unlimited() {
2946 let tmp_dir = TempDir::new().unwrap();
2947 let config_path = tmp_dir.path().join("unlimited.toml");
2948
2949 let toml_content = r"
2950 [workspace]
2951 max_file_size = 0
2952 ";
2953
2954 fs::write(&config_path, toml_content).unwrap();
2955
2956 let config = ServerConfig::load_from(&config_path).unwrap();
2957 assert_eq!(config.workspace.max_file_size, SizeLimit::UNLIMITED);
2958 assert_eq!(
2959 config.workspace.resource_limits().max_file_size,
2960 SizeLimit::UNLIMITED
2961 );
2962 }
2963
2964 #[test]
2967 fn test_max_documents_rejects_negative_value() {
2968 let tmp_dir = TempDir::new().unwrap();
2969 let config_path = tmp_dir.path().join("negative_max_documents.toml");
2970
2971 let toml_content = r"
2972 [workspace]
2973 max_documents = -1
2974 ";
2975
2976 fs::write(&config_path, toml_content).unwrap();
2977
2978 let result = ServerConfig::load_from(&config_path);
2979 if let Err(Error::TomlDe(e)) = &result {
2984 let msg = e.to_string();
2985 assert!(
2986 msg.contains("-1") && msg.contains("usize"),
2987 "expected a type-mismatch message naming the offending value and the \
2988 expected type, got: {msg}"
2989 );
2990 } else {
2991 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2992 }
2993 }
2994
2995 #[test]
2998 fn test_max_file_size_rejects_string_value() {
2999 let tmp_dir = TempDir::new().unwrap();
3000 let config_path = tmp_dir.path().join("string_max_file_size.toml");
3001
3002 let toml_content = r#"
3003 [workspace]
3004 max_file_size = "10MB"
3005 "#;
3006
3007 fs::write(&config_path, toml_content).unwrap();
3008
3009 let result = ServerConfig::load_from(&config_path);
3010 if let Err(Error::TomlDe(e)) = &result {
3011 let msg = e.to_string();
3012 assert!(
3013 msg.contains("10MB") && msg.contains("u64"),
3014 "expected a type-mismatch message naming the offending value and the \
3015 expected type, got: {msg}"
3016 );
3017 } else {
3018 panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
3019 }
3020 }
3021
3022 #[test]
3023 fn test_workspace_config_resource_limits_maps_fields() {
3024 let workspace = WorkspaceConfig {
3025 max_documents: DocumentLimit::new(250),
3026 max_file_size: SizeLimit::UNLIMITED,
3027 ..WorkspaceConfig::default()
3028 };
3029
3030 let limits = workspace.resource_limits();
3031 assert_eq!(limits.max_documents, DocumentLimit::new(250));
3032 assert_eq!(limits.max_file_size, SizeLimit::UNLIMITED);
3033 }
3034
3035 #[test]
3036 fn test_workspace_config_toml_round_trip() {
3037 let original = WorkspaceConfig {
3038 roots: vec![ConfiguredRoot::new("/tmp/round-trip").unwrap()],
3039 position_encodings: PositionEncodings::new(vec![PositionEncoding::Utf8]).unwrap(),
3040 language_extensions: vec![LanguageExtensionMapping {
3041 extensions: vec![FileExtension::from_static("nu")],
3042 language_id: LanguageId::from_static("nushell"),
3043 }],
3044 heuristics_max_depth: SearchDepth::new(5).unwrap(),
3045 max_documents: DocumentLimit::new(500),
3046 max_file_size: SizeLimit::UNLIMITED,
3047 indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::new(45).unwrap(),
3048 max_concurrent_server_starts: ServerStartConcurrency::new(3).unwrap(),
3049 };
3050
3051 let toml_content = toml::to_string_pretty(&original).unwrap();
3052 let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
3053
3054 assert_eq!(round_tripped.roots, original.roots);
3055 assert_eq!(
3056 round_tripped.position_encodings,
3057 original.position_encodings
3058 );
3059 assert_eq!(
3060 round_tripped.language_extensions.len(),
3061 original.language_extensions.len()
3062 );
3063 assert_eq!(
3064 round_tripped.language_extensions[0].extensions,
3065 original.language_extensions[0].extensions
3066 );
3067 assert_eq!(
3068 round_tripped.language_extensions[0].language_id,
3069 original.language_extensions[0].language_id
3070 );
3071 assert_eq!(
3072 round_tripped.heuristics_max_depth,
3073 original.heuristics_max_depth
3074 );
3075 assert_eq!(round_tripped.max_documents, original.max_documents);
3076 assert_eq!(round_tripped.max_file_size, original.max_file_size);
3077 assert_eq!(
3078 round_tripped.indexing_ready_timeout_seconds,
3079 original.indexing_ready_timeout_seconds
3080 );
3081 }
3082
3083 #[test]
3084 fn test_mcp_config_parses_from_toml_section() {
3085 let tmp_dir = TempDir::new().unwrap();
3086 let config_path = tmp_dir.path().join("config.toml");
3087
3088 let toml_content = r#"
3089 [mcp]
3090 title = "Custom Title"
3091 description = "Custom description"
3092 instructions = "Custom instructions."
3093 "#;
3094
3095 fs::write(&config_path, toml_content).unwrap();
3096
3097 let config = ServerConfig::load_from(&config_path).unwrap();
3098 assert_eq!(config.mcp.title.unwrap(), "Custom Title");
3099 assert_eq!(config.mcp.description.unwrap(), "Custom description");
3100 assert_eq!(config.mcp.instructions.unwrap(), "Custom instructions.");
3101 }
3102
3103 #[test]
3104 fn test_mcp_config_defaults_to_none_when_section_absent() {
3105 let tmp_dir = TempDir::new().unwrap();
3106 let config_path = tmp_dir.path().join("config.toml");
3107
3108 fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
3109
3110 let config = ServerConfig::load_from(&config_path).unwrap();
3111 assert_eq!(config.mcp.title, None);
3112 assert_eq!(config.mcp.description, None);
3113 assert_eq!(config.mcp.instructions, None);
3114 }
3115
3116 #[test]
3117 fn test_mcp_config_rejects_unknown_field() {
3118 let tmp_dir = TempDir::new().unwrap();
3119 let config_path = tmp_dir.path().join("config.toml");
3120
3121 fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
3122
3123 let result = ServerConfig::load_from(&config_path);
3124 assert_matches!(result, Err(Error::TomlDe(_)));
3125 }
3126
3127 #[test]
3128 fn test_validate_rejects_empty_mcp_title() {
3129 let tmp_dir = TempDir::new().unwrap();
3130 let config_path = tmp_dir.path().join("config.toml");
3131
3132 fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
3133
3134 let result = ServerConfig::load_from(&config_path);
3135 if let Err(Error::TomlDe(e)) = result {
3136 let msg = e.message();
3137 assert_eq!(msg, "cannot be empty");
3138 } else {
3139 panic!("Expected TomlDe error, got {result:?}");
3140 }
3141 }
3142
3143 #[test]
3146 fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
3147 let tmp_dir = TempDir::new().unwrap();
3148 let config_path = tmp_dir.path().join("config.toml");
3149
3150 fs::write(&config_path, "[mcp]\ntitle = \" \"\n").unwrap();
3151
3152 let result = ServerConfig::load_from(&config_path);
3153 if let Err(Error::TomlDe(e)) = result {
3154 let msg = e.message();
3155 assert!(msg.contains("cannot be empty"));
3156 } else {
3157 panic!("Expected TomlDe error, got {result:?}");
3158 }
3159 }
3160
3161 #[test]
3162 fn test_validate_rejects_empty_mcp_description() {
3163 let tmp_dir = TempDir::new().unwrap();
3164 let config_path = tmp_dir.path().join("config.toml");
3165
3166 fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
3167
3168 let result = ServerConfig::load_from(&config_path);
3169 if let Err(Error::TomlDe(e)) = result {
3170 let msg = e.message();
3171 assert_eq!(msg, "cannot be empty");
3172 } else {
3173 panic!("Expected TomlDe error, got {result:?}");
3174 }
3175 }
3176
3177 #[test]
3178 fn test_validate_rejects_empty_mcp_instructions() {
3179 let tmp_dir = TempDir::new().unwrap();
3180 let config_path = tmp_dir.path().join("config.toml");
3181
3182 fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
3183
3184 let result = ServerConfig::load_from(&config_path);
3185 if let Err(Error::TomlDe(e)) = result {
3186 let msg = e.message();
3187 assert_eq!(msg, "cannot be empty");
3188 } else {
3189 panic!("Expected TomlDe error, got {result:?}");
3190 }
3191 }
3192
3193 #[test]
3194 fn test_validate_rejects_over_length_mcp_title() {
3195 let tmp_dir = TempDir::new().unwrap();
3196 let config_path = tmp_dir.path().join("config.toml");
3197
3198 let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
3199 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3200
3201 let result = ServerConfig::load_from(&config_path);
3202 if let Err(Error::TomlDe(e)) = result {
3203 let msg = e.message();
3204 assert_eq!(
3205 msg,
3206 format!(
3207 "exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
3208 MAX_MCP_TITLE_BYTES + 1
3209 )
3210 );
3211 } else {
3212 panic!("Expected TomlDe error, got {result:?}");
3213 }
3214 }
3215
3216 #[test]
3217 fn test_validate_accepts_mcp_title_at_exact_cap() {
3218 let tmp_dir = TempDir::new().unwrap();
3219 let config_path = tmp_dir.path().join("config.toml");
3220
3221 let title = "a".repeat(MAX_MCP_TITLE_BYTES);
3222 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3223
3224 let result = ServerConfig::load_from(&config_path);
3225 assert!(result.is_ok(), "expected Ok, got {result:?}");
3226 }
3227
3228 #[test]
3229 fn test_validate_rejects_over_length_mcp_description() {
3230 let tmp_dir = TempDir::new().unwrap();
3231 let config_path = tmp_dir.path().join("config.toml");
3232
3233 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
3234 fs::write(
3235 &config_path,
3236 format!("[mcp]\ndescription = \"{description}\"\n"),
3237 )
3238 .unwrap();
3239
3240 let result = ServerConfig::load_from(&config_path);
3241 if let Err(Error::TomlDe(e)) = result {
3242 let msg = e.message();
3243 assert!(msg.contains("exceeds the maximum"));
3244 assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
3245 } else {
3246 panic!("Expected TomlDe error, got {result:?}");
3247 }
3248 }
3249
3250 #[test]
3251 fn test_validate_accepts_mcp_description_at_exact_cap() {
3252 let tmp_dir = TempDir::new().unwrap();
3253 let config_path = tmp_dir.path().join("config.toml");
3254
3255 let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
3256 fs::write(
3257 &config_path,
3258 format!("[mcp]\ndescription = \"{description}\"\n"),
3259 )
3260 .unwrap();
3261
3262 let result = ServerConfig::load_from(&config_path);
3263 assert!(result.is_ok(), "expected Ok, got {result:?}");
3264 }
3265
3266 #[test]
3271 fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
3272 let tmp_dir = TempDir::new().unwrap();
3273 let config_path = tmp_dir.path().join("config.toml");
3274
3275 let title = "é".repeat(65);
3276 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
3277 assert_eq!(title.chars().count(), 65);
3278 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3279
3280 let result = ServerConfig::load_from(&config_path);
3281 if let Err(Error::TomlDe(e)) = result {
3282 let msg = e.message();
3283 assert!(msg.contains("exceeds the maximum"));
3284 } else {
3285 panic!("Expected TomlDe error, got {result:?}");
3286 }
3287 }
3288
3289 #[test]
3290 fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
3291 let tmp_dir = TempDir::new().unwrap();
3292 let config_path = tmp_dir.path().join("config.toml");
3293
3294 let title = "é".repeat(64);
3295 assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
3296 fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3297
3298 let result = ServerConfig::load_from(&config_path);
3299 assert!(result.is_ok(), "expected Ok, got {result:?}");
3300 }
3301
3302 #[test]
3303 fn test_validate_rejects_over_length_mcp_instructions() {
3304 let tmp_dir = TempDir::new().unwrap();
3305 let config_path = tmp_dir.path().join("config.toml");
3306
3307 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
3308 fs::write(
3309 &config_path,
3310 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
3311 )
3312 .unwrap();
3313
3314 let result = ServerConfig::load_from(&config_path);
3315 if let Err(Error::TomlDe(e)) = result {
3316 let msg = e.message();
3317 assert!(msg.contains("exceeds the maximum"));
3318 assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
3319 } else {
3320 panic!("Expected TomlDe error, got {result:?}");
3321 }
3322 }
3323
3324 #[test]
3325 fn test_validate_accepts_mcp_instructions_at_exact_cap() {
3326 let tmp_dir = TempDir::new().unwrap();
3327 let config_path = tmp_dir.path().join("config.toml");
3328
3329 let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
3330 fs::write(
3331 &config_path,
3332 format!("[mcp]\ninstructions = \"{instructions}\"\n"),
3333 )
3334 .unwrap();
3335
3336 let result = ServerConfig::load_from(&config_path);
3337 assert!(result.is_ok(), "expected Ok, got {result:?}");
3338 }
3339
3340 #[test]
3345 fn test_tool_prefix_accepts_valid_and_round_trips() {
3346 let tmp_dir = TempDir::new().unwrap();
3347 let config_path = tmp_dir.path().join("config.toml");
3348 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
3349
3350 let config = ServerConfig::load_from(&config_path).unwrap();
3351 assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
3352
3353 let serialized = toml::to_string_pretty(&config).unwrap();
3354 let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
3355 assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
3356 }
3357
3358 #[test]
3359 fn test_tool_prefix_accepts_digits_and_mixed_case() {
3360 let prefix: ToolPrefix = "Optics2".parse().unwrap();
3361 assert_eq!(prefix.as_str(), "Optics2");
3362 }
3363
3364 #[test]
3365 fn test_tool_prefix_accepts_single_alphanumeric_char() {
3366 assert!("x".parse::<ToolPrefix>().is_ok());
3367 assert!("9".parse::<ToolPrefix>().is_ok());
3368 }
3369
3370 #[test]
3371 fn test_tool_prefix_rejects_empty() {
3372 let err = "".parse::<ToolPrefix>().unwrap_err();
3373 assert_eq!(err, InvalidToolPrefix::Empty);
3374 assert_eq!(
3375 err.to_string(),
3376 "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
3377 use unprefixed tool names)"
3378 );
3379 }
3380
3381 #[test]
3382 fn test_tool_prefix_rejects_whitespace_only() {
3383 let err = " ".parse::<ToolPrefix>().unwrap_err();
3384 assert_eq!(err, InvalidToolPrefix::Empty);
3385 }
3386
3387 #[test]
3388 fn test_tool_prefix_rejects_leading_separator() {
3389 for bad in ["_optics", "-optics"] {
3390 let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
3391 assert!(
3392 err.contains("cannot start with"),
3393 "for input {bad:?}: {err}"
3394 );
3395 }
3396 }
3397
3398 #[test]
3399 fn test_tool_prefix_rejects_trailing_separator() {
3400 for bad in ["optics_", "optics-"] {
3401 let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
3402 assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
3403 assert!(err.contains("inserted automatically"));
3404 }
3405 }
3406
3407 #[test]
3408 fn test_tool_prefix_rejects_dot() {
3409 let err = "op.tics".parse::<ToolPrefix>().unwrap_err().to_string();
3410 assert!(err.contains("invalid character '.'"));
3411 }
3412
3413 #[test]
3414 fn test_tool_prefix_rejects_space() {
3415 let err = "op tics".parse::<ToolPrefix>().unwrap_err().to_string();
3416 assert!(err.contains("invalid character ' '"));
3417 }
3418
3419 #[test]
3420 fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
3421 let err = "optiсs".parse::<ToolPrefix>().unwrap_err().to_string();
3422 assert!(err.contains("invalid character 'с'"), "{err}");
3423 }
3424
3425 #[test]
3426 fn test_tool_prefix_rejects_over_length() {
3427 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
3428 let err = prefix.parse::<ToolPrefix>().unwrap_err();
3429 assert_eq!(
3430 err,
3431 InvalidToolPrefix::TooLong {
3432 len: MAX_MCP_TOOL_PREFIX_BYTES + 1
3433 }
3434 );
3435 }
3436
3437 #[test]
3438 fn test_tool_prefix_accepts_exact_length_cap() {
3439 let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
3440 assert!(prefix.parse::<ToolPrefix>().is_ok());
3441 }
3442
3443 #[test]
3444 fn test_tool_prefix_from_str_shares_config_validator() {
3445 assert!("optics_".parse::<ToolPrefix>().is_err());
3450 let tmp_dir = TempDir::new().unwrap();
3451 let config_path = tmp_dir.path().join("config.toml");
3452 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3453 assert_matches!(ServerConfig::load_from(&config_path), Err(Error::TomlDe(_)));
3454 }
3455
3456 #[test]
3463 fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
3464 let tmp_dir = TempDir::new().unwrap();
3465 let config_path = tmp_dir.path().join("config.toml");
3466 fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3467
3468 let result = ServerConfig::load_from(&config_path);
3469 let Err(Error::TomlDe(err)) = result else {
3470 panic!("Expected TomlDe error, got {result:?}");
3471 };
3472 let msg = err.to_string();
3473 assert!(msg.contains("mcp.tool_prefix"), "{msg}");
3474 assert!(msg.contains("cannot end with"), "{msg}");
3475 assert!(msg.contains("line 2"), "{msg}");
3481 }
3482
3483 mod trust_tests {
3484 use super::*;
3485
3486 fn config_with_servers(trust: WorkspaceTrust) -> ServerConfig {
3487 ServerConfig {
3488 workspace_trust: trust,
3489 ..ServerConfig::default()
3490 }
3491 }
3492
3493 #[test]
3494 fn test_validate_accepts_allowed_ids_of_configured_servers() {
3495 let config =
3496 config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rust")]));
3497 assert!(config.validate().is_ok());
3498 }
3499
3500 #[test]
3501 fn test_validate_rejects_an_allowed_id_that_names_no_configured_server() {
3502 let config =
3503 config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rsut")]));
3504 let err = config.validate().unwrap_err();
3505 let text = err.to_string();
3506 assert_matches!(
3507 err,
3508 Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
3509 if server.as_str() == "rsut"
3510 );
3511 assert!(text.contains("'rsut'") && text.contains("rust"), "{text}");
3512 }
3513
3514 #[test]
3515 fn test_apply_workspace_trust_rejects_an_unknown_allowed_id_and_keeps_the_trust() {
3516 let mut config = ServerConfig::default();
3517 let err = config
3518 .apply_workspace_trust(
3519 &WorkspaceTrust::untrusted([ServerId::from_static("rsut")]),
3520 None,
3521 )
3522 .unwrap_err();
3523 assert_matches!(
3524 err,
3525 Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
3526 if server.as_str() == "rsut"
3527 );
3528 assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
3529 }
3530
3531 #[test]
3532 fn test_validate_ignores_the_allowlist_when_trusted() {
3533 assert!(
3534 config_with_servers(WorkspaceTrust::Trusted)
3535 .validate()
3536 .is_ok()
3537 );
3538 }
3539
3540 #[test]
3541 fn test_workspace_trust_is_never_read_from_toml() {
3542 let config: ServerConfig = toml::from_str("").unwrap();
3543 assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
3544 let err = toml::from_str::<ServerConfig>("workspace_trust = \"untrusted\"");
3545 assert!(err.is_err(), "an unknown key is rejected");
3546 assert!(!toml::to_string(&config).unwrap().contains("trust"));
3547 }
3548
3549 fn temp_roots_config(root: &Path) -> ServerConfig {
3550 let mut config = ServerConfig::default();
3551 config.workspace.roots = vec![ConfiguredRoot::new(root).unwrap()];
3552 config
3553 }
3554
3555 #[test]
3556 fn test_ensure_outside_workspace_rejects_a_config_inside_a_root() {
3557 let dir = TempDir::new().unwrap();
3558 let file = dir.path().join("mcpls.toml");
3559 fs::write(&file, "").unwrap();
3560 let err = temp_roots_config(dir.path())
3561 .ensure_outside_workspace(&file, ConfigOrigin::Argument)
3562 .unwrap_err();
3563 assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3564 }
3565
3566 #[test]
3567 fn test_ensure_outside_workspace_rejects_a_config_inside_the_process_cwd() {
3568 let cwd = std::env::current_dir().unwrap();
3569 let dir = tempfile::tempdir_in(&cwd).unwrap();
3570 let file = dir.path().join("mcpls.toml");
3571 fs::write(&file, "").unwrap();
3572 let elsewhere = TempDir::new().unwrap();
3573 let config = temp_roots_config(elsewhere.path());
3574 let err = config
3575 .ensure_outside_workspace(&file, ConfigOrigin::Environment)
3576 .unwrap_err();
3577 assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3578 assert!(
3579 config
3580 .ensure_outside_workspace(&file, ConfigOrigin::Argument)
3581 .is_ok(),
3582 "an absolute path given as an argument is only checked against the roots"
3583 );
3584 }
3585
3586 #[test]
3587 fn test_ensure_outside_workspace_checks_cwd_for_a_relative_path() {
3588 let cwd = std::env::current_dir().unwrap();
3589 let dir = tempfile::tempdir_in(&cwd).unwrap();
3590 let file = dir.path().join("mcpls.toml");
3591 fs::write(&file, "").unwrap();
3592 let relative = file.strip_prefix(&cwd).unwrap();
3593 let elsewhere = TempDir::new().unwrap();
3594 let err = temp_roots_config(elsewhere.path())
3595 .ensure_outside_workspace(relative, ConfigOrigin::Argument)
3596 .unwrap_err();
3597 assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3598 }
3599
3600 #[test]
3601 fn test_ensure_outside_workspace_admits_a_config_outside_roots_and_cwd() {
3602 let roots = TempDir::new().unwrap();
3603 let outside = TempDir::new().unwrap();
3604 let file = outside.path().join("mcpls.toml");
3605 fs::write(&file, "").unwrap();
3606 assert!(
3607 temp_roots_config(roots.path())
3608 .ensure_outside_workspace(&file, ConfigOrigin::Environment)
3609 .is_ok()
3610 );
3611 }
3612 }
3613}