mod bounded_number;
mod bounded_secs;
mod command_stem;
mod host_os;
mod language;
mod language_id;
mod language_map;
mod limits;
mod patterns;
mod position_encodings;
mod routing;
mod server;
mod server_args;
mod server_env;
mod settings;
pub(crate) mod text_newtype;
mod trust;
mod workspace_root;
use std::collections::HashMap;
use std::num::NonZeroU64;
use std::path::{Component, Path, PathBuf};
pub use bounded_secs::{BoundedSecs, IndexingReadyTimeoutSecs, InvalidSecs, TimeoutSecs};
pub use command_stem::CommandStem;
pub use host_os::HostOs;
pub use language::{base_language_id, react_variant_language_id};
pub use language_id::{InvalidLanguageId, LanguageId};
pub use language_map::{FileKey, LanguageMap};
pub use limits::{
BoundedText, DEFAULT_HEURISTICS_MAX_DEPTH, DocumentLimit, InvalidBoundedText,
InvalidSearchDepth, InvalidServerStartConcurrency, InvalidSizeLimit, MAX_FILE_SIZE_LIMIT,
MAX_HEURISTICS_DEPTH, MAX_TIMEOUT_SECONDS, SearchDepth, ServerStartConcurrency, SizeLimit,
};
pub use patterns::{
FileExtension, FileName, FilePattern, InvalidFileExtension, InvalidFileName,
InvalidProjectMarker, PatternTarget, ProjectMarker, UnsupportedFilePattern,
};
pub use position_encodings::{InvalidPositionEncodings, PositionEncodings};
pub use routing::{
EntrySummary, InvalidServerId, InvalidToolSet, NoServerReason, ServerId, ServerSettlement,
ToolKind, ToolRouter, ToolSet,
};
use serde::{Deserialize, Serialize};
pub(crate) use server::MarkerScan;
pub use server::{
BuiltinServer, InvalidAutoSelection, InvalidServerCommand, LaunchCommand, LspServerConfig,
ServerCommand, ServerHeuristics, ServerSelection,
};
pub use server_args::{InvalidServerArgs, ServerArgs};
pub use server_env::{
DuplicateEnvKey, EnvKey, EnvTooLarge, EnvValue, InvalidEnvKey, InvalidEnvValue,
InvalidServerEnv, MAX_ENV_TOTAL_BYTES, ServerEnv,
};
pub use settings::{InvalidLspSettings, LspSettings};
use text_newtype::impl_text_newtype;
pub(crate) use trust::login_home_dir;
pub use trust::{ServerAllowlist, WorkspaceTrust};
pub use workspace_root::{ConfiguredRoot, InvalidWorkspaceRoot};
use crate::bridge::{ResourceLimits, WorkspaceRoots, probe_root};
use crate::error::{ConfigError, Error, Result, TomlParseError};
use crate::util::{OpenRegularFileError, ReadBoundedError, RegularFile};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageExtensionMapping {
pub extensions: Vec<FileExtension>,
pub language_id: LanguageId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub workspace: WorkspaceConfig,
#[serde(default)]
pub lsp_servers: Vec<LspServerConfig>,
#[serde(skip)]
pub project_config_status: ProjectConfigStatus,
#[serde(skip)]
pub workspace_trust: WorkspaceTrust,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<McpTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<McpDescription>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<McpInstructions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_prefix: Option<ToolPrefix>,
}
pub const MAX_MCP_TITLE_BYTES: usize = 128;
pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
pub type McpTitle = BoundedText<MAX_MCP_TITLE_BYTES>;
pub type McpDescription = BoundedText<MAX_MCP_DESCRIPTION_BYTES>;
pub type McpInstructions = BoundedText<MAX_MCP_INSTRUCTIONS_BYTES>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct ToolPrefix(String);
impl ToolPrefix {
pub fn new(value: impl Into<String>) -> std::result::Result<Self, InvalidToolPrefix> {
let value = value.into();
InvalidToolPrefix::check(&value).map_or(Ok(Self(value)), Err)
}
}
impl_text_newtype!(ToolPrefix, InvalidToolPrefix);
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidToolPrefix {
#[error(
"mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to use \
unprefixed tool names)"
)]
Empty,
#[error(
"mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} given)"
)]
TooLong {
len: usize,
},
#[error(
"mcp.tool_prefix contains an invalid character {0:?} (allowed: ASCII letters, digits, \
'_', and '-')"
)]
InvalidChar(char),
#[error("mcp.tool_prefix cannot start with {0:?} (must start with an ASCII letter or digit)")]
LeadingSeparator(char),
#[error(
"mcp.tool_prefix cannot end with {0:?} (the '_' separator between the prefix and each \
tool name is inserted automatically by mcpls -- remove the trailing separator character)"
)]
TrailingSeparator(char),
}
impl InvalidToolPrefix {
fn check(value: &str) -> Option<Self> {
let (Some(first), Some(last)) = (value.chars().next(), value.chars().next_back()) else {
return Some(Self::Empty);
};
if value.trim().is_empty() {
return Some(Self::Empty);
}
if value.len() > MAX_MCP_TOOL_PREFIX_BYTES {
return Some(Self::TooLong { len: value.len() });
}
if let Some(bad) = value
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
{
return Some(Self::InvalidChar(bad));
}
if !first.is_ascii_alphanumeric() {
return Some(Self::LeadingSeparator(first));
}
if !last.is_ascii_alphanumeric() {
return Some(Self::TrailingSeparator(last));
}
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
#[serde(default)]
pub roots: Vec<ConfiguredRoot>,
#[serde(default)]
pub position_encodings: PositionEncodings,
#[serde(default)]
pub language_extensions: Vec<LanguageExtensionMapping>,
#[serde(default)]
pub heuristics_max_depth: SearchDepth,
#[serde(default)]
pub max_documents: DocumentLimit,
#[serde(default)]
pub max_file_size: SizeLimit,
#[serde(default)]
pub indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs,
#[serde(default)]
pub max_concurrent_server_starts: ServerStartConcurrency,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
roots: Vec::new(),
position_encodings: PositionEncodings::DEFAULT,
language_extensions: default_language_extensions(),
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
}
}
}
impl WorkspaceConfig {
#[must_use]
pub fn build_extension_map(&self) -> HashMap<FileExtension, LanguageId> {
let mut map = HashMap::new();
for mapping in &self.language_extensions {
for ext in &mapping.extensions {
map.insert(ext.clone(), mapping.language_id.clone());
}
}
map
}
#[must_use]
pub const fn resource_limits(&self) -> ResourceLimits {
ResourceLimits {
max_documents: self.max_documents,
max_file_size: self.max_file_size,
}
}
}
fn language_id_for_pattern_target(
server_language_id: &LanguageId,
target: &PatternTarget,
) -> LanguageId {
match target {
PatternTarget::Extension(extension) => {
react_variant_language_id(server_language_id, extension.as_str())
.unwrap_or_else(|| server_language_id.clone())
}
PatternTarget::Name(_) => server_language_id.clone(),
}
}
#[expect(
clippy::too_many_lines,
reason = "a flat table of built-in extension mappings"
)]
fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
vec![
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("rs")],
language_id: const { LanguageId::from_static("rust") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("py"),
FileExtension::from_static("pyw"),
FileExtension::from_static("pyi"),
],
language_id: const { LanguageId::from_static("python") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("js"),
FileExtension::from_static("mjs"),
FileExtension::from_static("cjs"),
],
language_id: const { LanguageId::from_static("javascript") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("ts"),
FileExtension::from_static("mts"),
FileExtension::from_static("cts"),
],
language_id: const { LanguageId::from_static("typescript") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("tsx")],
language_id: const { LanguageId::from_static("typescriptreact") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("jsx")],
language_id: const { LanguageId::from_static("javascriptreact") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("go")],
language_id: const { LanguageId::from_static("go") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("c"),
FileExtension::from_static("h"),
],
language_id: const { LanguageId::from_static("c") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("cpp"),
FileExtension::from_static("cc"),
FileExtension::from_static("cxx"),
FileExtension::from_static("hpp"),
FileExtension::from_static("hh"),
FileExtension::from_static("hxx"),
],
language_id: const { LanguageId::from_static("cpp") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("java")],
language_id: const { LanguageId::from_static("java") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("rb")],
language_id: const { LanguageId::from_static("ruby") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("php")],
language_id: const { LanguageId::from_static("php") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("swift")],
language_id: const { LanguageId::from_static("swift") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("kt"),
FileExtension::from_static("kts"),
],
language_id: const { LanguageId::from_static("kotlin") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("scala"),
FileExtension::from_static("sc"),
],
language_id: const { LanguageId::from_static("scala") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("zig")],
language_id: const { LanguageId::from_static("zig") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("lua")],
language_id: const { LanguageId::from_static("lua") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("sh"),
FileExtension::from_static("bash"),
FileExtension::from_static("zsh"),
],
language_id: const { LanguageId::from_static("shellscript") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("json")],
language_id: const { LanguageId::from_static("json") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("toml")],
language_id: const { LanguageId::from_static("toml") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("yaml"),
FileExtension::from_static("yml"),
],
language_id: const { LanguageId::from_static("yaml") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("xml")],
language_id: const { LanguageId::from_static("xml") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("html"),
FileExtension::from_static("htm"),
],
language_id: const { LanguageId::from_static("html") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("css")],
language_id: const { LanguageId::from_static("css") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("scss")],
language_id: const { LanguageId::from_static("scss") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("less")],
language_id: const { LanguageId::from_static("less") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("md"),
FileExtension::from_static("markdown"),
],
language_id: const { LanguageId::from_static("markdown") },
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("cs")],
language_id: const { LanguageId::from_static("csharp") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("fs"),
FileExtension::from_static("fsi"),
FileExtension::from_static("fsx"),
],
language_id: const { LanguageId::from_static("fsharp") },
},
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("r"),
FileExtension::from_static("R"),
],
language_id: const { LanguageId::from_static("r") },
},
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigOrigin {
Argument,
Environment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CreateDefault {
Yes,
No,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectConfigTrust {
Untrusted,
Trusted,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProjectConfigStatus {
#[default]
NotIgnored,
IgnoredUntrusted,
}
const MAX_CONFIG_FILE_BYTES: NonZeroU64 =
NonZeroU64::new(8 * 1024 * 1024).expect("the config file limit must be non-zero");
fn rebase_relative_roots(
roots: &mut [ConfiguredRoot],
config_path: &Path,
base: RelativeRootBase,
) -> Result<()> {
if !roots.iter().any(|root| root.as_path().is_relative()) {
return Ok(());
}
let config_dir = match base {
RelativeRootBase::Cwd => None,
RelativeRootBase::ConfigDir => Some(config_dir_as_given(config_path)?),
};
for root in roots.iter_mut().filter(|root| root.as_path().is_relative()) {
if let Some(dir) = config_dir.as_deref() {
let rebased = root.join_onto(dir);
probe_root(root.as_path(), as_base_display(dir), rebased.as_path())?;
*root = rebased;
} else {
let cwd = std::env::current_dir().map_err(Error::Io)?;
probe_root(root.as_path(), &cwd, root.join_onto(&cwd).as_path())?;
}
}
Ok(())
}
fn as_base_display(dir: &Path) -> &Path {
if dir.is_empty() { Path::new(".") } else { dir }
}
fn config_dir_as_given(config_path: &Path) -> Result<PathBuf> {
let drive_or_root_qualified = config_path
.components()
.next()
.is_some_and(|c| matches!(c, Component::Prefix(_) | Component::RootDir));
let path = if config_path.is_relative() && drive_or_root_qualified {
std::path::absolute(config_path).map_err(Error::Io)?
} else {
config_path.to_path_buf()
};
path.parent()
.map(Path::to_path_buf)
.ok_or_else(|| ConfigError::NoParentDirectory { path }.into())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RelativeRootBase {
ConfigDir,
Cwd,
}
impl ServerConfig {
#[must_use]
pub fn build_effective_language_map(&self) -> LanguageMap {
let mut map = LanguageMap::from(self.workspace.build_extension_map());
for server in &self.lsp_servers {
for pattern in &server.file_patterns {
let target = pattern.target().clone();
let language_id = language_id_for_pattern_target(&server.language_id, &target);
map.insert(target, language_id);
}
}
map
}
pub fn load() -> Result<Self> {
Self::load_with_trust(ProjectConfigTrust::Untrusted)
}
pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
Self::discover(trust, CreateDefault::Yes).map(|(config, _)| config)
}
pub fn load_discovered(trust: ProjectConfigTrust, workspace: &WorkspaceTrust) -> Result<Self> {
let create = match workspace {
WorkspaceTrust::Trusted => CreateDefault::Yes,
WorkspaceTrust::Untrusted(_) => CreateDefault::No,
};
let (mut config, source) = Self::discover(trust, create)?;
let source = source
.as_deref()
.map(|path| (path, ConfigOrigin::Environment));
config.apply_workspace_trust(workspace, source)?;
Ok(config)
}
pub fn load_explicit(
path: &Path,
origin: ConfigOrigin,
workspace: &WorkspaceTrust,
) -> Result<Self> {
let mut config = Self::load_from(path)?;
config.apply_workspace_trust(workspace, Some((path, origin)))?;
Ok(config)
}
fn apply_workspace_trust(
&mut self,
workspace: &WorkspaceTrust,
source: Option<(&Path, ConfigOrigin)>,
) -> Result<()> {
if let (WorkspaceTrust::Untrusted(_), Some((source, origin))) = (workspace, source) {
self.ensure_outside_workspace(source, origin)?;
}
Self::check_allowlist(workspace, &self.lsp_servers)?;
self.workspace_trust = workspace.clone();
Ok(())
}
fn discover(
trust: ProjectConfigTrust,
create: CreateDefault,
) -> Result<(Self, Option<PathBuf>)> {
if let Some(path) = std::env::var_os("MCPLS_CONFIG") {
let path = PathBuf::from(path);
return Self::load_from(&path).map(|config| (config, Some(path)));
}
let mut project_config_status = ProjectConfigStatus::NotIgnored;
let local_config = PathBuf::from("mcpls.toml");
if local_config.exists() {
match trust {
ProjectConfigTrust::Trusted => {
return Self::load_from(&local_config)
.map(|config| (config, Some(local_config.clone())));
}
ProjectConfigTrust::Untrusted => {
project_config_status = ProjectConfigStatus::IgnoredUntrusted;
let display_path = local_config.canonicalize().unwrap_or_else(|_| {
std::env::current_dir()
.map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
});
tracing::warn!(
"ignoring untrusted project-local config at {}; pass \
--trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
load it",
display_path.display()
);
}
}
}
if let Some(config_dir) = dirs::config_dir() {
let user_config = config_dir.join("mcpls").join("mcpls.toml");
if user_config.exists() {
let mut config =
Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
config.project_config_status = project_config_status;
return Ok((config, Some(user_config)));
}
if create == CreateDefault::Yes {
if let Err(e) = Self::create_default_config_file(&user_config) {
tracing::warn!(
"Failed to create default config at {}: {}. Using in-memory defaults.",
user_config.display(),
e
);
} else {
tracing::info!("Created default config at {}", user_config.display());
}
}
}
Ok((
Self {
project_config_status,
..Self::default()
},
None,
))
}
pub fn load_from(path: &Path) -> Result<Self> {
Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
}
fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
let file = RegularFile::open(path).map_err(|error| match error {
OpenRegularFileError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
Error::ConfigNotFound(path.to_path_buf())
}
other => other.into_error(path),
})?;
let buf = file
.read_bounded(MAX_CONFIG_FILE_BYTES)
.map_err(|e| match e {
ReadBoundedError::TooLarge(exceeded) => ConfigError::FileTooLarge(exceeded).into(),
ReadBoundedError::Io(source) => Error::FileIo {
path: path.to_path_buf(),
source,
},
})?;
let content = String::from_utf8(buf).map_err(ConfigError::NotUtf8)?;
let mut config: Self = toml::from_str(&content)
.map_err(|error| Error::TomlDe(TomlParseError::new(error, &content)))?;
rebase_relative_roots(&mut config.workspace.roots, path, relative_root_base)?;
Ok(config)
}
fn create_default_config_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let default_config = Self::default();
let toml_content = toml::to_string_pretty(&default_config)?;
std::fs::write(path, toml_content)?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
Self::check_allowlist(&self.workspace_trust, &self.lsp_servers)
}
fn check_allowlist(trust: &WorkspaceTrust, servers: &[LspServerConfig]) -> Result<()> {
let WorkspaceTrust::Untrusted(allowlist) = trust else {
return Ok(());
};
let configured: Vec<ServerId> = servers.iter().map(LspServerConfig::id).collect();
let unknown = allowlist
.as_slice()
.iter()
.find(|id| !configured.contains(id));
unknown.map_or(Ok(()), |unknown| {
Err(ConfigError::UnknownAllowedServer {
server: unknown.clone(),
configured: configured.clone(),
}
.into())
})
}
pub fn ensure_outside_workspace(&self, source: &Path, origin: ConfigOrigin) -> Result<()> {
let canonical = dunce::canonicalize(source).map_err(|io| Error::FileIo {
path: source.to_path_buf(),
source: io,
})?;
let roots = WorkspaceRoots::from_configured(&self.workspace.roots)?;
let home = trust::login_home_dir();
let roots = roots.untrusted_boundary(&self.workspace.roots, home.as_deref());
let cwd_matters = origin == ConfigOrigin::Environment || !source.is_absolute();
let cwd = cwd_matters
.then(|| WorkspaceRoots::from_configured(&[]))
.transpose()?
.map(|cwd| cwd.checkout_scoped(home.as_deref()));
if roots.contains_canonical(&canonical)
|| cwd.is_some_and(|cwd| cwd.contains_canonical(&canonical))
{
return Err(Error::ConfigInsideWorkspace { path: canonical });
}
Ok(())
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![
LspServerConfig::rust_analyzer(),
LspServerConfig::pyright(),
LspServerConfig::typescript(),
LspServerConfig::gopls(),
LspServerConfig::clangd(),
LspServerConfig::zls(),
],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: WorkspaceTrust::default(),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::{assert_matches, fs};
use tempfile::TempDir;
use super::*;
#[cfg(unix)]
use crate::bridge::ProcessCwd;
use crate::bridge::{
INDEXING_STALENESS_BOUND, PROGRESS_SETTLE, PositionEncoding, WorkspaceRoots,
};
#[cfg(unix)]
use crate::test_lsp::client_path;
use crate::test_lsp::toml_path_literal;
use crate::util::SizeExceeded;
#[test]
fn test_default_commands_match_builtin_servers() {
let config = ServerConfig::default();
let defaults: HashSet<&str> = config
.lsp_servers
.iter()
.map(|s| s.command.as_str())
.collect();
let builtins: HashSet<&str> = BuiltinServer::ALL.iter().map(|b| b.as_str()).collect();
assert_eq!(defaults, builtins);
}
#[test]
fn test_builtin_server_npm_table() {
let npm: Vec<_> = BuiltinServer::ALL
.into_iter()
.filter(|b| b.is_npm_package())
.collect();
assert_eq!(
npm,
[
BuiltinServer::Pyright,
BuiltinServer::TypescriptLanguageServer
]
);
}
#[test]
fn test_builtin_server_roundtrip_and_hint() {
for b in BuiltinServer::ALL {
assert_eq!(BuiltinServer::from_command(b.as_str()), Some(b));
assert_ne!(b.install_hint(), "");
}
}
#[test]
fn test_default_config() {
let config = ServerConfig::default();
assert_eq!(config.lsp_servers.len(), 6);
assert_eq!(config.lsp_servers[0].language_id, "rust");
assert_eq!(config.lsp_servers[1].language_id, "python");
assert_eq!(config.lsp_servers[2].language_id, "typescript");
assert_eq!(config.lsp_servers[3].language_id, "go");
assert_eq!(config.lsp_servers[4].language_id, "cpp");
assert_eq!(config.lsp_servers[5].language_id, "zig");
assert_eq!(
config.workspace.position_encodings,
PositionEncodings::DEFAULT
);
}
#[test]
fn test_default_position_encodings() {
assert_eq!(
PositionEncodings::DEFAULT.as_slice(),
[PositionEncoding::Utf8, PositionEncoding::Utf16]
);
}
#[test]
fn test_load_from_valid_toml() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let workspace_root = tmp_dir.path().join("workspace");
fs::create_dir(&workspace_root).unwrap();
let workspace_root_literal = toml_path_literal(&workspace_root);
let toml_content = format!(
r#"
[workspace]
roots = [{workspace_root_literal}]
position_encodings = ["utf-8"]
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
timeout_seconds = 30
"#
);
fs::write(&config_path, &toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(
config.workspace.roots,
vec![ConfiguredRoot::new(workspace_root).unwrap()]
);
assert_eq!(
config.workspace.position_encodings.as_slice(),
[PositionEncoding::Utf8]
);
assert_eq!(config.lsp_servers.len(), 1);
assert_eq!(config.lsp_servers[0].language_id, "rust");
}
#[test]
fn test_load_from_resolves_relative_roots_against_config_directory() {
let tmp_dir = TempDir::new().unwrap();
let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
let config_dir = project_root.join(".agents");
fs::create_dir(&config_dir).unwrap();
let config_path = config_dir.join("mcpls.toml");
fs::write(
&config_path,
r#"
[workspace]
roots = [".", ".."]
"#,
)
.unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert!(
config
.workspace
.roots
.iter()
.all(|root| root.as_path().is_absolute())
);
let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
assert_eq!(roots.canonical(), [config_dir, project_root]);
}
#[test]
fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
let config_tmp_dir = TempDir::new().unwrap();
let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
let config_path = config_dir.join("mcpls.toml");
fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
let cwd_tmp_dir = TempDir::new().unwrap();
let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
let expected_root = cwd.join("relative-root");
fs::create_dir(&expected_root).unwrap();
let config = {
let _guard = CwdGuard::enter(&cwd);
ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
};
assert_eq!(
config.workspace.roots,
vec![ConfiguredRoot::new("relative-root").unwrap()]
);
let roots = {
let _guard = CwdGuard::enter(&cwd);
WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
};
assert_eq!(roots.canonical(), [expected_root]);
}
#[test]
fn test_load_from_rejects_nonexistent_relative_workspace_root() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
let err = ServerConfig::load_from(&config_path).unwrap_err();
let Error::Config(config_err) = err else {
panic!("expected Config, got {err:?}");
};
let message = config_err.to_string();
assert!(message.contains("workspace root 'missing'"));
assert!(message.contains(&tmp_dir.path().display().to_string()));
}
#[cfg(unix)]
struct SymlinkTree {
_dir: TempDir,
base: PathBuf,
real: PathBuf,
link: PathBuf,
}
#[cfg(unix)]
fn symlink_tree() -> SymlinkTree {
let dir = TempDir::new().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let real = base.join("real");
fs::create_dir_all(real.join("src")).unwrap();
fs::write(real.join("src/main.rs"), "fn main() {}").unwrap();
let outside = base.join("outside");
fs::create_dir(&outside).unwrap();
fs::write(outside.join("secret.rs"), "").unwrap();
std::os::unix::fs::symlink(&outside, real.join("out")).unwrap();
let link = base.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
SymlinkTree {
_dir: dir,
base,
real,
link,
}
}
#[cfg(unix)]
fn load_roots(config_path: &Path) -> WorkspaceRoots {
let config = ServerConfig::load_from(config_path).unwrap();
WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
}
#[cfg(unix)]
#[test]
fn test_load_from_symlinked_absolute_root_admits_configured_spelling() {
let tree = symlink_tree();
let config_path = tree.base.join("cfg.toml");
let literal = toml_path_literal(&tree.link);
fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(
config.workspace.roots,
vec![ConfiguredRoot::new(tree.link.clone()).unwrap()]
);
let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
let via_link = roots
.validate_blocking(&client_path(tree.link.join("src/main.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_eq!(via_link.unwrap(), tree.real.join("src/main.rs"));
let via_real = roots
.validate_blocking(&client_path(tree.real.join("src/main.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert!(via_real.is_ok());
}
#[cfg(unix)]
#[test]
fn test_load_from_symlinked_root_still_rejects_escape_symlink() {
let tree = symlink_tree();
let config_path = tree.base.join("cfg.toml");
let literal = toml_path_literal(&tree.link);
fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
let roots = load_roots(&config_path);
for root in [&tree.link, &tree.real] {
let err = roots
.validate_blocking(&client_path(root.join("out/secret.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf)
.unwrap_err();
assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
}
}
#[cfg(unix)]
#[test]
fn test_load_from_relative_root_keeps_symlinked_config_dir_spelling() {
let tree = symlink_tree();
let cfg_real = tree.base.join("cfg_real");
fs::create_dir(&cfg_real).unwrap();
std::os::unix::fs::symlink(&tree.real, cfg_real.join("proj")).unwrap();
let cfg_link = tree.base.join("cfg_link");
std::os::unix::fs::symlink(&cfg_real, &cfg_link).unwrap();
fs::write(
cfg_real.join("mcpls.toml"),
"[workspace]\nroots = [\"proj\"]\n",
)
.unwrap();
let roots = load_roots(&cfg_link.join("mcpls.toml"));
let admitted = roots
.validate_blocking(&client_path(cfg_link.join("proj/src/main.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
}
#[cfg(unix)]
#[test]
fn test_load_from_relative_config_path_keeps_pwd_spelling() {
let tree = symlink_tree();
fs::write(
tree.real.join("mcpls.toml"),
"[workspace]\nroots = [\".\"]\n",
)
.unwrap();
let config = {
let _guard = CwdGuard::enter(&tree.real);
ServerConfig::load_from(Path::new("mcpls.toml")).unwrap()
};
assert!(
config
.workspace
.roots
.iter()
.all(|root| root.as_path().is_relative())
);
let roots = WorkspaceRoots::from_configured_with(&config.workspace.roots, || {
Ok(ProcessCwd::new(
tree.real.clone(),
Some(tree.link.clone().into_os_string()),
))
})
.unwrap();
let admitted = roots
.validate_blocking(&client_path(tree.link.join("src/main.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
}
#[cfg(unix)]
#[test]
fn test_load_from_absolute_config_path_does_not_read_removed_cwd() {
let tree = symlink_tree();
fs::create_dir(tree.base.join("proj")).unwrap();
let config_path = tree.base.join("mcpls.toml");
fs::write(&config_path, "[workspace]\nroots = [\"proj\"]\n").unwrap();
let doomed = TempDir::new().unwrap();
let config = {
let _guard = CwdGuard::enter(doomed.path());
fs::remove_dir(doomed.path()).unwrap();
ServerConfig::load_from(&config_path).unwrap()
};
assert_eq!(
config.workspace.roots,
vec![ConfiguredRoot::new(tree.base.join("proj")).unwrap()]
);
}
#[cfg(unix)]
#[test]
fn test_load_from_parent_root_through_symlinked_config_dir_records_no_alias() {
let tree = symlink_tree();
let data = tree.base.join("data/a/b");
fs::create_dir_all(&data).unwrap();
let home = tree.base.join("home");
fs::create_dir(&home).unwrap();
let proj_link = home.join("proj_link");
std::os::unix::fs::symlink(&data, &proj_link).unwrap();
fs::write(data.join("mcpls.toml"), "[workspace]\nroots = [\"..\"]\n").unwrap();
fs::write(home.join("other.rs"), "").unwrap();
let roots = load_roots(&proj_link.join("mcpls.toml"));
assert_eq!(roots.canonical(), [tree.base.join("data/a")]);
let err = roots
.validate_blocking(&client_path(home.join("other.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf)
.unwrap_err();
assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
}
#[test]
fn test_load_from_relative_config_path_reports_missing_root_as_written() {
let tmp_dir = TempDir::new().unwrap();
fs::write(
tmp_dir.path().join("mcpls.toml"),
"[workspace]\nroots = [\"missing\"]\n",
)
.unwrap();
let err = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_from(Path::new("mcpls.toml")).unwrap_err()
};
let Error::Config(config_err) = err else {
panic!("expected Config, got {err:?}");
};
let message = config_err.to_string();
assert!(message.contains("workspace root 'missing'"), "{message}");
}
#[test]
fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
timeout_seconds = 30
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), 30);
}
#[test]
fn test_validate_rejects_heuristics_max_depth_above_max() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = format!(
"[workspace]\nheuristics_max_depth = {}\n",
MAX_HEURISTICS_DEPTH + 1
);
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(
result,
Err(Error::TomlDe(ref e))
if e.message().contains("heuristics_max_depth")
&& e.message().contains("exceeds the maximum")
);
}
#[test]
fn test_validate_accepts_heuristics_max_depth_below_max() {
for depth in [1, MAX_HEURISTICS_DEPTH - 1] {
let mut config = ServerConfig::default();
config.workspace.heuristics_max_depth = SearchDepth::new(depth).unwrap();
assert!(config.validate().is_ok(), "depth {depth} must be accepted");
}
}
#[test]
fn test_validate_max_file_size_bound() {
for (size, ok) in [
(0, true),
(MAX_FILE_SIZE_LIMIT, true),
(MAX_FILE_SIZE_LIMIT + 1, false),
] {
let result =
toml::from_str::<ServerConfig>(&format!("[workspace]\nmax_file_size = {size}\n"));
assert_eq!(result.is_ok(), ok, "max_file_size {size}: {result:?}");
if let Err(e) = result {
assert!(e.message().contains("max_file_size"));
}
}
}
#[test]
fn test_validate_accepts_heuristics_max_depth_at_max() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = format!("[workspace]\nheuristics_max_depth = {MAX_HEURISTICS_DEPTH}\n");
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(
config.workspace.heuristics_max_depth.get(),
MAX_HEURISTICS_DEPTH
);
}
#[test]
fn test_validate_accepts_request_timeout_seconds_at_max() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = format!(
r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
"#
);
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_validate_accepts_timeout_seconds_at_max() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = format!(
r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
timeout_seconds = {MAX_TIMEOUT_SECONDS}
"#
);
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_validate_accepts_indexing_ready_timeout_seconds_within_bounds() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(
&config_path,
r"
[workspace]
indexing_ready_timeout_seconds = 45
",
)
.unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
assert_eq!(
result
.unwrap()
.workspace
.indexing_ready_timeout_seconds
.get(),
45
);
}
#[test]
fn test_indexing_ready_timeout_seconds_default() {
let config = ServerConfig::default();
assert_eq!(
config.workspace.indexing_ready_timeout_seconds,
IndexingReadyTimeoutSecs::DEFAULT
);
}
#[test]
fn test_max_concurrent_server_starts_default_and_toml() {
assert_eq!(
ServerConfig::default()
.workspace
.max_concurrent_server_starts,
ServerStartConcurrency::DEFAULT
);
let config: ServerConfig =
toml::from_str("[workspace]\nmax_concurrent_server_starts = 3").unwrap();
assert_eq!(config.workspace.max_concurrent_server_starts.get(), 3);
}
#[test]
fn test_max_concurrent_server_starts_rejects_zero() {
let err = toml::from_str::<ServerConfig>("[workspace]\nmax_concurrent_server_starts = 0")
.unwrap_err();
assert!(err.to_string().contains("at least 1"), "{err}");
}
fn assert_toml_rejected(toml_content: &str, needles: &[&str]) {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, toml_content).unwrap();
let err = ServerConfig::load_from(&config_path).unwrap_err();
let Error::TomlDe(source) = err else {
panic!("expected TomlDe, got {err:?}");
};
let message = source.to_string();
for needle in needles {
assert!(message.contains(needle), "{needle:?} not in {message}");
}
}
#[test]
fn test_load_rejects_out_of_range_timeouts() {
for field in ["timeout_seconds", "request_timeout_seconds"] {
for value in [0, MAX_TIMEOUT_SECONDS + 1] {
assert_toml_rejected(
&format!(
"[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n{field} = {value}\n"
),
&["must be between 1 and 900 seconds", &format!("got {value}")],
);
}
}
}
#[test]
fn test_load_accepts_timeouts_at_bounds() {
for value in [1, MAX_TIMEOUT_SECONDS] {
let config: ServerConfig = toml::from_str(&format!(
"[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n\
timeout_seconds = {value}\nrequest_timeout_seconds = {value}\n"
))
.unwrap();
assert_eq!(config.lsp_servers[0].timeout_seconds.get(), value);
assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), value);
}
}
#[test]
fn test_load_rejects_indexing_ready_timeout_outside_open_interval() {
for value in [
PROGRESS_SETTLE.as_secs(),
INDEXING_STALENESS_BOUND.as_secs(),
] {
assert_toml_rejected(
&format!("[workspace]\nindexing_ready_timeout_seconds = {value}\n"),
&["must be between", &format!("got {value}")],
);
}
}
#[test]
fn test_load_accepts_indexing_ready_timeout_at_bounds() {
for value in [
PROGRESS_SETTLE.as_secs() + 1,
INDEXING_STALENESS_BOUND.as_secs() - 1,
] {
let config: ServerConfig = toml::from_str(&format!(
"[workspace]\nindexing_ready_timeout_seconds = {value}\n"
))
.unwrap();
assert_eq!(config.workspace.indexing_ready_timeout_seconds.get(), value);
}
}
#[test]
fn test_load_rejects_empty_language_id() {
assert_toml_rejected(
"[[lsp_servers]]\nlanguage_id = \"\"\ncommand = \"x\"\n",
&["language_id cannot be blank"],
);
}
#[test]
fn test_load_rejects_empty_position_encodings() {
assert_toml_rejected(
"[workspace]\nposition_encodings = []\n",
&["position_encodings cannot be empty"],
);
}
#[test]
fn test_load_rejects_unrecognized_position_encoding() {
assert_toml_rejected(
"[workspace]\nposition_encodings = [\"utf-8\", \"utf-7\"]\n",
&["utf-7", "utf-8", "utf-16", "utf-32"],
);
}
#[test]
fn test_default_config_round_trips_through_toml() {
let original = ServerConfig::default();
let text = toml::to_string(&original).unwrap();
let reloaded: ServerConfig = toml::from_str(&text).unwrap();
assert_eq!(
reloaded.workspace.position_encodings,
original.workspace.position_encodings
);
assert_eq!(
reloaded.workspace.indexing_ready_timeout_seconds,
original.workspace.indexing_ready_timeout_seconds
);
assert_eq!(reloaded.lsp_servers.len(), original.lsp_servers.len());
for (a, b) in reloaded.lsp_servers.iter().zip(&original.lsp_servers) {
assert_eq!(a.language_id, b.language_id);
assert_eq!(a.timeout_seconds, b.timeout_seconds);
assert_eq!(a.request_timeout_seconds, b.request_timeout_seconds);
}
}
#[test]
fn test_default_config_keeps_typescript_selection_auto_through_toml() {
let text = toml::to_string(&ServerConfig::default()).unwrap();
assert_eq!(text.matches("selection = \"auto\"").count(), 1, "{text}");
let reloaded: ServerConfig = toml::from_str(&text).unwrap();
for server in &reloaded.lsp_servers {
let expected = if server.language_id == "typescript" {
ServerSelection::Auto
} else {
ServerSelection::Explicit
};
assert_eq!(
server.command.selection(),
expected,
"{}",
server.language_id
);
}
}
#[test]
fn test_entry_without_selection_key_is_explicit() {
let config: ServerConfig = toml::from_str(
r#"
[[lsp_servers]]
language_id = "typescript"
command = "typescript-language-server"
"#,
)
.unwrap();
assert_eq!(
config.lsp_servers[0].command.selection(),
ServerSelection::Explicit
);
}
#[test]
fn test_auto_selection_accepts_tsls_command_spellings() {
for command in [
"typescript-language-server",
"/opt/node/bin/typescript-language-server",
"typescript-language-server.cmd",
] {
let mut typescript = LspServerConfig::typescript();
typescript.command =
LaunchCommand::auto(ServerCommand::new(command.to_string()).unwrap()).unwrap();
let config = ServerConfig {
lsp_servers: vec![typescript],
..ServerConfig::default()
};
assert!(config.validate().is_ok(), "{command}");
}
}
#[test]
fn test_auto_selection_is_unrepresentable_on_other_servers() {
let rust = LspServerConfig::rust_analyzer();
assert!(LaunchCommand::auto(rust.command.server_command().clone()).is_err());
let err = toml::from_str::<ServerConfig>(
r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
selection = "auto"
"#,
)
.unwrap_err();
assert!(
err.message()
.contains("selection = \"auto\" is only valid for typescript-language-server"),
"{err}"
);
}
#[test]
fn test_load_from_nonexistent_file() {
let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
assert!(result.is_err());
if let Err(Error::ConfigNotFound(path)) = result {
assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
} else {
panic!("Expected ConfigNotFound error");
}
}
#[test]
fn test_load_from_invalid_toml() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("invalid.toml");
fs::write(&config_path, "invalid toml content {{}").unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_err());
}
#[test]
fn test_load_from_rejects_oversized_file() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("oversized.toml");
let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() + 1);
fs::write(&config_path, &oversized).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(
result,
Err(Error::Config(ConfigError::FileTooLarge(SizeExceeded { max, .. })))
if max == MAX_CONFIG_FILE_BYTES
);
}
#[test]
fn test_load_from_accepts_file_at_exact_size_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("exact.toml");
let mut toml_content = "[workspace]\n# ".to_string();
toml_content.push_str(
&"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() - toml_content.len()),
);
assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES.get());
fs::write(&config_path, &toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[cfg(unix)]
#[test]
fn test_load_from_rejects_infinite_special_file() {
let path = Path::new("/dev/zero");
assert_eq!(
fs::metadata(path).unwrap().len(),
0,
"test assumption: /dev/zero must report zero length"
);
let result = ServerConfig::load_from(path);
assert_matches!(result, Err(Error::NotARegularFile(ref p)) if p == path);
}
#[cfg(unix)]
#[test]
fn test_load_from_rejects_fifo_without_blocking() {
let tmp_dir = TempDir::new().unwrap();
let fifo = tmp_dir.path().join("config.fifo");
crate::test_lsp::make_fifo(&fifo);
let (tx, rx) = std::sync::mpsc::channel();
let path = fifo.clone();
std::thread::spawn(move || tx.send(ServerConfig::load_from(&path)).unwrap());
let result = rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap_or_else(|_| panic!("loading a FIFO config must not block"));
assert_matches!(result, Err(Error::NotARegularFile(ref p)) if *p == fifo);
}
#[test]
fn test_load_from_rejects_directory_as_not_regular() {
let tmp_dir = TempDir::new().unwrap();
let result = ServerConfig::load_from(tmp_dir.path());
#[cfg(unix)]
assert_matches!(result, Err(Error::NotARegularFile(_)));
#[cfg(not(unix))]
assert!(result.is_err());
}
#[test]
fn test_validate_empty_command() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "rust"
command = ""
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("command cannot be blank"));
}
#[test]
fn test_blank_server_name_is_rejected_at_load() {
for name in ["", " ", "\\t"] {
let toml = format!(
"[[lsp_servers]]\nname = \"{name}\"\nlanguage_id = \"python\"\n\
command = \"pyright-langserver\"\n"
);
let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
assert!(
err.message().contains("server id cannot be blank"),
"{name:?}: {}",
err.message()
);
}
}
#[test]
fn test_validate_empty_handles() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "python"
command = "pylsp"
handles = []
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("handles cannot be empty"));
}
#[test]
fn test_validate_duplicate_tool_in_handles() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "python"
command = "pylsp"
handles = ["diagnostics", "diagnostics"]
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(
result,
Err(Error::TomlDe(ref e)) if e.message().contains("duplicate tool 'diagnostics'")
);
}
#[test]
fn test_load_rejects_empty_workspace_root_entry() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[workspace]
roots = [""]
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(
result,
Err(Error::TomlDe(ref e)) if e.message().contains("workspace.roots entries cannot be empty")
);
}
#[test]
fn test_validate_duplicate_name_warns_but_loads() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
name = "dup"
language_id = "python"
command = "pyright-langserver"
[[lsp_servers]]
name = "dup"
language_id = "typescript"
command = "typescript-language-server"
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "duplicate name must only warn at load time");
}
#[test]
fn test_workspace_config_defaults() {
let workspace = WorkspaceConfig::default();
assert_eq!(workspace.roots.len(), 0);
assert_eq!(workspace.position_encodings, PositionEncodings::DEFAULT);
assert!(!workspace.language_extensions.is_empty());
assert_eq!(workspace.language_extensions.len(), 30);
assert_eq!(
workspace.heuristics_max_depth.get(),
DEFAULT_HEURISTICS_MAX_DEPTH
);
}
#[test]
fn test_load_multiple_servers() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("multi.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
args = ["--stdio"]
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.lsp_servers.len(), 2);
assert_eq!(config.lsp_servers[0].language_id, "rust");
assert_eq!(config.lsp_servers[1].language_id, "python");
assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
}
#[test]
fn test_deny_unknown_fields() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("unknown.toml");
let toml_content = r#"
unknown_field = "value"
[workspace]
roots = []
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_err(), "Should reject unknown fields");
}
#[test]
fn test_empty_config_file() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("empty.toml");
fs::write(&config_path, "").unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.roots.len(), 0);
assert!(config.lsp_servers.is_empty());
}
#[test]
fn test_config_with_initialization_options() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("init_opts.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = "rust"
command = "rust-analyzer"
[lsp_servers.initialization_options]
cargo = { allFeatures = true }
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert!(config.lsp_servers[0].initialization_options.is_some());
}
#[test]
fn test_language_extensions_in_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("extensions.toml");
let toml_content = r#"
[[workspace.language_extensions]]
extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
language_id = "cpp"
[[workspace.language_extensions]]
extensions = ["nu"]
language_id = "nushell"
[[workspace.language_extensions]]
extensions = ["py", "pyw", "pyi"]
language_id = "python"
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.language_extensions.len(), 3);
assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
assert_eq!(
config.workspace.language_extensions[0].extensions,
vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
);
assert_eq!(
config.workspace.language_extensions[1].language_id,
"nushell"
);
assert_eq!(
config.workspace.language_extensions[1].extensions,
vec!["nu"]
);
}
#[test]
fn test_build_extension_map() {
let workspace = WorkspaceConfig {
roots: vec![],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("cpp"),
FileExtension::from_static("cc"),
FileExtension::from_static("cxx"),
],
language_id: LanguageId::from_static("cpp"),
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("nu")],
language_id: LanguageId::from_static("nushell"),
},
],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
};
let map = workspace.build_extension_map();
assert_eq!(map.get("cpp"), Some(&LanguageId::from_static("cpp")));
assert_eq!(map.get("cc"), Some(&LanguageId::from_static("cpp")));
assert_eq!(map.get("cxx"), Some(&LanguageId::from_static("cpp")));
assert_eq!(map.get("nu"), Some(&LanguageId::from_static("nushell")));
assert_eq!(map.get("unknown"), None);
}
#[test]
fn test_build_effective_language_map_overrides_with_file_patterns() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("cpp"),
command: ServerCommand::from_static("clangd").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![
FilePattern::from_static("**/*.c"),
FilePattern::from_static("**/*.h"),
],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let map = config.build_effective_language_map();
assert_eq!(map.detect(Path::new("x.c")), "cpp");
assert_eq!(map.detect(Path::new("x.h")), "cpp");
}
#[test]
fn test_build_effective_language_map_derives_tsx_language_id() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("typescript"),
command: ServerCommand::from_static("tsgo").into(),
args: crate::config::ServerArgs::try_from(vec![
"--lsp".to_string(),
"--stdio".to_string(),
])
.unwrap(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![
FilePattern::from_static("**/*.ts"),
FilePattern::from_static("**/*.tsx"),
],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let map = config.build_effective_language_map();
assert_eq!(map.detect(Path::new("x.ts")), "typescript");
assert_eq!(map.detect(Path::new("x.tsx")), "typescriptreact");
}
#[test]
fn test_build_effective_language_map_derives_jsx_language_id() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: LanguageId::from_static("javascript"),
command: ServerCommand::from_static("typescript-language-server").into(),
args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![
FilePattern::from_static("**/*.js"),
FilePattern::from_static("**/*.jsx"),
],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
}],
project_config_status: ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let map = config.build_effective_language_map();
assert_eq!(map.detect(Path::new("x.js")), "javascript");
assert_eq!(map.detect(Path::new("x.jsx")), "javascriptreact");
}
#[test]
fn test_build_effective_language_map_maps_name_patterns() {
let toml = "[[lsp_servers]]\nlanguage_id = \"make\"\ncommand = \"make-lsp\"\n\
file_patterns = [\"**/Makefile\", \"GNUmakefile\", \"**/*.mk\"]\n";
let config = toml::from_str::<ServerConfig>(toml).unwrap();
let map = config.build_effective_language_map();
assert_eq!(map.detect(Path::new("sub/Makefile")), "make");
assert_eq!(map.detect(Path::new("GNUmakefile")), "make");
assert_eq!(map.detect(Path::new("rules.mk")), "make");
assert_eq!(map.detect(Path::new("Dockerfile")), LanguageId::PLAINTEXT);
}
#[test]
fn test_load_rejects_every_unsupported_file_pattern_form_naming_the_server() {
for &pattern in patterns::UNSUPPORTED_FILE_PATTERNS {
let toml = format!(
"[[lsp_servers]]\nlanguage_id = \"cpp\"\ncommand = \"clangd\"\n\
file_patterns = [{pattern:?}]\n"
);
let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
let message = err.message();
assert!(
message.contains("lsp_servers entry 'cpp'"),
"{pattern}: {message}"
);
assert!(
message.contains(&format!("'{pattern}'")),
"{pattern}: {message}"
);
assert!(message.contains("*.EXT"), "{pattern}: {message}");
}
}
#[test]
fn test_unsupported_file_pattern_names_the_explicit_server_name() {
let toml = "[[lsp_servers]]\nlanguage_id = \"python\"\ncommand = \"pylsp\"\n\
name = \"mine\"\nfile_patterns = [\"**/*.{py,pyi}\"]\n";
let err = toml::from_str::<ServerConfig>(toml).unwrap_err();
assert!(
err.message().contains("lsp_servers entry 'mine'"),
"{}",
err.message()
);
}
#[test]
fn test_language_for_extension() {
let workspace = WorkspaceConfig {
roots: vec![],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: vec![
LanguageExtensionMapping {
extensions: vec![
FileExtension::from_static("hpp"),
FileExtension::from_static("hh"),
],
language_id: LanguageId::from_static("cpp"),
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("py")],
language_id: LanguageId::from_static("python"),
},
],
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
};
let map = workspace.build_extension_map();
assert_eq!(
map.get("hpp").cloned(),
Some(LanguageId::from_static("cpp"))
);
assert_eq!(map.get("hh").cloned(), Some(LanguageId::from_static("cpp")));
assert_eq!(
map.get("py").cloned(),
Some(LanguageId::from_static("python"))
);
assert_eq!(map.get("unknown").cloned(), None);
}
#[test]
fn test_default_language_extensions() {
let workspace = WorkspaceConfig::default();
let map = workspace.build_extension_map();
assert!(!map.is_empty());
assert_eq!(
map.get("rs").cloned(),
Some(LanguageId::from_static("rust"))
);
assert_eq!(
map.get("py").cloned(),
Some(LanguageId::from_static("python"))
);
assert_eq!(
map.get("cpp").cloned(),
Some(LanguageId::from_static("cpp"))
);
}
#[test]
fn test_create_default_config_file() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
ServerConfig::create_default_config_file(&config_path).unwrap();
assert!(config_path.exists());
let loaded_config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
assert_eq!(loaded_config.lsp_servers.len(), 6);
assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
}
#[test]
fn test_load_returns_default_config() {
let config = ServerConfig::default();
assert_eq!(config.workspace.language_extensions.len(), 30);
assert_eq!(config.lsp_servers.len(), 6);
assert_eq!(config.lsp_servers[0].language_id, "rust");
}
use crate::test_support::CwdGuard;
fn assert_mcpls_config_env_unset() {
assert!(
std::env::var_os("MCPLS_CONFIG").is_none(),
"this test requires MCPLS_CONFIG to be unset in the test environment, since \
load_with_trust returns before consulting CWD when it's set"
);
}
#[test]
fn test_load_ignores_untrusted_project_local_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
let custom_toml = r#"
[workspace]
roots = ["/should-never-load-attacker-path"]
[[lsp_servers]]
language_id = "definitely-not-a-real-language-marker"
command = "rm"
args = ["-rf", "/"]
"#;
fs::write(&config_path, custom_toml).unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load().unwrap()
};
assert!(
!config
.workspace
.roots
.contains(&ConfiguredRoot::new("/should-never-load-attacker-path").unwrap())
);
assert!(
!config
.lsp_servers
.iter()
.any(|s| s.language_id == "definitely-not-a-real-language-marker")
);
}
#[test]
fn test_load_with_trust_loads_trusted_project_local_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
let custom_root = tmp_dir.path().join("custom");
fs::create_dir(&custom_root).unwrap();
let custom_root_literal = toml_path_literal(&custom_root);
let custom_toml = format!(
r#"
[workspace]
roots = [{custom_root_literal}]
[[lsp_servers]]
language_id = "python"
command = "pyright-langserver"
"#
);
fs::write(&config_path, &custom_toml).unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
};
assert_eq!(
config.workspace.roots,
vec![ConfiguredRoot::new(custom_root).unwrap()]
);
assert_eq!(config.lsp_servers.len(), 1);
assert_eq!(config.lsp_servers[0].language_id, "python");
}
#[test]
fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
let custom_toml = r#"
[workspace]
roots = ["/attacker/controlled"]
heuristics_max_depth = 999999
[[lsp_servers]]
language_id = "evil"
command = "rm"
args = ["-rf", "/"]
"#;
fs::write(&config_path, custom_toml).unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
};
assert!(
!config
.workspace
.roots
.contains(&ConfiguredRoot::new("/attacker/controlled").unwrap())
);
assert_ne!(config.workspace.heuristics_max_depth.get(), 999_999);
assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
}
#[test]
fn test_load_with_trust_sets_project_config_status() {
assert_mcpls_config_env_unset();
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
};
assert_eq!(
config.project_config_status,
ProjectConfigStatus::IgnoredUntrusted
);
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("mcpls.toml");
fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
};
assert_eq!(
config.project_config_status,
ProjectConfigStatus::NotIgnored
);
}
#[test]
fn test_load_no_local_config_leaves_flag_unset() {
assert_mcpls_config_env_unset();
let tmp_dir = TempDir::new().unwrap();
let config = {
let _guard = CwdGuard::enter(tmp_dir.path());
ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
};
assert_eq!(
config.project_config_status,
ProjectConfigStatus::NotIgnored
);
}
#[test]
fn test_config_file_creation_with_proper_structure() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
ServerConfig::create_default_config_file(&config_path).unwrap();
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("[mcp]"));
assert!(content.contains("[workspace]"));
assert!(content.contains("[[workspace.language_extensions]]"));
assert!(content.contains("[[lsp_servers]]"));
assert!(content.contains("language_id = \"rust\""));
assert!(content.contains("extensions = [\"rs\"]"));
}
#[test]
fn test_heuristics_max_depth_default() {
let config = WorkspaceConfig::default();
assert_eq!(config.heuristics_max_depth.get(), 10);
}
#[test]
fn test_heuristics_max_depth_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("depth.toml");
let toml_content = r"
[workspace]
heuristics_max_depth = 5
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.heuristics_max_depth.get(), 5);
}
#[test]
fn test_heuristics_max_depth_uses_default_when_not_specified() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("no_depth.toml");
let toml_content = r"
[workspace]
roots = []
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(
config.workspace.heuristics_max_depth.get(),
DEFAULT_HEURISTICS_MAX_DEPTH
);
}
#[test]
fn test_max_documents_default() {
let config = WorkspaceConfig::default();
assert_eq!(config.max_documents, DocumentLimit::DEFAULT);
}
#[test]
fn test_max_file_size_default() {
let config = WorkspaceConfig::default();
assert_eq!(config.max_file_size, SizeLimit::DEFAULT);
}
#[test]
fn test_max_documents_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("limits.toml");
let toml_content = r"
[workspace]
max_documents = 500
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.max_documents, DocumentLimit::new(500));
}
#[test]
fn test_max_file_size_from_config() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("limits.toml");
let toml_content = r"
[workspace]
max_file_size = 20971520
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(
config.workspace.max_file_size,
SizeLimit::from_static(20_971_520)
);
}
#[test]
fn test_max_documents_uses_default_when_not_specified() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("no_limits.toml");
let toml_content = r"
[workspace]
roots = []
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.max_documents, DocumentLimit::DEFAULT);
assert_eq!(config.workspace.max_file_size, SizeLimit::DEFAULT);
}
#[test]
fn test_max_file_size_zero_means_unlimited() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("unlimited.toml");
let toml_content = r"
[workspace]
max_file_size = 0
";
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.workspace.max_file_size, SizeLimit::UNLIMITED);
assert_eq!(
config.workspace.resource_limits().max_file_size,
SizeLimit::UNLIMITED
);
}
#[test]
fn test_max_documents_rejects_negative_value() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("negative_max_documents.toml");
let toml_content = r"
[workspace]
max_documents = -1
";
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = &result {
let msg = e.to_string();
assert!(
msg.contains("-1") && msg.contains("usize"),
"expected a type-mismatch message naming the offending value and the \
expected type, got: {msg}"
);
} else {
panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
}
}
#[test]
fn test_max_file_size_rejects_string_value() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("string_max_file_size.toml");
let toml_content = r#"
[workspace]
max_file_size = "10MB"
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = &result {
let msg = e.to_string();
assert!(
msg.contains("10MB") && msg.contains("u64"),
"expected a type-mismatch message naming the offending value and the \
expected type, got: {msg}"
);
} else {
panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
}
}
#[test]
fn test_workspace_config_resource_limits_maps_fields() {
let workspace = WorkspaceConfig {
max_documents: DocumentLimit::new(250),
max_file_size: SizeLimit::UNLIMITED,
..WorkspaceConfig::default()
};
let limits = workspace.resource_limits();
assert_eq!(limits.max_documents, DocumentLimit::new(250));
assert_eq!(limits.max_file_size, SizeLimit::UNLIMITED);
}
#[test]
fn test_workspace_config_toml_round_trip() {
let original = WorkspaceConfig {
roots: vec![ConfiguredRoot::new("/tmp/round-trip").unwrap()],
position_encodings: PositionEncodings::new(vec![PositionEncoding::Utf8]).unwrap(),
language_extensions: vec![LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("nu")],
language_id: LanguageId::from_static("nushell"),
}],
heuristics_max_depth: SearchDepth::new(5).unwrap(),
max_documents: DocumentLimit::new(500),
max_file_size: SizeLimit::UNLIMITED,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::new(45).unwrap(),
max_concurrent_server_starts: ServerStartConcurrency::new(3).unwrap(),
};
let toml_content = toml::to_string_pretty(&original).unwrap();
let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
assert_eq!(round_tripped.roots, original.roots);
assert_eq!(
round_tripped.position_encodings,
original.position_encodings
);
assert_eq!(
round_tripped.language_extensions.len(),
original.language_extensions.len()
);
assert_eq!(
round_tripped.language_extensions[0].extensions,
original.language_extensions[0].extensions
);
assert_eq!(
round_tripped.language_extensions[0].language_id,
original.language_extensions[0].language_id
);
assert_eq!(
round_tripped.heuristics_max_depth,
original.heuristics_max_depth
);
assert_eq!(round_tripped.max_documents, original.max_documents);
assert_eq!(round_tripped.max_file_size, original.max_file_size);
assert_eq!(
round_tripped.indexing_ready_timeout_seconds,
original.indexing_ready_timeout_seconds
);
}
#[test]
fn test_mcp_config_parses_from_toml_section() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[mcp]
title = "Custom Title"
description = "Custom description"
instructions = "Custom instructions."
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.mcp.title.unwrap(), "Custom Title");
assert_eq!(config.mcp.description.unwrap(), "Custom description");
assert_eq!(config.mcp.instructions.unwrap(), "Custom instructions.");
}
#[test]
fn test_mcp_config_defaults_to_none_when_section_absent() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.mcp.title, None);
assert_eq!(config.mcp.description, None);
assert_eq!(config.mcp.instructions, None);
}
#[test]
fn test_mcp_config_rejects_unknown_field() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
assert_matches!(result, Err(Error::TomlDe(_)));
}
#[test]
fn test_validate_rejects_empty_mcp_title() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert_eq!(msg, "cannot be empty");
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ntitle = \" \"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert!(msg.contains("cannot be empty"));
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_empty_mcp_description() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert_eq!(msg, "cannot be empty");
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_empty_mcp_instructions() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert_eq!(msg, "cannot be empty");
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_over_length_mcp_title() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert_eq!(
msg,
format!(
"exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
MAX_MCP_TITLE_BYTES + 1
)
);
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_accepts_mcp_title_at_exact_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let title = "a".repeat(MAX_MCP_TITLE_BYTES);
fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_validate_rejects_over_length_mcp_description() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
fs::write(
&config_path,
format!("[mcp]\ndescription = \"{description}\"\n"),
)
.unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert!(msg.contains("exceeds the maximum"));
assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_accepts_mcp_description_at_exact_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
fs::write(
&config_path,
format!("[mcp]\ndescription = \"{description}\"\n"),
)
.unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let title = "é".repeat(65);
assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
assert_eq!(title.chars().count(), 65);
fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert!(msg.contains("exceeds the maximum"));
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let title = "é".repeat(64);
assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_validate_rejects_over_length_mcp_instructions() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
fs::write(
&config_path,
format!("[mcp]\ninstructions = \"{instructions}\"\n"),
)
.unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::TomlDe(e)) = result {
let msg = e.message();
assert!(msg.contains("exceeds the maximum"));
assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
} else {
panic!("Expected TomlDe error, got {result:?}");
}
}
#[test]
fn test_validate_accepts_mcp_instructions_at_exact_cap() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
fs::write(
&config_path,
format!("[mcp]\ninstructions = \"{instructions}\"\n"),
)
.unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn test_tool_prefix_accepts_valid_and_round_trips() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
let config = ServerConfig::load_from(&config_path).unwrap();
assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
let serialized = toml::to_string_pretty(&config).unwrap();
let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
}
#[test]
fn test_tool_prefix_accepts_digits_and_mixed_case() {
let prefix: ToolPrefix = "Optics2".parse().unwrap();
assert_eq!(prefix.as_str(), "Optics2");
}
#[test]
fn test_tool_prefix_accepts_single_alphanumeric_char() {
assert!("x".parse::<ToolPrefix>().is_ok());
assert!("9".parse::<ToolPrefix>().is_ok());
}
#[test]
fn test_tool_prefix_rejects_empty() {
let err = "".parse::<ToolPrefix>().unwrap_err();
assert_eq!(err, InvalidToolPrefix::Empty);
assert_eq!(
err.to_string(),
"mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
use unprefixed tool names)"
);
}
#[test]
fn test_tool_prefix_rejects_whitespace_only() {
let err = " ".parse::<ToolPrefix>().unwrap_err();
assert_eq!(err, InvalidToolPrefix::Empty);
}
#[test]
fn test_tool_prefix_rejects_leading_separator() {
for bad in ["_optics", "-optics"] {
let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
assert!(
err.contains("cannot start with"),
"for input {bad:?}: {err}"
);
}
}
#[test]
fn test_tool_prefix_rejects_trailing_separator() {
for bad in ["optics_", "optics-"] {
let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
assert!(err.contains("inserted automatically"));
}
}
#[test]
fn test_tool_prefix_rejects_dot() {
let err = "op.tics".parse::<ToolPrefix>().unwrap_err().to_string();
assert!(err.contains("invalid character '.'"));
}
#[test]
fn test_tool_prefix_rejects_space() {
let err = "op tics".parse::<ToolPrefix>().unwrap_err().to_string();
assert!(err.contains("invalid character ' '"));
}
#[test]
fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
let err = "optiсs".parse::<ToolPrefix>().unwrap_err().to_string();
assert!(err.contains("invalid character 'с'"), "{err}");
}
#[test]
fn test_tool_prefix_rejects_over_length() {
let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
let err = prefix.parse::<ToolPrefix>().unwrap_err();
assert_eq!(
err,
InvalidToolPrefix::TooLong {
len: MAX_MCP_TOOL_PREFIX_BYTES + 1
}
);
}
#[test]
fn test_tool_prefix_accepts_exact_length_cap() {
let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
assert!(prefix.parse::<ToolPrefix>().is_ok());
}
#[test]
fn test_tool_prefix_from_str_shares_config_validator() {
assert!("optics_".parse::<ToolPrefix>().is_err());
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
assert_matches!(ServerConfig::load_from(&config_path), Err(Error::TomlDe(_)));
}
#[test]
fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
let result = ServerConfig::load_from(&config_path);
let Err(Error::TomlDe(err)) = result else {
panic!("Expected TomlDe error, got {result:?}");
};
let msg = err.to_string();
assert!(msg.contains("mcp.tool_prefix"), "{msg}");
assert!(msg.contains("cannot end with"), "{msg}");
assert!(msg.contains("line 2"), "{msg}");
}
mod trust_tests {
use super::*;
fn config_with_servers(trust: WorkspaceTrust) -> ServerConfig {
ServerConfig {
workspace_trust: trust,
..ServerConfig::default()
}
}
#[test]
fn test_validate_accepts_allowed_ids_of_configured_servers() {
let config =
config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rust")]));
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_an_allowed_id_that_names_no_configured_server() {
let config =
config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rsut")]));
let err = config.validate().unwrap_err();
let text = err.to_string();
assert_matches!(
err,
Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
if server.as_str() == "rsut"
);
assert!(text.contains("'rsut'") && text.contains("rust"), "{text}");
}
#[test]
fn test_apply_workspace_trust_rejects_an_unknown_allowed_id_and_keeps_the_trust() {
let mut config = ServerConfig::default();
let err = config
.apply_workspace_trust(
&WorkspaceTrust::untrusted([ServerId::from_static("rsut")]),
None,
)
.unwrap_err();
assert_matches!(
err,
Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
if server.as_str() == "rsut"
);
assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
}
#[test]
fn test_validate_ignores_the_allowlist_when_trusted() {
assert!(
config_with_servers(WorkspaceTrust::Trusted)
.validate()
.is_ok()
);
}
#[test]
fn test_workspace_trust_is_never_read_from_toml() {
let config: ServerConfig = toml::from_str("").unwrap();
assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
let err = toml::from_str::<ServerConfig>("workspace_trust = \"untrusted\"");
assert!(err.is_err(), "an unknown key is rejected");
assert!(!toml::to_string(&config).unwrap().contains("trust"));
}
fn temp_roots_config(root: &Path) -> ServerConfig {
let mut config = ServerConfig::default();
config.workspace.roots = vec![ConfiguredRoot::new(root).unwrap()];
config
}
#[test]
fn test_ensure_outside_workspace_rejects_a_config_inside_a_root() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("mcpls.toml");
fs::write(&file, "").unwrap();
let err = temp_roots_config(dir.path())
.ensure_outside_workspace(&file, ConfigOrigin::Argument)
.unwrap_err();
assert_matches!(err, Error::ConfigInsideWorkspace { .. });
}
#[test]
fn test_ensure_outside_workspace_names_the_path_it_cannot_canonicalize() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("missing.toml");
let err = ServerConfig::default()
.ensure_outside_workspace(&missing, ConfigOrigin::Argument)
.unwrap_err();
assert_matches!(err, Error::FileIo { path, .. } if path == missing);
}
#[test]
fn test_ensure_outside_workspace_rejects_a_config_inside_the_process_cwd() {
let cwd = std::env::current_dir().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let file = dir.path().join("mcpls.toml");
fs::write(&file, "").unwrap();
let elsewhere = TempDir::new().unwrap();
let config = temp_roots_config(elsewhere.path());
let err = config
.ensure_outside_workspace(&file, ConfigOrigin::Environment)
.unwrap_err();
assert_matches!(err, Error::ConfigInsideWorkspace { .. });
assert!(
config
.ensure_outside_workspace(&file, ConfigOrigin::Argument)
.is_ok(),
"an absolute path given as an argument is only checked against the roots"
);
}
#[test]
fn test_ensure_outside_workspace_checks_cwd_for_a_relative_path() {
let cwd = std::env::current_dir().unwrap();
let dir = tempfile::tempdir_in(&cwd).unwrap();
let file = dir.path().join("mcpls.toml");
fs::write(&file, "").unwrap();
let relative = file.strip_prefix(&cwd).unwrap();
let elsewhere = TempDir::new().unwrap();
let err = temp_roots_config(elsewhere.path())
.ensure_outside_workspace(relative, ConfigOrigin::Argument)
.unwrap_err();
assert_matches!(err, Error::ConfigInsideWorkspace { .. });
}
#[test]
fn test_ensure_outside_workspace_admits_a_config_outside_roots_and_cwd() {
let roots = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let file = outside.path().join("mcpls.toml");
fs::write(&file, "").unwrap();
assert!(
temp_roots_config(roots.path())
.ensure_outside_workspace(&file, ConfigOrigin::Environment)
.is_ok()
);
}
}
}