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