Skip to main content

mcpls_core/config/
mod.rs

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