Skip to main content

mcpls_core/config/
mod.rs

1//! Configuration types and loading.
2//!
3//! This module provides configuration structures for MCPLS,
4//! including LSP server definitions and workspace settings.
5
6mod language;
7mod routing;
8mod server;
9
10use std::collections::{HashMap, HashSet};
11use std::io::Read;
12use std::path::{Path, PathBuf};
13
14pub use language::{base_language_id, react_variant_language_id};
15pub use routing::{NoServerReason, ServerId, ToolKind, ToolRouter};
16use serde::{Deserialize, Serialize};
17pub use server::{
18    DEFAULT_HEURISTICS_MAX_DEPTH, LspServerConfig, MAX_TIMEOUT_SECONDS, ServerHeuristics,
19};
20
21use crate::bridge::{DEFAULT_MAX_DOCUMENTS, DEFAULT_MAX_FILE_SIZE, ResourceLimits};
22use crate::error::{Error, Result};
23
24/// Maps file extensions to LSP language identifiers.
25///
26/// Used to detect the language ID for files based on their extension.
27/// Extensions are mapped to language IDs like "rust", "python", "cpp", etc.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct LanguageExtensionMapping {
30    /// Array of extensions and their corresponding language ID.
31    pub extensions: Vec<String>,
32    /// Language ID to report to the LSP server.
33    pub language_id: String,
34}
35
36/// Main configuration for the MCPLS server.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ServerConfig {
40    /// MCP `serverInfo`/`initialize` presentation overrides.
41    #[serde(default)]
42    pub mcp: McpConfig,
43
44    /// Workspace configuration.
45    #[serde(default)]
46    pub workspace: WorkspaceConfig,
47
48    /// LSP server configurations.
49    #[serde(default)]
50    pub lsp_servers: Vec<LspServerConfig>,
51
52    /// Whether a CWD-discovered `./mcpls.toml` was ignored as untrusted
53    /// during this load (see [`ProjectConfigTrust`]).
54    ///
55    /// Load-time metadata, not user-configurable: never read from or written
56    /// to a TOML file. Consumed by `McplsServer::get_info` (the
57    /// `ServerHandler` implementation in `crate::mcp::server`) to surface
58    /// the ignore decision in-band to MCP clients, supplementing the
59    /// `tracing::warn!` emitted at load time (which is stderr-only and
60    /// typically invisible to an MCP client).
61    #[serde(skip)]
62    pub project_config_ignored: bool,
63}
64
65/// Optional overrides for the text mcpls reports about itself over MCP.
66///
67/// Every field is `None` by default, which keeps today's hardcoded
68/// `serverInfo.title`/`description` and built-in capability blurb
69/// unchanged. `serverInfo.name`, `version`, and `website_url` are not
70/// configurable here: `name` is the MCP-spec machine identifier asserted in
71/// integration tests, and `version`/`website_url` are project metadata, not
72/// presentation text.
73///
74/// A configured [`instructions`](Self::instructions) **replaces** the
75/// built-in capability blurb in `ServerInfo.instructions` rather than
76/// appending to it -- an agent that reads `instructions` at connection time
77/// (see `skills/mcpls/SKILL.md`) sees only the configured text, plus the
78/// unrelated untrusted-project-config NOTE (see
79/// [`ServerConfig::project_config_ignored`]), which is always appended
80/// afterward regardless of this field.
81///
82/// Every field reaches an MCP client verbatim on every `initialize`
83/// response, into what is typically an LLM context window -- this is why
84/// [`ServerConfig::validate`] enforces the `MAX_MCP_*` byte caps on all
85/// three.
86///
87/// # Examples
88///
89/// ```
90/// use mcpls_core::config::ServerConfig;
91///
92/// let toml = r#"
93///     [mcp]
94///     title = "My Custom Bridge"
95///     description = "Internal LSP bridge for Acme Corp"
96///     instructions = "Use get_hover before get_definition."
97/// "#;
98/// let config: ServerConfig = toml::from_str(toml).unwrap();
99/// assert_eq!(config.mcp.title.as_deref(), Some("My Custom Bridge"));
100/// ```
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct McpConfig {
104    /// Overrides `serverInfo.title`. Omit to keep the built-in title.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub title: Option<String>,
107
108    /// Overrides `serverInfo.description`. Omit to keep the built-in
109    /// description (`CARGO_PKG_DESCRIPTION`).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub description: Option<String>,
112
113    /// Replaces the built-in `ServerInfo.instructions` capability blurb.
114    /// Omit to keep the built-in text. The untrusted-project-config NOTE
115    /// (see [`ServerConfig::project_config_ignored`]) is still appended
116    /// after this value when set.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub instructions: Option<String>,
119
120    /// Prefixes every MCP tool name with `{tool_prefix}_`, so an MCP client
121    /// running multiple mcpls bridges concurrently (one per project) can
122    /// tell their tools apart. Omit to keep the default, unprefixed tool
123    /// names.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub tool_prefix: Option<ToolPrefix>,
126}
127
128/// Maximum byte length of a configured [`McpConfig::title`].
129///
130/// UTF-8 bytes, not chars, consistent with `MAX_CONFIG_FILE_BYTES`. Named
131/// so the limit can appear in the [`Error::InvalidConfig`] message it backs.
132pub const MAX_MCP_TITLE_BYTES: usize = 128;
133
134/// Maximum byte length of a configured [`McpConfig::description`].
135///
136/// UTF-8 bytes, not chars. See [`MAX_MCP_TITLE_BYTES`].
137pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
138
139/// Maximum byte length of a configured [`McpConfig::instructions`].
140///
141/// UTF-8 bytes, not chars. Applies to the raw configured string only -- the
142/// untrusted-project-config NOTE appended in `McplsServer::get_info` is
143/// fixed-size built-in text and does not count against this budget. See
144/// [`MAX_MCP_TITLE_BYTES`].
145pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
146
147/// Maximum byte length of a configured [`McpConfig::tool_prefix`].
148///
149/// UTF-8 bytes, not chars -- but since [`ToolPrefix`]'s charset is
150/// ASCII-only, bytes and chars coincide here. Chosen well under rmcp's
151/// 128-byte `SHOULD`-level tool name limit (`joined = prefix + '_' +
152/// tool_name`), and conservatively under stricter tool-name limits some LLM
153/// client APIs have historically enforced (e.g. `^[a-zA-Z0-9_-]{1,64}$`),
154/// so a prefix accepted here is unlikely to be rejected downstream by the
155/// client. See `mcp::server::MAX_TOOL_NAME_BYTES` for the compile-time proof
156/// tying this constant to the longest currently-registered tool name.
157pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
158
159/// A validated [`McpConfig::tool_prefix`] value.
160///
161/// Every mcpls tool name gains a `{prefix}_` prefix when this is configured,
162/// so an MCP client can tell apart tools exposed by multiple concurrently
163/// running mcpls bridges. A value must be non-empty, at most
164/// [`MAX_MCP_TOOL_PREFIX_BYTES`] bytes, contain only ASCII letters, digits,
165/// `_`, and `-`, and both start and end with an ASCII letter or digit --
166/// this last rule rejects (rather than silently strips) a trailing
167/// separator, so `"optics"` and `"optics_"` cannot become two spellings of
168/// the same configuration. The validator runs once, at construction, making
169/// an invalid prefix unrepresentable: there is no way to observe a
170/// `ToolPrefix` whose value doesn't already satisfy these rules.
171///
172/// This differs from `title`/`description`/`instructions`, whose invalid
173/// values surface as [`Error::InvalidConfig`] from [`ServerConfig::validate`]
174/// -- called explicitly, after loading. A malformed prefix is not merely
175/// cosmetic (it would put an invalid tool name on the wire, an MCP protocol
176/// violation), and [`McplsServer::new`](crate::mcp::McplsServer::new)
177/// is `pub` and infallible, so this type validates eagerly during
178/// deserialization instead and surfaces failures as [`Error::TomlDe`], which
179/// additionally carries the offending line from the TOML source.
180///
181/// # Examples
182///
183/// ```
184/// use mcpls_core::config::ToolPrefix;
185///
186/// let prefix: ToolPrefix = "optics".parse().unwrap();
187/// assert_eq!(prefix.as_str(), "optics");
188/// assert!("optics_".parse::<ToolPrefix>().is_err());
189/// ```
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
191pub struct ToolPrefix(String);
192
193impl ToolPrefix {
194    /// Returns the validated prefix as a string slice.
195    #[must_use]
196    pub fn as_str(&self) -> &str {
197        &self.0
198    }
199}
200
201impl std::fmt::Display for ToolPrefix {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.write_str(&self.0)
204    }
205}
206
207impl std::str::FromStr for ToolPrefix {
208    type Err = String;
209
210    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
211        validate_tool_prefix(s)?;
212        Ok(Self(s.to_string()))
213    }
214}
215
216impl<'de> Deserialize<'de> for ToolPrefix {
217    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
218    where
219        D: serde::Deserializer<'de>,
220    {
221        let value = String::deserialize(deserializer)?;
222        value.parse().map_err(serde::de::Error::custom)
223    }
224}
225
226/// Shared validator behind [`ToolPrefix::from_str`] and its `Deserialize`
227/// impl, so a prefix constructed programmatically is held to the same rules
228/// as one loaded from TOML.
229fn validate_tool_prefix(value: &str) -> std::result::Result<(), String> {
230    if value.trim().is_empty() {
231        return Err(
232            "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
233             use unprefixed tool names)"
234                .to_string(),
235        );
236    }
237    let len = value.len();
238    if len > MAX_MCP_TOOL_PREFIX_BYTES {
239        return Err(format!(
240            "mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} \
241             given)"
242        ));
243    }
244    if let Some(bad) = value
245        .chars()
246        .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
247    {
248        return Err(format!(
249            // `{bad:?}` (not `'{bad}'`): `char`'s `Debug` quotes and escapes
250            // control characters (e.g. ESC becomes `'\u{1b}'`), so a TOML
251            // value smuggling a raw control/ANSI-escape byte can't be
252            // echoed verbatim into this message and onward into a
253            // terminal via `tracing`.
254            "mcp.tool_prefix contains an invalid character {bad:?} (allowed: ASCII letters, \
255             digits, '_', and '-')"
256        ));
257    }
258    // `value.trim().is_empty()` above already rejected the empty string, so
259    // `next()`/`next_back()` never actually fall back here -- kept as a
260    // defensive default rather than an `unwrap()`, since `clippy::unwrap_used`
261    // is a workspace-wide warn-as-error (mirrors `validate_mcp_field` above).
262    let first = value.chars().next().unwrap_or_default();
263    let last = value.chars().next_back().unwrap_or_default();
264    if !first.is_ascii_alphanumeric() {
265        return Err(format!(
266            "mcp.tool_prefix cannot start with '{first}' (must start with an ASCII letter or \
267             digit)"
268        ));
269    }
270    if !last.is_ascii_alphanumeric() {
271        return Err(format!(
272            "mcp.tool_prefix cannot end with '{last}' (the '_' separator between the prefix \
273             and each tool name is inserted automatically by mcpls -- remove the trailing \
274             separator character)"
275        ));
276    }
277    Ok(())
278}
279
280/// Workspace-level configuration.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct WorkspaceConfig {
284    /// Root directories for the workspace.
285    #[serde(default)]
286    pub roots: Vec<PathBuf>,
287
288    /// Position encoding preference order, offered to each spawned LSP
289    /// server as `capabilities.general.positionEncodings` during the
290    /// `initialize` handshake (see [`crate::lsp::LspServer::spawn`]), in the
291    /// order configured here.
292    ///
293    /// Valid values: `"utf-8"`, `"utf-16"`, `"utf-32"`. Must be non-empty;
294    /// [`ServerConfig::validate`] rejects an empty list or an unrecognized
295    /// value.
296    #[serde(default = "default_position_encodings")]
297    pub position_encodings: Vec<String>,
298
299    /// File extension to language ID mappings.
300    /// Allows users to customize which file extensions map to which language servers.
301    #[serde(default)]
302    pub language_extensions: Vec<LanguageExtensionMapping>,
303
304    /// Maximum depth for recursive project marker search.
305    /// Controls how deeply nested projects can be detected.
306    /// Default: 10
307    #[serde(default = "default_heuristics_max_depth")]
308    pub heuristics_max_depth: usize,
309
310    /// Maximum number of documents `DocumentTracker` will keep open
311    /// simultaneously. A `textDocument/didOpen`-triggering tool call (hover,
312    /// definition, diagnostics, etc.) for a document beyond this count fails
313    /// with `DocumentLimitExceeded`. Documents stay tracked for the whole
314    /// mcpls process lifetime (there is no eviction), so once the ceiling is
315    /// reached, opening any further new path fails until either the process
316    /// is restarted or this limit is raised; already-tracked paths are
317    /// unaffected. `0` disables the limit.
318    /// Default: 100
319    #[serde(default = "default_max_documents")]
320    pub max_documents: usize,
321
322    /// Maximum size, in bytes, of a single file `DocumentTracker` will open.
323    /// A file larger than this fails with `FileSizeLimitExceeded`. `0`
324    /// disables the limit.
325    /// Default: 10485760 (10MB)
326    #[serde(default = "default_max_file_size")]
327    pub max_file_size: u64,
328}
329
330impl Default for WorkspaceConfig {
331    fn default() -> Self {
332        Self {
333            roots: Vec::new(),
334            position_encodings: default_position_encodings(),
335            language_extensions: default_language_extensions(),
336            heuristics_max_depth: default_heuristics_max_depth(),
337            max_documents: default_max_documents(),
338            max_file_size: default_max_file_size(),
339        }
340    }
341}
342
343const fn default_heuristics_max_depth() -> usize {
344    DEFAULT_HEURISTICS_MAX_DEPTH
345}
346
347const fn default_max_documents() -> usize {
348    DEFAULT_MAX_DOCUMENTS
349}
350
351const fn default_max_file_size() -> u64 {
352    DEFAULT_MAX_FILE_SIZE
353}
354
355impl WorkspaceConfig {
356    /// Build a map of file extensions to language IDs from the configuration.
357    ///
358    /// # Returns
359    ///
360    /// A `HashMap` where keys are file extensions (without the dot) and values
361    /// are the corresponding language IDs to report to LSP servers.
362    #[must_use]
363    pub fn build_extension_map(&self) -> HashMap<String, String> {
364        let mut map = HashMap::new();
365        for mapping in &self.language_extensions {
366            for ext in &mapping.extensions {
367                map.insert(ext.clone(), mapping.language_id.clone());
368            }
369        }
370        map
371    }
372
373    /// Returns the language ID for a file extension.
374    ///
375    /// # Arguments
376    ///
377    /// * `extension` - The file extension (without the dot)
378    ///
379    /// # Returns
380    ///
381    /// The language ID if found, `None` otherwise.
382    #[must_use]
383    pub fn language_for_extension(&self, extension: &str) -> Option<String> {
384        for mapping in &self.language_extensions {
385            if mapping.extensions.contains(&extension.to_string()) {
386                return Some(mapping.language_id.clone());
387            }
388        }
389        None
390    }
391
392    /// Maps the configured `max_documents`/`max_file_size` onto the bridge
393    /// layer's [`ResourceLimits`], for [`Translator::with_resource_limits`](crate::bridge::Translator::with_resource_limits).
394    #[must_use]
395    pub const fn resource_limits(&self) -> ResourceLimits {
396        ResourceLimits {
397            max_documents: self.max_documents,
398            max_file_size: self.max_file_size,
399        }
400    }
401}
402
403/// Extract a file extension from a glob-like file pattern.
404///
405/// Supports common patterns such as `**/*.rs` and `*.h`.
406/// Returns `None` for patterns without a simple trailing extension.
407fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
408    let basename = pattern.rsplit('/').next().unwrap_or(pattern);
409    if basename.starts_with('.') {
410        return None;
411    }
412
413    let (_, ext) = basename.rsplit_once('.')?;
414    if ext.is_empty() {
415        return None;
416    }
417
418    // Keep this conservative: only accept plain extension-like tokens.
419    if ext
420        .chars()
421        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
422    {
423        Some(ext.to_string())
424    } else {
425        None
426    }
427}
428
429fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
430    react_variant_language_id(server_language_id, extension)
431        .unwrap_or(server_language_id)
432        .to_string()
433}
434
435/// The client-preference order offered to every spawned server during
436/// `initialize`.
437///
438/// `utf-8` is listed first deliberately, not just historically: probing both
439/// rust-analyzer and clangd (this project's two flagship servers) against
440/// exactly this offer shows both negotiate down to `utf-8`, so it is the
441/// common case, not a rare fallback. Earlier revisions of this file
442/// (`#290`/`#291`) treated the non-UTF-16 conversion path in
443/// `bridge/encoding.rs` as an edge case on that (false) assumption, which
444/// hid a char-boundary panic and an uncached-disk-read cost on what turned
445/// out to be the default path for both servers. Both are now fixed
446/// (`bridge/encoding.rs`'s boundary guards; `bridge/translator.rs`'s
447/// `EncodingCtx` preferring `DocumentTracker`'s in-memory content over
448/// disk), so there is no longer a correctness or performance reason to
449/// prefer `utf-16` here -- reordering would only reintroduce UTF-16 by
450/// default bias, undoing the point of negotiating an encoding at all.
451pub(crate) fn default_position_encodings() -> Vec<String> {
452    vec!["utf-8".to_string(), "utf-16".to_string()]
453}
454
455/// Parse a configured position-encoding string into an [`lsp_types::PositionEncodingKind`].
456///
457/// Recognizes the three values the LSP spec defines for
458/// `PositionEncodingKind`: `"utf-8"`, `"utf-16"`, `"utf-32"`. Returns `None`
459/// for anything else, letting the caller decide how to handle an invalid
460/// value (see [`ServerConfig::validate`], which rejects it at load time, and
461/// [`crate::lsp::LspServer::spawn`], which falls back to a default rather
462/// than failing the handshake for a config built without going through
463/// `validate`).
464pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
465    match value {
466        "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
467        "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
468        "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
469        _ => None,
470    }
471}
472
473/// Build default language extension mappings.
474///
475/// Returns all built-in language extensions that MCPLS recognizes by default.
476/// These mappings are used when no custom configuration is provided.
477#[allow(clippy::too_many_lines)]
478fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
479    vec![
480        LanguageExtensionMapping {
481            extensions: vec!["rs".to_string()],
482            language_id: "rust".to_string(),
483        },
484        LanguageExtensionMapping {
485            extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
486            language_id: "python".to_string(),
487        },
488        LanguageExtensionMapping {
489            extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
490            language_id: "javascript".to_string(),
491        },
492        LanguageExtensionMapping {
493            extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
494            language_id: "typescript".to_string(),
495        },
496        LanguageExtensionMapping {
497            extensions: vec!["tsx".to_string()],
498            language_id: "typescriptreact".to_string(),
499        },
500        LanguageExtensionMapping {
501            extensions: vec!["jsx".to_string()],
502            language_id: "javascriptreact".to_string(),
503        },
504        LanguageExtensionMapping {
505            extensions: vec!["go".to_string()],
506            language_id: "go".to_string(),
507        },
508        LanguageExtensionMapping {
509            extensions: vec!["c".to_string(), "h".to_string()],
510            language_id: "c".to_string(),
511        },
512        LanguageExtensionMapping {
513            extensions: vec![
514                "cpp".to_string(),
515                "cc".to_string(),
516                "cxx".to_string(),
517                "hpp".to_string(),
518                "hh".to_string(),
519                "hxx".to_string(),
520            ],
521            language_id: "cpp".to_string(),
522        },
523        LanguageExtensionMapping {
524            extensions: vec!["java".to_string()],
525            language_id: "java".to_string(),
526        },
527        LanguageExtensionMapping {
528            extensions: vec!["rb".to_string()],
529            language_id: "ruby".to_string(),
530        },
531        LanguageExtensionMapping {
532            extensions: vec!["php".to_string()],
533            language_id: "php".to_string(),
534        },
535        LanguageExtensionMapping {
536            extensions: vec!["swift".to_string()],
537            language_id: "swift".to_string(),
538        },
539        LanguageExtensionMapping {
540            extensions: vec!["kt".to_string(), "kts".to_string()],
541            language_id: "kotlin".to_string(),
542        },
543        LanguageExtensionMapping {
544            extensions: vec!["scala".to_string(), "sc".to_string()],
545            language_id: "scala".to_string(),
546        },
547        LanguageExtensionMapping {
548            extensions: vec!["zig".to_string()],
549            language_id: "zig".to_string(),
550        },
551        LanguageExtensionMapping {
552            extensions: vec!["lua".to_string()],
553            language_id: "lua".to_string(),
554        },
555        LanguageExtensionMapping {
556            extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
557            language_id: "shellscript".to_string(),
558        },
559        LanguageExtensionMapping {
560            extensions: vec!["json".to_string()],
561            language_id: "json".to_string(),
562        },
563        LanguageExtensionMapping {
564            extensions: vec!["toml".to_string()],
565            language_id: "toml".to_string(),
566        },
567        LanguageExtensionMapping {
568            extensions: vec!["yaml".to_string(), "yml".to_string()],
569            language_id: "yaml".to_string(),
570        },
571        LanguageExtensionMapping {
572            extensions: vec!["xml".to_string()],
573            language_id: "xml".to_string(),
574        },
575        LanguageExtensionMapping {
576            extensions: vec!["html".to_string(), "htm".to_string()],
577            language_id: "html".to_string(),
578        },
579        LanguageExtensionMapping {
580            extensions: vec!["css".to_string()],
581            language_id: "css".to_string(),
582        },
583        LanguageExtensionMapping {
584            extensions: vec!["scss".to_string()],
585            language_id: "scss".to_string(),
586        },
587        LanguageExtensionMapping {
588            extensions: vec!["less".to_string()],
589            language_id: "less".to_string(),
590        },
591        LanguageExtensionMapping {
592            extensions: vec!["md".to_string(), "markdown".to_string()],
593            language_id: "markdown".to_string(),
594        },
595        LanguageExtensionMapping {
596            extensions: vec!["cs".to_string()],
597            language_id: "csharp".to_string(),
598        },
599        LanguageExtensionMapping {
600            extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
601            language_id: "fsharp".to_string(),
602        },
603        LanguageExtensionMapping {
604            extensions: vec!["r".to_string(), "R".to_string()],
605            language_id: "r".to_string(),
606        },
607    ]
608}
609
610/// Trust level applied to a `./mcpls.toml` discovered relative to the
611/// process's current working directory.
612///
613/// A CWD-discovered project-local config is not the same trust tier as an
614/// explicit `--config`/`MCPLS_CONFIG` path: it can be planted by whoever
615/// controls the checked-out repository, and it controls the `command` and
616/// `args` mcpls spawns as well as `[workspace]` (which can redirect the
617/// spawn target via `roots` or drive a filesystem-walk `DoS` via
618/// `heuristics_max_depth`). [`ServerConfig::load`] treats it as
619/// [`Untrusted`](Self::Untrusted) by default; callers that want it honored
620/// must opt in via [`ServerConfig::load_with_trust`].
621///
622/// An explicitly passed `--config`/`MCPLS_CONFIG` path is unaffected by this
623/// enum and is always trusted: naming a path is itself the user's consent.
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625pub enum ProjectConfigTrust {
626    /// Ignore a CWD-discovered `./mcpls.toml` entirely; fall through to the
627    /// global config tier or built-in defaults.
628    Untrusted,
629    /// Load a CWD-discovered `./mcpls.toml` normally.
630    Trusted,
631}
632
633/// Maximum size, in bytes, of a config file `load_from` will read.
634///
635/// A config file is trusted TOML on a normal setup, but nothing stops a
636/// path from pointing at an arbitrarily large or adversarial file (e.g. a
637/// misconfigured `$MCPLS_CONFIG`) -- `load_from` used to call
638/// `std::fs::read_to_string` with no upper bound, so it could be made to
639/// buffer an unbounded amount of memory before `toml::from_str` ever runs
640/// (#309). 8 MiB is far larger than any legitimate `mcpls.toml`, which
641/// realistically stays in the low kilobytes even with dozens of configured
642/// servers.
643///
644/// Enforced via a bounded read (`Read::take`), not a `std::fs::metadata`
645/// pre-check: `metadata().len()` reports `0` for character devices, FIFOs,
646/// and many procfs entries regardless of how much data they can actually
647/// produce (e.g. `/dev/zero`), so a path pointing at one of those would
648/// sail past a size-only pre-check and still block `read_to_string` on an
649/// effectively infinite read -- the exact "slow/infinite device" case #309
650/// named. A pure metadata check is also TOCTOU-able for a regular file that
651/// grows between the check and the read. Reading `MAX_CONFIG_FILE_BYTES +
652/// 1` bytes, one past the cap, is what distinguishes "exactly at the
653/// boundary" (allowed) from "over" (rejected) without needing a second
654/// syscall.
655const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
656
657/// What a relative [`WorkspaceConfig::roots`] entry resolves against, for
658/// [`ServerConfig::load_from_with_root_base`].
659#[derive(Clone, Copy, Debug, PartialEq, Eq)]
660enum RelativeRootBase {
661    /// Resolve against the directory containing the loaded config file.
662    /// [`ServerConfig::load_from`]'s documented behavior, used for an
663    /// explicitly named config path (including a trusted project-local
664    /// `mcpls.toml` and `$MCPLS_CONFIG`) -- portable when mcpls is launched
665    /// from a different working directory than the config lives in.
666    ConfigDir,
667    /// Resolve against the process's current working directory. Used only
668    /// for the auto-discovered global/user config
669    /// (`~/.config/mcpls/mcpls.toml`), which is not tied to any particular
670    /// project (#348 case 2).
671    Cwd,
672}
673
674impl ServerConfig {
675    /// Build the effective extension map used for language detection.
676    ///
677    /// Starts with workspace mappings and overlays mappings inferred from
678    /// configured LSP server `file_patterns`.
679    #[must_use]
680    pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
681        let mut map = self.workspace.build_extension_map();
682
683        for server in &self.lsp_servers {
684            for pattern in &server.file_patterns {
685                if let Some(ext) = extract_extension_from_pattern(pattern) {
686                    let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
687                    map.insert(ext, language_id);
688                }
689            }
690        }
691
692        map
693    }
694
695    /// Load configuration from the default path, treating a CWD-discovered
696    /// `./mcpls.toml` as untrusted.
697    ///
698    /// Default paths checked in order:
699    /// 1. `$MCPLS_CONFIG` environment variable (always trusted)
700    /// 2. `./mcpls.toml` (current directory) — **skipped**; see
701    ///    [`load_with_trust`](Self::load_with_trust) to opt in
702    /// 3. Platform user-config directory:
703    ///    - Linux: `$XDG_CONFIG_HOME/mcpls/mcpls.toml`, else `~/.config/mcpls/mcpls.toml`
704    ///    - macOS: `~/Library/Application Support/mcpls/mcpls.toml`
705    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
706    ///
707    /// If no configuration file exists, creates a default configuration file
708    /// in the user's config directory with all default language extensions.
709    ///
710    /// This is a thin wrapper around
711    /// [`load_with_trust(ProjectConfigTrust::Untrusted)`](Self::load_with_trust) —
712    /// the safe default for library callers that haven't made a trust
713    /// decision.
714    ///
715    /// # Errors
716    ///
717    /// Returns an error if parsing an existing config fails.
718    /// If config creation fails, returns default config with graceful degradation.
719    pub fn load() -> Result<Self> {
720        Self::load_with_trust(ProjectConfigTrust::Untrusted)
721    }
722
723    /// Load configuration from the default path, with explicit control over
724    /// whether a CWD-discovered `./mcpls.toml` is honored.
725    ///
726    /// Behaves like [`load`](Self::load), except a `./mcpls.toml` found in
727    /// the current directory is only loaded when `trust` is
728    /// [`ProjectConfigTrust::Trusted`]. When untrusted, the file is skipped
729    /// entirely (including its `[workspace]` section) and a warning is
730    /// logged naming the ignored path; discovery falls through to the
731    /// global config tier or built-in defaults, so project-marker
732    /// heuristics (e.g. `Cargo.toml` → rust-analyzer) still apply normally.
733    /// The returned config's [`project_config_ignored`](Self::project_config_ignored)
734    /// is set to `true` in that case, so callers with access to the loaded
735    /// config (e.g. `McplsServer::get_info`) can surface the ignore decision
736    /// in-band, not just via the stderr-only warning.
737    ///
738    /// `$MCPLS_CONFIG` and an explicit path are unaffected by `trust` and
739    /// are always loaded: naming a path is itself the user's consent.
740    ///
741    /// Unlike [`load_from`](Self::load_from)'s documented default (relative
742    /// [`WorkspaceConfig::roots`] resolved against the config file's own
743    /// directory), the global/user config tier
744    /// (`~/.config/mcpls/mcpls.toml`, or the platform equivalent) resolves
745    /// relative roots against the process's current working directory
746    /// instead -- it isn't tied to any particular project, so cwd is the
747    /// more intuitive base (#348).
748    ///
749    /// # Errors
750    ///
751    /// Returns an error if parsing an existing config fails.
752    /// If config creation fails, returns default config with graceful degradation.
753    pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
754        // This `$MCPLS_CONFIG` check is unreachable from the `mcpls` binary:
755        // `crates/mcpls-cli/src/args.rs` already binds `env = "MCPLS_CONFIG"`
756        // to `--config`, so the CLI resolves that variable before `load`/
757        // `load_with_trust` is ever called. It only fires for library
758        // callers that invoke this function directly without going through
759        // `Args`. The actual, CLI-enforced guarantee that `$MCPLS_CONFIG` is
760        // always trusted lives in `main.rs`'s `--config` branch, not here.
761        if let Ok(path) = std::env::var("MCPLS_CONFIG") {
762            return Self::load_from(Path::new(&path));
763        }
764
765        let mut project_config_ignored = false;
766
767        let local_config = PathBuf::from("mcpls.toml");
768        if local_config.exists() {
769            match trust {
770                ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
771                ProjectConfigTrust::Untrusted => {
772                    project_config_ignored = true;
773                    let display_path = local_config.canonicalize().unwrap_or_else(|_| {
774                        std::env::current_dir()
775                            .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
776                    });
777                    tracing::warn!(
778                        "ignoring untrusted project-local config at {}; pass \
779                         --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
780                         load it",
781                        display_path.display()
782                    );
783                }
784            }
785        }
786
787        if let Some(config_dir) = dirs::config_dir() {
788            let user_config = config_dir.join("mcpls").join("mcpls.toml");
789            if user_config.exists() {
790                // The auto-discovered global/user config is not tied to any
791                // particular project, so a relative `workspace.roots` entry
792                // is more intuitively resolved against the process cwd than
793                // against `~/.config/mcpls/` itself (matches pre-#345
794                // behavior; #348 case 2). This differs from `load_from`'s
795                // public default, which resolves against the directory of
796                // an explicitly named config file -- that behavior is kept
797                // unchanged for project-local `mcpls.toml` and `$MCPLS_CONFIG`.
798                let mut config =
799                    Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
800                config.project_config_ignored = project_config_ignored;
801                return Ok(config);
802            }
803
804            // No config found - create default config file
805            if let Err(e) = Self::create_default_config_file(&user_config) {
806                tracing::warn!(
807                    "Failed to create default config at {}: {}. Using in-memory defaults.",
808                    user_config.display(),
809                    e
810                );
811            } else {
812                tracing::info!("Created default config at {}", user_config.display());
813            }
814        }
815
816        // Return default configuration
817        Ok(Self {
818            project_config_ignored,
819            ..Self::default()
820        })
821    }
822
823    /// Load configuration from a specific path.
824    ///
825    /// Relative [`WorkspaceConfig::roots`] are resolved against the directory
826    /// containing `path`, then canonicalized. This keeps an explicitly named
827    /// config portable when mcpls is launched from a different working
828    /// directory.
829    ///
830    /// # Errors
831    ///
832    /// Returns an error if the file doesn't exist, exceeds the maximum
833    /// allowed config file size, or parsing fails.
834    pub fn load_from(path: &Path) -> Result<Self> {
835        Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
836    }
837
838    /// Implements [`load_from`](Self::load_from), parameterized over what a
839    /// relative [`WorkspaceConfig::roots`] entry resolves against.
840    ///
841    /// [`load_with_trust`](Self::load_with_trust) uses
842    /// [`RelativeRootBase::Cwd`] for the auto-discovered global/user config
843    /// (#348 case 2); every other caller (including the public
844    /// [`load_from`](Self::load_from)) uses
845    /// [`RelativeRootBase::ConfigDir`], preserving #345's original behavior.
846    fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
847        let file = std::fs::File::open(path).map_err(|e| {
848            if e.kind() == std::io::ErrorKind::NotFound {
849                Error::ConfigNotFound(path.to_path_buf())
850            } else {
851                Error::Io(e)
852            }
853        })?;
854
855        // Bounded read, not a `metadata().len()` pre-check -- see
856        // `MAX_CONFIG_FILE_BYTES`'s doc for why the pre-check alone is
857        // bypassable.
858        let mut buf = Vec::new();
859        file.take(MAX_CONFIG_FILE_BYTES + 1)
860            .read_to_end(&mut buf)
861            .map_err(Error::Io)?;
862        if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
863            return Err(Error::FileSizeLimitExceeded {
864                size: buf.len() as u64,
865                max: MAX_CONFIG_FILE_BYTES,
866            });
867        }
868        let content = String::from_utf8(buf)
869            .map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
870
871        let mut config: Self = toml::from_str(&content)?;
872        config.validate()?;
873
874        if !config.workspace.roots.is_empty() {
875            config.workspace.roots = if config.workspace.roots.iter().any(|root| root.is_relative())
876            {
877                // A relative root needs an absolute base directory to
878                // resolve against -- compute `config_dir` (and, for `Cwd`,
879                // `current_dir()`) only in this branch: an all-absolute
880                // `workspace.roots` must not fail just because `path` needs
881                // `current_dir()` to become absolute, or because
882                // `config_dir` is unreadable/removed (#348 case 4; mirrors
883                // the analogous `serve_with` fix for case 1).
884                let absolute_config_path = if path.is_absolute() {
885                    path.to_path_buf()
886                } else {
887                    std::env::current_dir().map_err(Error::Io)?.join(path)
888                };
889                let config_dir = absolute_config_path.parent().ok_or_else(|| {
890                    Error::InvalidConfig(format!(
891                        "configuration path has no parent directory: {}",
892                        absolute_config_path.display()
893                    ))
894                })?;
895
896                let base_dir = match relative_root_base {
897                    RelativeRootBase::ConfigDir => {
898                        dunce::canonicalize(config_dir).map_err(|source| {
899                            Error::InvalidConfig(format!(
900                                "configuration directory '{}' could not be canonicalized: {source}",
901                                config_dir.display()
902                            ))
903                        })?
904                    }
905                    RelativeRootBase::Cwd => std::env::current_dir().map_err(Error::Io)?,
906                };
907                crate::resolve_workspace_roots(&config.workspace.roots, &base_dir)?
908            } else {
909                // Every root is absolute already, so no base directory is
910                // ever joined against -- pass an arbitrary placeholder
911                // rather than computing one.
912                crate::canonicalize_workspace_roots(&config.workspace.roots, Path::new(""))?
913            };
914        }
915
916        Ok(config)
917    }
918
919    /// Create a default configuration file with all built-in extensions.
920    ///
921    /// Creates the parent directory if it doesn't exist.
922    ///
923    /// # Errors
924    ///
925    /// Returns an error if directory or file creation fails.
926    fn create_default_config_file(path: &Path) -> Result<()> {
927        if let Some(parent) = path.parent() {
928            std::fs::create_dir_all(parent)?;
929        }
930
931        let default_config = Self::default();
932        let toml_content = toml::to_string_pretty(&default_config)?;
933        std::fs::write(path, toml_content)?;
934
935        Ok(())
936    }
937
938    /// Validate the configuration.
939    ///
940    /// This covers only workspace-*independent* rules — checks that hold
941    /// regardless of which servers end up applicable in a given workspace.
942    /// Workspace-scoped routing rules (duplicate `ServerId`, conflicting
943    /// `handles` claims across applicable servers) are enforced later, by
944    /// `ToolRouter::from_configs` over the post-heuristics config subset in
945    /// `serve_with` — see that function's module docs for why the split
946    /// exists (two servers for one language with mutually exclusive
947    /// `heuristics` is a legitimate config that must still load here).
948    ///
949    /// [`Self::load_from`] always calls this, and so do [`crate::serve`] and
950    /// [`crate::serve_with`] for every `ServerConfig` regardless of origin —
951    /// a caller-constructed config (not loaded via TOML) gets the same
952    /// diagnosable [`Error::InvalidConfig`] rejection as one loaded from
953    /// disk, instead of only failing later via silent accessor-level
954    /// clamping (see [`crate::lsp::LspClient::request_timeout`]). Remains
955    /// `pub` so a caller can also validate a config up front, before handing
956    /// it to `serve`/`serve_with` (which consume it by value and run until
957    /// shutdown).
958    ///
959    /// # Errors
960    ///
961    /// Returns [`Error::InvalidConfig`] on the first rule violated.
962    ///
963    /// # Examples
964    ///
965    /// ```
966    /// use mcpls_core::config::ServerConfig;
967    ///
968    /// let config = ServerConfig::default();
969    /// assert!(config.validate().is_ok());
970    /// ```
971    pub fn validate(&self) -> Result<()> {
972        self.validate_mcp()?;
973
974        if self.workspace.position_encodings.is_empty() {
975            return Err(Error::InvalidConfig(
976                "workspace.position_encodings cannot be empty".to_string(),
977            ));
978        }
979        for encoding in &self.workspace.position_encodings {
980            if parse_position_encoding(encoding).is_none() {
981                return Err(Error::InvalidConfig(format!(
982                    "invalid workspace.position_encodings value '{encoding}'; expected one of \
983                     \"utf-8\", \"utf-16\", \"utf-32\""
984                )));
985            }
986        }
987        // `Path::is_relative()` is `true` for an empty path, and joining it
988        // onto a base directory silently yields that base directory
989        // unchanged rather than the empty string the user presumably meant
990        // to be an accident -- reject it explicitly instead of letting it
991        // pass through workspace-root resolution unnoticed (#348 M4).
992        if self
993            .workspace
994            .roots
995            .iter()
996            .any(|root| root.as_os_str().is_empty())
997        {
998            return Err(Error::InvalidConfig(
999                "workspace.roots entries cannot be empty".to_string(),
1000            ));
1001        }
1002
1003        let mut seen_names: HashMap<&str, &str> = HashMap::new();
1004        for server in &self.lsp_servers {
1005            if server.language_id.is_empty() {
1006                return Err(Error::InvalidConfig(
1007                    "language_id cannot be empty".to_string(),
1008                ));
1009            }
1010            if server.command.is_empty() {
1011                return Err(Error::InvalidConfig(format!(
1012                    "command cannot be empty for language '{}'",
1013                    server.language_id
1014                )));
1015            }
1016            if server.timeout_seconds == 0 {
1017                return Err(Error::InvalidConfig(format!(
1018                    "timeout_seconds cannot be 0 for language '{}'",
1019                    server.language_id
1020                )));
1021            }
1022            if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
1023                return Err(Error::InvalidConfig(format!(
1024                    "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
1025                    server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1026                )));
1027            }
1028            if server.request_timeout_seconds == 0 {
1029                return Err(Error::InvalidConfig(format!(
1030                    "request_timeout_seconds cannot be 0 for language '{}'",
1031                    server.language_id
1032                )));
1033            }
1034            if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
1035                return Err(Error::InvalidConfig(format!(
1036                    "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
1037                     language '{}'",
1038                    server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
1039                )));
1040            }
1041            if let Some(name) = &server.name {
1042                if name.is_empty() {
1043                    return Err(Error::InvalidConfig(format!(
1044                        "name cannot be empty for language '{}' (omit `name` to default to \
1045                         the language id)",
1046                        server.language_id
1047                    )));
1048                }
1049                if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
1050                    // Not a hard error here: whether this is actually ambiguous
1051                    // depends on which of these servers end up applicable in a
1052                    // given workspace, which this function cannot know. The
1053                    // workspace-scoped check in `ToolRouter::from_configs` is
1054                    // authoritative.
1055                    tracing::warn!(
1056                        "duplicate explicit server name '{name}' in config (language ids: \
1057                         '{prev_language}', '{}'); this is only an error if both entries are \
1058                         applicable in the same workspace",
1059                        server.language_id
1060                    );
1061                }
1062            }
1063            if let Some(handles) = &server.handles {
1064                if handles.is_empty() {
1065                    return Err(Error::InvalidConfig(format!(
1066                        "handles cannot be empty for language '{}' (omit `handles` for a \
1067                         catch-all server)",
1068                        server.language_id
1069                    )));
1070                }
1071                let mut seen_tools = HashSet::new();
1072                for tool in handles {
1073                    if !seen_tools.insert(*tool) {
1074                        return Err(Error::InvalidConfig(format!(
1075                            "duplicate tool '{tool}' in `handles` for language '{}'",
1076                            server.language_id
1077                        )));
1078                    }
1079                }
1080            }
1081        }
1082        Ok(())
1083    }
1084
1085    /// Validates the `[mcp]` section: each configured field is rejected if
1086    /// whitespace-only or over its `MAX_MCP_*` byte cap. Split out of
1087    /// [`Self::validate`] to keep that function under clippy's line count
1088    /// threshold.
1089    fn validate_mcp(&self) -> Result<()> {
1090        validate_mcp_field(self.mcp.title.as_deref(), "mcp.title", MAX_MCP_TITLE_BYTES)?;
1091        validate_mcp_field(
1092            self.mcp.description.as_deref(),
1093            "mcp.description",
1094            MAX_MCP_DESCRIPTION_BYTES,
1095        )?;
1096        validate_mcp_field(
1097            self.mcp.instructions.as_deref(),
1098            "mcp.instructions",
1099            MAX_MCP_INSTRUCTIONS_BYTES,
1100        )
1101    }
1102}
1103
1104/// Validates one [`McpConfig`] string field: rejects a whitespace-only value
1105/// before checking length, so `title = "   "` reports "cannot be empty"
1106/// rather than a length error, and caps only the raw configured string --
1107/// text appended later (e.g. the untrusted-project-config NOTE in
1108/// `McplsServer::get_info`) is not part of `value` and is unaffected.
1109fn validate_mcp_field(value: Option<&str>, field: &str, max_bytes: usize) -> Result<()> {
1110    let Some(value) = value else {
1111        return Ok(());
1112    };
1113    if value.trim().is_empty() {
1114        // `rsplit('.').next()` always yields at least one item for any
1115        // input (including one with no '.'), so `unwrap_or(field)` never
1116        // actually falls back for the "mcp.<field>" strings this is called
1117        // with -- kept as a defensive default rather than an `unwrap()`,
1118        // since `clippy::unwrap_used` is a workspace-wide warn-as-error.
1119        return Err(Error::InvalidConfig(format!(
1120            "{field} cannot be empty (omit `{}` from the `[mcp]` section to use the built-in default)",
1121            field.rsplit('.').next().unwrap_or(field)
1122        )));
1123    }
1124    let len = value.len();
1125    if len > max_bytes {
1126        return Err(Error::InvalidConfig(format!(
1127            "{field} exceeds the maximum of {max_bytes} bytes ({len} given)"
1128        )));
1129    }
1130    Ok(())
1131}
1132
1133impl Default for ServerConfig {
1134    fn default() -> Self {
1135        Self {
1136            mcp: McpConfig::default(),
1137            workspace: WorkspaceConfig::default(),
1138            lsp_servers: vec![
1139                LspServerConfig::rust_analyzer(),
1140                LspServerConfig::pyright(),
1141                LspServerConfig::typescript(),
1142                LspServerConfig::gopls(),
1143                LspServerConfig::clangd(),
1144                LspServerConfig::zls(),
1145            ],
1146            project_config_ignored: false,
1147        }
1148    }
1149}
1150
1151#[cfg(test)]
1152#[allow(clippy::unwrap_used)]
1153mod tests {
1154    use std::fs;
1155
1156    use tempfile::TempDir;
1157
1158    use super::*;
1159
1160    fn toml_path_literal(path: &Path) -> String {
1161        toml::Value::String(path.to_string_lossy().into_owned()).to_string()
1162    }
1163
1164    #[test]
1165    fn test_default_config() {
1166        let config = ServerConfig::default();
1167        assert_eq!(config.lsp_servers.len(), 6);
1168        assert_eq!(config.lsp_servers[0].language_id, "rust");
1169        assert_eq!(config.lsp_servers[1].language_id, "python");
1170        assert_eq!(config.lsp_servers[2].language_id, "typescript");
1171        assert_eq!(config.lsp_servers[3].language_id, "go");
1172        assert_eq!(config.lsp_servers[4].language_id, "cpp");
1173        assert_eq!(config.lsp_servers[5].language_id, "zig");
1174        assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
1175    }
1176
1177    #[test]
1178    fn test_default_position_encodings() {
1179        let encodings = default_position_encodings();
1180        assert_eq!(encodings, vec!["utf-8", "utf-16"]);
1181    }
1182
1183    #[test]
1184    fn test_load_from_valid_toml() {
1185        let tmp_dir = TempDir::new().unwrap();
1186        let config_path = tmp_dir.path().join("config.toml");
1187        let workspace_root = tmp_dir.path().join("workspace");
1188        fs::create_dir(&workspace_root).unwrap();
1189        let workspace_root_literal = toml_path_literal(&workspace_root);
1190
1191        let toml_content = format!(
1192            r#"
1193            [workspace]
1194            roots = [{workspace_root_literal}]
1195            position_encodings = ["utf-8"]
1196
1197            [[lsp_servers]]
1198            language_id = "rust"
1199            command = "rust-analyzer"
1200            timeout_seconds = 30
1201        "#
1202        );
1203
1204        fs::write(&config_path, &toml_content).unwrap();
1205
1206        let config = ServerConfig::load_from(&config_path).unwrap();
1207        assert_eq!(
1208            config.workspace.roots,
1209            vec![dunce::canonicalize(workspace_root).unwrap()]
1210        );
1211        assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
1212        assert_eq!(config.lsp_servers.len(), 1);
1213        assert_eq!(config.lsp_servers[0].language_id, "rust");
1214    }
1215
1216    #[test]
1217    fn test_load_from_resolves_relative_roots_against_config_directory() {
1218        let tmp_dir = TempDir::new().unwrap();
1219        let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1220        let config_dir = project_root.join(".agents");
1221        fs::create_dir(&config_dir).unwrap();
1222        let config_path = config_dir.join("mcpls.toml");
1223        fs::write(
1224            &config_path,
1225            r#"
1226                [workspace]
1227                roots = [".", ".."]
1228            "#,
1229        )
1230        .unwrap();
1231
1232        let config = ServerConfig::load_from(&config_path).unwrap();
1233
1234        assert_eq!(config.workspace.roots, vec![config_dir, project_root]);
1235        assert!(config.workspace.roots.iter().all(|root| root.is_absolute()));
1236    }
1237
1238    /// #348 case 2: unlike `load_from`'s `ConfigDir` default (see
1239    /// `test_load_from_resolves_relative_roots_against_config_directory`
1240    /// above), `load_with_trust`'s auto-discovered global/user config uses
1241    /// `RelativeRootBase::Cwd` so a relative root resolves against the
1242    /// process cwd instead of `~/.config/mcpls/`. Exercises the private
1243    /// `load_from_with_root_base` helper directly, since the global config
1244    /// path itself lives under `dirs::config_dir()`, which tests cannot
1245    /// override without mutating process-wide env state (denied by this
1246    /// crate's `unsafe_code` lint).
1247    #[test]
1248    fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1249        let config_tmp_dir = TempDir::new().unwrap();
1250        let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1251        let config_path = config_dir.join("mcpls.toml");
1252        fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1253
1254        let cwd_tmp_dir = TempDir::new().unwrap();
1255        let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1256        let expected_root = cwd.join("relative-root");
1257        fs::create_dir(&expected_root).unwrap();
1258
1259        let config = {
1260            let _guard = CwdGuard::enter(&cwd);
1261            ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1262        };
1263
1264        assert_eq!(config.workspace.roots, vec![expected_root]);
1265    }
1266
1267    #[test]
1268    fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1269        let tmp_dir = TempDir::new().unwrap();
1270        let config_path = tmp_dir.path().join("mcpls.toml");
1271        fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1272
1273        let err = ServerConfig::load_from(&config_path).unwrap_err();
1274
1275        let Error::InvalidConfig(message) = err else {
1276            panic!("expected InvalidConfig, got {err:?}");
1277        };
1278        assert!(message.contains("workspace root 'missing'"));
1279        let config_dir = dunce::canonicalize(tmp_dir.path()).unwrap();
1280        assert!(message.contains(&config_dir.display().to_string()));
1281    }
1282
1283    #[test]
1284    fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1285        // Mirrors the shape of every auto-generated pre-#267 config file:
1286        // `timeout_seconds` present, `request_timeout_seconds` absent.
1287        let tmp_dir = TempDir::new().unwrap();
1288        let config_path = tmp_dir.path().join("config.toml");
1289
1290        let toml_content = r#"
1291            [[lsp_servers]]
1292            language_id = "rust"
1293            command = "rust-analyzer"
1294            timeout_seconds = 30
1295        "#;
1296
1297        fs::write(&config_path, toml_content).unwrap();
1298
1299        let config = ServerConfig::load_from(&config_path).unwrap();
1300        assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
1301    }
1302
1303    #[test]
1304    fn test_validate_rejects_zero_timeout_seconds() {
1305        let tmp_dir = TempDir::new().unwrap();
1306        let config_path = tmp_dir.path().join("config.toml");
1307
1308        let toml_content = r#"
1309            [[lsp_servers]]
1310            language_id = "rust"
1311            command = "rust-analyzer"
1312            timeout_seconds = 0
1313        "#;
1314
1315        fs::write(&config_path, toml_content).unwrap();
1316
1317        let result = ServerConfig::load_from(&config_path);
1318        if let Err(Error::InvalidConfig(msg)) = result {
1319            // `contains("timeout_seconds cannot be 0")` would also match the
1320            // `request_timeout_seconds` message below (it ends in the same
1321            // suffix), so assert the exact message to actually discriminate
1322            // which field triggered the error.
1323            assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
1324        } else {
1325            panic!("Expected InvalidConfig error, got {result:?}");
1326        }
1327    }
1328
1329    #[test]
1330    fn test_validate_rejects_zero_request_timeout_seconds() {
1331        let tmp_dir = TempDir::new().unwrap();
1332        let config_path = tmp_dir.path().join("config.toml");
1333
1334        let toml_content = r#"
1335            [[lsp_servers]]
1336            language_id = "rust"
1337            command = "rust-analyzer"
1338            request_timeout_seconds = 0
1339        "#;
1340
1341        fs::write(&config_path, toml_content).unwrap();
1342
1343        let result = ServerConfig::load_from(&config_path);
1344        if let Err(Error::InvalidConfig(msg)) = result {
1345            assert_eq!(
1346                msg,
1347                "request_timeout_seconds cannot be 0 for language 'rust'"
1348            );
1349        } else {
1350            panic!("Expected InvalidConfig error, got {result:?}");
1351        }
1352    }
1353
1354    #[test]
1355    fn test_validate_rejects_request_timeout_seconds_above_max() {
1356        let tmp_dir = TempDir::new().unwrap();
1357        let config_path = tmp_dir.path().join("config.toml");
1358
1359        let toml_content = format!(
1360            r#"
1361            [[lsp_servers]]
1362            language_id = "rust"
1363            command = "rust-analyzer"
1364            request_timeout_seconds = {}
1365        "#,
1366            MAX_TIMEOUT_SECONDS + 1
1367        );
1368
1369        fs::write(&config_path, toml_content).unwrap();
1370
1371        let result = ServerConfig::load_from(&config_path);
1372        if let Err(Error::InvalidConfig(msg)) = result {
1373            assert!(msg.contains("request_timeout_seconds"));
1374            assert!(msg.contains("exceeds the maximum"));
1375        } else {
1376            panic!("Expected InvalidConfig error, got {result:?}");
1377        }
1378    }
1379
1380    #[test]
1381    fn test_validate_accepts_request_timeout_seconds_at_max() {
1382        let tmp_dir = TempDir::new().unwrap();
1383        let config_path = tmp_dir.path().join("config.toml");
1384
1385        let toml_content = format!(
1386            r#"
1387            [[lsp_servers]]
1388            language_id = "rust"
1389            command = "rust-analyzer"
1390            request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1391        "#
1392        );
1393
1394        fs::write(&config_path, toml_content).unwrap();
1395
1396        let result = ServerConfig::load_from(&config_path);
1397        assert!(result.is_ok(), "expected Ok, got {result:?}");
1398    }
1399
1400    #[test]
1401    fn test_validate_rejects_timeout_seconds_above_max() {
1402        let tmp_dir = TempDir::new().unwrap();
1403        let config_path = tmp_dir.path().join("config.toml");
1404
1405        let toml_content = format!(
1406            r#"
1407            [[lsp_servers]]
1408            language_id = "rust"
1409            command = "rust-analyzer"
1410            timeout_seconds = {}
1411        "#,
1412            MAX_TIMEOUT_SECONDS + 1
1413        );
1414
1415        fs::write(&config_path, toml_content).unwrap();
1416
1417        let result = ServerConfig::load_from(&config_path);
1418        if let Err(Error::InvalidConfig(msg)) = result {
1419            assert!(msg.contains("timeout_seconds"));
1420            assert!(msg.contains("exceeds the maximum"));
1421        } else {
1422            panic!("Expected InvalidConfig error, got {result:?}");
1423        }
1424    }
1425
1426    #[test]
1427    fn test_validate_accepts_timeout_seconds_at_max() {
1428        let tmp_dir = TempDir::new().unwrap();
1429        let config_path = tmp_dir.path().join("config.toml");
1430
1431        let toml_content = format!(
1432            r#"
1433            [[lsp_servers]]
1434            language_id = "rust"
1435            command = "rust-analyzer"
1436            timeout_seconds = {MAX_TIMEOUT_SECONDS}
1437        "#
1438        );
1439
1440        fs::write(&config_path, toml_content).unwrap();
1441
1442        let result = ServerConfig::load_from(&config_path);
1443        assert!(result.is_ok(), "expected Ok, got {result:?}");
1444    }
1445
1446    #[test]
1447    fn test_load_from_nonexistent_file() {
1448        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
1449        assert!(result.is_err());
1450
1451        if let Err(Error::ConfigNotFound(path)) = result {
1452            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
1453        } else {
1454            panic!("Expected ConfigNotFound error");
1455        }
1456    }
1457
1458    #[test]
1459    fn test_load_from_invalid_toml() {
1460        let tmp_dir = TempDir::new().unwrap();
1461        let config_path = tmp_dir.path().join("invalid.toml");
1462
1463        fs::write(&config_path, "invalid toml content {{}").unwrap();
1464
1465        let result = ServerConfig::load_from(&config_path);
1466        assert!(result.is_err());
1467    }
1468
1469    /// #309: a config file larger than `MAX_CONFIG_FILE_BYTES` must be
1470    /// rejected before `read_to_string` buffers it, not merely fail to
1471    /// parse as TOML afterward.
1472    #[test]
1473    fn test_load_from_rejects_oversized_file() {
1474        let tmp_dir = TempDir::new().unwrap();
1475        let config_path = tmp_dir.path().join("oversized.toml");
1476
1477        // One byte over the cap; content doesn't need to be valid TOML since
1478        // the size check runs before parsing.
1479        let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1480        fs::write(&config_path, &oversized).unwrap();
1481
1482        let result = ServerConfig::load_from(&config_path);
1483        assert!(matches!(
1484            result,
1485            Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1486        ));
1487    }
1488
1489    #[test]
1490    fn test_load_from_accepts_file_at_exact_size_cap() {
1491        let tmp_dir = TempDir::new().unwrap();
1492        let config_path = tmp_dir.path().join("exact.toml");
1493
1494        // Pad a valid, minimal TOML document with a trailing comment up to
1495        // exactly the cap -- the boundary itself must not be rejected.
1496        let mut toml_content = "[workspace]\n# ".to_string();
1497        toml_content.push_str(
1498            &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1499        );
1500        assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1501        fs::write(&config_path, &toml_content).unwrap();
1502
1503        let result = ServerConfig::load_from(&config_path);
1504        assert!(result.is_ok(), "expected Ok, got {result:?}");
1505    }
1506
1507    /// #309 S1: `std::fs::metadata` reports `len() == 0` for character
1508    /// devices regardless of how much data they can actually produce --
1509    /// `/dev/zero` is the canonical example. A size check based on metadata
1510    /// alone would pass and let `load_from` block on an effectively
1511    /// infinite read; the bounded `Read::take` must still reject it via
1512    /// `MAX_CONFIG_FILE_BYTES`, not hang or OOM.
1513    #[cfg(unix)]
1514    #[test]
1515    fn test_load_from_rejects_infinite_special_file() {
1516        let path = Path::new("/dev/zero");
1517        assert_eq!(
1518            fs::metadata(path).unwrap().len(),
1519            0,
1520            "test assumption: /dev/zero must report zero length"
1521        );
1522
1523        let result = ServerConfig::load_from(path);
1524        assert!(matches!(
1525            result,
1526            Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1527        ));
1528    }
1529
1530    #[test]
1531    fn test_validate_empty_language_id() {
1532        let tmp_dir = TempDir::new().unwrap();
1533        let config_path = tmp_dir.path().join("config.toml");
1534
1535        let toml_content = r#"
1536            [[lsp_servers]]
1537            language_id = ""
1538            command = "test"
1539        "#;
1540
1541        fs::write(&config_path, toml_content).unwrap();
1542
1543        let result = ServerConfig::load_from(&config_path);
1544        assert!(result.is_err());
1545
1546        if let Err(Error::InvalidConfig(msg)) = result {
1547            assert!(msg.contains("language_id cannot be empty"));
1548        } else {
1549            panic!("Expected InvalidConfig error");
1550        }
1551    }
1552
1553    #[test]
1554    fn test_validate_empty_command() {
1555        let tmp_dir = TempDir::new().unwrap();
1556        let config_path = tmp_dir.path().join("config.toml");
1557
1558        let toml_content = r#"
1559            [[lsp_servers]]
1560            language_id = "rust"
1561            command = ""
1562        "#;
1563
1564        fs::write(&config_path, toml_content).unwrap();
1565
1566        let result = ServerConfig::load_from(&config_path);
1567        assert!(result.is_err());
1568
1569        if let Err(Error::InvalidConfig(msg)) = result {
1570            assert!(msg.contains("command cannot be empty"));
1571        } else {
1572            panic!("Expected InvalidConfig error");
1573        }
1574    }
1575
1576    #[test]
1577    fn test_validate_empty_name() {
1578        let tmp_dir = TempDir::new().unwrap();
1579        let config_path = tmp_dir.path().join("config.toml");
1580
1581        let toml_content = r#"
1582            [[lsp_servers]]
1583            name = ""
1584            language_id = "python"
1585            command = "pyright-langserver"
1586        "#;
1587
1588        fs::write(&config_path, toml_content).unwrap();
1589
1590        let result = ServerConfig::load_from(&config_path);
1591        assert!(result.is_err());
1592
1593        if let Err(Error::InvalidConfig(msg)) = result {
1594            assert!(msg.contains("name cannot be empty"));
1595        } else {
1596            panic!("Expected InvalidConfig error");
1597        }
1598    }
1599
1600    #[test]
1601    fn test_validate_empty_handles() {
1602        let tmp_dir = TempDir::new().unwrap();
1603        let config_path = tmp_dir.path().join("config.toml");
1604
1605        let toml_content = r#"
1606            [[lsp_servers]]
1607            language_id = "python"
1608            command = "pylsp"
1609            handles = []
1610        "#;
1611
1612        fs::write(&config_path, toml_content).unwrap();
1613
1614        let result = ServerConfig::load_from(&config_path);
1615        assert!(result.is_err());
1616
1617        if let Err(Error::InvalidConfig(msg)) = result {
1618            assert!(msg.contains("handles cannot be empty"));
1619        } else {
1620            panic!("Expected InvalidConfig error");
1621        }
1622    }
1623
1624    #[test]
1625    fn test_validate_duplicate_tool_in_handles() {
1626        let tmp_dir = TempDir::new().unwrap();
1627        let config_path = tmp_dir.path().join("config.toml");
1628
1629        let toml_content = r#"
1630            [[lsp_servers]]
1631            language_id = "python"
1632            command = "pylsp"
1633            handles = ["diagnostics", "diagnostics"]
1634        "#;
1635
1636        fs::write(&config_path, toml_content).unwrap();
1637
1638        let result = ServerConfig::load_from(&config_path);
1639        assert!(result.is_err());
1640
1641        if let Err(Error::InvalidConfig(msg)) = result {
1642            assert!(msg.contains("duplicate tool"));
1643            assert!(msg.contains("diagnostics"));
1644        } else {
1645            panic!("Expected InvalidConfig error");
1646        }
1647    }
1648
1649    #[test]
1650    fn test_validate_rejects_empty_position_encodings() {
1651        let tmp_dir = TempDir::new().unwrap();
1652        let config_path = tmp_dir.path().join("config.toml");
1653
1654        let toml_content = r"
1655            [workspace]
1656            position_encodings = []
1657        ";
1658
1659        fs::write(&config_path, toml_content).unwrap();
1660
1661        let result = ServerConfig::load_from(&config_path);
1662        if let Err(Error::InvalidConfig(msg)) = result {
1663            assert_eq!(msg, "workspace.position_encodings cannot be empty");
1664        } else {
1665            panic!("Expected InvalidConfig error, got {result:?}");
1666        }
1667    }
1668
1669    /// #348 M4: `roots = [""]` previously reached workspace-root resolution
1670    /// (an empty path is `is_relative() == true`) and silently resolved to
1671    /// `base_dir` unchanged -- almost certainly not what an empty string in
1672    /// config was meant to express. `validate()` now rejects it outright.
1673    #[test]
1674    fn test_validate_rejects_empty_workspace_root_entry() {
1675        let tmp_dir = TempDir::new().unwrap();
1676        let config_path = tmp_dir.path().join("config.toml");
1677
1678        let toml_content = r#"
1679            [workspace]
1680            roots = [""]
1681        "#;
1682
1683        fs::write(&config_path, toml_content).unwrap();
1684
1685        let result = ServerConfig::load_from(&config_path);
1686        if let Err(Error::InvalidConfig(msg)) = result {
1687            assert_eq!(msg, "workspace.roots entries cannot be empty");
1688        } else {
1689            panic!("Expected InvalidConfig error, got {result:?}");
1690        }
1691    }
1692
1693    #[test]
1694    fn test_validate_rejects_unrecognized_position_encoding() {
1695        let tmp_dir = TempDir::new().unwrap();
1696        let config_path = tmp_dir.path().join("config.toml");
1697
1698        let toml_content = r#"
1699            [workspace]
1700            position_encodings = ["utf-8", "utf-7"]
1701        "#;
1702
1703        fs::write(&config_path, toml_content).unwrap();
1704
1705        let result = ServerConfig::load_from(&config_path);
1706        if let Err(Error::InvalidConfig(msg)) = result {
1707            assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1708        } else {
1709            panic!("Expected InvalidConfig error, got {result:?}");
1710        }
1711    }
1712
1713    #[test]
1714    fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1715        assert_eq!(
1716            parse_position_encoding("utf-8"),
1717            Some(lsp_types::PositionEncodingKind::UTF8)
1718        );
1719        assert_eq!(
1720            parse_position_encoding("utf-16"),
1721            Some(lsp_types::PositionEncodingKind::UTF16)
1722        );
1723        assert_eq!(
1724            parse_position_encoding("utf-32"),
1725            Some(lsp_types::PositionEncodingKind::UTF32)
1726        );
1727        assert_eq!(parse_position_encoding("utf-7"), None);
1728    }
1729
1730    #[test]
1731    fn test_validate_duplicate_name_warns_but_loads() {
1732        // Duplicate explicit `name` is only an error if both entries end up
1733        // applicable in the same workspace (enforced later by
1734        // `ToolRouter::from_configs`, see routing.rs); at load time it must
1735        // still succeed.
1736        let tmp_dir = TempDir::new().unwrap();
1737        let config_path = tmp_dir.path().join("config.toml");
1738
1739        let toml_content = r#"
1740            [[lsp_servers]]
1741            name = "dup"
1742            language_id = "python"
1743            command = "pyright-langserver"
1744
1745            [[lsp_servers]]
1746            name = "dup"
1747            language_id = "typescript"
1748            command = "typescript-language-server"
1749        "#;
1750
1751        fs::write(&config_path, toml_content).unwrap();
1752
1753        let result = ServerConfig::load_from(&config_path);
1754        assert!(result.is_ok(), "duplicate name must only warn at load time");
1755    }
1756
1757    #[test]
1758    fn test_workspace_config_defaults() {
1759        let workspace = WorkspaceConfig::default();
1760        assert!(workspace.roots.is_empty());
1761        assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1762        assert!(!workspace.language_extensions.is_empty());
1763        assert_eq!(workspace.language_extensions.len(), 30);
1764        assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1765    }
1766
1767    #[test]
1768    fn test_load_multiple_servers() {
1769        let tmp_dir = TempDir::new().unwrap();
1770        let config_path = tmp_dir.path().join("multi.toml");
1771
1772        let toml_content = r#"
1773            [[lsp_servers]]
1774            language_id = "rust"
1775            command = "rust-analyzer"
1776
1777            [[lsp_servers]]
1778            language_id = "python"
1779            command = "pyright-langserver"
1780            args = ["--stdio"]
1781        "#;
1782
1783        fs::write(&config_path, toml_content).unwrap();
1784
1785        let config = ServerConfig::load_from(&config_path).unwrap();
1786        assert_eq!(config.lsp_servers.len(), 2);
1787        assert_eq!(config.lsp_servers[0].language_id, "rust");
1788        assert_eq!(config.lsp_servers[1].language_id, "python");
1789        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1790    }
1791
1792    #[test]
1793    fn test_deny_unknown_fields() {
1794        let tmp_dir = TempDir::new().unwrap();
1795        let config_path = tmp_dir.path().join("unknown.toml");
1796
1797        let toml_content = r#"
1798            unknown_field = "value"
1799
1800            [workspace]
1801            roots = []
1802        "#;
1803
1804        fs::write(&config_path, toml_content).unwrap();
1805
1806        let result = ServerConfig::load_from(&config_path);
1807        assert!(result.is_err(), "Should reject unknown fields");
1808    }
1809
1810    #[test]
1811    fn test_empty_config_file() {
1812        let tmp_dir = TempDir::new().unwrap();
1813        let config_path = tmp_dir.path().join("empty.toml");
1814
1815        fs::write(&config_path, "").unwrap();
1816
1817        let config = ServerConfig::load_from(&config_path).unwrap();
1818        assert!(config.workspace.roots.is_empty());
1819        assert!(config.lsp_servers.is_empty());
1820    }
1821
1822    #[test]
1823    fn test_config_with_initialization_options() {
1824        let tmp_dir = TempDir::new().unwrap();
1825        let config_path = tmp_dir.path().join("init_opts.toml");
1826
1827        let toml_content = r#"
1828            [[lsp_servers]]
1829            language_id = "rust"
1830            command = "rust-analyzer"
1831
1832            [lsp_servers.initialization_options]
1833            cargo = { allFeatures = true }
1834        "#;
1835
1836        fs::write(&config_path, toml_content).unwrap();
1837
1838        let config = ServerConfig::load_from(&config_path).unwrap();
1839        assert!(config.lsp_servers[0].initialization_options.is_some());
1840    }
1841
1842    #[test]
1843    fn test_language_extensions_in_config() {
1844        let tmp_dir = TempDir::new().unwrap();
1845        let config_path = tmp_dir.path().join("extensions.toml");
1846
1847        let toml_content = r#"
1848            [[workspace.language_extensions]]
1849            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1850            language_id = "cpp"
1851
1852            [[workspace.language_extensions]]
1853            extensions = ["nu"]
1854            language_id = "nushell"
1855
1856            [[workspace.language_extensions]]
1857            extensions = ["py", "pyw", "pyi"]
1858            language_id = "python"
1859        "#;
1860
1861        fs::write(&config_path, toml_content).unwrap();
1862
1863        let config = ServerConfig::load_from(&config_path).unwrap();
1864        assert_eq!(config.workspace.language_extensions.len(), 3);
1865
1866        // Check C++ extensions
1867        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
1868        assert_eq!(
1869            config.workspace.language_extensions[0].extensions,
1870            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1871        );
1872
1873        // Check Nushell extension
1874        assert_eq!(
1875            config.workspace.language_extensions[1].language_id,
1876            "nushell"
1877        );
1878        assert_eq!(
1879            config.workspace.language_extensions[1].extensions,
1880            vec!["nu"]
1881        );
1882    }
1883
1884    #[test]
1885    fn test_build_extension_map() {
1886        let workspace = WorkspaceConfig {
1887            roots: vec![],
1888            position_encodings: vec![],
1889            language_extensions: vec![
1890                LanguageExtensionMapping {
1891                    extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
1892                    language_id: "cpp".to_string(),
1893                },
1894                LanguageExtensionMapping {
1895                    extensions: vec!["nu".to_string()],
1896                    language_id: "nushell".to_string(),
1897                },
1898            ],
1899            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1900            max_documents: DEFAULT_MAX_DOCUMENTS,
1901            max_file_size: DEFAULT_MAX_FILE_SIZE,
1902        };
1903
1904        let map = workspace.build_extension_map();
1905        assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
1906        assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
1907        assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
1908        assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
1909        assert_eq!(map.get("unknown"), None);
1910    }
1911
1912    #[test]
1913    fn test_extract_extension_from_pattern_empty_string() {
1914        assert_eq!(extract_extension_from_pattern(""), None);
1915    }
1916
1917    #[test]
1918    fn test_extract_extension_from_pattern_without_dot() {
1919        assert_eq!(extract_extension_from_pattern("**/*"), None);
1920    }
1921
1922    #[test]
1923    fn test_extract_extension_from_pattern_dotfile() {
1924        assert_eq!(extract_extension_from_pattern(".gitignore"), None);
1925    }
1926
1927    #[test]
1928    fn test_extract_extension_from_pattern_multi_dot_extension() {
1929        assert_eq!(
1930            extract_extension_from_pattern("foo.tar.gz"),
1931            Some("gz".to_string())
1932        );
1933    }
1934
1935    #[test]
1936    fn test_build_effective_extension_map_overrides_with_file_patterns() {
1937        let config = ServerConfig {
1938            mcp: McpConfig::default(),
1939            workspace: WorkspaceConfig::default(),
1940            lsp_servers: vec![LspServerConfig {
1941                language_id: "cpp".to_string(),
1942                command: "clangd".to_string(),
1943                args: vec![],
1944                env: HashMap::new(),
1945                file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
1946                initialization_options: None,
1947                timeout_seconds: 30,
1948                request_timeout_seconds: 30,
1949                heuristics: None,
1950                name: None,
1951                handles: None,
1952            }],
1953            project_config_ignored: false,
1954        };
1955
1956        let map = config.build_effective_extension_map();
1957        assert_eq!(map.get("c"), Some(&"cpp".to_string()));
1958        assert_eq!(map.get("h"), Some(&"cpp".to_string()));
1959    }
1960
1961    #[test]
1962    fn test_build_effective_extension_map_derives_tsx_language_id() {
1963        let config = ServerConfig {
1964            mcp: McpConfig::default(),
1965            workspace: WorkspaceConfig::default(),
1966            lsp_servers: vec![LspServerConfig {
1967                language_id: "typescript".to_string(),
1968                command: "tsgo".to_string(),
1969                args: vec!["--lsp".to_string(), "--stdio".to_string()],
1970                env: HashMap::new(),
1971                file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
1972                initialization_options: None,
1973                timeout_seconds: 30,
1974                request_timeout_seconds: 30,
1975                heuristics: None,
1976                name: None,
1977                handles: None,
1978            }],
1979            project_config_ignored: false,
1980        };
1981
1982        let map = config.build_effective_extension_map();
1983        assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1984        assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1985    }
1986
1987    #[test]
1988    fn test_build_effective_extension_map_derives_jsx_language_id() {
1989        let config = ServerConfig {
1990            mcp: McpConfig::default(),
1991            workspace: WorkspaceConfig::default(),
1992            lsp_servers: vec![LspServerConfig {
1993                language_id: "javascript".to_string(),
1994                command: "typescript-language-server".to_string(),
1995                args: vec!["--stdio".to_string()],
1996                env: HashMap::new(),
1997                file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1998                initialization_options: None,
1999                timeout_seconds: 30,
2000                request_timeout_seconds: 30,
2001                heuristics: None,
2002                name: None,
2003                handles: None,
2004            }],
2005            project_config_ignored: false,
2006        };
2007
2008        let map = config.build_effective_extension_map();
2009        assert_eq!(map.get("js"), Some(&"javascript".to_string()));
2010        assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
2011    }
2012
2013    #[test]
2014    fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
2015        let config = ServerConfig {
2016            mcp: McpConfig::default(),
2017            workspace: WorkspaceConfig::default(),
2018            lsp_servers: vec![LspServerConfig {
2019                language_id: "cpp".to_string(),
2020                command: "clangd".to_string(),
2021                args: vec![],
2022                env: HashMap::new(),
2023                file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
2024                initialization_options: None,
2025                timeout_seconds: 30,
2026                request_timeout_seconds: 30,
2027                heuristics: None,
2028                name: None,
2029                handles: None,
2030            }],
2031            project_config_ignored: false,
2032        };
2033
2034        let map = config.build_effective_extension_map();
2035        // Default C/C++ mappings remain unchanged when patterns cannot be parsed.
2036        assert_eq!(map.get("h"), Some(&"c".to_string()));
2037    }
2038
2039    #[test]
2040    fn test_language_for_extension() {
2041        let workspace = WorkspaceConfig {
2042            roots: vec![],
2043            position_encodings: vec![],
2044            language_extensions: vec![
2045                LanguageExtensionMapping {
2046                    extensions: vec!["hpp".to_string(), "hh".to_string()],
2047                    language_id: "cpp".to_string(),
2048                },
2049                LanguageExtensionMapping {
2050                    extensions: vec!["py".to_string()],
2051                    language_id: "python".to_string(),
2052                },
2053            ],
2054            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
2055            max_documents: DEFAULT_MAX_DOCUMENTS,
2056            max_file_size: DEFAULT_MAX_FILE_SIZE,
2057        };
2058
2059        assert_eq!(
2060            workspace.language_for_extension("hpp"),
2061            Some("cpp".to_string())
2062        );
2063        assert_eq!(
2064            workspace.language_for_extension("hh"),
2065            Some("cpp".to_string())
2066        );
2067        assert_eq!(
2068            workspace.language_for_extension("py"),
2069            Some("python".to_string())
2070        );
2071        assert_eq!(workspace.language_for_extension("unknown"), None);
2072    }
2073
2074    #[test]
2075    fn test_default_language_extensions() {
2076        let workspace = WorkspaceConfig::default();
2077        let map = workspace.build_extension_map();
2078        assert!(!map.is_empty());
2079        assert_eq!(
2080            workspace.language_for_extension("rs"),
2081            Some("rust".to_string())
2082        );
2083        assert_eq!(
2084            workspace.language_for_extension("py"),
2085            Some("python".to_string())
2086        );
2087        assert_eq!(
2088            workspace.language_for_extension("cpp"),
2089            Some("cpp".to_string())
2090        );
2091    }
2092
2093    #[test]
2094    fn test_create_default_config_file() {
2095        let tmp_dir = TempDir::new().unwrap();
2096        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
2097
2098        ServerConfig::create_default_config_file(&config_path).unwrap();
2099
2100        assert!(config_path.exists());
2101
2102        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
2103        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
2104        assert_eq!(loaded_config.lsp_servers.len(), 6);
2105        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
2106    }
2107
2108    #[test]
2109    fn test_load_returns_default_config() {
2110        // When called directly, default() should return config with all language extensions
2111        let config = ServerConfig::default();
2112        assert_eq!(config.workspace.language_extensions.len(), 30);
2113        assert_eq!(config.lsp_servers.len(), 6);
2114        assert_eq!(config.lsp_servers[0].language_id, "rust");
2115    }
2116
2117    // These tests mutate the process-wide CWD via `set_current_dir`, so they
2118    // use the crate-shared `CwdGuard` (see `crate::test_support`) rather
2119    // than a module-local guard: `lib.rs`'s own tests mutate cwd too, and
2120    // both modules' tests compile into the same binary, so a lock scoped to
2121    // just this module would not prevent a cross-module race under a plain
2122    // `cargo test` (nextest runs each test in its own process, so this only
2123    // matters there).
2124    use crate::test_support::CwdGuard;
2125
2126    /// Precondition for tests that assert on `ServerConfig::load_with_trust`'s
2127    /// CWD-local-file branch: a `$MCPLS_CONFIG` set in the ambient
2128    /// environment makes `load_with_trust` return before ever looking at
2129    /// CWD (see its `MCPLS_CONFIG` branch above), which would otherwise fail
2130    /// the test for a reason unrelated to the code under test.
2131    ///
2132    /// Scrubbing the variable for the test's duration would be the more
2133    /// thorough fix, but `std::env::remove_var`/`set_var` are `unsafe`
2134    /// (mutate process-wide state) and this crate denies `unsafe_code`
2135    /// workspace-wide with no existing exception — so this asserts the
2136    /// precondition instead of silently working around it, turning an
2137    /// environment-dependent false failure into an explicit, legible one.
2138    fn assert_mcpls_config_env_unset() {
2139        assert!(
2140            std::env::var_os("MCPLS_CONFIG").is_none(),
2141            "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2142             load_with_trust returns before consulting CWD when it's set"
2143        );
2144    }
2145
2146    #[test]
2147    fn test_load_ignores_untrusted_project_local_config() {
2148        // `ServerConfig::default()` (what untrusted discovery falls back to
2149        // once neither an untrusted local file nor a global config apply)
2150        // still exposes rust-analyzer via built-in project-marker
2151        // heuristics — see `test_default_config` above, which already
2152        // covers this without any filesystem interaction. This test only
2153        // needs to prove the planted attacker file's content never leaks
2154        // through `load()`.
2155        let tmp_dir = TempDir::new().unwrap();
2156        let config_path = tmp_dir.path().join("mcpls.toml");
2157
2158        // A marker language id / root that cannot collide with either the
2159        // built-in defaults or a machine-local global config, so this
2160        // assertion holds regardless of what `load()` actually falls
2161        // through to (built-in defaults on a clean machine, or the
2162        // machine's own customized global config in CI/dev environments).
2163        let custom_toml = r#"
2164            [workspace]
2165            roots = ["/should-never-load-attacker-path"]
2166
2167            [[lsp_servers]]
2168            language_id = "definitely-not-a-real-language-marker"
2169            command = "rm"
2170            args = ["-rf", "/"]
2171        "#;
2172
2173        fs::write(&config_path, custom_toml).unwrap();
2174
2175        let config = {
2176            let _guard = CwdGuard::enter(tmp_dir.path());
2177            ServerConfig::load().unwrap()
2178        };
2179
2180        assert!(
2181            !config
2182                .workspace
2183                .roots
2184                .contains(&PathBuf::from("/should-never-load-attacker-path"))
2185        );
2186        assert!(
2187            !config
2188                .lsp_servers
2189                .iter()
2190                .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2191        );
2192    }
2193
2194    #[test]
2195    fn test_load_with_trust_loads_trusted_project_local_config() {
2196        let tmp_dir = TempDir::new().unwrap();
2197        let config_path = tmp_dir.path().join("mcpls.toml");
2198        let custom_root = tmp_dir.path().join("custom");
2199        fs::create_dir(&custom_root).unwrap();
2200        let custom_root_literal = toml_path_literal(&custom_root);
2201
2202        let custom_toml = format!(
2203            r#"
2204            [workspace]
2205            roots = [{custom_root_literal}]
2206
2207            [[lsp_servers]]
2208            language_id = "python"
2209            command = "pyright-langserver"
2210        "#
2211        );
2212
2213        fs::write(&config_path, &custom_toml).unwrap();
2214
2215        let config = {
2216            let _guard = CwdGuard::enter(tmp_dir.path());
2217            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2218        };
2219
2220        assert_eq!(
2221            config.workspace.roots,
2222            vec![dunce::canonicalize(custom_root).unwrap()]
2223        );
2224        assert_eq!(config.lsp_servers.len(), 1);
2225        assert_eq!(config.lsp_servers[0].language_id, "python");
2226    }
2227
2228    #[test]
2229    fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2230        let tmp_dir = TempDir::new().unwrap();
2231        let config_path = tmp_dir.path().join("mcpls.toml");
2232
2233        let custom_toml = r#"
2234            [workspace]
2235            roots = ["/attacker/controlled"]
2236            heuristics_max_depth = 999999
2237
2238            [[lsp_servers]]
2239            language_id = "evil"
2240            command = "rm"
2241            args = ["-rf", "/"]
2242        "#;
2243
2244        fs::write(&config_path, custom_toml).unwrap();
2245
2246        let config = {
2247            let _guard = CwdGuard::enter(tmp_dir.path());
2248            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2249        };
2250
2251        assert!(
2252            !config
2253                .workspace
2254                .roots
2255                .contains(&PathBuf::from("/attacker/controlled"))
2256        );
2257        assert_ne!(config.workspace.heuristics_max_depth, 999_999);
2258        assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2259    }
2260
2261    #[test]
2262    fn test_load_with_trust_sets_project_config_ignored_flag() {
2263        assert_mcpls_config_env_unset();
2264
2265        let tmp_dir = TempDir::new().unwrap();
2266        let config_path = tmp_dir.path().join("mcpls.toml");
2267        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2268
2269        let config = {
2270            let _guard = CwdGuard::enter(tmp_dir.path());
2271            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2272        };
2273        assert!(config.project_config_ignored);
2274
2275        let tmp_dir = TempDir::new().unwrap();
2276        let config_path = tmp_dir.path().join("mcpls.toml");
2277        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2278
2279        let config = {
2280            let _guard = CwdGuard::enter(tmp_dir.path());
2281            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2282        };
2283        assert!(!config.project_config_ignored);
2284    }
2285
2286    #[test]
2287    fn test_load_no_local_config_leaves_flag_unset() {
2288        assert_mcpls_config_env_unset();
2289
2290        let tmp_dir = TempDir::new().unwrap();
2291
2292        let config = {
2293            let _guard = CwdGuard::enter(tmp_dir.path());
2294            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2295        };
2296        assert!(!config.project_config_ignored);
2297    }
2298
2299    #[test]
2300    fn test_config_file_creation_with_proper_structure() {
2301        let tmp_dir = TempDir::new().unwrap();
2302        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2303
2304        ServerConfig::create_default_config_file(&config_path).unwrap();
2305
2306        let content = fs::read_to_string(&config_path).unwrap();
2307
2308        assert!(content.contains("[mcp]"));
2309        assert!(content.contains("[workspace]"));
2310        assert!(content.contains("[[workspace.language_extensions]]"));
2311        assert!(content.contains("[[lsp_servers]]"));
2312        assert!(content.contains("language_id = \"rust\""));
2313        assert!(content.contains("extensions = [\"rs\"]"));
2314    }
2315
2316    #[test]
2317    fn test_heuristics_max_depth_default() {
2318        let config = WorkspaceConfig::default();
2319        assert_eq!(config.heuristics_max_depth, 10);
2320    }
2321
2322    #[test]
2323    fn test_heuristics_max_depth_from_config() {
2324        let tmp_dir = TempDir::new().unwrap();
2325        let config_path = tmp_dir.path().join("depth.toml");
2326
2327        let toml_content = r"
2328            [workspace]
2329            heuristics_max_depth = 5
2330        ";
2331
2332        fs::write(&config_path, toml_content).unwrap();
2333
2334        let config = ServerConfig::load_from(&config_path).unwrap();
2335        assert_eq!(config.workspace.heuristics_max_depth, 5);
2336    }
2337
2338    #[test]
2339    fn test_heuristics_max_depth_uses_default_when_not_specified() {
2340        let tmp_dir = TempDir::new().unwrap();
2341        let config_path = tmp_dir.path().join("no_depth.toml");
2342
2343        let toml_content = r"
2344            [workspace]
2345            roots = []
2346        ";
2347
2348        fs::write(&config_path, toml_content).unwrap();
2349
2350        let config = ServerConfig::load_from(&config_path).unwrap();
2351        assert_eq!(
2352            config.workspace.heuristics_max_depth,
2353            DEFAULT_HEURISTICS_MAX_DEPTH
2354        );
2355    }
2356
2357    #[test]
2358    fn test_max_documents_default() {
2359        let config = WorkspaceConfig::default();
2360        assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
2361    }
2362
2363    #[test]
2364    fn test_max_file_size_default() {
2365        let config = WorkspaceConfig::default();
2366        assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
2367    }
2368
2369    #[test]
2370    fn test_max_documents_from_config() {
2371        let tmp_dir = TempDir::new().unwrap();
2372        let config_path = tmp_dir.path().join("limits.toml");
2373
2374        let toml_content = r"
2375            [workspace]
2376            max_documents = 500
2377        ";
2378
2379        fs::write(&config_path, toml_content).unwrap();
2380
2381        let config = ServerConfig::load_from(&config_path).unwrap();
2382        assert_eq!(config.workspace.max_documents, 500);
2383    }
2384
2385    #[test]
2386    fn test_max_file_size_from_config() {
2387        let tmp_dir = TempDir::new().unwrap();
2388        let config_path = tmp_dir.path().join("limits.toml");
2389
2390        let toml_content = r"
2391            [workspace]
2392            max_file_size = 20971520
2393        ";
2394
2395        fs::write(&config_path, toml_content).unwrap();
2396
2397        let config = ServerConfig::load_from(&config_path).unwrap();
2398        assert_eq!(config.workspace.max_file_size, 20_971_520);
2399    }
2400
2401    #[test]
2402    fn test_max_documents_uses_default_when_not_specified() {
2403        let tmp_dir = TempDir::new().unwrap();
2404        let config_path = tmp_dir.path().join("no_limits.toml");
2405
2406        let toml_content = r"
2407            [workspace]
2408            roots = []
2409        ";
2410
2411        fs::write(&config_path, toml_content).unwrap();
2412
2413        let config = ServerConfig::load_from(&config_path).unwrap();
2414        assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
2415        assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
2416    }
2417
2418    /// `max_file_size = 0` is the documented "unlimited" sentinel (see
2419    /// `ResourceLimits::max_file_size`'s doc comment); config loading must
2420    /// pass it through unchanged rather than treating `0` as "unset".
2421    #[test]
2422    fn test_max_file_size_zero_means_unlimited() {
2423        let tmp_dir = TempDir::new().unwrap();
2424        let config_path = tmp_dir.path().join("unlimited.toml");
2425
2426        let toml_content = r"
2427            [workspace]
2428            max_file_size = 0
2429        ";
2430
2431        fs::write(&config_path, toml_content).unwrap();
2432
2433        let config = ServerConfig::load_from(&config_path).unwrap();
2434        assert_eq!(config.workspace.max_file_size, 0);
2435        assert_eq!(config.workspace.resource_limits().max_file_size, 0);
2436    }
2437
2438    /// #325: `max_documents` is `usize`, so a negative TOML integer must fail
2439    /// `toml::from_str` deserialization cleanly (`Err`), not panic.
2440    #[test]
2441    fn test_max_documents_rejects_negative_value() {
2442        let tmp_dir = TempDir::new().unwrap();
2443        let config_path = tmp_dir.path().join("negative_max_documents.toml");
2444
2445        let toml_content = r"
2446            [workspace]
2447            max_documents = -1
2448        ";
2449
2450        fs::write(&config_path, toml_content).unwrap();
2451
2452        let result = ServerConfig::load_from(&config_path);
2453        // Asserting on the message text (not just the `TomlDe` variant) rules
2454        // out passing for the wrong reason -- `WorkspaceConfig` also has
2455        // `#[serde(deny_unknown_fields)]`, which produces the same variant
2456        // for an unrelated typo'd field name.
2457        if let Err(Error::TomlDe(e)) = &result {
2458            let msg = e.to_string();
2459            assert!(
2460                msg.contains("-1") && msg.contains("usize"),
2461                "expected a type-mismatch message naming the offending value and the \
2462                 expected type, got: {msg}"
2463            );
2464        } else {
2465            panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2466        }
2467    }
2468
2469    /// #325: `max_file_size` is `u64`, so a string value must fail
2470    /// `toml::from_str` deserialization cleanly (`Err`), not panic.
2471    #[test]
2472    fn test_max_file_size_rejects_string_value() {
2473        let tmp_dir = TempDir::new().unwrap();
2474        let config_path = tmp_dir.path().join("string_max_file_size.toml");
2475
2476        let toml_content = r#"
2477            [workspace]
2478            max_file_size = "10MB"
2479        "#;
2480
2481        fs::write(&config_path, toml_content).unwrap();
2482
2483        let result = ServerConfig::load_from(&config_path);
2484        if let Err(Error::TomlDe(e)) = &result {
2485            let msg = e.to_string();
2486            assert!(
2487                msg.contains("10MB") && msg.contains("u64"),
2488                "expected a type-mismatch message naming the offending value and the \
2489                 expected type, got: {msg}"
2490            );
2491        } else {
2492            panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2493        }
2494    }
2495
2496    #[test]
2497    fn test_workspace_config_resource_limits_maps_fields() {
2498        let workspace = WorkspaceConfig {
2499            max_documents: 250,
2500            max_file_size: 0,
2501            ..WorkspaceConfig::default()
2502        };
2503
2504        let limits = workspace.resource_limits();
2505        assert_eq!(limits.max_documents, 250);
2506        assert_eq!(limits.max_file_size, 0);
2507    }
2508
2509    #[test]
2510    fn test_workspace_config_toml_round_trip() {
2511        let original = WorkspaceConfig {
2512            roots: vec![PathBuf::from("/tmp/round-trip")],
2513            position_encodings: vec!["utf-8".to_string()],
2514            language_extensions: vec![LanguageExtensionMapping {
2515                extensions: vec!["nu".to_string()],
2516                language_id: "nushell".to_string(),
2517            }],
2518            heuristics_max_depth: 5,
2519            max_documents: 500,
2520            max_file_size: 0,
2521        };
2522
2523        let toml_content = toml::to_string_pretty(&original).unwrap();
2524        let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2525
2526        assert_eq!(round_tripped.roots, original.roots);
2527        assert_eq!(
2528            round_tripped.position_encodings,
2529            original.position_encodings
2530        );
2531        assert_eq!(
2532            round_tripped.language_extensions.len(),
2533            original.language_extensions.len()
2534        );
2535        assert_eq!(
2536            round_tripped.language_extensions[0].extensions,
2537            original.language_extensions[0].extensions
2538        );
2539        assert_eq!(
2540            round_tripped.language_extensions[0].language_id,
2541            original.language_extensions[0].language_id
2542        );
2543        assert_eq!(
2544            round_tripped.heuristics_max_depth,
2545            original.heuristics_max_depth
2546        );
2547        assert_eq!(round_tripped.max_documents, original.max_documents);
2548        assert_eq!(round_tripped.max_file_size, original.max_file_size);
2549    }
2550
2551    #[test]
2552    fn test_mcp_config_parses_from_toml_section() {
2553        let tmp_dir = TempDir::new().unwrap();
2554        let config_path = tmp_dir.path().join("config.toml");
2555
2556        let toml_content = r#"
2557            [mcp]
2558            title = "Custom Title"
2559            description = "Custom description"
2560            instructions = "Custom instructions."
2561        "#;
2562
2563        fs::write(&config_path, toml_content).unwrap();
2564
2565        let config = ServerConfig::load_from(&config_path).unwrap();
2566        assert_eq!(config.mcp.title.as_deref(), Some("Custom Title"));
2567        assert_eq!(
2568            config.mcp.description.as_deref(),
2569            Some("Custom description")
2570        );
2571        assert_eq!(
2572            config.mcp.instructions.as_deref(),
2573            Some("Custom instructions.")
2574        );
2575    }
2576
2577    #[test]
2578    fn test_mcp_config_defaults_to_none_when_section_absent() {
2579        let tmp_dir = TempDir::new().unwrap();
2580        let config_path = tmp_dir.path().join("config.toml");
2581
2582        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2583
2584        let config = ServerConfig::load_from(&config_path).unwrap();
2585        assert_eq!(config.mcp.title, None);
2586        assert_eq!(config.mcp.description, None);
2587        assert_eq!(config.mcp.instructions, None);
2588    }
2589
2590    #[test]
2591    fn test_mcp_config_rejects_unknown_field() {
2592        let tmp_dir = TempDir::new().unwrap();
2593        let config_path = tmp_dir.path().join("config.toml");
2594
2595        fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
2596
2597        let result = ServerConfig::load_from(&config_path);
2598        assert!(matches!(result, Err(Error::TomlDe(_))));
2599    }
2600
2601    #[test]
2602    fn test_validate_rejects_empty_mcp_title() {
2603        let tmp_dir = TempDir::new().unwrap();
2604        let config_path = tmp_dir.path().join("config.toml");
2605
2606        fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
2607
2608        let result = ServerConfig::load_from(&config_path);
2609        if let Err(Error::InvalidConfig(msg)) = result {
2610            assert_eq!(
2611                msg,
2612                "mcp.title cannot be empty (omit `title` from the `[mcp]` section to use the \
2613                 built-in default)"
2614            );
2615        } else {
2616            panic!("Expected InvalidConfig error, got {result:?}");
2617        }
2618    }
2619
2620    /// A whitespace-only value must report as *empty*, not over-length --
2621    /// the trim-empty check must run before the length check.
2622    #[test]
2623    fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
2624        let tmp_dir = TempDir::new().unwrap();
2625        let config_path = tmp_dir.path().join("config.toml");
2626
2627        fs::write(&config_path, "[mcp]\ntitle = \"   \"\n").unwrap();
2628
2629        let result = ServerConfig::load_from(&config_path);
2630        if let Err(Error::InvalidConfig(msg)) = result {
2631            assert!(msg.contains("cannot be empty"));
2632        } else {
2633            panic!("Expected InvalidConfig error, got {result:?}");
2634        }
2635    }
2636
2637    #[test]
2638    fn test_validate_rejects_empty_mcp_description() {
2639        let tmp_dir = TempDir::new().unwrap();
2640        let config_path = tmp_dir.path().join("config.toml");
2641
2642        fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
2643
2644        let result = ServerConfig::load_from(&config_path);
2645        if let Err(Error::InvalidConfig(msg)) = result {
2646            assert_eq!(
2647                msg,
2648                "mcp.description cannot be empty (omit `description` from the `[mcp]` section \
2649                 to use the built-in default)"
2650            );
2651        } else {
2652            panic!("Expected InvalidConfig error, got {result:?}");
2653        }
2654    }
2655
2656    #[test]
2657    fn test_validate_rejects_empty_mcp_instructions() {
2658        let tmp_dir = TempDir::new().unwrap();
2659        let config_path = tmp_dir.path().join("config.toml");
2660
2661        fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
2662
2663        let result = ServerConfig::load_from(&config_path);
2664        if let Err(Error::InvalidConfig(msg)) = result {
2665            assert_eq!(
2666                msg,
2667                "mcp.instructions cannot be empty (omit `instructions` from the `[mcp]` \
2668                 section to use the built-in default)"
2669            );
2670        } else {
2671            panic!("Expected InvalidConfig error, got {result:?}");
2672        }
2673    }
2674
2675    #[test]
2676    fn test_validate_rejects_over_length_mcp_title() {
2677        let tmp_dir = TempDir::new().unwrap();
2678        let config_path = tmp_dir.path().join("config.toml");
2679
2680        let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
2681        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2682
2683        let result = ServerConfig::load_from(&config_path);
2684        if let Err(Error::InvalidConfig(msg)) = result {
2685            assert_eq!(
2686                msg,
2687                format!(
2688                    "mcp.title exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
2689                    MAX_MCP_TITLE_BYTES + 1
2690                )
2691            );
2692        } else {
2693            panic!("Expected InvalidConfig error, got {result:?}");
2694        }
2695    }
2696
2697    #[test]
2698    fn test_validate_accepts_mcp_title_at_exact_cap() {
2699        let tmp_dir = TempDir::new().unwrap();
2700        let config_path = tmp_dir.path().join("config.toml");
2701
2702        let title = "a".repeat(MAX_MCP_TITLE_BYTES);
2703        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2704
2705        let result = ServerConfig::load_from(&config_path);
2706        assert!(result.is_ok(), "expected Ok, got {result:?}");
2707    }
2708
2709    #[test]
2710    fn test_validate_rejects_over_length_mcp_description() {
2711        let tmp_dir = TempDir::new().unwrap();
2712        let config_path = tmp_dir.path().join("config.toml");
2713
2714        let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
2715        fs::write(
2716            &config_path,
2717            format!("[mcp]\ndescription = \"{description}\"\n"),
2718        )
2719        .unwrap();
2720
2721        let result = ServerConfig::load_from(&config_path);
2722        if let Err(Error::InvalidConfig(msg)) = result {
2723            assert!(msg.contains("mcp.description exceeds the maximum"));
2724            assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
2725        } else {
2726            panic!("Expected InvalidConfig error, got {result:?}");
2727        }
2728    }
2729
2730    #[test]
2731    fn test_validate_accepts_mcp_description_at_exact_cap() {
2732        let tmp_dir = TempDir::new().unwrap();
2733        let config_path = tmp_dir.path().join("config.toml");
2734
2735        let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
2736        fs::write(
2737            &config_path,
2738            format!("[mcp]\ndescription = \"{description}\"\n"),
2739        )
2740        .unwrap();
2741
2742        let result = ServerConfig::load_from(&config_path);
2743        assert!(result.is_ok(), "expected Ok, got {result:?}");
2744    }
2745
2746    /// Pins the documented "bytes, not chars" contract: `é` is 1 char but 2
2747    /// UTF-8 bytes, so a naive `.chars().count()` cap would accept both
2748    /// cases below. `MAX_MCP_TITLE_BYTES` (128) is even, so 64 `é`s land
2749    /// exactly at the byte cap and 65 land one byte over it.
2750    #[test]
2751    fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
2752        let tmp_dir = TempDir::new().unwrap();
2753        let config_path = tmp_dir.path().join("config.toml");
2754
2755        let title = "é".repeat(65);
2756        assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
2757        assert_eq!(title.chars().count(), 65);
2758        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2759
2760        let result = ServerConfig::load_from(&config_path);
2761        if let Err(Error::InvalidConfig(msg)) = result {
2762            assert!(msg.contains("mcp.title exceeds the maximum"));
2763        } else {
2764            panic!("Expected InvalidConfig error, got {result:?}");
2765        }
2766    }
2767
2768    #[test]
2769    fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
2770        let tmp_dir = TempDir::new().unwrap();
2771        let config_path = tmp_dir.path().join("config.toml");
2772
2773        let title = "é".repeat(64);
2774        assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
2775        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
2776
2777        let result = ServerConfig::load_from(&config_path);
2778        assert!(result.is_ok(), "expected Ok, got {result:?}");
2779    }
2780
2781    #[test]
2782    fn test_validate_rejects_over_length_mcp_instructions() {
2783        let tmp_dir = TempDir::new().unwrap();
2784        let config_path = tmp_dir.path().join("config.toml");
2785
2786        let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
2787        fs::write(
2788            &config_path,
2789            format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2790        )
2791        .unwrap();
2792
2793        let result = ServerConfig::load_from(&config_path);
2794        if let Err(Error::InvalidConfig(msg)) = result {
2795            assert!(msg.contains("mcp.instructions exceeds the maximum"));
2796            assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
2797        } else {
2798            panic!("Expected InvalidConfig error, got {result:?}");
2799        }
2800    }
2801
2802    #[test]
2803    fn test_validate_accepts_mcp_instructions_at_exact_cap() {
2804        let tmp_dir = TempDir::new().unwrap();
2805        let config_path = tmp_dir.path().join("config.toml");
2806
2807        let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
2808        fs::write(
2809            &config_path,
2810            format!("[mcp]\ninstructions = \"{instructions}\"\n"),
2811        )
2812        .unwrap();
2813
2814        let result = ServerConfig::load_from(&config_path);
2815        assert!(result.is_ok(), "expected Ok, got {result:?}");
2816    }
2817
2818    // ------------------------------------------------------------------
2819    // ToolPrefix tests
2820    // ------------------------------------------------------------------
2821
2822    #[test]
2823    fn test_tool_prefix_accepts_valid_and_round_trips() {
2824        let tmp_dir = TempDir::new().unwrap();
2825        let config_path = tmp_dir.path().join("config.toml");
2826        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
2827
2828        let config = ServerConfig::load_from(&config_path).unwrap();
2829        assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
2830
2831        let serialized = toml::to_string_pretty(&config).unwrap();
2832        let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
2833        assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
2834    }
2835
2836    #[test]
2837    fn test_tool_prefix_accepts_digits_and_mixed_case() {
2838        let prefix: ToolPrefix = "Optics2".parse().unwrap();
2839        assert_eq!(prefix.as_str(), "Optics2");
2840    }
2841
2842    #[test]
2843    fn test_tool_prefix_accepts_single_alphanumeric_char() {
2844        assert!("x".parse::<ToolPrefix>().is_ok());
2845        assert!("9".parse::<ToolPrefix>().is_ok());
2846    }
2847
2848    #[test]
2849    fn test_tool_prefix_rejects_empty() {
2850        let err = "".parse::<ToolPrefix>().unwrap_err();
2851        assert_eq!(
2852            err,
2853            "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
2854             use unprefixed tool names)"
2855        );
2856    }
2857
2858    #[test]
2859    fn test_tool_prefix_rejects_whitespace_only() {
2860        let err = "   ".parse::<ToolPrefix>().unwrap_err();
2861        assert!(err.contains("cannot be empty"));
2862    }
2863
2864    #[test]
2865    fn test_tool_prefix_rejects_leading_separator() {
2866        for bad in ["_optics", "-optics"] {
2867            let err = bad.parse::<ToolPrefix>().unwrap_err();
2868            assert!(
2869                err.contains("cannot start with"),
2870                "for input {bad:?}: {err}"
2871            );
2872        }
2873    }
2874
2875    #[test]
2876    fn test_tool_prefix_rejects_trailing_separator() {
2877        for bad in ["optics_", "optics-"] {
2878            let err = bad.parse::<ToolPrefix>().unwrap_err();
2879            assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
2880            assert!(err.contains("inserted automatically"));
2881        }
2882    }
2883
2884    #[test]
2885    fn test_tool_prefix_rejects_dot() {
2886        let err = "op.tics".parse::<ToolPrefix>().unwrap_err();
2887        assert!(err.contains("invalid character '.'"));
2888    }
2889
2890    #[test]
2891    fn test_tool_prefix_rejects_space() {
2892        let err = "op tics".parse::<ToolPrefix>().unwrap_err();
2893        assert!(err.contains("invalid character ' '"));
2894    }
2895
2896    #[test]
2897    fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
2898        let err = "optiсs".parse::<ToolPrefix>().unwrap_err();
2899        assert!(err.contains("invalid character 'с'"), "{err}");
2900    }
2901
2902    #[test]
2903    fn test_tool_prefix_rejects_over_length() {
2904        let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
2905        let err = prefix.parse::<ToolPrefix>().unwrap_err();
2906        assert!(err.contains(&format!(
2907            "exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes"
2908        )));
2909    }
2910
2911    #[test]
2912    fn test_tool_prefix_accepts_exact_length_cap() {
2913        let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
2914        assert!(prefix.parse::<ToolPrefix>().is_ok());
2915    }
2916
2917    #[test]
2918    fn test_tool_prefix_from_str_shares_config_validator() {
2919        // Proves the programmatic path (`ToolPrefix::from_str`, used by
2920        // `serve_with` callers that build `McpConfig` directly) is held to
2921        // the same rules as the TOML deserialization path, not a separate,
2922        // looser check.
2923        assert!("optics_".parse::<ToolPrefix>().is_err());
2924        let tmp_dir = TempDir::new().unwrap();
2925        let config_path = tmp_dir.path().join("config.toml");
2926        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
2927        assert!(matches!(
2928            ServerConfig::load_from(&config_path),
2929            Err(Error::TomlDe(_))
2930        ));
2931    }
2932
2933    /// Pins the actual behavior of a `serde::de::Error::custom` raised from
2934    /// inside `ToolPrefix`'s hand-written `Deserialize` impl, since the
2935    /// `toml` crate's line-reference attachment for such errors is not
2936    /// guaranteed by its public API and must be verified empirically rather
2937    /// than assumed (see [`ToolPrefix`]'s doc comment on the `Error::TomlDe`
2938    /// vs `Error::InvalidConfig` trade-off).
2939    #[test]
2940    fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
2941        let tmp_dir = TempDir::new().unwrap();
2942        let config_path = tmp_dir.path().join("config.toml");
2943        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
2944
2945        let result = ServerConfig::load_from(&config_path);
2946        let Err(Error::TomlDe(err)) = result else {
2947            panic!("Expected TomlDe error, got {result:?}");
2948        };
2949        let msg = err.to_string();
2950        assert!(msg.contains("mcp.tool_prefix"), "{msg}");
2951        assert!(msg.contains("cannot end with"), "{msg}");
2952        // Empirically confirmed (not merely assumed): `toml` attaches a
2953        // "line N, column M" reference to a `serde::de::Error::custom`
2954        // raised from within a field's `Deserialize` impl, giving this
2955        // error strictly more location information than the sibling
2956        // `Error::InvalidConfig` fields (`title`/`description`/
2957        // `instructions`) get.
2958        assert!(msg.contains("line 2"), "{msg}");
2959    }
2960}