mod language;
mod routing;
mod server;
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
pub use language::{base_language_id, react_variant_language_id};
pub use routing::{NoServerReason, ServerId, ToolKind, ToolRouter};
use serde::{Deserialize, Serialize};
pub use server::{
DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, MAX_TIMEOUT_SECONDS, ServerHeuristics,
};
use crate::bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE, ResourceLimits};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageExtensionMapping {
pub extensions: Vec<String>,
pub language_id: String,
}
#[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_ignored: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
pub const MAX_MCP_TITLE_BYTES: usize = 128;
pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
#[serde(default)]
pub roots: Vec<PathBuf>,
#[serde(default = "default_position_encodings")]
pub position_encodings: Vec<String>,
#[serde(default)]
pub language_extensions: Vec<LanguageExtensionMapping>,
#[serde(default = "default_heuristics_max_depth")]
pub heuristics_max_depth: usize,
#[serde(default = "default_max_documents")]
pub max_documents: usize,
#[serde(default = "default_max_file_size")]
pub max_file_size: u64,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
roots: Vec::new(),
position_encodings: default_position_encodings(),
language_extensions: default_language_extensions(),
heuristics_max_depth: default_heuristics_max_depth(),
max_documents: default_max_documents(),
max_file_size: default_max_file_size(),
}
}
}
const fn default_heuristics_max_depth() -> usize {
DEFAULT_HEURISTICS_MAX_DEPTH
}
const fn default_max_documents() -> usize {
DEFAULT_MAX_DOCUMENTS
}
const fn default_max_file_size() -> u64 {
DEFAULT_MAX_FILE_SIZE
}
impl WorkspaceConfig {
#[must_use]
pub fn build_extension_map(&self) -> HashMap<String, String> {
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 fn get_language_for_extension(&self, extension: &str) -> Option<String> {
for mapping in &self.language_extensions {
if mapping.extensions.contains(&extension.to_string()) {
return Some(mapping.language_id.clone());
}
}
None
}
#[must_use]
pub const fn resource_limits(&self) -> ResourceLimits {
ResourceLimits {
max_documents: self.max_documents,
max_file_size: self.max_file_size,
}
}
}
fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
let basename = pattern.rsplit('/').next().unwrap_or(pattern);
if basename.starts_with('.') {
return None;
}
let (_, ext) = basename.rsplit_once('.')?;
if ext.is_empty() {
return None;
}
if ext
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Some(ext.to_string())
} else {
None
}
}
fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
react_variant_language_id(server_language_id, extension)
.unwrap_or(server_language_id)
.to_string()
}
pub(crate) fn default_position_encodings() -> Vec<String> {
vec!["utf-8".to_string(), "utf-16".to_string()]
}
pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
match value {
"utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
"utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
"utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
_ => None,
}
}
#[allow(clippy::too_many_lines)]
fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
vec![
LanguageExtensionMapping {
extensions: vec!["rs".to_string()],
language_id: "rust".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
language_id: "python".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
language_id: "javascript".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
language_id: "typescript".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["tsx".to_string()],
language_id: "typescriptreact".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["jsx".to_string()],
language_id: "javascriptreact".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["go".to_string()],
language_id: "go".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["c".to_string(), "h".to_string()],
language_id: "c".to_string(),
},
LanguageExtensionMapping {
extensions: vec![
"cpp".to_string(),
"cc".to_string(),
"cxx".to_string(),
"hpp".to_string(),
"hh".to_string(),
"hxx".to_string(),
],
language_id: "cpp".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["java".to_string()],
language_id: "java".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["rb".to_string()],
language_id: "ruby".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["php".to_string()],
language_id: "php".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["swift".to_string()],
language_id: "swift".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["kt".to_string(), "kts".to_string()],
language_id: "kotlin".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["scala".to_string(), "sc".to_string()],
language_id: "scala".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["zig".to_string()],
language_id: "zig".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["lua".to_string()],
language_id: "lua".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
language_id: "shellscript".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["json".to_string()],
language_id: "json".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["toml".to_string()],
language_id: "toml".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["yaml".to_string(), "yml".to_string()],
language_id: "yaml".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["xml".to_string()],
language_id: "xml".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["html".to_string(), "htm".to_string()],
language_id: "html".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["css".to_string()],
language_id: "css".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["scss".to_string()],
language_id: "scss".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["less".to_string()],
language_id: "less".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["md".to_string(), "markdown".to_string()],
language_id: "markdown".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["cs".to_string()],
language_id: "csharp".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
language_id: "fsharp".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["r".to_string(), "R".to_string()],
language_id: "r".to_string(),
},
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectConfigTrust {
Untrusted,
Trusted,
}
const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RelativeRootBase {
ConfigDir,
Cwd,
}
impl ServerConfig {
#[must_use]
pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
let mut map = self.workspace.build_extension_map();
for server in &self.lsp_servers {
for pattern in &server.file_patterns {
if let Some(ext) = extract_extension_from_pattern(pattern) {
let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
map.insert(ext, language_id);
}
}
}
map
}
pub fn load() -> Result<Self> {
Self::load_with_trust(ProjectConfigTrust::Untrusted)
}
pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
if let Ok(path) = std::env::var("MCPLS_CONFIG") {
return Self::load_from(Path::new(&path));
}
let mut project_config_ignored = false;
let local_config = PathBuf::from("mcpls.toml");
if local_config.exists() {
match trust {
ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
ProjectConfigTrust::Untrusted => {
project_config_ignored = true;
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_ignored = project_config_ignored;
return Ok(config);
}
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_ignored,
..Self::default()
})
}
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 = std::fs::File::open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::ConfigNotFound(path.to_path_buf())
} else {
Error::Io(e)
}
})?;
let mut buf = Vec::new();
file.take(MAX_CONFIG_FILE_BYTES + 1)
.read_to_end(&mut buf)
.map_err(Error::Io)?;
if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
return Err(Error::FileSizeLimitExceeded {
size: buf.len() as u64,
max: MAX_CONFIG_FILE_BYTES,
});
}
let content = String::from_utf8(buf)
.map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
let mut config: Self = toml::from_str(&content)?;
config.validate()?;
if !config.workspace.roots.is_empty() {
config.workspace.roots = if config.workspace.roots.iter().any(|root| root.is_relative())
{
let absolute_config_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(Error::Io)?.join(path)
};
let config_dir = absolute_config_path.parent().ok_or_else(|| {
Error::InvalidConfig(format!(
"configuration path has no parent directory: {}",
absolute_config_path.display()
))
})?;
let base_dir = match relative_root_base {
RelativeRootBase::ConfigDir => {
dunce::canonicalize(config_dir).map_err(|source| {
Error::InvalidConfig(format!(
"configuration directory '{}' could not be canonicalized: {source}",
config_dir.display()
))
})?
}
RelativeRootBase::Cwd => std::env::current_dir().map_err(Error::Io)?,
};
crate::resolve_workspace_roots(&config.workspace.roots, &base_dir)?
} else {
crate::canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
};
}
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.validate_mcp()?;
if self.workspace.position_encodings.is_empty() {
return Err(Error::InvalidConfig(
"workspace.position_encodings cannot be empty".to_string(),
));
}
for encoding in &self.workspace.position_encodings {
if parse_position_encoding(encoding).is_none() {
return Err(Error::InvalidConfig(format!(
"invalid workspace.position_encodings value '{encoding}'; expected one of \
\"utf-8\", \"utf-16\", \"utf-32\""
)));
}
}
if self
.workspace
.roots
.iter()
.any(|root| root.as_os_str().is_empty())
{
return Err(Error::InvalidConfig(
"workspace.roots entries cannot be empty".to_string(),
));
}
let mut seen_names: HashMap<&str, &str> = HashMap::new();
for server in &self.lsp_servers {
if server.language_id.is_empty() {
return Err(Error::InvalidConfig(
"language_id cannot be empty".to_string(),
));
}
if server.command.is_empty() {
return Err(Error::InvalidConfig(format!(
"command cannot be empty for language '{}'",
server.language_id
)));
}
if server.timeout_seconds == 0 {
return Err(Error::InvalidConfig(format!(
"timeout_seconds cannot be 0 for language '{}'",
server.language_id
)));
}
if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
return Err(Error::InvalidConfig(format!(
"timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
)));
}
if server.request_timeout_seconds == 0 {
return Err(Error::InvalidConfig(format!(
"request_timeout_seconds cannot be 0 for language '{}'",
server.language_id
)));
}
if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
return Err(Error::InvalidConfig(format!(
"request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
language '{}'",
server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
)));
}
if let Some(name) = &server.name {
if name.is_empty() {
return Err(Error::InvalidConfig(format!(
"name cannot be empty for language '{}' (omit `name` to default to \
the language id)",
server.language_id
)));
}
if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
tracing::warn!(
"duplicate explicit server name '{name}' in config (language ids: \
'{prev_language}', '{}'); this is only an error if both entries are \
applicable in the same workspace",
server.language_id
);
}
}
if let Some(handles) = &server.handles {
if handles.is_empty() {
return Err(Error::InvalidConfig(format!(
"handles cannot be empty for language '{}' (omit `handles` for a \
catch-all server)",
server.language_id
)));
}
let mut seen_tools = HashSet::new();
for tool in handles {
if !seen_tools.insert(*tool) {
return Err(Error::InvalidConfig(format!(
"duplicate tool '{tool}' in `handles` for language '{}'",
server.language_id
)));
}
}
}
}
Ok(())
}
fn validate_mcp(&self) -> Result<()> {
validate_mcp_field(self.mcp.title.as_deref(), "mcp.title", MAX_MCP_TITLE_BYTES)?;
validate_mcp_field(
self.mcp.description.as_deref(),
"mcp.description",
MAX_MCP_DESCRIPTION_BYTES,
)?;
validate_mcp_field(
self.mcp.instructions.as_deref(),
"mcp.instructions",
MAX_MCP_INSTRUCTIONS_BYTES,
)
}
}
fn validate_mcp_field(value: Option<&str>, field: &str, max_bytes: usize) -> Result<()> {
let Some(value) = value else {
return Ok(());
};
if value.trim().is_empty() {
return Err(Error::InvalidConfig(format!(
"{field} cannot be empty (omit `{}` from the `[mcp]` section to use the built-in default)",
field.rsplit('.').next().unwrap_or(field)
)));
}
let len = value.len();
if len > max_bytes {
return Err(Error::InvalidConfig(format!(
"{field} exceeds the maximum of {max_bytes} bytes ({len} given)"
)));
}
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_ignored: false,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn toml_path_literal(path: &Path) -> String {
toml::Value::String(path.to_string_lossy().into_owned()).to_string()
}
#[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, vec!["utf-8", "utf-16"]);
}
#[test]
fn test_default_position_encodings() {
let encodings = default_position_encodings();
assert_eq!(encodings, vec!["utf-8", "utf-16"]);
}
#[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![dunce::canonicalize(workspace_root).unwrap()]
);
assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
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_eq!(config.workspace.roots, vec![config_dir, project_root]);
assert!(config.workspace.roots.iter().all(|root| root.is_absolute()));
}
#[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![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::InvalidConfig(message) = err else {
panic!("expected InvalidConfig, got {err:?}");
};
assert!(message.contains("workspace root 'missing'"));
let config_dir = dunce::canonicalize(tmp_dir.path()).unwrap();
assert!(message.contains(&config_dir.display().to_string()));
}
#[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, 30);
}
#[test]
fn test_validate_rejects_zero_timeout_seconds() {
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 = 0
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_zero_request_timeout_seconds() {
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"
request_timeout_seconds = 0
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert_eq!(
msg,
"request_timeout_seconds cannot be 0 for language 'rust'"
);
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_request_timeout_seconds_above_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 + 1
);
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("request_timeout_seconds"));
assert!(msg.contains("exceeds the maximum"));
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[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_rejects_timeout_seconds_above_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 + 1
);
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("timeout_seconds"));
assert!(msg.contains("exceeds the maximum"));
} else {
panic!("Expected InvalidConfig error, 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_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).unwrap() + 1);
fs::write(&config_path, &oversized).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(matches!(
result,
Err(Error::FileSizeLimitExceeded { 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).unwrap() - toml_content.len()),
);
assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
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::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
));
}
#[test]
fn test_validate_empty_language_id() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
language_id = ""
command = "test"
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_err());
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("language_id cannot be empty"));
} else {
panic!("Expected InvalidConfig error");
}
}
#[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!(result.is_err());
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("command cannot be empty"));
} else {
panic!("Expected InvalidConfig error");
}
}
#[test]
fn test_validate_empty_name() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[[lsp_servers]]
name = ""
language_id = "python"
command = "pyright-langserver"
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
assert!(result.is_err());
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("name cannot be empty"));
} else {
panic!("Expected InvalidConfig error");
}
}
#[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!(result.is_err());
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("handles cannot be empty"));
} else {
panic!("Expected InvalidConfig error");
}
}
#[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!(result.is_err());
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("duplicate tool"));
assert!(msg.contains("diagnostics"));
} else {
panic!("Expected InvalidConfig error");
}
}
#[test]
fn test_validate_rejects_empty_position_encodings() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r"
[workspace]
position_encodings = []
";
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert_eq!(msg, "workspace.position_encodings cannot be empty");
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[test]
fn test_validate_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);
if let Err(Error::InvalidConfig(msg)) = result {
assert_eq!(msg, "workspace.roots entries cannot be empty");
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[test]
fn test_validate_rejects_unrecognized_position_encoding() {
let tmp_dir = TempDir::new().unwrap();
let config_path = tmp_dir.path().join("config.toml");
let toml_content = r#"
[workspace]
position_encodings = ["utf-8", "utf-7"]
"#;
fs::write(&config_path, toml_content).unwrap();
let result = ServerConfig::load_from(&config_path);
if let Err(Error::InvalidConfig(msg)) = result {
assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
} else {
panic!("Expected InvalidConfig error, got {result:?}");
}
}
#[test]
fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
assert_eq!(
parse_position_encoding("utf-8"),
Some(lsp_types::PositionEncodingKind::UTF8)
);
assert_eq!(
parse_position_encoding("utf-16"),
Some(lsp_types::PositionEncodingKind::UTF16)
);
assert_eq!(
parse_position_encoding("utf-32"),
Some(lsp_types::PositionEncodingKind::UTF32)
);
assert_eq!(parse_position_encoding("utf-7"), None);
}
#[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!(workspace.roots.is_empty());
assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
assert!(!workspace.language_extensions.is_empty());
assert_eq!(workspace.language_extensions.len(), 30);
assert_eq!(workspace.heuristics_max_depth, 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!(config.workspace.roots.is_empty());
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: vec![],
language_extensions: vec![
LanguageExtensionMapping {
extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
language_id: "cpp".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["nu".to_string()],
language_id: "nushell".to_string(),
},
],
heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
};
let map = workspace.build_extension_map();
assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
assert_eq!(map.get("unknown"), None);
}
#[test]
fn test_extract_extension_from_pattern_empty_string() {
assert_eq!(extract_extension_from_pattern(""), None);
}
#[test]
fn test_extract_extension_from_pattern_without_dot() {
assert_eq!(extract_extension_from_pattern("**/*"), None);
}
#[test]
fn test_extract_extension_from_pattern_dotfile() {
assert_eq!(extract_extension_from_pattern(".gitignore"), None);
}
#[test]
fn test_extract_extension_from_pattern_multi_dot_extension() {
assert_eq!(
extract_extension_from_pattern("foo.tar.gz"),
Some("gz".to_string())
);
}
#[test]
fn test_build_effective_extension_map_overrides_with_file_patterns() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: "cpp".to_string(),
command: "clangd".to_string(),
args: vec![],
env: HashMap::new(),
file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let map = config.build_effective_extension_map();
assert_eq!(map.get("c"), Some(&"cpp".to_string()));
assert_eq!(map.get("h"), Some(&"cpp".to_string()));
}
#[test]
fn test_build_effective_extension_map_derives_tsx_language_id() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: "typescript".to_string(),
command: "tsgo".to_string(),
args: vec!["--lsp".to_string(), "--stdio".to_string()],
env: HashMap::new(),
file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let map = config.build_effective_extension_map();
assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
}
#[test]
fn test_build_effective_extension_map_derives_jsx_language_id() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: "javascript".to_string(),
command: "typescript-language-server".to_string(),
args: vec!["--stdio".to_string()],
env: HashMap::new(),
file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let map = config.build_effective_extension_map();
assert_eq!(map.get("js"), Some(&"javascript".to_string()));
assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
}
#[test]
fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
let config = ServerConfig {
mcp: McpConfig::default(),
workspace: WorkspaceConfig::default(),
lsp_servers: vec![LspServerConfig {
language_id: "cpp".to_string(),
command: "clangd".to_string(),
args: vec![],
env: HashMap::new(),
file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
initialization_options: None,
timeout_seconds: 30,
request_timeout_seconds: 30,
heuristics: None,
name: None,
handles: None,
}],
project_config_ignored: false,
};
let map = config.build_effective_extension_map();
assert_eq!(map.get("h"), Some(&"c".to_string()));
}
#[test]
fn test_get_language_for_extension() {
let workspace = WorkspaceConfig {
roots: vec![],
position_encodings: vec![],
language_extensions: vec![
LanguageExtensionMapping {
extensions: vec!["hpp".to_string(), "hh".to_string()],
language_id: "cpp".to_string(),
},
LanguageExtensionMapping {
extensions: vec!["py".to_string()],
language_id: "python".to_string(),
},
],
heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
max_documents: DEFAULT_MAX_DOCUMENTS,
max_file_size: DEFAULT_MAX_FILE_SIZE,
};
assert_eq!(
workspace.get_language_for_extension("hpp"),
Some("cpp".to_string())
);
assert_eq!(
workspace.get_language_for_extension("hh"),
Some("cpp".to_string())
);
assert_eq!(
workspace.get_language_for_extension("py"),
Some("python".to_string())
);
assert_eq!(workspace.get_language_for_extension("unknown"), None);
}
#[test]
fn test_default_language_extensions() {
let workspace = WorkspaceConfig::default();
let map = workspace.build_extension_map();
assert!(!map.is_empty());
assert_eq!(
workspace.get_language_for_extension("rs"),
Some("rust".to_string())
);
assert_eq!(
workspace.get_language_for_extension("py"),
Some("python".to_string())
);
assert_eq!(
workspace.get_language_for_extension("cpp"),
Some("cpp".to_string())
);
}
#[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(&PathBuf::from("/should-never-load-attacker-path"))
);
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![dunce::canonicalize(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(&PathBuf::from("/attacker/controlled"))
);
assert_ne!(config.workspace.heuristics_max_depth, 999_999);
assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
}
#[test]
fn test_load_with_trust_sets_project_config_ignored_flag() {
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!(config.project_config_ignored);
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!(!config.project_config_ignored);
}
#[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!(!config.project_config_ignored);
}
#[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, 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, 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,
DEFAULT_HEURISTICS_MAX_DEPTH
);
}
#[test]
fn test_max_documents_default() {
let config = WorkspaceConfig::default();
assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
}
#[test]
fn test_max_file_size_default() {
let config = WorkspaceConfig::default();
assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
}
#[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, 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, 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, DEFAULT_MAX_DOCUMENTS);
assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
}
#[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, 0);
assert_eq!(config.workspace.resource_limits().max_file_size, 0);
}
#[test]
fn test_workspace_config_resource_limits_maps_fields() {
let workspace = WorkspaceConfig {
max_documents: 250,
max_file_size: 0,
..WorkspaceConfig::default()
};
let limits = workspace.resource_limits();
assert_eq!(limits.max_documents, 250);
assert_eq!(limits.max_file_size, 0);
}
#[test]
fn test_workspace_config_toml_round_trip() {
let original = WorkspaceConfig {
roots: vec![PathBuf::from("/tmp/round-trip")],
position_encodings: vec!["utf-8".to_string()],
language_extensions: vec![LanguageExtensionMapping {
extensions: vec!["nu".to_string()],
language_id: "nushell".to_string(),
}],
heuristics_max_depth: 5,
max_documents: 500,
max_file_size: 0,
};
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);
}
#[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.as_deref(), Some("Custom Title"));
assert_eq!(
config.mcp.description.as_deref(),
Some("Custom description")
);
assert_eq!(
config.mcp.instructions.as_deref(),
Some("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]\ntool_prefix = \"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::InvalidConfig(msg)) = result {
assert_eq!(
msg,
"mcp.title cannot be empty (omit `title` from the `[mcp]` section to use the \
built-in default)"
);
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert!(msg.contains("cannot be empty"));
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert_eq!(
msg,
"mcp.description cannot be empty (omit `description` from the `[mcp]` section \
to use the built-in default)"
);
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert_eq!(
msg,
"mcp.instructions cannot be empty (omit `instructions` from the `[mcp]` \
section to use the built-in default)"
);
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert_eq!(
msg,
format!(
"mcp.title exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
MAX_MCP_TITLE_BYTES + 1
)
);
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert!(msg.contains("mcp.description exceeds the maximum"));
assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert!(msg.contains("mcp.title exceeds the maximum"));
} else {
panic!("Expected InvalidConfig 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::InvalidConfig(msg)) = result {
assert!(msg.contains("mcp.instructions exceeds the maximum"));
assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
} else {
panic!("Expected InvalidConfig 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:?}");
}
}