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    /// Workspace configuration.
41    #[serde(default)]
42    pub workspace: WorkspaceConfig,
43
44    /// LSP server configurations.
45    #[serde(default)]
46    pub lsp_servers: Vec<LspServerConfig>,
47
48    /// Whether a CWD-discovered `./mcpls.toml` was ignored as untrusted
49    /// during this load (see [`ProjectConfigTrust`]).
50    ///
51    /// Load-time metadata, not user-configurable: never read from or written
52    /// to a TOML file. Consumed by `McplsServer::get_info` (the
53    /// `ServerHandler` implementation in `crate::mcp::server`) to surface
54    /// the ignore decision in-band to MCP clients, supplementing the
55    /// `tracing::warn!` emitted at load time (which is stderr-only and
56    /// typically invisible to an MCP client).
57    #[serde(skip)]
58    pub project_config_ignored: bool,
59}
60
61/// Workspace-level configuration.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct WorkspaceConfig {
65    /// Root directories for the workspace.
66    #[serde(default)]
67    pub roots: Vec<PathBuf>,
68
69    /// Position encoding preference order, offered to each spawned LSP
70    /// server as `capabilities.general.positionEncodings` during the
71    /// `initialize` handshake (see [`crate::lsp::LspServer::spawn`]), in the
72    /// order configured here.
73    ///
74    /// Valid values: `"utf-8"`, `"utf-16"`, `"utf-32"`. Must be non-empty;
75    /// [`ServerConfig::validate`] rejects an empty list or an unrecognized
76    /// value.
77    #[serde(default = "default_position_encodings")]
78    pub position_encodings: Vec<String>,
79
80    /// File extension to language ID mappings.
81    /// Allows users to customize which file extensions map to which language servers.
82    #[serde(default)]
83    pub language_extensions: Vec<LanguageExtensionMapping>,
84
85    /// Maximum depth for recursive project marker search.
86    /// Controls how deeply nested projects can be detected.
87    /// Default: 10
88    #[serde(default = "default_heuristics_max_depth")]
89    pub heuristics_max_depth: usize,
90
91    /// Maximum number of documents `DocumentTracker` will keep open
92    /// simultaneously. A `textDocument/didOpen`-triggering tool call (hover,
93    /// definition, diagnostics, etc.) for a document beyond this count fails
94    /// with `DocumentLimitExceeded`. Documents stay tracked for the whole
95    /// mcpls process lifetime (there is no eviction), so once the ceiling is
96    /// reached, opening any further new path fails until either the process
97    /// is restarted or this limit is raised; already-tracked paths are
98    /// unaffected. `0` disables the limit.
99    /// Default: 100
100    #[serde(default = "default_max_documents")]
101    pub max_documents: usize,
102
103    /// Maximum size, in bytes, of a single file `DocumentTracker` will open.
104    /// A file larger than this fails with `FileSizeLimitExceeded`. `0`
105    /// disables the limit.
106    /// Default: 10485760 (10MB)
107    #[serde(default = "default_max_file_size")]
108    pub max_file_size: u64,
109}
110
111impl Default for WorkspaceConfig {
112    fn default() -> Self {
113        Self {
114            roots: Vec::new(),
115            position_encodings: default_position_encodings(),
116            language_extensions: default_language_extensions(),
117            heuristics_max_depth: default_heuristics_max_depth(),
118            max_documents: default_max_documents(),
119            max_file_size: default_max_file_size(),
120        }
121    }
122}
123
124const fn default_heuristics_max_depth() -> usize {
125    DEFAULT_HEURISTICS_MAX_DEPTH
126}
127
128const fn default_max_documents() -> usize {
129    DEFAULT_MAX_DOCUMENTS
130}
131
132const fn default_max_file_size() -> u64 {
133    DEFAULT_MAX_FILE_SIZE
134}
135
136impl WorkspaceConfig {
137    /// Build a map of file extensions to language IDs from the configuration.
138    ///
139    /// # Returns
140    ///
141    /// A `HashMap` where keys are file extensions (without the dot) and values
142    /// are the corresponding language IDs to report to LSP servers.
143    #[must_use]
144    pub fn build_extension_map(&self) -> HashMap<String, String> {
145        let mut map = HashMap::new();
146        for mapping in &self.language_extensions {
147            for ext in &mapping.extensions {
148                map.insert(ext.clone(), mapping.language_id.clone());
149            }
150        }
151        map
152    }
153
154    /// Get the language ID for a file extension.
155    ///
156    /// # Arguments
157    ///
158    /// * `extension` - The file extension (without the dot)
159    ///
160    /// # Returns
161    ///
162    /// The language ID if found, `None` otherwise.
163    #[must_use]
164    pub fn get_language_for_extension(&self, extension: &str) -> Option<String> {
165        for mapping in &self.language_extensions {
166            if mapping.extensions.contains(&extension.to_string()) {
167                return Some(mapping.language_id.clone());
168            }
169        }
170        None
171    }
172
173    /// Maps the configured `max_documents`/`max_file_size` onto the bridge
174    /// layer's [`ResourceLimits`], for [`Translator::with_resource_limits`](crate::bridge::Translator::with_resource_limits).
175    #[must_use]
176    pub const fn resource_limits(&self) -> ResourceLimits {
177        ResourceLimits {
178            max_documents: self.max_documents,
179            max_file_size: self.max_file_size,
180        }
181    }
182}
183
184/// Extract a file extension from a glob-like file pattern.
185///
186/// Supports common patterns such as `**/*.rs` and `*.h`.
187/// Returns `None` for patterns without a simple trailing extension.
188fn extract_extension_from_pattern(pattern: &str) -> Option<String> {
189    let basename = pattern.rsplit('/').next().unwrap_or(pattern);
190    if basename.starts_with('.') {
191        return None;
192    }
193
194    let (_, ext) = basename.rsplit_once('.')?;
195    if ext.is_empty() {
196        return None;
197    }
198
199    // Keep this conservative: only accept plain extension-like tokens.
200    if ext
201        .chars()
202        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
203    {
204        Some(ext.to_string())
205    } else {
206        None
207    }
208}
209
210fn language_id_for_pattern_extension(server_language_id: &str, extension: &str) -> String {
211    react_variant_language_id(server_language_id, extension)
212        .unwrap_or(server_language_id)
213        .to_string()
214}
215
216/// The client-preference order offered to every spawned server during
217/// `initialize`.
218///
219/// `utf-8` is listed first deliberately, not just historically: probing both
220/// rust-analyzer and clangd (this project's two flagship servers) against
221/// exactly this offer shows both negotiate down to `utf-8`, so it is the
222/// common case, not a rare fallback. Earlier revisions of this file
223/// (`#290`/`#291`) treated the non-UTF-16 conversion path in
224/// `bridge/encoding.rs` as an edge case on that (false) assumption, which
225/// hid a char-boundary panic and an uncached-disk-read cost on what turned
226/// out to be the default path for both servers. Both are now fixed
227/// (`bridge/encoding.rs`'s boundary guards; `bridge/translator.rs`'s
228/// `EncodingCtx` preferring `DocumentTracker`'s in-memory content over
229/// disk), so there is no longer a correctness or performance reason to
230/// prefer `utf-16` here -- reordering would only reintroduce UTF-16 by
231/// default bias, undoing the point of negotiating an encoding at all.
232pub(crate) fn default_position_encodings() -> Vec<String> {
233    vec!["utf-8".to_string(), "utf-16".to_string()]
234}
235
236/// Parse a configured position-encoding string into an [`lsp_types::PositionEncodingKind`].
237///
238/// Recognizes the three values the LSP spec defines for
239/// `PositionEncodingKind`: `"utf-8"`, `"utf-16"`, `"utf-32"`. Returns `None`
240/// for anything else, letting the caller decide how to handle an invalid
241/// value (see [`ServerConfig::validate`], which rejects it at load time, and
242/// [`crate::lsp::LspServer::spawn`], which falls back to a default rather
243/// than failing the handshake for a config built without going through
244/// `validate`).
245pub(crate) fn parse_position_encoding(value: &str) -> Option<lsp_types::PositionEncodingKind> {
246    match value {
247        "utf-8" => Some(lsp_types::PositionEncodingKind::UTF8),
248        "utf-16" => Some(lsp_types::PositionEncodingKind::UTF16),
249        "utf-32" => Some(lsp_types::PositionEncodingKind::UTF32),
250        _ => None,
251    }
252}
253
254/// Build default language extension mappings.
255///
256/// Returns all built-in language extensions that MCPLS recognizes by default.
257/// These mappings are used when no custom configuration is provided.
258#[allow(clippy::too_many_lines)]
259fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
260    vec![
261        LanguageExtensionMapping {
262            extensions: vec!["rs".to_string()],
263            language_id: "rust".to_string(),
264        },
265        LanguageExtensionMapping {
266            extensions: vec!["py".to_string(), "pyw".to_string(), "pyi".to_string()],
267            language_id: "python".to_string(),
268        },
269        LanguageExtensionMapping {
270            extensions: vec!["js".to_string(), "mjs".to_string(), "cjs".to_string()],
271            language_id: "javascript".to_string(),
272        },
273        LanguageExtensionMapping {
274            extensions: vec!["ts".to_string(), "mts".to_string(), "cts".to_string()],
275            language_id: "typescript".to_string(),
276        },
277        LanguageExtensionMapping {
278            extensions: vec!["tsx".to_string()],
279            language_id: "typescriptreact".to_string(),
280        },
281        LanguageExtensionMapping {
282            extensions: vec!["jsx".to_string()],
283            language_id: "javascriptreact".to_string(),
284        },
285        LanguageExtensionMapping {
286            extensions: vec!["go".to_string()],
287            language_id: "go".to_string(),
288        },
289        LanguageExtensionMapping {
290            extensions: vec!["c".to_string(), "h".to_string()],
291            language_id: "c".to_string(),
292        },
293        LanguageExtensionMapping {
294            extensions: vec![
295                "cpp".to_string(),
296                "cc".to_string(),
297                "cxx".to_string(),
298                "hpp".to_string(),
299                "hh".to_string(),
300                "hxx".to_string(),
301            ],
302            language_id: "cpp".to_string(),
303        },
304        LanguageExtensionMapping {
305            extensions: vec!["java".to_string()],
306            language_id: "java".to_string(),
307        },
308        LanguageExtensionMapping {
309            extensions: vec!["rb".to_string()],
310            language_id: "ruby".to_string(),
311        },
312        LanguageExtensionMapping {
313            extensions: vec!["php".to_string()],
314            language_id: "php".to_string(),
315        },
316        LanguageExtensionMapping {
317            extensions: vec!["swift".to_string()],
318            language_id: "swift".to_string(),
319        },
320        LanguageExtensionMapping {
321            extensions: vec!["kt".to_string(), "kts".to_string()],
322            language_id: "kotlin".to_string(),
323        },
324        LanguageExtensionMapping {
325            extensions: vec!["scala".to_string(), "sc".to_string()],
326            language_id: "scala".to_string(),
327        },
328        LanguageExtensionMapping {
329            extensions: vec!["zig".to_string()],
330            language_id: "zig".to_string(),
331        },
332        LanguageExtensionMapping {
333            extensions: vec!["lua".to_string()],
334            language_id: "lua".to_string(),
335        },
336        LanguageExtensionMapping {
337            extensions: vec!["sh".to_string(), "bash".to_string(), "zsh".to_string()],
338            language_id: "shellscript".to_string(),
339        },
340        LanguageExtensionMapping {
341            extensions: vec!["json".to_string()],
342            language_id: "json".to_string(),
343        },
344        LanguageExtensionMapping {
345            extensions: vec!["toml".to_string()],
346            language_id: "toml".to_string(),
347        },
348        LanguageExtensionMapping {
349            extensions: vec!["yaml".to_string(), "yml".to_string()],
350            language_id: "yaml".to_string(),
351        },
352        LanguageExtensionMapping {
353            extensions: vec!["xml".to_string()],
354            language_id: "xml".to_string(),
355        },
356        LanguageExtensionMapping {
357            extensions: vec!["html".to_string(), "htm".to_string()],
358            language_id: "html".to_string(),
359        },
360        LanguageExtensionMapping {
361            extensions: vec!["css".to_string()],
362            language_id: "css".to_string(),
363        },
364        LanguageExtensionMapping {
365            extensions: vec!["scss".to_string()],
366            language_id: "scss".to_string(),
367        },
368        LanguageExtensionMapping {
369            extensions: vec!["less".to_string()],
370            language_id: "less".to_string(),
371        },
372        LanguageExtensionMapping {
373            extensions: vec!["md".to_string(), "markdown".to_string()],
374            language_id: "markdown".to_string(),
375        },
376        LanguageExtensionMapping {
377            extensions: vec!["cs".to_string()],
378            language_id: "csharp".to_string(),
379        },
380        LanguageExtensionMapping {
381            extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
382            language_id: "fsharp".to_string(),
383        },
384        LanguageExtensionMapping {
385            extensions: vec!["r".to_string(), "R".to_string()],
386            language_id: "r".to_string(),
387        },
388    ]
389}
390
391/// Trust level applied to a `./mcpls.toml` discovered relative to the
392/// process's current working directory.
393///
394/// A CWD-discovered project-local config is not the same trust tier as an
395/// explicit `--config`/`MCPLS_CONFIG` path: it can be planted by whoever
396/// controls the checked-out repository, and it controls the `command` and
397/// `args` mcpls spawns as well as `[workspace]` (which can redirect the
398/// spawn target via `roots` or drive a filesystem-walk `DoS` via
399/// `heuristics_max_depth`). [`ServerConfig::load`] treats it as
400/// [`Untrusted`](Self::Untrusted) by default; callers that want it honored
401/// must opt in via [`ServerConfig::load_with_trust`].
402///
403/// An explicitly passed `--config`/`MCPLS_CONFIG` path is unaffected by this
404/// enum and is always trusted: naming a path is itself the user's consent.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum ProjectConfigTrust {
407    /// Ignore a CWD-discovered `./mcpls.toml` entirely; fall through to the
408    /// global config tier or built-in defaults.
409    Untrusted,
410    /// Load a CWD-discovered `./mcpls.toml` normally.
411    Trusted,
412}
413
414/// Maximum size, in bytes, of a config file `load_from` will read.
415///
416/// A config file is trusted TOML on a normal setup, but nothing stops a
417/// path from pointing at an arbitrarily large or adversarial file (e.g. a
418/// misconfigured `$MCPLS_CONFIG`) -- `load_from` used to call
419/// `std::fs::read_to_string` with no upper bound, so it could be made to
420/// buffer an unbounded amount of memory before `toml::from_str` ever runs
421/// (#309). 8 MiB is far larger than any legitimate `mcpls.toml`, which
422/// realistically stays in the low kilobytes even with dozens of configured
423/// servers.
424///
425/// Enforced via a bounded read (`Read::take`), not a `std::fs::metadata`
426/// pre-check: `metadata().len()` reports `0` for character devices, FIFOs,
427/// and many procfs entries regardless of how much data they can actually
428/// produce (e.g. `/dev/zero`), so a path pointing at one of those would
429/// sail past a size-only pre-check and still block `read_to_string` on an
430/// effectively infinite read -- the exact "slow/infinite device" case #309
431/// named. A pure metadata check is also TOCTOU-able for a regular file that
432/// grows between the check and the read. Reading `MAX_CONFIG_FILE_BYTES +
433/// 1` bytes, one past the cap, is what distinguishes "exactly at the
434/// boundary" (allowed) from "over" (rejected) without needing a second
435/// syscall.
436const MAX_CONFIG_FILE_BYTES: u64 = 8 * 1024 * 1024;
437
438impl ServerConfig {
439    /// Build the effective extension map used for language detection.
440    ///
441    /// Starts with workspace mappings and overlays mappings inferred from
442    /// configured LSP server `file_patterns`.
443    #[must_use]
444    pub fn build_effective_extension_map(&self) -> HashMap<String, String> {
445        let mut map = self.workspace.build_extension_map();
446
447        for server in &self.lsp_servers {
448            for pattern in &server.file_patterns {
449                if let Some(ext) = extract_extension_from_pattern(pattern) {
450                    let language_id = language_id_for_pattern_extension(&server.language_id, &ext);
451                    map.insert(ext, language_id);
452                }
453            }
454        }
455
456        map
457    }
458
459    /// Load configuration from the default path, treating a CWD-discovered
460    /// `./mcpls.toml` as untrusted.
461    ///
462    /// Default paths checked in order:
463    /// 1. `$MCPLS_CONFIG` environment variable (always trusted)
464    /// 2. `./mcpls.toml` (current directory) — **skipped**; see
465    ///    [`load_with_trust`](Self::load_with_trust) to opt in
466    /// 3. Platform user-config directory:
467    ///    - Linux: `$XDG_CONFIG_HOME/mcpls/mcpls.toml`, else `~/.config/mcpls/mcpls.toml`
468    ///    - macOS: `~/Library/Application Support/mcpls/mcpls.toml`
469    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
470    ///
471    /// If no configuration file exists, creates a default configuration file
472    /// in the user's config directory with all default language extensions.
473    ///
474    /// This is a thin wrapper around
475    /// [`load_with_trust(ProjectConfigTrust::Untrusted)`](Self::load_with_trust) —
476    /// the safe default for library callers that haven't made a trust
477    /// decision.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if parsing an existing config fails.
482    /// If config creation fails, returns default config with graceful degradation.
483    pub fn load() -> Result<Self> {
484        Self::load_with_trust(ProjectConfigTrust::Untrusted)
485    }
486
487    /// Load configuration from the default path, with explicit control over
488    /// whether a CWD-discovered `./mcpls.toml` is honored.
489    ///
490    /// Behaves like [`load`](Self::load), except a `./mcpls.toml` found in
491    /// the current directory is only loaded when `trust` is
492    /// [`ProjectConfigTrust::Trusted`]. When untrusted, the file is skipped
493    /// entirely (including its `[workspace]` section) and a warning is
494    /// logged naming the ignored path; discovery falls through to the
495    /// global config tier or built-in defaults, so project-marker
496    /// heuristics (e.g. `Cargo.toml` → rust-analyzer) still apply normally.
497    /// The returned config's [`project_config_ignored`](Self::project_config_ignored)
498    /// is set to `true` in that case, so callers with access to the loaded
499    /// config (e.g. `McplsServer::get_info`) can surface the ignore decision
500    /// in-band, not just via the stderr-only warning.
501    ///
502    /// `$MCPLS_CONFIG` and an explicit path are unaffected by `trust` and
503    /// are always loaded: naming a path is itself the user's consent.
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if parsing an existing config fails.
508    /// If config creation fails, returns default config with graceful degradation.
509    pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
510        // This `$MCPLS_CONFIG` check is unreachable from the `mcpls` binary:
511        // `crates/mcpls-cli/src/args.rs` already binds `env = "MCPLS_CONFIG"`
512        // to `--config`, so the CLI resolves that variable before `load`/
513        // `load_with_trust` is ever called. It only fires for library
514        // callers that invoke this function directly without going through
515        // `Args`. The actual, CLI-enforced guarantee that `$MCPLS_CONFIG` is
516        // always trusted lives in `main.rs`'s `--config` branch, not here.
517        if let Ok(path) = std::env::var("MCPLS_CONFIG") {
518            return Self::load_from(Path::new(&path));
519        }
520
521        let mut project_config_ignored = false;
522
523        let local_config = PathBuf::from("mcpls.toml");
524        if local_config.exists() {
525            match trust {
526                ProjectConfigTrust::Trusted => return Self::load_from(&local_config),
527                ProjectConfigTrust::Untrusted => {
528                    project_config_ignored = true;
529                    let display_path = local_config.canonicalize().unwrap_or_else(|_| {
530                        std::env::current_dir()
531                            .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
532                    });
533                    tracing::warn!(
534                        "ignoring untrusted project-local config at {}; pass \
535                         --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
536                         load it",
537                        display_path.display()
538                    );
539                }
540            }
541        }
542
543        if let Some(config_dir) = dirs::config_dir() {
544            let user_config = config_dir.join("mcpls").join("mcpls.toml");
545            if user_config.exists() {
546                let mut config = Self::load_from(&user_config)?;
547                config.project_config_ignored = project_config_ignored;
548                return Ok(config);
549            }
550
551            // No config found - create default config file
552            if let Err(e) = Self::create_default_config_file(&user_config) {
553                tracing::warn!(
554                    "Failed to create default config at {}: {}. Using in-memory defaults.",
555                    user_config.display(),
556                    e
557                );
558            } else {
559                tracing::info!("Created default config at {}", user_config.display());
560            }
561        }
562
563        // Return default configuration
564        Ok(Self {
565            project_config_ignored,
566            ..Self::default()
567        })
568    }
569
570    /// Load configuration from a specific path.
571    ///
572    /// # Errors
573    ///
574    /// Returns an error if the file doesn't exist, exceeds the maximum
575    /// allowed config file size, or parsing fails.
576    pub fn load_from(path: &Path) -> Result<Self> {
577        let file = std::fs::File::open(path).map_err(|e| {
578            if e.kind() == std::io::ErrorKind::NotFound {
579                Error::ConfigNotFound(path.to_path_buf())
580            } else {
581                Error::Io(e)
582            }
583        })?;
584
585        // Bounded read, not a `metadata().len()` pre-check -- see
586        // `MAX_CONFIG_FILE_BYTES`'s doc for why the pre-check alone is
587        // bypassable.
588        let mut buf = Vec::new();
589        file.take(MAX_CONFIG_FILE_BYTES + 1)
590            .read_to_end(&mut buf)
591            .map_err(Error::Io)?;
592        if buf.len() as u64 > MAX_CONFIG_FILE_BYTES {
593            return Err(Error::FileSizeLimitExceeded {
594                size: buf.len() as u64,
595                max: MAX_CONFIG_FILE_BYTES,
596            });
597        }
598        let content = String::from_utf8(buf)
599            .map_err(|e| Error::InvalidConfig(format!("config file is not valid UTF-8: {e}")))?;
600
601        let config: Self = toml::from_str(&content)?;
602        config.validate()?;
603        Ok(config)
604    }
605
606    /// Create a default configuration file with all built-in extensions.
607    ///
608    /// Creates the parent directory if it doesn't exist.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if directory or file creation fails.
613    fn create_default_config_file(path: &Path) -> Result<()> {
614        if let Some(parent) = path.parent() {
615            std::fs::create_dir_all(parent)?;
616        }
617
618        let default_config = Self::default();
619        let toml_content = toml::to_string_pretty(&default_config)?;
620        std::fs::write(path, toml_content)?;
621
622        Ok(())
623    }
624
625    /// Validate the configuration.
626    ///
627    /// This covers only workspace-*independent* rules — checks that hold
628    /// regardless of which servers end up applicable in a given workspace.
629    /// Workspace-scoped routing rules (duplicate `ServerId`, conflicting
630    /// `handles` claims across applicable servers) are enforced later, by
631    /// `ToolRouter::from_configs` over the post-heuristics config subset in
632    /// `serve_with` — see that function's module docs for why the split
633    /// exists (two servers for one language with mutually exclusive
634    /// `heuristics` is a legitimate config that must still load here).
635    ///
636    /// [`Self::load_from`] always calls this, and so do [`crate::serve`] and
637    /// [`crate::serve_with`] for every `ServerConfig` regardless of origin —
638    /// a caller-constructed config (not loaded via TOML) gets the same
639    /// diagnosable [`Error::InvalidConfig`] rejection as one loaded from
640    /// disk, instead of only failing later via silent accessor-level
641    /// clamping (see [`crate::lsp::LspClient::request_timeout`]). Remains
642    /// `pub` so a caller can also validate a config up front, before handing
643    /// it to `serve`/`serve_with` (which consume it by value and run until
644    /// shutdown).
645    ///
646    /// # Errors
647    ///
648    /// Returns [`Error::InvalidConfig`] on the first rule violated.
649    ///
650    /// # Examples
651    ///
652    /// ```
653    /// use mcpls_core::config::ServerConfig;
654    ///
655    /// let config = ServerConfig::default();
656    /// assert!(config.validate().is_ok());
657    /// ```
658    pub fn validate(&self) -> Result<()> {
659        if self.workspace.position_encodings.is_empty() {
660            return Err(Error::InvalidConfig(
661                "workspace.position_encodings cannot be empty".to_string(),
662            ));
663        }
664        for encoding in &self.workspace.position_encodings {
665            if parse_position_encoding(encoding).is_none() {
666                return Err(Error::InvalidConfig(format!(
667                    "invalid workspace.position_encodings value '{encoding}'; expected one of \
668                     \"utf-8\", \"utf-16\", \"utf-32\""
669                )));
670            }
671        }
672
673        let mut seen_names: HashMap<&str, &str> = HashMap::new();
674        for server in &self.lsp_servers {
675            if server.language_id.is_empty() {
676                return Err(Error::InvalidConfig(
677                    "language_id cannot be empty".to_string(),
678                ));
679            }
680            if server.command.is_empty() {
681                return Err(Error::InvalidConfig(format!(
682                    "command cannot be empty for language '{}'",
683                    server.language_id
684                )));
685            }
686            if server.timeout_seconds == 0 {
687                return Err(Error::InvalidConfig(format!(
688                    "timeout_seconds cannot be 0 for language '{}'",
689                    server.language_id
690                )));
691            }
692            if server.timeout_seconds > MAX_TIMEOUT_SECONDS {
693                return Err(Error::InvalidConfig(format!(
694                    "timeout_seconds ({}) exceeds the maximum of {} seconds for language '{}'",
695                    server.timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
696                )));
697            }
698            if server.request_timeout_seconds == 0 {
699                return Err(Error::InvalidConfig(format!(
700                    "request_timeout_seconds cannot be 0 for language '{}'",
701                    server.language_id
702                )));
703            }
704            if server.request_timeout_seconds > MAX_TIMEOUT_SECONDS {
705                return Err(Error::InvalidConfig(format!(
706                    "request_timeout_seconds ({}) exceeds the maximum of {} seconds for \
707                     language '{}'",
708                    server.request_timeout_seconds, MAX_TIMEOUT_SECONDS, server.language_id
709                )));
710            }
711            if let Some(name) = &server.name {
712                if name.is_empty() {
713                    return Err(Error::InvalidConfig(format!(
714                        "name cannot be empty for language '{}' (omit `name` to default to \
715                         the language id)",
716                        server.language_id
717                    )));
718                }
719                if let Some(prev_language) = seen_names.insert(name.as_str(), &server.language_id) {
720                    // Not a hard error here: whether this is actually ambiguous
721                    // depends on which of these servers end up applicable in a
722                    // given workspace, which this function cannot know. The
723                    // workspace-scoped check in `ToolRouter::from_configs` is
724                    // authoritative.
725                    tracing::warn!(
726                        "duplicate explicit server name '{name}' in config (language ids: \
727                         '{prev_language}', '{}'); this is only an error if both entries are \
728                         applicable in the same workspace",
729                        server.language_id
730                    );
731                }
732            }
733            if let Some(handles) = &server.handles {
734                if handles.is_empty() {
735                    return Err(Error::InvalidConfig(format!(
736                        "handles cannot be empty for language '{}' (omit `handles` for a \
737                         catch-all server)",
738                        server.language_id
739                    )));
740                }
741                let mut seen_tools = HashSet::new();
742                for tool in handles {
743                    if !seen_tools.insert(*tool) {
744                        return Err(Error::InvalidConfig(format!(
745                            "duplicate tool '{tool}' in `handles` for language '{}'",
746                            server.language_id
747                        )));
748                    }
749                }
750            }
751        }
752        Ok(())
753    }
754}
755
756impl Default for ServerConfig {
757    fn default() -> Self {
758        Self {
759            workspace: WorkspaceConfig::default(),
760            lsp_servers: vec![
761                LspServerConfig::rust_analyzer(),
762                LspServerConfig::pyright(),
763                LspServerConfig::typescript(),
764                LspServerConfig::gopls(),
765                LspServerConfig::clangd(),
766                LspServerConfig::zls(),
767            ],
768            project_config_ignored: false,
769        }
770    }
771}
772
773#[cfg(test)]
774#[allow(clippy::unwrap_used)]
775mod tests {
776    use std::fs;
777
778    use tempfile::TempDir;
779
780    use super::*;
781
782    #[test]
783    fn test_default_config() {
784        let config = ServerConfig::default();
785        assert_eq!(config.lsp_servers.len(), 6);
786        assert_eq!(config.lsp_servers[0].language_id, "rust");
787        assert_eq!(config.lsp_servers[1].language_id, "python");
788        assert_eq!(config.lsp_servers[2].language_id, "typescript");
789        assert_eq!(config.lsp_servers[3].language_id, "go");
790        assert_eq!(config.lsp_servers[4].language_id, "cpp");
791        assert_eq!(config.lsp_servers[5].language_id, "zig");
792        assert_eq!(config.workspace.position_encodings, vec!["utf-8", "utf-16"]);
793    }
794
795    #[test]
796    fn test_default_position_encodings() {
797        let encodings = default_position_encodings();
798        assert_eq!(encodings, vec!["utf-8", "utf-16"]);
799    }
800
801    #[test]
802    fn test_load_from_valid_toml() {
803        let tmp_dir = TempDir::new().unwrap();
804        let config_path = tmp_dir.path().join("config.toml");
805
806        let toml_content = r#"
807            [workspace]
808            roots = ["/tmp/workspace"]
809            position_encodings = ["utf-8"]
810
811            [[lsp_servers]]
812            language_id = "rust"
813            command = "rust-analyzer"
814            timeout_seconds = 30
815        "#;
816
817        fs::write(&config_path, toml_content).unwrap();
818
819        let config = ServerConfig::load_from(&config_path).unwrap();
820        assert_eq!(
821            config.workspace.roots,
822            vec![PathBuf::from("/tmp/workspace")]
823        );
824        assert_eq!(config.workspace.position_encodings, vec!["utf-8"]);
825        assert_eq!(config.lsp_servers.len(), 1);
826        assert_eq!(config.lsp_servers[0].language_id, "rust");
827    }
828
829    #[test]
830    fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
831        // Mirrors the shape of every auto-generated pre-#267 config file:
832        // `timeout_seconds` present, `request_timeout_seconds` absent.
833        let tmp_dir = TempDir::new().unwrap();
834        let config_path = tmp_dir.path().join("config.toml");
835
836        let toml_content = r#"
837            [[lsp_servers]]
838            language_id = "rust"
839            command = "rust-analyzer"
840            timeout_seconds = 30
841        "#;
842
843        fs::write(&config_path, toml_content).unwrap();
844
845        let config = ServerConfig::load_from(&config_path).unwrap();
846        assert_eq!(config.lsp_servers[0].request_timeout_seconds, 30);
847    }
848
849    #[test]
850    fn test_validate_rejects_zero_timeout_seconds() {
851        let tmp_dir = TempDir::new().unwrap();
852        let config_path = tmp_dir.path().join("config.toml");
853
854        let toml_content = r#"
855            [[lsp_servers]]
856            language_id = "rust"
857            command = "rust-analyzer"
858            timeout_seconds = 0
859        "#;
860
861        fs::write(&config_path, toml_content).unwrap();
862
863        let result = ServerConfig::load_from(&config_path);
864        if let Err(Error::InvalidConfig(msg)) = result {
865            // `contains("timeout_seconds cannot be 0")` would also match the
866            // `request_timeout_seconds` message below (it ends in the same
867            // suffix), so assert the exact message to actually discriminate
868            // which field triggered the error.
869            assert_eq!(msg, "timeout_seconds cannot be 0 for language 'rust'");
870        } else {
871            panic!("Expected InvalidConfig error, got {result:?}");
872        }
873    }
874
875    #[test]
876    fn test_validate_rejects_zero_request_timeout_seconds() {
877        let tmp_dir = TempDir::new().unwrap();
878        let config_path = tmp_dir.path().join("config.toml");
879
880        let toml_content = r#"
881            [[lsp_servers]]
882            language_id = "rust"
883            command = "rust-analyzer"
884            request_timeout_seconds = 0
885        "#;
886
887        fs::write(&config_path, toml_content).unwrap();
888
889        let result = ServerConfig::load_from(&config_path);
890        if let Err(Error::InvalidConfig(msg)) = result {
891            assert_eq!(
892                msg,
893                "request_timeout_seconds cannot be 0 for language 'rust'"
894            );
895        } else {
896            panic!("Expected InvalidConfig error, got {result:?}");
897        }
898    }
899
900    #[test]
901    fn test_validate_rejects_request_timeout_seconds_above_max() {
902        let tmp_dir = TempDir::new().unwrap();
903        let config_path = tmp_dir.path().join("config.toml");
904
905        let toml_content = format!(
906            r#"
907            [[lsp_servers]]
908            language_id = "rust"
909            command = "rust-analyzer"
910            request_timeout_seconds = {}
911        "#,
912            MAX_TIMEOUT_SECONDS + 1
913        );
914
915        fs::write(&config_path, toml_content).unwrap();
916
917        let result = ServerConfig::load_from(&config_path);
918        if let Err(Error::InvalidConfig(msg)) = result {
919            assert!(msg.contains("request_timeout_seconds"));
920            assert!(msg.contains("exceeds the maximum"));
921        } else {
922            panic!("Expected InvalidConfig error, got {result:?}");
923        }
924    }
925
926    #[test]
927    fn test_validate_accepts_request_timeout_seconds_at_max() {
928        let tmp_dir = TempDir::new().unwrap();
929        let config_path = tmp_dir.path().join("config.toml");
930
931        let toml_content = format!(
932            r#"
933            [[lsp_servers]]
934            language_id = "rust"
935            command = "rust-analyzer"
936            request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
937        "#
938        );
939
940        fs::write(&config_path, toml_content).unwrap();
941
942        let result = ServerConfig::load_from(&config_path);
943        assert!(result.is_ok(), "expected Ok, got {result:?}");
944    }
945
946    #[test]
947    fn test_validate_rejects_timeout_seconds_above_max() {
948        let tmp_dir = TempDir::new().unwrap();
949        let config_path = tmp_dir.path().join("config.toml");
950
951        let toml_content = format!(
952            r#"
953            [[lsp_servers]]
954            language_id = "rust"
955            command = "rust-analyzer"
956            timeout_seconds = {}
957        "#,
958            MAX_TIMEOUT_SECONDS + 1
959        );
960
961        fs::write(&config_path, toml_content).unwrap();
962
963        let result = ServerConfig::load_from(&config_path);
964        if let Err(Error::InvalidConfig(msg)) = result {
965            assert!(msg.contains("timeout_seconds"));
966            assert!(msg.contains("exceeds the maximum"));
967        } else {
968            panic!("Expected InvalidConfig error, got {result:?}");
969        }
970    }
971
972    #[test]
973    fn test_validate_accepts_timeout_seconds_at_max() {
974        let tmp_dir = TempDir::new().unwrap();
975        let config_path = tmp_dir.path().join("config.toml");
976
977        let toml_content = format!(
978            r#"
979            [[lsp_servers]]
980            language_id = "rust"
981            command = "rust-analyzer"
982            timeout_seconds = {MAX_TIMEOUT_SECONDS}
983        "#
984        );
985
986        fs::write(&config_path, toml_content).unwrap();
987
988        let result = ServerConfig::load_from(&config_path);
989        assert!(result.is_ok(), "expected Ok, got {result:?}");
990    }
991
992    #[test]
993    fn test_load_from_nonexistent_file() {
994        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
995        assert!(result.is_err());
996
997        if let Err(Error::ConfigNotFound(path)) = result {
998            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
999        } else {
1000            panic!("Expected ConfigNotFound error");
1001        }
1002    }
1003
1004    #[test]
1005    fn test_load_from_invalid_toml() {
1006        let tmp_dir = TempDir::new().unwrap();
1007        let config_path = tmp_dir.path().join("invalid.toml");
1008
1009        fs::write(&config_path, "invalid toml content {{}").unwrap();
1010
1011        let result = ServerConfig::load_from(&config_path);
1012        assert!(result.is_err());
1013    }
1014
1015    /// #309: a config file larger than `MAX_CONFIG_FILE_BYTES` must be
1016    /// rejected before `read_to_string` buffers it, not merely fail to
1017    /// parse as TOML afterward.
1018    #[test]
1019    fn test_load_from_rejects_oversized_file() {
1020        let tmp_dir = TempDir::new().unwrap();
1021        let config_path = tmp_dir.path().join("oversized.toml");
1022
1023        // One byte over the cap; content doesn't need to be valid TOML since
1024        // the size check runs before parsing.
1025        let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() + 1);
1026        fs::write(&config_path, &oversized).unwrap();
1027
1028        let result = ServerConfig::load_from(&config_path);
1029        assert!(matches!(
1030            result,
1031            Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1032        ));
1033    }
1034
1035    #[test]
1036    fn test_load_from_accepts_file_at_exact_size_cap() {
1037        let tmp_dir = TempDir::new().unwrap();
1038        let config_path = tmp_dir.path().join("exact.toml");
1039
1040        // Pad a valid, minimal TOML document with a trailing comment up to
1041        // exactly the cap -- the boundary itself must not be rejected.
1042        let mut toml_content = "[workspace]\n# ".to_string();
1043        toml_content.push_str(
1044            &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES).unwrap() - toml_content.len()),
1045        );
1046        assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES);
1047        fs::write(&config_path, &toml_content).unwrap();
1048
1049        let result = ServerConfig::load_from(&config_path);
1050        assert!(result.is_ok(), "expected Ok, got {result:?}");
1051    }
1052
1053    /// #309 S1: `std::fs::metadata` reports `len() == 0` for character
1054    /// devices regardless of how much data they can actually produce --
1055    /// `/dev/zero` is the canonical example. A size check based on metadata
1056    /// alone would pass and let `load_from` block on an effectively
1057    /// infinite read; the bounded `Read::take` must still reject it via
1058    /// `MAX_CONFIG_FILE_BYTES`, not hang or OOM.
1059    #[cfg(unix)]
1060    #[test]
1061    fn test_load_from_rejects_infinite_special_file() {
1062        let path = Path::new("/dev/zero");
1063        assert_eq!(
1064            fs::metadata(path).unwrap().len(),
1065            0,
1066            "test assumption: /dev/zero must report zero length"
1067        );
1068
1069        let result = ServerConfig::load_from(path);
1070        assert!(matches!(
1071            result,
1072            Err(Error::FileSizeLimitExceeded { max, .. }) if max == MAX_CONFIG_FILE_BYTES
1073        ));
1074    }
1075
1076    #[test]
1077    fn test_validate_empty_language_id() {
1078        let tmp_dir = TempDir::new().unwrap();
1079        let config_path = tmp_dir.path().join("config.toml");
1080
1081        let toml_content = r#"
1082            [[lsp_servers]]
1083            language_id = ""
1084            command = "test"
1085        "#;
1086
1087        fs::write(&config_path, toml_content).unwrap();
1088
1089        let result = ServerConfig::load_from(&config_path);
1090        assert!(result.is_err());
1091
1092        if let Err(Error::InvalidConfig(msg)) = result {
1093            assert!(msg.contains("language_id cannot be empty"));
1094        } else {
1095            panic!("Expected InvalidConfig error");
1096        }
1097    }
1098
1099    #[test]
1100    fn test_validate_empty_command() {
1101        let tmp_dir = TempDir::new().unwrap();
1102        let config_path = tmp_dir.path().join("config.toml");
1103
1104        let toml_content = r#"
1105            [[lsp_servers]]
1106            language_id = "rust"
1107            command = ""
1108        "#;
1109
1110        fs::write(&config_path, toml_content).unwrap();
1111
1112        let result = ServerConfig::load_from(&config_path);
1113        assert!(result.is_err());
1114
1115        if let Err(Error::InvalidConfig(msg)) = result {
1116            assert!(msg.contains("command cannot be empty"));
1117        } else {
1118            panic!("Expected InvalidConfig error");
1119        }
1120    }
1121
1122    #[test]
1123    fn test_validate_empty_name() {
1124        let tmp_dir = TempDir::new().unwrap();
1125        let config_path = tmp_dir.path().join("config.toml");
1126
1127        let toml_content = r#"
1128            [[lsp_servers]]
1129            name = ""
1130            language_id = "python"
1131            command = "pyright-langserver"
1132        "#;
1133
1134        fs::write(&config_path, toml_content).unwrap();
1135
1136        let result = ServerConfig::load_from(&config_path);
1137        assert!(result.is_err());
1138
1139        if let Err(Error::InvalidConfig(msg)) = result {
1140            assert!(msg.contains("name cannot be empty"));
1141        } else {
1142            panic!("Expected InvalidConfig error");
1143        }
1144    }
1145
1146    #[test]
1147    fn test_validate_empty_handles() {
1148        let tmp_dir = TempDir::new().unwrap();
1149        let config_path = tmp_dir.path().join("config.toml");
1150
1151        let toml_content = r#"
1152            [[lsp_servers]]
1153            language_id = "python"
1154            command = "pylsp"
1155            handles = []
1156        "#;
1157
1158        fs::write(&config_path, toml_content).unwrap();
1159
1160        let result = ServerConfig::load_from(&config_path);
1161        assert!(result.is_err());
1162
1163        if let Err(Error::InvalidConfig(msg)) = result {
1164            assert!(msg.contains("handles cannot be empty"));
1165        } else {
1166            panic!("Expected InvalidConfig error");
1167        }
1168    }
1169
1170    #[test]
1171    fn test_validate_duplicate_tool_in_handles() {
1172        let tmp_dir = TempDir::new().unwrap();
1173        let config_path = tmp_dir.path().join("config.toml");
1174
1175        let toml_content = r#"
1176            [[lsp_servers]]
1177            language_id = "python"
1178            command = "pylsp"
1179            handles = ["diagnostics", "diagnostics"]
1180        "#;
1181
1182        fs::write(&config_path, toml_content).unwrap();
1183
1184        let result = ServerConfig::load_from(&config_path);
1185        assert!(result.is_err());
1186
1187        if let Err(Error::InvalidConfig(msg)) = result {
1188            assert!(msg.contains("duplicate tool"));
1189            assert!(msg.contains("diagnostics"));
1190        } else {
1191            panic!("Expected InvalidConfig error");
1192        }
1193    }
1194
1195    #[test]
1196    fn test_validate_rejects_empty_position_encodings() {
1197        let tmp_dir = TempDir::new().unwrap();
1198        let config_path = tmp_dir.path().join("config.toml");
1199
1200        let toml_content = r"
1201            [workspace]
1202            position_encodings = []
1203        ";
1204
1205        fs::write(&config_path, toml_content).unwrap();
1206
1207        let result = ServerConfig::load_from(&config_path);
1208        if let Err(Error::InvalidConfig(msg)) = result {
1209            assert_eq!(msg, "workspace.position_encodings cannot be empty");
1210        } else {
1211            panic!("Expected InvalidConfig error, got {result:?}");
1212        }
1213    }
1214
1215    #[test]
1216    fn test_validate_rejects_unrecognized_position_encoding() {
1217        let tmp_dir = TempDir::new().unwrap();
1218        let config_path = tmp_dir.path().join("config.toml");
1219
1220        let toml_content = r#"
1221            [workspace]
1222            position_encodings = ["utf-8", "utf-7"]
1223        "#;
1224
1225        fs::write(&config_path, toml_content).unwrap();
1226
1227        let result = ServerConfig::load_from(&config_path);
1228        if let Err(Error::InvalidConfig(msg)) = result {
1229            assert!(msg.contains("invalid workspace.position_encodings value 'utf-7'"));
1230        } else {
1231            panic!("Expected InvalidConfig error, got {result:?}");
1232        }
1233    }
1234
1235    #[test]
1236    fn test_parse_position_encoding_maps_valid_values_and_rejects_unknown() {
1237        assert_eq!(
1238            parse_position_encoding("utf-8"),
1239            Some(lsp_types::PositionEncodingKind::UTF8)
1240        );
1241        assert_eq!(
1242            parse_position_encoding("utf-16"),
1243            Some(lsp_types::PositionEncodingKind::UTF16)
1244        );
1245        assert_eq!(
1246            parse_position_encoding("utf-32"),
1247            Some(lsp_types::PositionEncodingKind::UTF32)
1248        );
1249        assert_eq!(parse_position_encoding("utf-7"), None);
1250    }
1251
1252    #[test]
1253    fn test_validate_duplicate_name_warns_but_loads() {
1254        // Duplicate explicit `name` is only an error if both entries end up
1255        // applicable in the same workspace (enforced later by
1256        // `ToolRouter::from_configs`, see routing.rs); at load time it must
1257        // still succeed.
1258        let tmp_dir = TempDir::new().unwrap();
1259        let config_path = tmp_dir.path().join("config.toml");
1260
1261        let toml_content = r#"
1262            [[lsp_servers]]
1263            name = "dup"
1264            language_id = "python"
1265            command = "pyright-langserver"
1266
1267            [[lsp_servers]]
1268            name = "dup"
1269            language_id = "typescript"
1270            command = "typescript-language-server"
1271        "#;
1272
1273        fs::write(&config_path, toml_content).unwrap();
1274
1275        let result = ServerConfig::load_from(&config_path);
1276        assert!(result.is_ok(), "duplicate name must only warn at load time");
1277    }
1278
1279    #[test]
1280    fn test_workspace_config_defaults() {
1281        let workspace = WorkspaceConfig::default();
1282        assert!(workspace.roots.is_empty());
1283        assert_eq!(workspace.position_encodings, vec!["utf-8", "utf-16"]);
1284        assert!(!workspace.language_extensions.is_empty());
1285        assert_eq!(workspace.language_extensions.len(), 30);
1286        assert_eq!(workspace.heuristics_max_depth, DEFAULT_HEURISTICS_MAX_DEPTH);
1287    }
1288
1289    #[test]
1290    fn test_load_multiple_servers() {
1291        let tmp_dir = TempDir::new().unwrap();
1292        let config_path = tmp_dir.path().join("multi.toml");
1293
1294        let toml_content = r#"
1295            [[lsp_servers]]
1296            language_id = "rust"
1297            command = "rust-analyzer"
1298
1299            [[lsp_servers]]
1300            language_id = "python"
1301            command = "pyright-langserver"
1302            args = ["--stdio"]
1303        "#;
1304
1305        fs::write(&config_path, toml_content).unwrap();
1306
1307        let config = ServerConfig::load_from(&config_path).unwrap();
1308        assert_eq!(config.lsp_servers.len(), 2);
1309        assert_eq!(config.lsp_servers[0].language_id, "rust");
1310        assert_eq!(config.lsp_servers[1].language_id, "python");
1311        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
1312    }
1313
1314    #[test]
1315    fn test_deny_unknown_fields() {
1316        let tmp_dir = TempDir::new().unwrap();
1317        let config_path = tmp_dir.path().join("unknown.toml");
1318
1319        let toml_content = r#"
1320            unknown_field = "value"
1321
1322            [workspace]
1323            roots = []
1324        "#;
1325
1326        fs::write(&config_path, toml_content).unwrap();
1327
1328        let result = ServerConfig::load_from(&config_path);
1329        assert!(result.is_err(), "Should reject unknown fields");
1330    }
1331
1332    #[test]
1333    fn test_empty_config_file() {
1334        let tmp_dir = TempDir::new().unwrap();
1335        let config_path = tmp_dir.path().join("empty.toml");
1336
1337        fs::write(&config_path, "").unwrap();
1338
1339        let config = ServerConfig::load_from(&config_path).unwrap();
1340        assert!(config.workspace.roots.is_empty());
1341        assert!(config.lsp_servers.is_empty());
1342    }
1343
1344    #[test]
1345    fn test_config_with_initialization_options() {
1346        let tmp_dir = TempDir::new().unwrap();
1347        let config_path = tmp_dir.path().join("init_opts.toml");
1348
1349        let toml_content = r#"
1350            [[lsp_servers]]
1351            language_id = "rust"
1352            command = "rust-analyzer"
1353
1354            [lsp_servers.initialization_options]
1355            cargo = { allFeatures = true }
1356        "#;
1357
1358        fs::write(&config_path, toml_content).unwrap();
1359
1360        let config = ServerConfig::load_from(&config_path).unwrap();
1361        assert!(config.lsp_servers[0].initialization_options.is_some());
1362    }
1363
1364    #[test]
1365    fn test_language_extensions_in_config() {
1366        let tmp_dir = TempDir::new().unwrap();
1367        let config_path = tmp_dir.path().join("extensions.toml");
1368
1369        let toml_content = r#"
1370            [[workspace.language_extensions]]
1371            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1372            language_id = "cpp"
1373
1374            [[workspace.language_extensions]]
1375            extensions = ["nu"]
1376            language_id = "nushell"
1377
1378            [[workspace.language_extensions]]
1379            extensions = ["py", "pyw", "pyi"]
1380            language_id = "python"
1381        "#;
1382
1383        fs::write(&config_path, toml_content).unwrap();
1384
1385        let config = ServerConfig::load_from(&config_path).unwrap();
1386        assert_eq!(config.workspace.language_extensions.len(), 3);
1387
1388        // Check C++ extensions
1389        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
1390        assert_eq!(
1391            config.workspace.language_extensions[0].extensions,
1392            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
1393        );
1394
1395        // Check Nushell extension
1396        assert_eq!(
1397            config.workspace.language_extensions[1].language_id,
1398            "nushell"
1399        );
1400        assert_eq!(
1401            config.workspace.language_extensions[1].extensions,
1402            vec!["nu"]
1403        );
1404    }
1405
1406    #[test]
1407    fn test_build_extension_map() {
1408        let workspace = WorkspaceConfig {
1409            roots: vec![],
1410            position_encodings: vec![],
1411            language_extensions: vec![
1412                LanguageExtensionMapping {
1413                    extensions: vec!["cpp".to_string(), "cc".to_string(), "cxx".to_string()],
1414                    language_id: "cpp".to_string(),
1415                },
1416                LanguageExtensionMapping {
1417                    extensions: vec!["nu".to_string()],
1418                    language_id: "nushell".to_string(),
1419                },
1420            ],
1421            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1422            max_documents: DEFAULT_MAX_DOCUMENTS,
1423            max_file_size: DEFAULT_MAX_FILE_SIZE,
1424        };
1425
1426        let map = workspace.build_extension_map();
1427        assert_eq!(map.get("cpp"), Some(&"cpp".to_string()));
1428        assert_eq!(map.get("cc"), Some(&"cpp".to_string()));
1429        assert_eq!(map.get("cxx"), Some(&"cpp".to_string()));
1430        assert_eq!(map.get("nu"), Some(&"nushell".to_string()));
1431        assert_eq!(map.get("unknown"), None);
1432    }
1433
1434    #[test]
1435    fn test_extract_extension_from_pattern_empty_string() {
1436        assert_eq!(extract_extension_from_pattern(""), None);
1437    }
1438
1439    #[test]
1440    fn test_extract_extension_from_pattern_without_dot() {
1441        assert_eq!(extract_extension_from_pattern("**/*"), None);
1442    }
1443
1444    #[test]
1445    fn test_extract_extension_from_pattern_dotfile() {
1446        assert_eq!(extract_extension_from_pattern(".gitignore"), None);
1447    }
1448
1449    #[test]
1450    fn test_extract_extension_from_pattern_multi_dot_extension() {
1451        assert_eq!(
1452            extract_extension_from_pattern("foo.tar.gz"),
1453            Some("gz".to_string())
1454        );
1455    }
1456
1457    #[test]
1458    fn test_build_effective_extension_map_overrides_with_file_patterns() {
1459        let config = ServerConfig {
1460            workspace: WorkspaceConfig::default(),
1461            lsp_servers: vec![LspServerConfig {
1462                language_id: "cpp".to_string(),
1463                command: "clangd".to_string(),
1464                args: vec![],
1465                env: HashMap::new(),
1466                file_patterns: vec!["**/*.c".to_string(), "**/*.h".to_string()],
1467                initialization_options: None,
1468                timeout_seconds: 30,
1469                request_timeout_seconds: 30,
1470                heuristics: None,
1471                name: None,
1472                handles: None,
1473            }],
1474            project_config_ignored: false,
1475        };
1476
1477        let map = config.build_effective_extension_map();
1478        assert_eq!(map.get("c"), Some(&"cpp".to_string()));
1479        assert_eq!(map.get("h"), Some(&"cpp".to_string()));
1480    }
1481
1482    #[test]
1483    fn test_build_effective_extension_map_derives_tsx_language_id() {
1484        let config = ServerConfig {
1485            workspace: WorkspaceConfig::default(),
1486            lsp_servers: vec![LspServerConfig {
1487                language_id: "typescript".to_string(),
1488                command: "tsgo".to_string(),
1489                args: vec!["--lsp".to_string(), "--stdio".to_string()],
1490                env: HashMap::new(),
1491                file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
1492                initialization_options: None,
1493                timeout_seconds: 30,
1494                request_timeout_seconds: 30,
1495                heuristics: None,
1496                name: None,
1497                handles: None,
1498            }],
1499            project_config_ignored: false,
1500        };
1501
1502        let map = config.build_effective_extension_map();
1503        assert_eq!(map.get("ts"), Some(&"typescript".to_string()));
1504        assert_eq!(map.get("tsx"), Some(&"typescriptreact".to_string()));
1505    }
1506
1507    #[test]
1508    fn test_build_effective_extension_map_derives_jsx_language_id() {
1509        let config = ServerConfig {
1510            workspace: WorkspaceConfig::default(),
1511            lsp_servers: vec![LspServerConfig {
1512                language_id: "javascript".to_string(),
1513                command: "typescript-language-server".to_string(),
1514                args: vec!["--stdio".to_string()],
1515                env: HashMap::new(),
1516                file_patterns: vec!["**/*.js".to_string(), "**/*.jsx".to_string()],
1517                initialization_options: None,
1518                timeout_seconds: 30,
1519                request_timeout_seconds: 30,
1520                heuristics: None,
1521                name: None,
1522                handles: None,
1523            }],
1524            project_config_ignored: false,
1525        };
1526
1527        let map = config.build_effective_extension_map();
1528        assert_eq!(map.get("js"), Some(&"javascript".to_string()));
1529        assert_eq!(map.get("jsx"), Some(&"javascriptreact".to_string()));
1530    }
1531
1532    #[test]
1533    fn test_build_effective_extension_map_ignores_complex_patterns_without_extension() {
1534        let config = ServerConfig {
1535            workspace: WorkspaceConfig::default(),
1536            lsp_servers: vec![LspServerConfig {
1537                language_id: "cpp".to_string(),
1538                command: "clangd".to_string(),
1539                args: vec![],
1540                env: HashMap::new(),
1541                file_patterns: vec!["**/*".to_string(), "**/*.{h,hpp}".to_string()],
1542                initialization_options: None,
1543                timeout_seconds: 30,
1544                request_timeout_seconds: 30,
1545                heuristics: None,
1546                name: None,
1547                handles: None,
1548            }],
1549            project_config_ignored: false,
1550        };
1551
1552        let map = config.build_effective_extension_map();
1553        // Default C/C++ mappings remain unchanged when patterns cannot be parsed.
1554        assert_eq!(map.get("h"), Some(&"c".to_string()));
1555    }
1556
1557    #[test]
1558    fn test_get_language_for_extension() {
1559        let workspace = WorkspaceConfig {
1560            roots: vec![],
1561            position_encodings: vec![],
1562            language_extensions: vec![
1563                LanguageExtensionMapping {
1564                    extensions: vec!["hpp".to_string(), "hh".to_string()],
1565                    language_id: "cpp".to_string(),
1566                },
1567                LanguageExtensionMapping {
1568                    extensions: vec!["py".to_string()],
1569                    language_id: "python".to_string(),
1570                },
1571            ],
1572            heuristics_max_depth: DEFAULT_HEURISTICS_MAX_DEPTH,
1573            max_documents: DEFAULT_MAX_DOCUMENTS,
1574            max_file_size: DEFAULT_MAX_FILE_SIZE,
1575        };
1576
1577        assert_eq!(
1578            workspace.get_language_for_extension("hpp"),
1579            Some("cpp".to_string())
1580        );
1581        assert_eq!(
1582            workspace.get_language_for_extension("hh"),
1583            Some("cpp".to_string())
1584        );
1585        assert_eq!(
1586            workspace.get_language_for_extension("py"),
1587            Some("python".to_string())
1588        );
1589        assert_eq!(workspace.get_language_for_extension("unknown"), None);
1590    }
1591
1592    #[test]
1593    fn test_default_language_extensions() {
1594        let workspace = WorkspaceConfig::default();
1595        let map = workspace.build_extension_map();
1596        assert!(!map.is_empty());
1597        assert_eq!(
1598            workspace.get_language_for_extension("rs"),
1599            Some("rust".to_string())
1600        );
1601        assert_eq!(
1602            workspace.get_language_for_extension("py"),
1603            Some("python".to_string())
1604        );
1605        assert_eq!(
1606            workspace.get_language_for_extension("cpp"),
1607            Some("cpp".to_string())
1608        );
1609    }
1610
1611    #[test]
1612    fn test_create_default_config_file() {
1613        let tmp_dir = TempDir::new().unwrap();
1614        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
1615
1616        ServerConfig::create_default_config_file(&config_path).unwrap();
1617
1618        assert!(config_path.exists());
1619
1620        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
1621        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
1622        assert_eq!(loaded_config.lsp_servers.len(), 6);
1623        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
1624    }
1625
1626    #[test]
1627    fn test_load_returns_default_config() {
1628        // When called directly, default() should return config with all language extensions
1629        let config = ServerConfig::default();
1630        assert_eq!(config.workspace.language_extensions.len(), 30);
1631        assert_eq!(config.lsp_servers.len(), 6);
1632        assert_eq!(config.lsp_servers[0].language_id, "rust");
1633    }
1634
1635    // These tests mutate the process-wide CWD via `set_current_dir`, so they
1636    // must not run concurrently with each other or with any other test that
1637    // relies on CWD (e.g. via a bare `load()`/`load_with_trust()` call).
1638    // Nextest runs each test in its own process, but `cargo test` in-process
1639    // would race; guard with a mutex. `CwdGuard` below additionally restores
1640    // the original directory on drop, so a panic mid-test (e.g. a failed
1641    // `assert_eq!` between the temp-dir switch and the manual restore) can
1642    // never leave the process cwd changed for the rest of the run.
1643    static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1644
1645    /// RAII guard that serializes CWD-mutating tests behind [`CWD_LOCK`] and
1646    /// switches into `dir` for the guard's lifetime, restoring the original
1647    /// working directory on drop — including on an early return or panic.
1648    struct CwdGuard {
1649        _lock: std::sync::MutexGuard<'static, ()>,
1650        original_dir: PathBuf,
1651    }
1652
1653    impl CwdGuard {
1654        fn enter(dir: &Path) -> Self {
1655            let lock = CWD_LOCK
1656                .lock()
1657                .unwrap_or_else(std::sync::PoisonError::into_inner);
1658            let original_dir = std::env::current_dir().unwrap();
1659            std::env::set_current_dir(dir).unwrap();
1660            Self {
1661                _lock: lock,
1662                original_dir,
1663            }
1664        }
1665    }
1666
1667    impl Drop for CwdGuard {
1668        fn drop(&mut self) {
1669            let restored = std::env::set_current_dir(&self.original_dir);
1670            // A failure here during an already-unwinding panic must not
1671            // panic again (double panic aborts the process, losing the
1672            // original failure's message). On the normal path, though,
1673            // silently swallowing this would leave the process cwd wrong
1674            // for every subsequent test with no diagnostic — panic loudly
1675            // instead, since that's exactly the failure mode this guard
1676            // exists to prevent.
1677            if !std::thread::panicking() {
1678                #[allow(clippy::expect_used)]
1679                restored.expect("CwdGuard failed to restore original working directory");
1680            }
1681        }
1682    }
1683
1684    #[test]
1685    fn test_cwd_guard_restores_cwd_on_panic() {
1686        let original_dir = std::env::current_dir().unwrap();
1687        let tmp_dir = TempDir::new().unwrap();
1688
1689        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1690            let _guard = CwdGuard::enter(tmp_dir.path());
1691            panic!("boom");
1692        }));
1693
1694        assert!(result.is_err());
1695        assert_eq!(std::env::current_dir().unwrap(), original_dir);
1696    }
1697
1698    /// Precondition for tests that assert on `ServerConfig::load_with_trust`'s
1699    /// CWD-local-file branch: a `$MCPLS_CONFIG` set in the ambient
1700    /// environment makes `load_with_trust` return before ever looking at
1701    /// CWD (see its `MCPLS_CONFIG` branch above), which would otherwise fail
1702    /// the test for a reason unrelated to the code under test.
1703    ///
1704    /// Scrubbing the variable for the test's duration would be the more
1705    /// thorough fix, but `std::env::remove_var`/`set_var` are `unsafe`
1706    /// (mutate process-wide state) and this crate denies `unsafe_code`
1707    /// workspace-wide with no existing exception — so this asserts the
1708    /// precondition instead of silently working around it, turning an
1709    /// environment-dependent false failure into an explicit, legible one.
1710    fn assert_mcpls_config_env_unset() {
1711        assert!(
1712            std::env::var_os("MCPLS_CONFIG").is_none(),
1713            "this test requires MCPLS_CONFIG to be unset in the test environment, since \
1714             load_with_trust returns before consulting CWD when it's set"
1715        );
1716    }
1717
1718    #[test]
1719    fn test_load_ignores_untrusted_project_local_config() {
1720        // `ServerConfig::default()` (what untrusted discovery falls back to
1721        // once neither an untrusted local file nor a global config apply)
1722        // still exposes rust-analyzer via built-in project-marker
1723        // heuristics — see `test_default_config` above, which already
1724        // covers this without any filesystem interaction. This test only
1725        // needs to prove the planted attacker file's content never leaks
1726        // through `load()`.
1727        let tmp_dir = TempDir::new().unwrap();
1728        let config_path = tmp_dir.path().join("mcpls.toml");
1729
1730        // A marker language id / root that cannot collide with either the
1731        // built-in defaults or a machine-local global config, so this
1732        // assertion holds regardless of what `load()` actually falls
1733        // through to (built-in defaults on a clean machine, or the
1734        // machine's own customized global config in CI/dev environments).
1735        let custom_toml = r#"
1736            [workspace]
1737            roots = ["/should-never-load-attacker-path"]
1738
1739            [[lsp_servers]]
1740            language_id = "definitely-not-a-real-language-marker"
1741            command = "rm"
1742            args = ["-rf", "/"]
1743        "#;
1744
1745        fs::write(&config_path, custom_toml).unwrap();
1746
1747        let config = {
1748            let _guard = CwdGuard::enter(tmp_dir.path());
1749            ServerConfig::load().unwrap()
1750        };
1751
1752        assert!(
1753            !config
1754                .workspace
1755                .roots
1756                .contains(&PathBuf::from("/should-never-load-attacker-path"))
1757        );
1758        assert!(
1759            !config
1760                .lsp_servers
1761                .iter()
1762                .any(|s| s.language_id == "definitely-not-a-real-language-marker")
1763        );
1764    }
1765
1766    #[test]
1767    fn test_load_with_trust_loads_trusted_project_local_config() {
1768        let tmp_dir = TempDir::new().unwrap();
1769        let config_path = tmp_dir.path().join("mcpls.toml");
1770
1771        let custom_toml = r#"
1772            [workspace]
1773            roots = ["/custom/path"]
1774
1775            [[lsp_servers]]
1776            language_id = "python"
1777            command = "pyright-langserver"
1778        "#;
1779
1780        fs::write(&config_path, custom_toml).unwrap();
1781
1782        let config = {
1783            let _guard = CwdGuard::enter(tmp_dir.path());
1784            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
1785        };
1786
1787        assert_eq!(config.workspace.roots, vec![PathBuf::from("/custom/path")]);
1788        assert_eq!(config.lsp_servers.len(), 1);
1789        assert_eq!(config.lsp_servers[0].language_id, "python");
1790    }
1791
1792    #[test]
1793    fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
1794        let tmp_dir = TempDir::new().unwrap();
1795        let config_path = tmp_dir.path().join("mcpls.toml");
1796
1797        let custom_toml = r#"
1798            [workspace]
1799            roots = ["/attacker/controlled"]
1800            heuristics_max_depth = 999999
1801
1802            [[lsp_servers]]
1803            language_id = "evil"
1804            command = "rm"
1805            args = ["-rf", "/"]
1806        "#;
1807
1808        fs::write(&config_path, custom_toml).unwrap();
1809
1810        let config = {
1811            let _guard = CwdGuard::enter(tmp_dir.path());
1812            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1813        };
1814
1815        assert!(
1816            !config
1817                .workspace
1818                .roots
1819                .contains(&PathBuf::from("/attacker/controlled"))
1820        );
1821        assert_ne!(config.workspace.heuristics_max_depth, 999_999);
1822        assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
1823    }
1824
1825    #[test]
1826    fn test_load_with_trust_sets_project_config_ignored_flag() {
1827        assert_mcpls_config_env_unset();
1828
1829        let tmp_dir = TempDir::new().unwrap();
1830        let config_path = tmp_dir.path().join("mcpls.toml");
1831        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
1832
1833        let config = {
1834            let _guard = CwdGuard::enter(tmp_dir.path());
1835            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1836        };
1837        assert!(config.project_config_ignored);
1838
1839        let tmp_dir = TempDir::new().unwrap();
1840        let config_path = tmp_dir.path().join("mcpls.toml");
1841        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
1842
1843        let config = {
1844            let _guard = CwdGuard::enter(tmp_dir.path());
1845            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
1846        };
1847        assert!(!config.project_config_ignored);
1848    }
1849
1850    #[test]
1851    fn test_load_no_local_config_leaves_flag_unset() {
1852        assert_mcpls_config_env_unset();
1853
1854        let tmp_dir = TempDir::new().unwrap();
1855
1856        let config = {
1857            let _guard = CwdGuard::enter(tmp_dir.path());
1858            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
1859        };
1860        assert!(!config.project_config_ignored);
1861    }
1862
1863    #[test]
1864    fn test_config_file_creation_with_proper_structure() {
1865        let tmp_dir = TempDir::new().unwrap();
1866        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
1867
1868        ServerConfig::create_default_config_file(&config_path).unwrap();
1869
1870        let content = fs::read_to_string(&config_path).unwrap();
1871
1872        assert!(content.contains("[workspace]"));
1873        assert!(content.contains("[[workspace.language_extensions]]"));
1874        assert!(content.contains("[[lsp_servers]]"));
1875        assert!(content.contains("language_id = \"rust\""));
1876        assert!(content.contains("extensions = [\"rs\"]"));
1877    }
1878
1879    #[test]
1880    fn test_heuristics_max_depth_default() {
1881        let config = WorkspaceConfig::default();
1882        assert_eq!(config.heuristics_max_depth, 10);
1883    }
1884
1885    #[test]
1886    fn test_heuristics_max_depth_from_config() {
1887        let tmp_dir = TempDir::new().unwrap();
1888        let config_path = tmp_dir.path().join("depth.toml");
1889
1890        let toml_content = r"
1891            [workspace]
1892            heuristics_max_depth = 5
1893        ";
1894
1895        fs::write(&config_path, toml_content).unwrap();
1896
1897        let config = ServerConfig::load_from(&config_path).unwrap();
1898        assert_eq!(config.workspace.heuristics_max_depth, 5);
1899    }
1900
1901    #[test]
1902    fn test_heuristics_max_depth_uses_default_when_not_specified() {
1903        let tmp_dir = TempDir::new().unwrap();
1904        let config_path = tmp_dir.path().join("no_depth.toml");
1905
1906        let toml_content = r"
1907            [workspace]
1908            roots = []
1909        ";
1910
1911        fs::write(&config_path, toml_content).unwrap();
1912
1913        let config = ServerConfig::load_from(&config_path).unwrap();
1914        assert_eq!(
1915            config.workspace.heuristics_max_depth,
1916            DEFAULT_HEURISTICS_MAX_DEPTH
1917        );
1918    }
1919
1920    #[test]
1921    fn test_max_documents_default() {
1922        let config = WorkspaceConfig::default();
1923        assert_eq!(config.max_documents, DEFAULT_MAX_DOCUMENTS);
1924    }
1925
1926    #[test]
1927    fn test_max_file_size_default() {
1928        let config = WorkspaceConfig::default();
1929        assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
1930    }
1931
1932    #[test]
1933    fn test_max_documents_from_config() {
1934        let tmp_dir = TempDir::new().unwrap();
1935        let config_path = tmp_dir.path().join("limits.toml");
1936
1937        let toml_content = r"
1938            [workspace]
1939            max_documents = 500
1940        ";
1941
1942        fs::write(&config_path, toml_content).unwrap();
1943
1944        let config = ServerConfig::load_from(&config_path).unwrap();
1945        assert_eq!(config.workspace.max_documents, 500);
1946    }
1947
1948    #[test]
1949    fn test_max_file_size_from_config() {
1950        let tmp_dir = TempDir::new().unwrap();
1951        let config_path = tmp_dir.path().join("limits.toml");
1952
1953        let toml_content = r"
1954            [workspace]
1955            max_file_size = 20971520
1956        ";
1957
1958        fs::write(&config_path, toml_content).unwrap();
1959
1960        let config = ServerConfig::load_from(&config_path).unwrap();
1961        assert_eq!(config.workspace.max_file_size, 20_971_520);
1962    }
1963
1964    #[test]
1965    fn test_max_documents_uses_default_when_not_specified() {
1966        let tmp_dir = TempDir::new().unwrap();
1967        let config_path = tmp_dir.path().join("no_limits.toml");
1968
1969        let toml_content = r"
1970            [workspace]
1971            roots = []
1972        ";
1973
1974        fs::write(&config_path, toml_content).unwrap();
1975
1976        let config = ServerConfig::load_from(&config_path).unwrap();
1977        assert_eq!(config.workspace.max_documents, DEFAULT_MAX_DOCUMENTS);
1978        assert_eq!(config.workspace.max_file_size, DEFAULT_MAX_FILE_SIZE);
1979    }
1980
1981    /// `max_file_size = 0` is the documented "unlimited" sentinel (see
1982    /// `ResourceLimits::max_file_size`'s doc comment); config loading must
1983    /// pass it through unchanged rather than treating `0` as "unset".
1984    #[test]
1985    fn test_max_file_size_zero_means_unlimited() {
1986        let tmp_dir = TempDir::new().unwrap();
1987        let config_path = tmp_dir.path().join("unlimited.toml");
1988
1989        let toml_content = r"
1990            [workspace]
1991            max_file_size = 0
1992        ";
1993
1994        fs::write(&config_path, toml_content).unwrap();
1995
1996        let config = ServerConfig::load_from(&config_path).unwrap();
1997        assert_eq!(config.workspace.max_file_size, 0);
1998        assert_eq!(config.workspace.resource_limits().max_file_size, 0);
1999    }
2000
2001    #[test]
2002    fn test_workspace_config_resource_limits_maps_fields() {
2003        let workspace = WorkspaceConfig {
2004            max_documents: 250,
2005            max_file_size: 0,
2006            ..WorkspaceConfig::default()
2007        };
2008
2009        let limits = workspace.resource_limits();
2010        assert_eq!(limits.max_documents, 250);
2011        assert_eq!(limits.max_file_size, 0);
2012    }
2013
2014    #[test]
2015    fn test_workspace_config_toml_round_trip() {
2016        let original = WorkspaceConfig {
2017            roots: vec![PathBuf::from("/tmp/round-trip")],
2018            position_encodings: vec!["utf-8".to_string()],
2019            language_extensions: vec![LanguageExtensionMapping {
2020                extensions: vec!["nu".to_string()],
2021                language_id: "nushell".to_string(),
2022            }],
2023            heuristics_max_depth: 5,
2024            max_documents: 500,
2025            max_file_size: 0,
2026        };
2027
2028        let toml_content = toml::to_string_pretty(&original).unwrap();
2029        let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
2030
2031        assert_eq!(round_tripped.roots, original.roots);
2032        assert_eq!(
2033            round_tripped.position_encodings,
2034            original.position_encodings
2035        );
2036        assert_eq!(
2037            round_tripped.language_extensions.len(),
2038            original.language_extensions.len()
2039        );
2040        assert_eq!(
2041            round_tripped.language_extensions[0].extensions,
2042            original.language_extensions[0].extensions
2043        );
2044        assert_eq!(
2045            round_tripped.language_extensions[0].language_id,
2046            original.language_extensions[0].language_id
2047        );
2048        assert_eq!(
2049            round_tripped.heuristics_max_depth,
2050            original.heuristics_max_depth
2051        );
2052        assert_eq!(round_tripped.max_documents, original.max_documents);
2053        assert_eq!(round_tripped.max_file_size, original.max_file_size);
2054    }
2055}