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