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 bounded_number;
7mod bounded_secs;
8mod command_stem;
9mod host_os;
10mod language;
11mod language_id;
12mod language_map;
13mod limits;
14mod patterns;
15mod position_encodings;
16mod routing;
17mod server;
18mod server_env;
19mod settings;
20mod text_newtype;
21mod trust;
22mod workspace_root;
23
24use std::collections::HashMap;
25use std::num::NonZeroU64;
26use std::path::{Component, Path, PathBuf};
27
28pub use bounded_secs::{BoundedSecs, IndexingReadyTimeoutSecs, InvalidSecs, TimeoutSecs};
29pub use command_stem::CommandStem;
30pub use host_os::HostOs;
31pub use language::{base_language_id, react_variant_language_id};
32pub use language_id::{InvalidLanguageId, LanguageId};
33pub use language_map::{FileKey, LanguageMap};
34pub use limits::{
35    BoundedText, DEFAULT_HEURISTICS_MAX_DEPTH, DocumentLimit, InvalidBoundedText,
36    InvalidSearchDepth, InvalidServerStartConcurrency, InvalidSizeLimit, MAX_FILE_SIZE_LIMIT,
37    MAX_HEURISTICS_DEPTH, MAX_TIMEOUT_SECONDS, SearchDepth, ServerStartConcurrency, SizeLimit,
38};
39pub use patterns::{
40    FileExtension, FileName, FilePattern, InvalidFileExtension, InvalidFileName,
41    InvalidProjectMarker, PatternTarget, ProjectMarker, UnsupportedFilePattern,
42};
43pub use position_encodings::{InvalidPositionEncodings, PositionEncodings};
44pub use routing::{
45    EntrySummary, InvalidServerId, InvalidToolSet, NoServerReason, ServerId, ServerSettlement,
46    ToolKind, ToolRouter, ToolSet,
47};
48use serde::{Deserialize, Serialize};
49pub(crate) use server::MarkerScan;
50pub use server::{
51    BuiltinServer, InvalidAutoSelection, InvalidServerCommand, LaunchCommand, LspServerConfig,
52    ServerCommand, ServerHeuristics, ServerSelection,
53};
54pub use server_env::{DuplicateEnvKey, EnvKey, InvalidEnvKey, ServerEnv};
55pub use settings::{InvalidLspSettings, LspSettings};
56use text_newtype::impl_text_newtype;
57pub(crate) use trust::login_home_dir;
58pub use trust::{ServerAllowlist, WorkspaceTrust};
59pub use workspace_root::{ConfiguredRoot, InvalidWorkspaceRoot};
60
61use crate::bridge::{ResourceLimits, WorkspaceRoots, probe_root};
62use crate::error::{ConfigError, Error, Result};
63use crate::util::{OpenRegularFileError, ReadBoundedError, RegularFile};
64
65/// Maps file extensions to LSP language identifiers.
66///
67/// Used to detect the language ID for files based on their extension.
68/// Extensions are mapped to language IDs like "rust", "python", "cpp", etc.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct LanguageExtensionMapping {
71    /// Array of extensions and their corresponding language ID.
72    pub extensions: Vec<FileExtension>,
73    /// Language ID to report to the LSP server.
74    pub language_id: LanguageId,
75}
76
77/// Main configuration for the MCPLS server.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct ServerConfig {
81    /// MCP `serverInfo`/`initialize` presentation overrides.
82    #[serde(default)]
83    pub mcp: McpConfig,
84
85    /// Workspace configuration.
86    #[serde(default)]
87    pub workspace: WorkspaceConfig,
88
89    /// LSP server configurations.
90    #[serde(default)]
91    pub lsp_servers: Vec<LspServerConfig>,
92
93    /// Whether a CWD-discovered `./mcpls.toml` was ignored as untrusted
94    /// during this load (see [`ProjectConfigTrust`]).
95    ///
96    /// Load-time metadata, not user-configurable: never read from or written
97    /// to a TOML file. Consumed by `McplsServer::get_info` (the
98    /// `ServerHandler` implementation in `crate::mcp::server`) to surface
99    /// the ignore decision in-band to MCP clients, supplementing the
100    /// `tracing::warn!` emitted at load time (which is stderr-only and
101    /// typically invisible to an MCP client).
102    #[serde(skip)]
103    pub project_config_status: ProjectConfigStatus,
104
105    /// Whether the analyzed workspace is trusted (see [`WorkspaceTrust`]).
106    ///
107    /// Never read from or written to a TOML file: consent comes only from the
108    /// command line of whoever launches mcpls, so a config file planted in the
109    /// workspace cannot grant it.
110    #[serde(skip)]
111    pub workspace_trust: WorkspaceTrust,
112}
113
114/// Optional overrides for the text mcpls reports about itself over MCP.
115///
116/// Every field is `None` by default, which keeps today's hardcoded
117/// `serverInfo.title`/`description` and built-in capability blurb
118/// unchanged. `serverInfo.name`, `version`, and `website_url` are not
119/// configurable here: `name` is the MCP-spec machine identifier asserted in
120/// integration tests, and `version`/`website_url` are project metadata, not
121/// presentation text.
122///
123/// A configured [`instructions`](Self::instructions) **replaces** the
124/// built-in capability blurb in `RmcpServerConfig.instructions` rather than
125/// appending to it -- an agent that reads `instructions` at connection time
126/// (see `skills/mcpls/SKILL.md`) sees only the configured text, plus the
127/// unrelated untrusted-project-config NOTE (see
128/// [`ServerConfig::project_config_status`]), which is always appended
129/// afterward regardless of this field.
130///
131/// Every field reaches an MCP client verbatim on every `initialize`
132/// response, into what is typically an LLM context window -- this is why
133/// their types ([`McpTitle`], [`McpDescription`], [`McpInstructions`])
134/// enforce the `MAX_MCP_*` byte caps on all three when the config is loaded.
135///
136/// # Examples
137///
138/// ```
139/// use mcpls_core::config::ServerConfig;
140///
141/// let toml = r#"
142///     [mcp]
143///     title = "My Custom Bridge"
144///     description = "Internal LSP bridge for Acme Corp"
145///     instructions = "Use get_hover before get_definition."
146/// "#;
147/// let config: ServerConfig = toml::from_str(toml).unwrap();
148/// assert_eq!(config.mcp.title.unwrap(), "My Custom Bridge");
149/// ```
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct McpConfig {
153    /// Overrides `serverInfo.title`. Omit to keep the built-in title.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub title: Option<McpTitle>,
156
157    /// Overrides `serverInfo.description`. Omit to keep the built-in
158    /// description (`CARGO_PKG_DESCRIPTION`).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub description: Option<McpDescription>,
161
162    /// Replaces the built-in `RmcpServerConfig.instructions` capability blurb.
163    /// Omit to keep the built-in text. The untrusted-project-config NOTE
164    /// (see [`ServerConfig::project_config_status`]) is still appended
165    /// after this value when set.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub instructions: Option<McpInstructions>,
168
169    /// Prefixes every MCP tool name with `{tool_prefix}_`, so an MCP client
170    /// running multiple mcpls bridges concurrently (one per project) can
171    /// tell their tools apart. Omit to keep the default, unprefixed tool
172    /// names.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub tool_prefix: Option<ToolPrefix>,
175}
176
177/// Maximum byte length of a configured [`McpConfig::title`].
178///
179/// UTF-8 bytes, not chars, consistent with `MAX_CONFIG_FILE_BYTES`.
180pub const MAX_MCP_TITLE_BYTES: usize = 128;
181
182/// Maximum byte length of a configured [`McpConfig::description`].
183///
184/// UTF-8 bytes, not chars. See [`MAX_MCP_TITLE_BYTES`].
185pub const MAX_MCP_DESCRIPTION_BYTES: usize = 1024;
186
187/// Maximum byte length of a configured [`McpConfig::instructions`].
188///
189/// UTF-8 bytes, not chars. Applies to the raw configured string only -- the
190/// untrusted-project-config NOTE appended in `McplsServer::get_info` is
191/// fixed-size built-in text and does not count against this budget. See
192/// [`MAX_MCP_TITLE_BYTES`].
193pub const MAX_MCP_INSTRUCTIONS_BYTES: usize = 4096;
194
195/// Maximum byte length of a configured [`McpConfig::tool_prefix`].
196///
197/// UTF-8 bytes, not chars -- but since [`ToolPrefix`]'s charset is
198/// ASCII-only, bytes and chars coincide here. Chosen well under rmcp's
199/// 128-byte `SHOULD`-level tool name limit (`joined = prefix + '_' +
200/// tool_name`), and conservatively under stricter tool-name limits some LLM
201/// client APIs have historically enforced (e.g. `^[a-zA-Z0-9_-]{1,64}$`),
202/// so a prefix accepted here is unlikely to be rejected downstream by the
203/// client. See `mcp::server::MAX_TOOL_NAME_BYTES` for the compile-time proof
204/// tying this constant to the longest currently-registered tool name.
205pub const MAX_MCP_TOOL_PREFIX_BYTES: usize = 32;
206
207/// A validated [`McpConfig::title`].
208pub type McpTitle = BoundedText<MAX_MCP_TITLE_BYTES>;
209
210/// A validated [`McpConfig::description`].
211pub type McpDescription = BoundedText<MAX_MCP_DESCRIPTION_BYTES>;
212
213/// A validated [`McpConfig::instructions`].
214pub type McpInstructions = BoundedText<MAX_MCP_INSTRUCTIONS_BYTES>;
215
216/// A validated [`McpConfig::tool_prefix`] value.
217///
218/// Every mcpls tool name gains a `{prefix}_` prefix when this is configured,
219/// so an MCP client can tell apart tools exposed by multiple concurrently
220/// running mcpls bridges. A value must be non-empty, at most
221/// [`MAX_MCP_TOOL_PREFIX_BYTES`] bytes, contain only ASCII letters, digits,
222/// `_`, and `-`, and both start and end with an ASCII letter or digit --
223/// this last rule rejects (rather than silently strips) a trailing
224/// separator, so `"optics"` and `"optics_"` cannot become two spellings of
225/// the same configuration. The validator runs once, at construction, making
226/// an invalid prefix unrepresentable: there is no way to observe a
227/// `ToolPrefix` whose value doesn't already satisfy these rules.
228///
229/// A malformed prefix is not merely cosmetic (it would put an invalid tool
230/// name on the wire, an MCP protocol violation), and
231/// [`McplsServer::new`](crate::mcp::McplsServer::new) is `pub` and infallible,
232/// so this type validates eagerly during deserialization and surfaces
233/// failures as [`Error::TomlDe`], which additionally carries the offending
234/// line from the TOML source.
235///
236/// # Examples
237///
238/// ```
239/// use mcpls_core::config::ToolPrefix;
240///
241/// let prefix: ToolPrefix = "optics".parse().unwrap();
242/// assert_eq!(prefix.as_str(), "optics");
243/// assert!("optics_".parse::<ToolPrefix>().is_err());
244/// ```
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(try_from = "String")]
247pub struct ToolPrefix(String);
248
249impl ToolPrefix {
250    /// Validates `value` as a tool prefix.
251    ///
252    /// # Errors
253    ///
254    /// Returns the [`InvalidToolPrefix`] reason when `value` is not accepted.
255    pub fn new(value: impl Into<String>) -> std::result::Result<Self, InvalidToolPrefix> {
256        let value = value.into();
257        InvalidToolPrefix::check(&value).map_or(Ok(Self(value)), Err)
258    }
259}
260
261impl_text_newtype!(ToolPrefix, InvalidToolPrefix);
262
263/// Why a string is not a valid [`ToolPrefix`].
264///
265/// Characters are shown with `Debug`, which quotes and escapes control
266/// characters, so a TOML value smuggling a raw ANSI escape byte cannot be
267/// echoed verbatim into a terminal through a log line.
268#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
269pub enum InvalidToolPrefix {
270    /// The prefix was empty or whitespace only.
271    #[error(
272        "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to use \
273         unprefixed tool names)"
274    )]
275    Empty,
276    /// The prefix was longer than [`MAX_MCP_TOOL_PREFIX_BYTES`].
277    #[error(
278        "mcp.tool_prefix exceeds the maximum of {MAX_MCP_TOOL_PREFIX_BYTES} bytes ({len} given)"
279    )]
280    TooLong {
281        /// Byte length of the rejected prefix.
282        len: usize,
283    },
284    /// The prefix contained a character outside ASCII letters, digits, `_` and `-`.
285    #[error(
286        "mcp.tool_prefix contains an invalid character {0:?} (allowed: ASCII letters, digits, \
287         '_', and '-')"
288    )]
289    InvalidChar(char),
290    /// The first character was not an ASCII letter or digit.
291    #[error("mcp.tool_prefix cannot start with {0:?} (must start with an ASCII letter or digit)")]
292    LeadingSeparator(char),
293    /// The last character was not an ASCII letter or digit.
294    #[error(
295        "mcp.tool_prefix cannot end with {0:?} (the '_' separator between the prefix and each \
296         tool name is inserted automatically by mcpls -- remove the trailing separator character)"
297    )]
298    TrailingSeparator(char),
299}
300
301impl InvalidToolPrefix {
302    fn check(value: &str) -> Option<Self> {
303        let (Some(first), Some(last)) = (value.chars().next(), value.chars().next_back()) else {
304            return Some(Self::Empty);
305        };
306        if value.trim().is_empty() {
307            return Some(Self::Empty);
308        }
309        if value.len() > MAX_MCP_TOOL_PREFIX_BYTES {
310            return Some(Self::TooLong { len: value.len() });
311        }
312        if let Some(bad) = value
313            .chars()
314            .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
315        {
316            return Some(Self::InvalidChar(bad));
317        }
318        if !first.is_ascii_alphanumeric() {
319            return Some(Self::LeadingSeparator(first));
320        }
321        if !last.is_ascii_alphanumeric() {
322            return Some(Self::TrailingSeparator(last));
323        }
324        None
325    }
326}
327
328/// Workspace-level configuration.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct WorkspaceConfig {
332    /// Root directories for the workspace.
333    ///
334    /// Entries are kept as written (relative entries of a loaded config file
335    /// are rebased by [`ServerConfig::load_from`] but not canonicalized);
336    /// [`crate::bridge::WorkspaceRoots::from_configured`] derives the
337    /// canonical roots and the accepted alternative spellings from them.
338    #[serde(default)]
339    pub roots: Vec<ConfiguredRoot>,
340
341    /// Position encoding preference order, offered to each spawned LSP
342    /// server as `capabilities.general.positionEncodings` during the
343    /// `initialize` handshake (see [`crate::lsp::LspServer::spawn`]), in the
344    /// order configured here.
345    ///
346    /// Valid values: `"utf-8"`, `"utf-16"`, `"utf-32"`. Must be non-empty;
347    /// an empty list or an unrecognized value is rejected when the config is
348    /// loaded.
349    #[serde(default)]
350    pub position_encodings: PositionEncodings,
351
352    /// File extension to language ID mappings.
353    /// Allows users to customize which file extensions map to which language servers.
354    #[serde(default)]
355    pub language_extensions: Vec<LanguageExtensionMapping>,
356
357    /// Maximum depth for recursive project marker search.
358    /// Controls how deeply nested projects can be detected.
359    /// Default: 10. Values above [`MAX_HEURISTICS_DEPTH`] are rejected when the config is loaded.
360    #[serde(default)]
361    pub heuristics_max_depth: SearchDepth,
362
363    /// Maximum number of documents `DocumentTracker` will keep open
364    /// simultaneously. A `textDocument/didOpen`-triggering tool call (hover,
365    /// definition, diagnostics, etc.) for a new document once this count is
366    /// reached evicts the least-recently-used tracked document that is both
367    /// unlocked (no `ensure_open`/`update` call currently in flight against
368    /// it) and disk-verified (sending `textDocument/didClose` to every
369    /// server that had it open), making room rather than failing;
370    /// already-tracked paths are unaffected. Falls back to
371    /// `DocumentLimitExceeded` when no tracked document is both, which at
372    /// the default of 100 only happens under pathological concurrency, but
373    /// at a very small configured value (e.g. 1-2) is easy to hit under
374    /// ordinary concurrent use. A too-small limit does not just risk that
375    /// error, either: it can cause silent thrashing, evicting and
376    /// re-reading a document (a full disk read plus a fresh `didOpen`) on
377    /// every access as the same handful of files repeatedly fall out of and
378    /// back into the tracker. `0` disables the limit.
379    /// Default: 100
380    #[serde(default)]
381    pub max_documents: DocumentLimit,
382
383    /// Maximum size, in bytes, of a single file `DocumentTracker` will open.
384    /// A file larger than this fails with `FileSizeLimitExceeded`. `0`
385    /// disables the limit. Values above [`MAX_FILE_SIZE_LIMIT`] are rejected
386    /// when the config is loaded. The per-response disk-read budget for
387    /// position conversion scales with this value.
388    /// Default: 10485760 (10MB)
389    #[serde(default)]
390    pub max_file_size: SizeLimit,
391
392    /// Maximum time, in seconds, a whole-workspace query (hover, definition,
393    /// references, rename, completions, code actions, call hierarchy
394    /// incoming/outgoing calls) waits for its routed LSP server to report
395    /// workspace-indexing readiness before failing with
396    /// [`crate::error::Error::WorkspaceIndexing`] (see
397    /// [`crate::bridge::Translator::with_indexing_ready_timeout`]). Only
398    /// takes effect once a readiness signal has actually shown indexing is
399    /// in progress -- a server that never reports one is never delayed.
400    /// Must be strictly between `PROGRESS_SETTLE` (3s) and
401    /// `INDEXING_STALENESS_BOUND` (60s); anything else is rejected when the
402    /// config is loaded.
403    /// Default: 30
404    #[serde(default)]
405    pub indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs,
406
407    /// Maximum number of LSP servers started at the same time; the rest
408    /// start as earlier ones settle.
409    /// Default: 8
410    #[serde(default)]
411    pub max_concurrent_server_starts: ServerStartConcurrency,
412}
413
414impl Default for WorkspaceConfig {
415    fn default() -> Self {
416        Self {
417            roots: Vec::new(),
418            position_encodings: PositionEncodings::DEFAULT,
419            language_extensions: default_language_extensions(),
420            heuristics_max_depth: SearchDepth::DEFAULT,
421            max_documents: DocumentLimit::DEFAULT,
422            max_file_size: SizeLimit::DEFAULT,
423            indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
424            max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
425        }
426    }
427}
428
429impl WorkspaceConfig {
430    /// Build a map of file extensions to language IDs from the configuration.
431    ///
432    /// # Returns
433    ///
434    /// A `HashMap` where keys are file extensions (without the dot) and values
435    /// are the corresponding language IDs to report to LSP servers.
436    #[must_use]
437    pub fn build_extension_map(&self) -> HashMap<FileExtension, LanguageId> {
438        let mut map = HashMap::new();
439        for mapping in &self.language_extensions {
440            for ext in &mapping.extensions {
441                map.insert(ext.clone(), mapping.language_id.clone());
442            }
443        }
444        map
445    }
446
447    /// Maps the configured `max_documents`/`max_file_size` onto the bridge
448    /// layer's [`ResourceLimits`], for [`Translator::with_resource_limits`](crate::bridge::Translator::with_resource_limits).
449    #[must_use]
450    pub const fn resource_limits(&self) -> ResourceLimits {
451        ResourceLimits {
452            max_documents: self.max_documents,
453            max_file_size: self.max_file_size,
454        }
455    }
456}
457
458fn language_id_for_pattern_target(
459    server_language_id: &LanguageId,
460    target: &PatternTarget,
461) -> LanguageId {
462    match target {
463        PatternTarget::Extension(extension) => {
464            react_variant_language_id(server_language_id, extension.as_str())
465                .unwrap_or_else(|| server_language_id.clone())
466        }
467        PatternTarget::Name(_) => server_language_id.clone(),
468    }
469}
470
471/// Build default language extension mappings.
472///
473/// Returns all built-in language extensions that MCPLS recognizes by default.
474/// These mappings are used when no custom configuration is provided.
475#[expect(
476    clippy::too_many_lines,
477    reason = "a flat table of built-in extension mappings"
478)]
479fn default_language_extensions() -> Vec<LanguageExtensionMapping> {
480    vec![
481        LanguageExtensionMapping {
482            extensions: vec![FileExtension::from_static("rs")],
483            language_id: const { LanguageId::from_static("rust") },
484        },
485        LanguageExtensionMapping {
486            extensions: vec![
487                FileExtension::from_static("py"),
488                FileExtension::from_static("pyw"),
489                FileExtension::from_static("pyi"),
490            ],
491            language_id: const { LanguageId::from_static("python") },
492        },
493        LanguageExtensionMapping {
494            extensions: vec![
495                FileExtension::from_static("js"),
496                FileExtension::from_static("mjs"),
497                FileExtension::from_static("cjs"),
498            ],
499            language_id: const { LanguageId::from_static("javascript") },
500        },
501        LanguageExtensionMapping {
502            extensions: vec![
503                FileExtension::from_static("ts"),
504                FileExtension::from_static("mts"),
505                FileExtension::from_static("cts"),
506            ],
507            language_id: const { LanguageId::from_static("typescript") },
508        },
509        LanguageExtensionMapping {
510            extensions: vec![FileExtension::from_static("tsx")],
511            language_id: const { LanguageId::from_static("typescriptreact") },
512        },
513        LanguageExtensionMapping {
514            extensions: vec![FileExtension::from_static("jsx")],
515            language_id: const { LanguageId::from_static("javascriptreact") },
516        },
517        LanguageExtensionMapping {
518            extensions: vec![FileExtension::from_static("go")],
519            language_id: const { LanguageId::from_static("go") },
520        },
521        LanguageExtensionMapping {
522            extensions: vec![
523                FileExtension::from_static("c"),
524                FileExtension::from_static("h"),
525            ],
526            language_id: const { LanguageId::from_static("c") },
527        },
528        LanguageExtensionMapping {
529            extensions: vec![
530                FileExtension::from_static("cpp"),
531                FileExtension::from_static("cc"),
532                FileExtension::from_static("cxx"),
533                FileExtension::from_static("hpp"),
534                FileExtension::from_static("hh"),
535                FileExtension::from_static("hxx"),
536            ],
537            language_id: const { LanguageId::from_static("cpp") },
538        },
539        LanguageExtensionMapping {
540            extensions: vec![FileExtension::from_static("java")],
541            language_id: const { LanguageId::from_static("java") },
542        },
543        LanguageExtensionMapping {
544            extensions: vec![FileExtension::from_static("rb")],
545            language_id: const { LanguageId::from_static("ruby") },
546        },
547        LanguageExtensionMapping {
548            extensions: vec![FileExtension::from_static("php")],
549            language_id: const { LanguageId::from_static("php") },
550        },
551        LanguageExtensionMapping {
552            extensions: vec![FileExtension::from_static("swift")],
553            language_id: const { LanguageId::from_static("swift") },
554        },
555        LanguageExtensionMapping {
556            extensions: vec![
557                FileExtension::from_static("kt"),
558                FileExtension::from_static("kts"),
559            ],
560            language_id: const { LanguageId::from_static("kotlin") },
561        },
562        LanguageExtensionMapping {
563            extensions: vec![
564                FileExtension::from_static("scala"),
565                FileExtension::from_static("sc"),
566            ],
567            language_id: const { LanguageId::from_static("scala") },
568        },
569        LanguageExtensionMapping {
570            extensions: vec![FileExtension::from_static("zig")],
571            language_id: const { LanguageId::from_static("zig") },
572        },
573        LanguageExtensionMapping {
574            extensions: vec![FileExtension::from_static("lua")],
575            language_id: const { LanguageId::from_static("lua") },
576        },
577        LanguageExtensionMapping {
578            extensions: vec![
579                FileExtension::from_static("sh"),
580                FileExtension::from_static("bash"),
581                FileExtension::from_static("zsh"),
582            ],
583            language_id: const { LanguageId::from_static("shellscript") },
584        },
585        LanguageExtensionMapping {
586            extensions: vec![FileExtension::from_static("json")],
587            language_id: const { LanguageId::from_static("json") },
588        },
589        LanguageExtensionMapping {
590            extensions: vec![FileExtension::from_static("toml")],
591            language_id: const { LanguageId::from_static("toml") },
592        },
593        LanguageExtensionMapping {
594            extensions: vec![
595                FileExtension::from_static("yaml"),
596                FileExtension::from_static("yml"),
597            ],
598            language_id: const { LanguageId::from_static("yaml") },
599        },
600        LanguageExtensionMapping {
601            extensions: vec![FileExtension::from_static("xml")],
602            language_id: const { LanguageId::from_static("xml") },
603        },
604        LanguageExtensionMapping {
605            extensions: vec![
606                FileExtension::from_static("html"),
607                FileExtension::from_static("htm"),
608            ],
609            language_id: const { LanguageId::from_static("html") },
610        },
611        LanguageExtensionMapping {
612            extensions: vec![FileExtension::from_static("css")],
613            language_id: const { LanguageId::from_static("css") },
614        },
615        LanguageExtensionMapping {
616            extensions: vec![FileExtension::from_static("scss")],
617            language_id: const { LanguageId::from_static("scss") },
618        },
619        LanguageExtensionMapping {
620            extensions: vec![FileExtension::from_static("less")],
621            language_id: const { LanguageId::from_static("less") },
622        },
623        LanguageExtensionMapping {
624            extensions: vec![
625                FileExtension::from_static("md"),
626                FileExtension::from_static("markdown"),
627            ],
628            language_id: const { LanguageId::from_static("markdown") },
629        },
630        LanguageExtensionMapping {
631            extensions: vec![FileExtension::from_static("cs")],
632            language_id: const { LanguageId::from_static("csharp") },
633        },
634        LanguageExtensionMapping {
635            extensions: vec![
636                FileExtension::from_static("fs"),
637                FileExtension::from_static("fsi"),
638                FileExtension::from_static("fsx"),
639            ],
640            language_id: const { LanguageId::from_static("fsharp") },
641        },
642        LanguageExtensionMapping {
643            extensions: vec![
644                FileExtension::from_static("r"),
645                FileExtension::from_static("R"),
646            ],
647            language_id: const { LanguageId::from_static("r") },
648        },
649    ]
650}
651
652/// Where a config file path came from, which decides whether the working
653/// directory counts as part of the workspace it must stay out of.
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655pub enum ConfigOrigin {
656    /// Given on the command line as an absolute or relative path.
657    Argument,
658    /// Taken from the environment (`MCPLS_CONFIG`, `$HOME`, `$XDG_CONFIG_HOME`),
659    /// which a checkout's tooling (direnv, a project-scoped client config)
660    /// can set.
661    Environment,
662}
663
664/// Whether discovery writes a default config file when none exists.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666enum CreateDefault {
667    Yes,
668    No,
669}
670
671/// Trust level applied to a `./mcpls.toml` discovered relative to the
672/// process's current working directory.
673///
674/// A CWD-discovered project-local config is not the same trust tier as an
675/// explicit `--config`/`MCPLS_CONFIG` path: it can be planted by whoever
676/// controls the checked-out repository, and it controls the `command` and
677/// `args` mcpls spawns as well as `[workspace]` (which can redirect the
678/// spawn target via `roots` or drive a filesystem-walk `DoS` via
679/// `heuristics_max_depth`). [`ServerConfig::load`] treats it as
680/// [`Untrusted`](Self::Untrusted) by default; callers that want it honored
681/// must opt in via [`ServerConfig::load_with_trust`].
682///
683/// An explicitly passed `--config`/`MCPLS_CONFIG` path is unaffected by this
684/// enum and is always trusted: naming a path is itself the user's consent.
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum ProjectConfigTrust {
687    /// Ignore a CWD-discovered `./mcpls.toml` entirely; fall through to the
688    /// global config tier or built-in defaults.
689    Untrusted,
690    /// Load a CWD-discovered `./mcpls.toml` normally.
691    Trusted,
692}
693
694/// Whether a CWD-discovered `./mcpls.toml` took part in a config load.
695///
696/// Load-time metadata carried on [`ServerConfig::project_config_status`] and
697/// surfaced to MCP clients in-band by `McplsServer::get_info`.
698///
699/// # Examples
700///
701/// ```
702/// use mcpls_core::config::ProjectConfigStatus;
703///
704/// assert_eq!(ProjectConfigStatus::default(), ProjectConfigStatus::NotIgnored);
705/// ```
706#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
707pub enum ProjectConfigStatus {
708    /// No project-local config was skipped.
709    #[default]
710    NotIgnored,
711    /// A CWD-discovered `./mcpls.toml` was skipped because it is untrusted.
712    IgnoredUntrusted,
713}
714
715/// Maximum size, in bytes, of a config file `load_from` will read.
716///
717/// A config file is trusted TOML on a normal setup, but nothing stops a
718/// path from pointing at an arbitrarily large or adversarial file (e.g. a
719/// misconfigured `$MCPLS_CONFIG`) -- `load_from` used to call
720/// `std::fs::read_to_string` with no upper bound, so it could be made to
721/// buffer an unbounded amount of memory before `toml::from_str` ever runs
722/// (#309). 8 MiB is far larger than any legitimate `mcpls.toml`, which
723/// realistically stays in the low kilobytes even with dozens of configured
724/// servers.
725///
726/// The path is opened through [`RegularFile`], which rejects anything but a
727/// regular file (a FIFO, a device such as `/dev/zero`, ...) on the open
728/// handle, so `metadata().len()` is meaningful. The read is still capped at
729/// `MAX_CONFIG_FILE_BYTES + 1` bytes because a regular file can grow between
730/// the stat and the read; reaching that one-past-the-cap length is what
731/// distinguishes "exactly at the boundary" (allowed) from "over" (rejected).
732const MAX_CONFIG_FILE_BYTES: NonZeroU64 =
733    NonZeroU64::new(8 * 1024 * 1024).expect("the config file limit must be non-zero");
734
735/// Rebases the relative `roots` of the config at `config_path` per `base`,
736/// keeping every spelling as written: nothing is canonicalized here.
737///
738/// An absolute config path gives absolute roots (the config directory as
739/// given, joined to the entry). A relative config path (the trusted
740/// project-local `mcpls.toml`) gives roots that stay relative, so the serve
741/// time resolution also admits the logical `$PWD` spelling; a config path
742/// that is relative but drive- or root-qualified (Windows `C:cfg\x.toml`)
743/// is made absolute first. Every rebased root must exist. The working
744/// directory is never read here except to make a drive-qualified relative
745/// config path absolute, so a removed cwd does not fail an absolute config
746/// (#348 case 4).
747fn rebase_relative_roots(
748    roots: &mut [ConfiguredRoot],
749    config_path: &Path,
750    base: RelativeRootBase,
751) -> Result<()> {
752    if !roots.iter().any(|root| root.as_path().is_relative()) {
753        return Ok(());
754    }
755    let config_dir = match base {
756        RelativeRootBase::Cwd => None,
757        RelativeRootBase::ConfigDir => Some(config_dir_as_given(config_path)?),
758    };
759    for root in roots.iter_mut().filter(|root| root.as_path().is_relative()) {
760        if let Some(dir) = config_dir.as_deref() {
761            let rebased = root.join_onto(dir);
762            probe_root(root.as_path(), as_base_display(dir), rebased.as_path())?;
763            *root = rebased;
764        } else {
765            let cwd = std::env::current_dir().map_err(Error::Io)?;
766            probe_root(root.as_path(), &cwd, root.join_onto(&cwd).as_path())?;
767        }
768    }
769    Ok(())
770}
771
772/// `dir` for display as a resolution base; the empty path reads as `.`.
773fn as_base_display(dir: &Path) -> &Path {
774    if dir.is_empty() { Path::new(".") } else { dir }
775}
776
777/// The directory containing `config_path`, spelled as the caller gave it.
778fn config_dir_as_given(config_path: &Path) -> Result<PathBuf> {
779    let drive_or_root_qualified = config_path
780        .components()
781        .next()
782        .is_some_and(|c| matches!(c, Component::Prefix(_) | Component::RootDir));
783    let path = if config_path.is_relative() && drive_or_root_qualified {
784        std::path::absolute(config_path).map_err(Error::Io)?
785    } else {
786        config_path.to_path_buf()
787    };
788    path.parent()
789        .map(Path::to_path_buf)
790        .ok_or_else(|| ConfigError::NoParentDirectory { path }.into())
791}
792
793/// What a relative [`WorkspaceConfig::roots`] entry resolves against, for
794/// [`ServerConfig::load_from_with_root_base`].
795#[derive(Clone, Copy, Debug, PartialEq, Eq)]
796enum RelativeRootBase {
797    /// Resolve against the directory containing the loaded config file.
798    /// [`ServerConfig::load_from`]'s documented behavior, used for an
799    /// explicitly named config path (including a trusted project-local
800    /// `mcpls.toml` and `$MCPLS_CONFIG`) -- portable when mcpls is launched
801    /// from a different working directory than the config lives in.
802    ConfigDir,
803    /// Resolve against the process's current working directory. Used only
804    /// for the auto-discovered global/user config
805    /// (`~/.config/mcpls/mcpls.toml`), which is not tied to any particular
806    /// project (#348 case 2).
807    Cwd,
808}
809
810impl ServerConfig {
811    /// Build the effective language map used for language detection.
812    ///
813    /// Starts with workspace mappings and overlays mappings inferred from
814    /// configured LSP server `file_patterns`: `*.EXT` patterns map extensions
815    /// and bare `NAME` patterns map extensionless file names.
816    #[must_use]
817    pub fn build_effective_language_map(&self) -> LanguageMap {
818        let mut map = LanguageMap::from(self.workspace.build_extension_map());
819
820        for server in &self.lsp_servers {
821            for pattern in &server.file_patterns {
822                let target = pattern.target().clone();
823                let language_id = language_id_for_pattern_target(&server.language_id, &target);
824                map.insert(target, language_id);
825            }
826        }
827
828        map
829    }
830
831    /// Load configuration from the default path, treating a CWD-discovered
832    /// `./mcpls.toml` as untrusted.
833    ///
834    /// Default paths checked in order:
835    /// 1. `$MCPLS_CONFIG` environment variable (always trusted)
836    /// 2. `./mcpls.toml` (current directory) — **skipped**; see
837    ///    [`load_with_trust`](Self::load_with_trust) to opt in
838    /// 3. Platform user-config directory:
839    ///    - Linux: `$XDG_CONFIG_HOME/mcpls/mcpls.toml`, else `~/.config/mcpls/mcpls.toml`
840    ///    - macOS: `~/Library/Application Support/mcpls/mcpls.toml`
841    /// 4. `%APPDATA%\mcpls\mcpls.toml` (Windows)
842    ///
843    /// If no configuration file exists, creates a default configuration file
844    /// in the user's config directory with all default language extensions.
845    ///
846    /// This is a thin wrapper around
847    /// [`load_with_trust(ProjectConfigTrust::Untrusted)`](Self::load_with_trust) —
848    /// the safe default for library callers that haven't made a trust
849    /// decision.
850    ///
851    /// # Errors
852    ///
853    /// Returns an error if parsing an existing config fails.
854    /// If config creation fails, returns default config with graceful degradation.
855    pub fn load() -> Result<Self> {
856        Self::load_with_trust(ProjectConfigTrust::Untrusted)
857    }
858
859    /// Load configuration from the default path, with explicit control over
860    /// whether a CWD-discovered `./mcpls.toml` is honored.
861    ///
862    /// Behaves like [`load`](Self::load), except a `./mcpls.toml` found in
863    /// the current directory is only loaded when `trust` is
864    /// [`ProjectConfigTrust::Trusted`]. When untrusted, the file is skipped
865    /// entirely (including its `[workspace]` section) and a warning is
866    /// logged naming the ignored path; discovery falls through to the
867    /// global config tier or built-in defaults, so project-marker
868    /// heuristics (e.g. `Cargo.toml` → rust-analyzer) still apply normally.
869    /// The returned config's [`project_config_status`](Self::project_config_status)
870    /// is [`ProjectConfigStatus::IgnoredUntrusted`] in that case, so callers with access to the loaded
871    /// config (e.g. `McplsServer::get_info`) can surface the ignore decision
872    /// in-band, not just via the stderr-only warning.
873    ///
874    /// `$MCPLS_CONFIG` and an explicit path are unaffected by `trust` and
875    /// are always loaded: naming a path is itself the user's consent.
876    ///
877    /// Unlike [`load_from`](Self::load_from)'s documented default (relative
878    /// [`WorkspaceConfig::roots`] joined to the config file's own
879    /// directory), the global/user config tier
880    /// (`~/.config/mcpls/mcpls.toml`, or the platform equivalent) leaves
881    /// relative roots relative, so they resolve against the process's
882    /// current working directory at startup -- it isn't tied to any
883    /// particular project, so cwd is the more intuitive base (#348).
884    ///
885    /// # Errors
886    ///
887    /// Returns an error if parsing an existing config fails.
888    /// If config creation fails, returns default config with graceful degradation.
889    pub fn load_with_trust(trust: ProjectConfigTrust) -> Result<Self> {
890        Self::discover(trust, CreateDefault::Yes).map(|(config, _)| config)
891    }
892
893    /// Discovers the configuration like [`load_with_trust`](Self::load_with_trust)
894    /// for a workspace trusted as `workspace`, applying that trust to the result.
895    ///
896    /// With [`WorkspaceTrust::Untrusted`] the file that was actually loaded is
897    /// checked with [`ensure_outside_workspace`](Self::ensure_outside_workspace)
898    /// (it is environment-derived: `$HOME`, `$XDG_CONFIG_HOME`,
899    /// `$MCPLS_CONFIG`), and no default config file is created, so nothing is
900    /// written on behalf of an untrusted checkout.
901    ///
902    /// # Errors
903    ///
904    /// As [`load_with_trust`](Self::load_with_trust), plus
905    /// [`Error::ConfigInsideWorkspace`] when the loaded file is inside the
906    /// workspace.
907    pub fn load_discovered(trust: ProjectConfigTrust, workspace: &WorkspaceTrust) -> Result<Self> {
908        let create = match workspace {
909            WorkspaceTrust::Trusted => CreateDefault::Yes,
910            WorkspaceTrust::Untrusted(_) => CreateDefault::No,
911        };
912        let (mut config, source) = Self::discover(trust, create)?;
913        let source = source
914            .as_deref()
915            .map(|path| (path, ConfigOrigin::Environment));
916        config.apply_workspace_trust(workspace, source)?;
917        Ok(config)
918    }
919
920    /// Loads the config file the user named (`--config` or `$MCPLS_CONFIG`) for
921    /// a workspace trusted as `workspace`, applying that trust to the result.
922    ///
923    /// Naming a path is consent to load it, so no project-config trust applies.
924    /// With [`WorkspaceTrust::Untrusted`] the file must lie outside the
925    /// workspace, as [`ensure_outside_workspace`](Self::ensure_outside_workspace)
926    /// checks.
927    ///
928    /// # Errors
929    ///
930    /// As [`load_from`](Self::load_from), plus [`Error::ConfigInsideWorkspace`]
931    /// when the file is inside an untrusted workspace.
932    ///
933    /// # Examples
934    ///
935    /// ```
936    /// use std::path::Path;
937    ///
938    /// use mcpls_core::config::ConfigOrigin;
939    /// use mcpls_core::{ServerConfig, WorkspaceTrust};
940    ///
941    /// let missing = Path::new("/nonexistent/mcpls.toml");
942    /// assert!(
943    ///     ServerConfig::load_explicit(missing, ConfigOrigin::Argument, &WorkspaceTrust::Trusted)
944    ///         .is_err()
945    /// );
946    /// ```
947    pub fn load_explicit(
948        path: &Path,
949        origin: ConfigOrigin,
950        workspace: &WorkspaceTrust,
951    ) -> Result<Self> {
952        let mut config = Self::load_from(path)?;
953        config.apply_workspace_trust(workspace, Some((path, origin)))?;
954        Ok(config)
955    }
956
957    fn apply_workspace_trust(
958        &mut self,
959        workspace: &WorkspaceTrust,
960        source: Option<(&Path, ConfigOrigin)>,
961    ) -> Result<()> {
962        if let (WorkspaceTrust::Untrusted(_), Some((source, origin))) = (workspace, source) {
963            self.ensure_outside_workspace(source, origin)?;
964        }
965        Self::check_allowlist(workspace, &self.lsp_servers)?;
966        self.workspace_trust = workspace.clone();
967        Ok(())
968    }
969
970    /// The discovery behind [`load_with_trust`](Self::load_with_trust): the
971    /// loaded config and the file it came from, if any.
972    fn discover(
973        trust: ProjectConfigTrust,
974        create: CreateDefault,
975    ) -> Result<(Self, Option<PathBuf>)> {
976        // This `$MCPLS_CONFIG` check is unreachable from the `mcpls` binary:
977        // `crates/mcpls-cli/src/args.rs` already binds `env = "MCPLS_CONFIG"`
978        // to `--config`, so the CLI resolves that variable before `load`/
979        // `load_with_trust` is ever called. It only fires for library
980        // callers that invoke this function directly without going through
981        // `Args`. The actual, CLI-enforced guarantee that `$MCPLS_CONFIG` is
982        // always trusted lives in `main.rs`'s `--config` branch, not here.
983        if let Some(path) = std::env::var_os("MCPLS_CONFIG") {
984            let path = PathBuf::from(path);
985            return Self::load_from(&path).map(|config| (config, Some(path)));
986        }
987
988        let mut project_config_status = ProjectConfigStatus::NotIgnored;
989
990        let local_config = PathBuf::from("mcpls.toml");
991        if local_config.exists() {
992            match trust {
993                ProjectConfigTrust::Trusted => {
994                    return Self::load_from(&local_config)
995                        .map(|config| (config, Some(local_config.clone())));
996                }
997                ProjectConfigTrust::Untrusted => {
998                    project_config_status = ProjectConfigStatus::IgnoredUntrusted;
999                    let display_path = local_config.canonicalize().unwrap_or_else(|_| {
1000                        std::env::current_dir()
1001                            .map_or_else(|_| local_config.clone(), |cwd| cwd.join(&local_config))
1002                    });
1003                    tracing::warn!(
1004                        "ignoring untrusted project-local config at {}; pass \
1005                         --trust-project-config (or set MCPLS_TRUST_PROJECT_CONFIG=true) to \
1006                         load it",
1007                        display_path.display()
1008                    );
1009                }
1010            }
1011        }
1012
1013        if let Some(config_dir) = dirs::config_dir() {
1014            let user_config = config_dir.join("mcpls").join("mcpls.toml");
1015            if user_config.exists() {
1016                // The auto-discovered global/user config is not tied to any
1017                // particular project, so a relative `workspace.roots` entry
1018                // is more intuitively resolved against the process cwd than
1019                // against `~/.config/mcpls/` itself (matches pre-#345
1020                // behavior; #348 case 2). This differs from `load_from`'s
1021                // public default, which resolves against the directory of
1022                // an explicitly named config file -- that behavior is kept
1023                // unchanged for project-local `mcpls.toml` and `$MCPLS_CONFIG`.
1024                let mut config =
1025                    Self::load_from_with_root_base(&user_config, RelativeRootBase::Cwd)?;
1026                config.project_config_status = project_config_status;
1027                return Ok((config, Some(user_config)));
1028            }
1029
1030            if create == CreateDefault::Yes {
1031                if let Err(e) = Self::create_default_config_file(&user_config) {
1032                    tracing::warn!(
1033                        "Failed to create default config at {}: {}. Using in-memory defaults.",
1034                        user_config.display(),
1035                        e
1036                    );
1037                } else {
1038                    tracing::info!("Created default config at {}", user_config.display());
1039                }
1040            }
1041        }
1042
1043        // Return default configuration
1044        Ok((
1045            Self {
1046                project_config_status,
1047                ..Self::default()
1048            },
1049            None,
1050        ))
1051    }
1052
1053    /// Load configuration from a specific path.
1054    ///
1055    /// Relative [`WorkspaceConfig::roots`] are joined to the directory
1056    /// containing `path` and must exist, which keeps an explicitly named
1057    /// config portable when mcpls is launched from a different working
1058    /// directory. Roots are kept as written, not canonicalized, so the
1059    /// spelling an operator configured (for example through a symlink) stays
1060    /// admissible; a relative `path` yields roots that stay relative.
1061    /// [`crate::bridge::WorkspaceRoots::from_configured`] canonicalizes them
1062    /// at startup.
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if the file doesn't exist, exceeds the maximum
1067    /// allowed config file size, or parsing fails.
1068    pub fn load_from(path: &Path) -> Result<Self> {
1069        Self::load_from_with_root_base(path, RelativeRootBase::ConfigDir)
1070    }
1071
1072    /// Implements [`load_from`](Self::load_from), parameterized over what a
1073    /// relative [`WorkspaceConfig::roots`] entry resolves against.
1074    ///
1075    /// [`load_with_trust`](Self::load_with_trust) uses
1076    /// [`RelativeRootBase::Cwd`] for the auto-discovered global/user config
1077    /// (#348 case 2); every other caller (including the public
1078    /// [`load_from`](Self::load_from)) uses
1079    /// [`RelativeRootBase::ConfigDir`], preserving #345's original behavior.
1080    fn load_from_with_root_base(path: &Path, relative_root_base: RelativeRootBase) -> Result<Self> {
1081        let file = RegularFile::open(path).map_err(|e| match e {
1082            OpenRegularFileError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {
1083                Error::ConfigNotFound(path.to_path_buf())
1084            }
1085            OpenRegularFileError::Io(e) => Error::Io(e),
1086            OpenRegularFileError::NotRegular => Error::NotARegularFile(path.to_path_buf()),
1087        })?;
1088        let buf = file
1089            .read_bounded(MAX_CONFIG_FILE_BYTES)
1090            .map_err(|e| match e {
1091                ReadBoundedError::TooLarge(exceeded) => ConfigError::FileTooLarge(exceeded).into(),
1092                ReadBoundedError::Io(e) => Error::Io(e),
1093            })?;
1094        let content = String::from_utf8(buf).map_err(ConfigError::NotUtf8)?;
1095
1096        let mut config: Self = toml::from_str(&content)?;
1097
1098        rebase_relative_roots(&mut config.workspace.roots, path, relative_root_base)?;
1099
1100        Ok(config)
1101    }
1102
1103    /// Create a default configuration file with all built-in extensions.
1104    ///
1105    /// Creates the parent directory if it doesn't exist.
1106    ///
1107    /// # Errors
1108    ///
1109    /// Returns an error if directory or file creation fails.
1110    fn create_default_config_file(path: &Path) -> Result<()> {
1111        if let Some(parent) = path.parent() {
1112            std::fs::create_dir_all(parent)?;
1113        }
1114
1115        let default_config = Self::default();
1116        let toml_content = toml::to_string_pretty(&default_config)?;
1117        std::fs::write(path, toml_content)?;
1118
1119        Ok(())
1120    }
1121
1122    /// Validate a configuration built in code.
1123    ///
1124    /// Covers the rules that no field type enforces: today, that every id
1125    /// allowed by an untrusted [`Self::workspace_trust`] names a configured
1126    /// server. Value ranges (timeouts, `position_encodings`, `language_id`) are
1127    /// not checked here: their types ([`TimeoutSecs`],
1128    /// [`IndexingReadyTimeoutSecs`], [`PositionEncodings`], [`LanguageId`])
1129    /// reject invalid values at construction. Workspace-scoped routing rules
1130    /// (duplicate `ServerId`, conflicting `handles` claims across applicable
1131    /// servers) are enforced later, by `ToolRouter::from_configs` over the
1132    /// post-heuristics config subset in `serve_with`, because two servers for
1133    /// one language with mutually exclusive `heuristics` are a legitimate
1134    /// config.
1135    ///
1136    /// The `load_*` functions check the allowlist as they apply the trust, so a
1137    /// loaded config needs no call; [`crate::serve`] and [`crate::serve_with`]
1138    /// run this once for every config regardless of origin.
1139    ///
1140    /// # Errors
1141    ///
1142    /// Returns [`Error::Config`] on the first rule violated.
1143    ///
1144    /// # Examples
1145    ///
1146    /// ```
1147    /// use mcpls_core::config::ServerConfig;
1148    ///
1149    /// let config = ServerConfig::default();
1150    /// assert!(config.validate().is_ok());
1151    /// ```
1152    pub fn validate(&self) -> Result<()> {
1153        Self::check_allowlist(&self.workspace_trust, &self.lsp_servers)
1154    }
1155
1156    /// Rejects an allowed server id that names no configured server, so a typo
1157    /// in `--allow-server` cannot silently leave a server refused. A server
1158    /// that heuristics later skip is still a configured one.
1159    fn check_allowlist(trust: &WorkspaceTrust, servers: &[LspServerConfig]) -> Result<()> {
1160        let WorkspaceTrust::Untrusted(allowlist) = trust else {
1161            return Ok(());
1162        };
1163        let configured: Vec<ServerId> = servers.iter().map(LspServerConfig::id).collect();
1164        let unknown = allowlist
1165            .as_slice()
1166            .iter()
1167            .find(|id| !configured.contains(id));
1168        unknown.map_or(Ok(()), |unknown| {
1169            Err(ConfigError::UnknownAllowedServer {
1170                server: unknown.clone(),
1171                configured: configured.clone(),
1172            }
1173            .into())
1174        })
1175    }
1176
1177    /// Rejects a config file that the analyzed checkout controls.
1178    ///
1179    /// Untrusted mode must not run such a config. The file chooses the roots,
1180    /// so it is always checked against the configured roots (or, with none,
1181    /// the working directory); checking only those would let
1182    /// `roots = ["/elsewhere"]` evade it. The working directory is also
1183    /// checked when `origin` is the environment or `source` is relative, since
1184    /// a project-scoped client config or `.envrc` launches mcpls there. A
1185    /// working directory that is the filesystem root or the home directory is
1186    /// never taken for a checkout.
1187    ///
1188    /// # Errors
1189    ///
1190    /// [`Error::ConfigInsideWorkspace`] when `source` is inside; [`Error::Io`]
1191    /// when `source` or the working directory cannot be resolved.
1192    pub fn ensure_outside_workspace(&self, source: &Path, origin: ConfigOrigin) -> Result<()> {
1193        let canonical = dunce::canonicalize(source).map_err(Error::Io)?;
1194        let roots = WorkspaceRoots::from_configured(&self.workspace.roots)?;
1195        let home = trust::login_home_dir();
1196        let roots = roots.untrusted_boundary(&self.workspace.roots, home.as_deref());
1197        let cwd_matters = origin == ConfigOrigin::Environment || !source.is_absolute();
1198        let cwd = cwd_matters
1199            .then(|| WorkspaceRoots::from_configured(&[]))
1200            .transpose()?
1201            .map(|cwd| cwd.checkout_scoped(home.as_deref()));
1202        if roots.contains_canonical(&canonical)
1203            || cwd.is_some_and(|cwd| cwd.contains_canonical(&canonical))
1204        {
1205            return Err(Error::ConfigInsideWorkspace { path: canonical });
1206        }
1207        Ok(())
1208    }
1209}
1210
1211impl Default for ServerConfig {
1212    fn default() -> Self {
1213        Self {
1214            mcp: McpConfig::default(),
1215            workspace: WorkspaceConfig::default(),
1216            lsp_servers: vec![
1217                LspServerConfig::rust_analyzer(),
1218                LspServerConfig::pyright(),
1219                LspServerConfig::typescript(),
1220                LspServerConfig::gopls(),
1221                LspServerConfig::clangd(),
1222                LspServerConfig::zls(),
1223            ],
1224            project_config_status: ProjectConfigStatus::NotIgnored,
1225            workspace_trust: WorkspaceTrust::default(),
1226        }
1227    }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use std::collections::HashSet;
1233    use std::{assert_matches, fs};
1234
1235    use tempfile::TempDir;
1236
1237    use super::*;
1238    #[cfg(unix)]
1239    use crate::bridge::ProcessCwd;
1240    use crate::bridge::{
1241        INDEXING_STALENESS_BOUND, PROGRESS_SETTLE, PositionEncoding, WorkspaceRoots,
1242    };
1243    #[cfg(unix)]
1244    use crate::test_lsp::client_path;
1245    use crate::test_lsp::toml_path_literal;
1246    use crate::util::SizeExceeded;
1247
1248    #[test]
1249    fn test_default_commands_match_builtin_servers() {
1250        let config = ServerConfig::default();
1251        let defaults: HashSet<&str> = config
1252            .lsp_servers
1253            .iter()
1254            .map(|s| s.command.as_str())
1255            .collect();
1256        let builtins: HashSet<&str> = BuiltinServer::ALL.iter().map(|b| b.command()).collect();
1257        assert_eq!(defaults, builtins);
1258    }
1259
1260    #[test]
1261    fn test_builtin_server_npm_table() {
1262        let npm: Vec<_> = BuiltinServer::ALL
1263            .into_iter()
1264            .filter(|b| b.is_npm_package())
1265            .collect();
1266        assert_eq!(
1267            npm,
1268            [
1269                BuiltinServer::Pyright,
1270                BuiltinServer::TypescriptLanguageServer
1271            ]
1272        );
1273    }
1274
1275    #[test]
1276    fn test_builtin_server_roundtrip_and_hint() {
1277        for b in BuiltinServer::ALL {
1278            assert_eq!(BuiltinServer::from_command(b.command()), Some(b));
1279            assert_ne!(b.install_hint(), "");
1280        }
1281    }
1282
1283    #[test]
1284    fn test_default_config() {
1285        let config = ServerConfig::default();
1286        assert_eq!(config.lsp_servers.len(), 6);
1287        assert_eq!(config.lsp_servers[0].language_id, "rust");
1288        assert_eq!(config.lsp_servers[1].language_id, "python");
1289        assert_eq!(config.lsp_servers[2].language_id, "typescript");
1290        assert_eq!(config.lsp_servers[3].language_id, "go");
1291        assert_eq!(config.lsp_servers[4].language_id, "cpp");
1292        assert_eq!(config.lsp_servers[5].language_id, "zig");
1293        assert_eq!(
1294            config.workspace.position_encodings,
1295            PositionEncodings::DEFAULT
1296        );
1297    }
1298
1299    #[test]
1300    fn test_default_position_encodings() {
1301        assert_eq!(
1302            PositionEncodings::DEFAULT.as_slice(),
1303            [PositionEncoding::Utf8, PositionEncoding::Utf16]
1304        );
1305    }
1306
1307    #[test]
1308    fn test_load_from_valid_toml() {
1309        let tmp_dir = TempDir::new().unwrap();
1310        let config_path = tmp_dir.path().join("config.toml");
1311        let workspace_root = tmp_dir.path().join("workspace");
1312        fs::create_dir(&workspace_root).unwrap();
1313        let workspace_root_literal = toml_path_literal(&workspace_root);
1314
1315        let toml_content = format!(
1316            r#"
1317            [workspace]
1318            roots = [{workspace_root_literal}]
1319            position_encodings = ["utf-8"]
1320
1321            [[lsp_servers]]
1322            language_id = "rust"
1323            command = "rust-analyzer"
1324            timeout_seconds = 30
1325        "#
1326        );
1327
1328        fs::write(&config_path, &toml_content).unwrap();
1329
1330        let config = ServerConfig::load_from(&config_path).unwrap();
1331        assert_eq!(
1332            config.workspace.roots,
1333            vec![ConfiguredRoot::new(workspace_root).unwrap()]
1334        );
1335        assert_eq!(
1336            config.workspace.position_encodings.as_slice(),
1337            [PositionEncoding::Utf8]
1338        );
1339        assert_eq!(config.lsp_servers.len(), 1);
1340        assert_eq!(config.lsp_servers[0].language_id, "rust");
1341    }
1342
1343    #[test]
1344    fn test_load_from_resolves_relative_roots_against_config_directory() {
1345        let tmp_dir = TempDir::new().unwrap();
1346        let project_root = dunce::canonicalize(tmp_dir.path()).unwrap();
1347        let config_dir = project_root.join(".agents");
1348        fs::create_dir(&config_dir).unwrap();
1349        let config_path = config_dir.join("mcpls.toml");
1350        fs::write(
1351            &config_path,
1352            r#"
1353                [workspace]
1354                roots = [".", ".."]
1355            "#,
1356        )
1357        .unwrap();
1358
1359        let config = ServerConfig::load_from(&config_path).unwrap();
1360
1361        assert!(
1362            config
1363                .workspace
1364                .roots
1365                .iter()
1366                .all(|root| root.as_path().is_absolute())
1367        );
1368        let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
1369        assert_eq!(roots.canonical(), [config_dir, project_root]);
1370    }
1371
1372    /// #348 case 2: unlike `load_from`'s `ConfigDir` default (see
1373    /// `test_load_from_resolves_relative_roots_against_config_directory`
1374    /// above), `load_with_trust`'s auto-discovered global/user config uses
1375    /// `RelativeRootBase::Cwd` so a relative root resolves against the
1376    /// process cwd instead of `~/.config/mcpls/`. Exercises the private
1377    /// `load_from_with_root_base` helper directly, since the global config
1378    /// path itself lives under `dirs::config_dir()`, which tests cannot
1379    /// override without mutating process-wide env state (denied by this
1380    /// crate's `unsafe_code` lint).
1381    #[test]
1382    fn test_load_from_with_root_base_cwd_resolves_relative_roots_against_cwd() {
1383        let config_tmp_dir = TempDir::new().unwrap();
1384        let config_dir = dunce::canonicalize(config_tmp_dir.path()).unwrap();
1385        let config_path = config_dir.join("mcpls.toml");
1386        fs::write(&config_path, "[workspace]\nroots = [\"relative-root\"]\n").unwrap();
1387
1388        let cwd_tmp_dir = TempDir::new().unwrap();
1389        let cwd = dunce::canonicalize(cwd_tmp_dir.path()).unwrap();
1390        let expected_root = cwd.join("relative-root");
1391        fs::create_dir(&expected_root).unwrap();
1392
1393        let config = {
1394            let _guard = CwdGuard::enter(&cwd);
1395            ServerConfig::load_from_with_root_base(&config_path, RelativeRootBase::Cwd).unwrap()
1396        };
1397
1398        assert_eq!(
1399            config.workspace.roots,
1400            vec![ConfiguredRoot::new("relative-root").unwrap()]
1401        );
1402        let roots = {
1403            let _guard = CwdGuard::enter(&cwd);
1404            WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
1405        };
1406        assert_eq!(roots.canonical(), [expected_root]);
1407    }
1408
1409    #[test]
1410    fn test_load_from_rejects_nonexistent_relative_workspace_root() {
1411        let tmp_dir = TempDir::new().unwrap();
1412        let config_path = tmp_dir.path().join("mcpls.toml");
1413        fs::write(&config_path, "[workspace]\nroots = [\"missing\"]\n").unwrap();
1414
1415        let err = ServerConfig::load_from(&config_path).unwrap_err();
1416
1417        let Error::Config(config_err) = err else {
1418            panic!("expected Config, got {err:?}");
1419        };
1420        let message = config_err.to_string();
1421        assert!(message.contains("workspace root 'missing'"));
1422        assert!(message.contains(&tmp_dir.path().display().to_string()));
1423    }
1424
1425    /// A temp tree with `real/{src/main.rs, out -> outside}` and
1426    /// `link -> real`, all under a canonical base.
1427    #[cfg(unix)]
1428    struct SymlinkTree {
1429        _dir: TempDir,
1430        base: PathBuf,
1431        real: PathBuf,
1432        link: PathBuf,
1433    }
1434
1435    #[cfg(unix)]
1436    fn symlink_tree() -> SymlinkTree {
1437        let dir = TempDir::new().unwrap();
1438        let base = dunce::canonicalize(dir.path()).unwrap();
1439        let real = base.join("real");
1440        fs::create_dir_all(real.join("src")).unwrap();
1441        fs::write(real.join("src/main.rs"), "fn main() {}").unwrap();
1442        let outside = base.join("outside");
1443        fs::create_dir(&outside).unwrap();
1444        fs::write(outside.join("secret.rs"), "").unwrap();
1445        std::os::unix::fs::symlink(&outside, real.join("out")).unwrap();
1446        let link = base.join("link");
1447        std::os::unix::fs::symlink(&real, &link).unwrap();
1448        SymlinkTree {
1449            _dir: dir,
1450            base,
1451            real,
1452            link,
1453        }
1454    }
1455
1456    #[cfg(unix)]
1457    fn load_roots(config_path: &Path) -> WorkspaceRoots {
1458        let config = ServerConfig::load_from(config_path).unwrap();
1459        WorkspaceRoots::from_configured(&config.workspace.roots).unwrap()
1460    }
1461
1462    /// #571 RT-001: the configured spelling of a file-loaded root survives
1463    /// the hand-off to the path validator.
1464    #[cfg(unix)]
1465    #[test]
1466    fn test_load_from_symlinked_absolute_root_admits_configured_spelling() {
1467        let tree = symlink_tree();
1468        let config_path = tree.base.join("cfg.toml");
1469        let literal = toml_path_literal(&tree.link);
1470        fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
1471
1472        let config = ServerConfig::load_from(&config_path).unwrap();
1473        assert_eq!(
1474            config.workspace.roots,
1475            vec![ConfiguredRoot::new(tree.link.clone()).unwrap()]
1476        );
1477        let roots = WorkspaceRoots::from_configured(&config.workspace.roots).unwrap();
1478
1479        let via_link = roots
1480            .validate_blocking(&client_path(tree.link.join("src/main.rs")))
1481            .map(crate::bridge::WorkspacePath::into_path_buf);
1482        assert_eq!(via_link.unwrap(), tree.real.join("src/main.rs"));
1483        let via_real = roots
1484            .validate_blocking(&client_path(tree.real.join("src/main.rs")))
1485            .map(crate::bridge::WorkspacePath::into_path_buf);
1486        assert!(via_real.is_ok());
1487    }
1488
1489    /// #571 RT-003: an inside-root symlink pointing out stays rejected under
1490    /// both spellings.
1491    #[cfg(unix)]
1492    #[test]
1493    fn test_load_from_symlinked_root_still_rejects_escape_symlink() {
1494        let tree = symlink_tree();
1495        let config_path = tree.base.join("cfg.toml");
1496        let literal = toml_path_literal(&tree.link);
1497        fs::write(&config_path, format!("[workspace]\nroots = [{literal}]\n")).unwrap();
1498        let roots = load_roots(&config_path);
1499
1500        for root in [&tree.link, &tree.real] {
1501            let err = roots
1502                .validate_blocking(&client_path(root.join("out/secret.rs")))
1503                .map(crate::bridge::WorkspacePath::into_path_buf)
1504                .unwrap_err();
1505            assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
1506        }
1507    }
1508
1509    /// #571 RT-004: a relative root joined to a config directory that is
1510    /// itself reached through a symlink keeps that spelling.
1511    #[cfg(unix)]
1512    #[test]
1513    fn test_load_from_relative_root_keeps_symlinked_config_dir_spelling() {
1514        let tree = symlink_tree();
1515        let cfg_real = tree.base.join("cfg_real");
1516        fs::create_dir(&cfg_real).unwrap();
1517        std::os::unix::fs::symlink(&tree.real, cfg_real.join("proj")).unwrap();
1518        let cfg_link = tree.base.join("cfg_link");
1519        std::os::unix::fs::symlink(&cfg_real, &cfg_link).unwrap();
1520        fs::write(
1521            cfg_real.join("mcpls.toml"),
1522            "[workspace]\nroots = [\"proj\"]\n",
1523        )
1524        .unwrap();
1525
1526        let roots = load_roots(&cfg_link.join("mcpls.toml"));
1527
1528        let admitted = roots
1529            .validate_blocking(&client_path(cfg_link.join("proj/src/main.rs")))
1530            .map(crate::bridge::WorkspacePath::into_path_buf);
1531        assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
1532    }
1533
1534    /// #571 S1: a relative config path (the trusted project-local
1535    /// `mcpls.toml`) keeps `.` relative so the logical `$PWD` spelling is
1536    /// admitted at serve time.
1537    #[cfg(unix)]
1538    #[test]
1539    fn test_load_from_relative_config_path_keeps_pwd_spelling() {
1540        let tree = symlink_tree();
1541        fs::write(
1542            tree.real.join("mcpls.toml"),
1543            "[workspace]\nroots = [\".\"]\n",
1544        )
1545        .unwrap();
1546
1547        let config = {
1548            let _guard = CwdGuard::enter(&tree.real);
1549            ServerConfig::load_from(Path::new("mcpls.toml")).unwrap()
1550        };
1551        assert!(
1552            config
1553                .workspace
1554                .roots
1555                .iter()
1556                .all(|root| root.as_path().is_relative())
1557        );
1558
1559        let roots = WorkspaceRoots::from_configured_with(&config.workspace.roots, || {
1560            Ok(ProcessCwd::new(
1561                tree.real.clone(),
1562                Some(tree.link.clone().into_os_string()),
1563            ))
1564        })
1565        .unwrap();
1566
1567        let admitted = roots
1568            .validate_blocking(&client_path(tree.link.join("src/main.rs")))
1569            .map(crate::bridge::WorkspacePath::into_path_buf);
1570        assert_eq!(admitted.unwrap(), tree.real.join("src/main.rs"));
1571    }
1572
1573    /// #348 class: an absolute config path with relative roots never needs
1574    /// the working directory, so a removed cwd must not fail the load.
1575    #[cfg(unix)]
1576    #[test]
1577    fn test_load_from_absolute_config_path_does_not_read_removed_cwd() {
1578        let tree = symlink_tree();
1579        fs::create_dir(tree.base.join("proj")).unwrap();
1580        let config_path = tree.base.join("mcpls.toml");
1581        fs::write(&config_path, "[workspace]\nroots = [\"proj\"]\n").unwrap();
1582        let doomed = TempDir::new().unwrap();
1583
1584        let config = {
1585            let _guard = CwdGuard::enter(doomed.path());
1586            fs::remove_dir(doomed.path()).unwrap();
1587            ServerConfig::load_from(&config_path).unwrap()
1588        };
1589
1590        assert_eq!(
1591            config.workspace.roots,
1592            vec![ConfiguredRoot::new(tree.base.join("proj")).unwrap()]
1593        );
1594    }
1595
1596    /// #571 S2: `..` after a symlinked config directory must not admit the
1597    /// lexical parent, which is a different tree than the physical one.
1598    #[cfg(unix)]
1599    #[test]
1600    fn test_load_from_parent_root_through_symlinked_config_dir_records_no_alias() {
1601        let tree = symlink_tree();
1602        let data = tree.base.join("data/a/b");
1603        fs::create_dir_all(&data).unwrap();
1604        let home = tree.base.join("home");
1605        fs::create_dir(&home).unwrap();
1606        let proj_link = home.join("proj_link");
1607        std::os::unix::fs::symlink(&data, &proj_link).unwrap();
1608        fs::write(data.join("mcpls.toml"), "[workspace]\nroots = [\"..\"]\n").unwrap();
1609        fs::write(home.join("other.rs"), "").unwrap();
1610
1611        let roots = load_roots(&proj_link.join("mcpls.toml"));
1612
1613        assert_eq!(roots.canonical(), [tree.base.join("data/a")]);
1614        let err = roots
1615            .validate_blocking(&client_path(home.join("other.rs")))
1616            .map(crate::bridge::WorkspacePath::into_path_buf)
1617            .unwrap_err();
1618        assert_matches!(err, Error::PathOutsideWorkspace(_), "{err:?}");
1619    }
1620
1621    /// A missing root is reported as written, not as the rebased entry.
1622    #[test]
1623    fn test_load_from_relative_config_path_reports_missing_root_as_written() {
1624        let tmp_dir = TempDir::new().unwrap();
1625        fs::write(
1626            tmp_dir.path().join("mcpls.toml"),
1627            "[workspace]\nroots = [\"missing\"]\n",
1628        )
1629        .unwrap();
1630
1631        let err = {
1632            let _guard = CwdGuard::enter(tmp_dir.path());
1633            ServerConfig::load_from(Path::new("mcpls.toml")).unwrap_err()
1634        };
1635
1636        let Error::Config(config_err) = err else {
1637            panic!("expected Config, got {err:?}");
1638        };
1639        let message = config_err.to_string();
1640        assert!(message.contains("workspace root 'missing'"), "{message}");
1641    }
1642
1643    #[test]
1644    fn test_load_from_toml_without_request_timeout_seconds_defaults_to_thirty() {
1645        // Mirrors the shape of every auto-generated pre-#267 config file:
1646        // `timeout_seconds` present, `request_timeout_seconds` absent.
1647        let tmp_dir = TempDir::new().unwrap();
1648        let config_path = tmp_dir.path().join("config.toml");
1649
1650        let toml_content = r#"
1651            [[lsp_servers]]
1652            language_id = "rust"
1653            command = "rust-analyzer"
1654            timeout_seconds = 30
1655        "#;
1656
1657        fs::write(&config_path, toml_content).unwrap();
1658
1659        let config = ServerConfig::load_from(&config_path).unwrap();
1660        assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), 30);
1661    }
1662
1663    #[test]
1664    fn test_validate_rejects_heuristics_max_depth_above_max() {
1665        let tmp_dir = TempDir::new().unwrap();
1666        let config_path = tmp_dir.path().join("config.toml");
1667
1668        let toml_content = format!(
1669            "[workspace]\nheuristics_max_depth = {}\n",
1670            MAX_HEURISTICS_DEPTH + 1
1671        );
1672        fs::write(&config_path, toml_content).unwrap();
1673
1674        let result = ServerConfig::load_from(&config_path);
1675        assert_matches!(
1676            result,
1677            Err(Error::TomlDe(ref e))
1678                if e.message().contains("heuristics_max_depth")
1679                    && e.message().contains("exceeds the maximum")
1680        );
1681    }
1682
1683    #[test]
1684    fn test_validate_accepts_heuristics_max_depth_below_max() {
1685        for depth in [1, MAX_HEURISTICS_DEPTH - 1] {
1686            let mut config = ServerConfig::default();
1687            config.workspace.heuristics_max_depth = SearchDepth::new(depth).unwrap();
1688            assert!(config.validate().is_ok(), "depth {depth} must be accepted");
1689        }
1690    }
1691
1692    #[test]
1693    fn test_validate_max_file_size_bound() {
1694        for (size, ok) in [
1695            (0, true),
1696            (MAX_FILE_SIZE_LIMIT, true),
1697            (MAX_FILE_SIZE_LIMIT + 1, false),
1698        ] {
1699            let result =
1700                toml::from_str::<ServerConfig>(&format!("[workspace]\nmax_file_size = {size}\n"));
1701            assert_eq!(result.is_ok(), ok, "max_file_size {size}: {result:?}");
1702            if let Err(e) = result {
1703                assert!(e.message().contains("max_file_size"));
1704            }
1705        }
1706    }
1707
1708    #[test]
1709    fn test_validate_accepts_heuristics_max_depth_at_max() {
1710        let tmp_dir = TempDir::new().unwrap();
1711        let config_path = tmp_dir.path().join("config.toml");
1712
1713        let toml_content = format!("[workspace]\nheuristics_max_depth = {MAX_HEURISTICS_DEPTH}\n");
1714        fs::write(&config_path, toml_content).unwrap();
1715
1716        let config = ServerConfig::load_from(&config_path).unwrap();
1717        assert_eq!(
1718            config.workspace.heuristics_max_depth.get(),
1719            MAX_HEURISTICS_DEPTH
1720        );
1721    }
1722
1723    #[test]
1724    fn test_validate_accepts_request_timeout_seconds_at_max() {
1725        let tmp_dir = TempDir::new().unwrap();
1726        let config_path = tmp_dir.path().join("config.toml");
1727
1728        let toml_content = format!(
1729            r#"
1730            [[lsp_servers]]
1731            language_id = "rust"
1732            command = "rust-analyzer"
1733            request_timeout_seconds = {MAX_TIMEOUT_SECONDS}
1734        "#
1735        );
1736
1737        fs::write(&config_path, toml_content).unwrap();
1738
1739        let result = ServerConfig::load_from(&config_path);
1740        assert!(result.is_ok(), "expected Ok, got {result:?}");
1741    }
1742
1743    #[test]
1744    fn test_validate_accepts_timeout_seconds_at_max() {
1745        let tmp_dir = TempDir::new().unwrap();
1746        let config_path = tmp_dir.path().join("config.toml");
1747
1748        let toml_content = format!(
1749            r#"
1750            [[lsp_servers]]
1751            language_id = "rust"
1752            command = "rust-analyzer"
1753            timeout_seconds = {MAX_TIMEOUT_SECONDS}
1754        "#
1755        );
1756
1757        fs::write(&config_path, toml_content).unwrap();
1758
1759        let result = ServerConfig::load_from(&config_path);
1760        assert!(result.is_ok(), "expected Ok, got {result:?}");
1761    }
1762
1763    #[test]
1764    fn test_validate_accepts_indexing_ready_timeout_seconds_within_bounds() {
1765        let tmp_dir = TempDir::new().unwrap();
1766        let config_path = tmp_dir.path().join("config.toml");
1767
1768        fs::write(
1769            &config_path,
1770            r"
1771            [workspace]
1772            indexing_ready_timeout_seconds = 45
1773        ",
1774        )
1775        .unwrap();
1776
1777        let result = ServerConfig::load_from(&config_path);
1778        assert!(result.is_ok(), "expected Ok, got {result:?}");
1779        assert_eq!(
1780            result
1781                .unwrap()
1782                .workspace
1783                .indexing_ready_timeout_seconds
1784                .get(),
1785            45
1786        );
1787    }
1788
1789    #[test]
1790    fn test_indexing_ready_timeout_seconds_default() {
1791        let config = ServerConfig::default();
1792        assert_eq!(
1793            config.workspace.indexing_ready_timeout_seconds,
1794            IndexingReadyTimeoutSecs::DEFAULT
1795        );
1796    }
1797
1798    #[test]
1799    fn test_max_concurrent_server_starts_default_and_toml() {
1800        assert_eq!(
1801            ServerConfig::default()
1802                .workspace
1803                .max_concurrent_server_starts,
1804            ServerStartConcurrency::DEFAULT
1805        );
1806        let config: ServerConfig =
1807            toml::from_str("[workspace]\nmax_concurrent_server_starts = 3").unwrap();
1808        assert_eq!(config.workspace.max_concurrent_server_starts.get(), 3);
1809    }
1810
1811    #[test]
1812    fn test_max_concurrent_server_starts_rejects_zero() {
1813        let err = toml::from_str::<ServerConfig>("[workspace]\nmax_concurrent_server_starts = 0")
1814            .unwrap_err();
1815        assert!(err.to_string().contains("at least 1"), "{err}");
1816    }
1817
1818    /// Loads `toml_content` and asserts it is rejected during deserialization
1819    /// with a message containing every one of `needles`.
1820    fn assert_toml_rejected(toml_content: &str, needles: &[&str]) {
1821        let tmp_dir = TempDir::new().unwrap();
1822        let config_path = tmp_dir.path().join("config.toml");
1823        fs::write(&config_path, toml_content).unwrap();
1824
1825        let err = ServerConfig::load_from(&config_path).unwrap_err();
1826        let Error::TomlDe(source) = err else {
1827            panic!("expected TomlDe, got {err:?}");
1828        };
1829        let message = source.to_string();
1830        for needle in needles {
1831            assert!(message.contains(needle), "{needle:?} not in {message}");
1832        }
1833    }
1834
1835    #[test]
1836    fn test_load_rejects_out_of_range_timeouts() {
1837        for field in ["timeout_seconds", "request_timeout_seconds"] {
1838            for value in [0, MAX_TIMEOUT_SECONDS + 1] {
1839                assert_toml_rejected(
1840                    &format!(
1841                        "[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n{field} = {value}\n"
1842                    ),
1843                    &["must be between 1 and 900 seconds", &format!("got {value}")],
1844                );
1845            }
1846        }
1847    }
1848
1849    #[test]
1850    fn test_load_accepts_timeouts_at_bounds() {
1851        for value in [1, MAX_TIMEOUT_SECONDS] {
1852            let config: ServerConfig = toml::from_str(&format!(
1853                "[[lsp_servers]]\nlanguage_id = \"rust\"\ncommand = \"rust-analyzer\"\n\
1854                 timeout_seconds = {value}\nrequest_timeout_seconds = {value}\n"
1855            ))
1856            .unwrap();
1857            assert_eq!(config.lsp_servers[0].timeout_seconds.get(), value);
1858            assert_eq!(config.lsp_servers[0].request_timeout_seconds.get(), value);
1859        }
1860    }
1861
1862    #[test]
1863    fn test_load_rejects_indexing_ready_timeout_outside_open_interval() {
1864        for value in [
1865            PROGRESS_SETTLE.as_secs(),
1866            INDEXING_STALENESS_BOUND.as_secs(),
1867        ] {
1868            assert_toml_rejected(
1869                &format!("[workspace]\nindexing_ready_timeout_seconds = {value}\n"),
1870                &["must be between", &format!("got {value}")],
1871            );
1872        }
1873    }
1874
1875    #[test]
1876    fn test_load_accepts_indexing_ready_timeout_at_bounds() {
1877        for value in [
1878            PROGRESS_SETTLE.as_secs() + 1,
1879            INDEXING_STALENESS_BOUND.as_secs() - 1,
1880        ] {
1881            let config: ServerConfig = toml::from_str(&format!(
1882                "[workspace]\nindexing_ready_timeout_seconds = {value}\n"
1883            ))
1884            .unwrap();
1885            assert_eq!(config.workspace.indexing_ready_timeout_seconds.get(), value);
1886        }
1887    }
1888
1889    #[test]
1890    fn test_load_rejects_empty_language_id() {
1891        assert_toml_rejected(
1892            "[[lsp_servers]]\nlanguage_id = \"\"\ncommand = \"x\"\n",
1893            &["language_id cannot be blank"],
1894        );
1895    }
1896
1897    #[test]
1898    fn test_load_rejects_empty_position_encodings() {
1899        assert_toml_rejected(
1900            "[workspace]\nposition_encodings = []\n",
1901            &["position_encodings cannot be empty"],
1902        );
1903    }
1904
1905    #[test]
1906    fn test_load_rejects_unrecognized_position_encoding() {
1907        assert_toml_rejected(
1908            "[workspace]\nposition_encodings = [\"utf-8\", \"utf-7\"]\n",
1909            &["utf-7", "utf-8", "utf-16", "utf-32"],
1910        );
1911    }
1912
1913    #[test]
1914    fn test_default_config_round_trips_through_toml() {
1915        let original = ServerConfig::default();
1916        let text = toml::to_string(&original).unwrap();
1917        let reloaded: ServerConfig = toml::from_str(&text).unwrap();
1918        assert_eq!(
1919            reloaded.workspace.position_encodings,
1920            original.workspace.position_encodings
1921        );
1922        assert_eq!(
1923            reloaded.workspace.indexing_ready_timeout_seconds,
1924            original.workspace.indexing_ready_timeout_seconds
1925        );
1926        assert_eq!(reloaded.lsp_servers.len(), original.lsp_servers.len());
1927        for (a, b) in reloaded.lsp_servers.iter().zip(&original.lsp_servers) {
1928            assert_eq!(a.language_id, b.language_id);
1929            assert_eq!(a.timeout_seconds, b.timeout_seconds);
1930            assert_eq!(a.request_timeout_seconds, b.request_timeout_seconds);
1931        }
1932    }
1933
1934    #[test]
1935    fn test_default_config_keeps_typescript_selection_auto_through_toml() {
1936        let text = toml::to_string(&ServerConfig::default()).unwrap();
1937        assert_eq!(text.matches("selection = \"auto\"").count(), 1, "{text}");
1938        let reloaded: ServerConfig = toml::from_str(&text).unwrap();
1939        for server in &reloaded.lsp_servers {
1940            let expected = if server.language_id == "typescript" {
1941                ServerSelection::Auto
1942            } else {
1943                ServerSelection::Explicit
1944            };
1945            assert_eq!(
1946                server.command.selection(),
1947                expected,
1948                "{}",
1949                server.language_id
1950            );
1951        }
1952    }
1953
1954    #[test]
1955    fn test_entry_without_selection_key_is_explicit() {
1956        let config: ServerConfig = toml::from_str(
1957            r#"
1958            [[lsp_servers]]
1959            language_id = "typescript"
1960            command = "typescript-language-server"
1961            "#,
1962        )
1963        .unwrap();
1964        assert_eq!(
1965            config.lsp_servers[0].command.selection(),
1966            ServerSelection::Explicit
1967        );
1968    }
1969
1970    #[test]
1971    fn test_auto_selection_accepts_tsls_command_spellings() {
1972        for command in [
1973            "typescript-language-server",
1974            "/opt/node/bin/typescript-language-server",
1975            "typescript-language-server.cmd",
1976        ] {
1977            let mut typescript = LspServerConfig::typescript();
1978            typescript.command =
1979                LaunchCommand::auto(ServerCommand::new(command.to_string()).unwrap()).unwrap();
1980            let config = ServerConfig {
1981                lsp_servers: vec![typescript],
1982                ..ServerConfig::default()
1983            };
1984            assert!(config.validate().is_ok(), "{command}");
1985        }
1986    }
1987
1988    #[test]
1989    fn test_auto_selection_is_unrepresentable_on_other_servers() {
1990        let rust = LspServerConfig::rust_analyzer();
1991        assert!(LaunchCommand::auto(rust.command.server_command().clone()).is_err());
1992
1993        let err = toml::from_str::<ServerConfig>(
1994            r#"
1995            [[lsp_servers]]
1996            language_id = "rust"
1997            command = "rust-analyzer"
1998            selection = "auto"
1999            "#,
2000        )
2001        .unwrap_err();
2002        assert!(
2003            err.message()
2004                .contains("selection = \"auto\" is only valid for typescript-language-server"),
2005            "{err}"
2006        );
2007    }
2008
2009    #[test]
2010    fn test_load_from_nonexistent_file() {
2011        let result = ServerConfig::load_from(Path::new("/nonexistent/config.toml"));
2012        assert!(result.is_err());
2013
2014        if let Err(Error::ConfigNotFound(path)) = result {
2015            assert_eq!(path, PathBuf::from("/nonexistent/config.toml"));
2016        } else {
2017            panic!("Expected ConfigNotFound error");
2018        }
2019    }
2020
2021    #[test]
2022    fn test_load_from_invalid_toml() {
2023        let tmp_dir = TempDir::new().unwrap();
2024        let config_path = tmp_dir.path().join("invalid.toml");
2025
2026        fs::write(&config_path, "invalid toml content {{}").unwrap();
2027
2028        let result = ServerConfig::load_from(&config_path);
2029        assert!(result.is_err());
2030    }
2031
2032    /// #309: a config file larger than `MAX_CONFIG_FILE_BYTES` must be
2033    /// rejected before `read_to_string` buffers it, not merely fail to
2034    /// parse as TOML afterward.
2035    #[test]
2036    fn test_load_from_rejects_oversized_file() {
2037        let tmp_dir = TempDir::new().unwrap();
2038        let config_path = tmp_dir.path().join("oversized.toml");
2039
2040        // One byte over the cap; content doesn't need to be valid TOML since
2041        // the size check runs before parsing.
2042        let oversized = "#".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() + 1);
2043        fs::write(&config_path, &oversized).unwrap();
2044
2045        let result = ServerConfig::load_from(&config_path);
2046        assert_matches!(
2047            result,
2048            Err(Error::Config(ConfigError::FileTooLarge(SizeExceeded { max, .. })))
2049                if max == MAX_CONFIG_FILE_BYTES
2050        );
2051    }
2052
2053    #[test]
2054    fn test_load_from_accepts_file_at_exact_size_cap() {
2055        let tmp_dir = TempDir::new().unwrap();
2056        let config_path = tmp_dir.path().join("exact.toml");
2057
2058        // Pad a valid, minimal TOML document with a trailing comment up to
2059        // exactly the cap -- the boundary itself must not be rejected.
2060        let mut toml_content = "[workspace]\n# ".to_string();
2061        toml_content.push_str(
2062            &"a".repeat(usize::try_from(MAX_CONFIG_FILE_BYTES.get()).unwrap() - toml_content.len()),
2063        );
2064        assert_eq!(toml_content.len() as u64, MAX_CONFIG_FILE_BYTES.get());
2065        fs::write(&config_path, &toml_content).unwrap();
2066
2067        let result = ServerConfig::load_from(&config_path);
2068        assert!(result.is_ok(), "expected Ok, got {result:?}");
2069    }
2070
2071    /// #309 S1: `std::fs::metadata` reports `len() == 0` for character
2072    /// devices regardless of how much data they can actually produce --
2073    /// `/dev/zero` is the canonical example. The shared open-and-verify
2074    /// (#632) rejects it as not a regular file before any read.
2075    #[cfg(unix)]
2076    #[test]
2077    fn test_load_from_rejects_infinite_special_file() {
2078        let path = Path::new("/dev/zero");
2079        assert_eq!(
2080            fs::metadata(path).unwrap().len(),
2081            0,
2082            "test assumption: /dev/zero must report zero length"
2083        );
2084
2085        let result = ServerConfig::load_from(path);
2086        assert_matches!(result, Err(Error::NotARegularFile(ref p)) if p == path);
2087    }
2088
2089    /// #632: a FIFO config path (`--config <(...)`) is rejected at once
2090    /// instead of blocking on a writer.
2091    #[cfg(unix)]
2092    #[test]
2093    fn test_load_from_rejects_fifo_without_blocking() {
2094        let tmp_dir = TempDir::new().unwrap();
2095        let fifo = tmp_dir.path().join("config.fifo");
2096        crate::test_lsp::make_fifo(&fifo);
2097
2098        let (tx, rx) = std::sync::mpsc::channel();
2099        let path = fifo.clone();
2100        std::thread::spawn(move || tx.send(ServerConfig::load_from(&path)).unwrap());
2101        let result = rx
2102            .recv_timeout(std::time::Duration::from_secs(5))
2103            .unwrap_or_else(|_| panic!("loading a FIFO config must not block"));
2104        assert_matches!(result, Err(Error::NotARegularFile(ref p)) if *p == fifo);
2105    }
2106
2107    #[test]
2108    fn test_load_from_rejects_directory_as_not_regular() {
2109        let tmp_dir = TempDir::new().unwrap();
2110        let result = ServerConfig::load_from(tmp_dir.path());
2111        #[cfg(unix)]
2112        assert_matches!(result, Err(Error::NotARegularFile(_)));
2113        #[cfg(not(unix))]
2114        assert!(result.is_err());
2115    }
2116
2117    #[test]
2118    fn test_validate_empty_command() {
2119        let tmp_dir = TempDir::new().unwrap();
2120        let config_path = tmp_dir.path().join("config.toml");
2121
2122        let toml_content = r#"
2123            [[lsp_servers]]
2124            language_id = "rust"
2125            command = ""
2126        "#;
2127
2128        fs::write(&config_path, toml_content).unwrap();
2129
2130        let result = ServerConfig::load_from(&config_path);
2131
2132        assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("command cannot be blank"));
2133    }
2134
2135    #[test]
2136    fn test_blank_server_name_is_rejected_at_load() {
2137        for name in ["", "  ", "\\t"] {
2138            let toml = format!(
2139                "[[lsp_servers]]\nname = \"{name}\"\nlanguage_id = \"python\"\n\
2140                 command = \"pyright-langserver\"\n"
2141            );
2142            let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
2143            assert!(
2144                err.message().contains("server id cannot be blank"),
2145                "{name:?}: {}",
2146                err.message()
2147            );
2148        }
2149    }
2150
2151    #[test]
2152    fn test_validate_empty_handles() {
2153        let tmp_dir = TempDir::new().unwrap();
2154        let config_path = tmp_dir.path().join("config.toml");
2155
2156        let toml_content = r#"
2157            [[lsp_servers]]
2158            language_id = "python"
2159            command = "pylsp"
2160            handles = []
2161        "#;
2162
2163        fs::write(&config_path, toml_content).unwrap();
2164
2165        let result = ServerConfig::load_from(&config_path);
2166
2167        assert_matches!(result, Err(Error::TomlDe(ref e)) if e.message().contains("handles cannot be empty"));
2168    }
2169
2170    #[test]
2171    fn test_validate_duplicate_tool_in_handles() {
2172        let tmp_dir = TempDir::new().unwrap();
2173        let config_path = tmp_dir.path().join("config.toml");
2174
2175        let toml_content = r#"
2176            [[lsp_servers]]
2177            language_id = "python"
2178            command = "pylsp"
2179            handles = ["diagnostics", "diagnostics"]
2180        "#;
2181
2182        fs::write(&config_path, toml_content).unwrap();
2183
2184        let result = ServerConfig::load_from(&config_path);
2185
2186        assert_matches!(
2187            result,
2188            Err(Error::TomlDe(ref e)) if e.message().contains("duplicate tool 'diagnostics'")
2189        );
2190    }
2191
2192    /// #348 M4: `roots = [""]` previously reached workspace-root resolution
2193    /// (an empty path is `is_relative() == true`) and silently resolved to
2194    /// `base_dir` unchanged -- almost certainly not what an empty string in
2195    /// config was meant to express. Deserialization now rejects it outright.
2196    #[test]
2197    fn test_load_rejects_empty_workspace_root_entry() {
2198        let tmp_dir = TempDir::new().unwrap();
2199        let config_path = tmp_dir.path().join("config.toml");
2200
2201        let toml_content = r#"
2202            [workspace]
2203            roots = [""]
2204        "#;
2205
2206        fs::write(&config_path, toml_content).unwrap();
2207
2208        let result = ServerConfig::load_from(&config_path);
2209        assert_matches!(
2210            result,
2211            Err(Error::TomlDe(ref e)) if e.message().contains("workspace.roots entries cannot be empty")
2212        );
2213    }
2214
2215    #[test]
2216    fn test_validate_duplicate_name_warns_but_loads() {
2217        // Duplicate explicit `name` is only an error if both entries end up
2218        // applicable in the same workspace (enforced later by
2219        // `ToolRouter::from_configs`, see routing.rs); at load time it must
2220        // still succeed.
2221        let tmp_dir = TempDir::new().unwrap();
2222        let config_path = tmp_dir.path().join("config.toml");
2223
2224        let toml_content = r#"
2225            [[lsp_servers]]
2226            name = "dup"
2227            language_id = "python"
2228            command = "pyright-langserver"
2229
2230            [[lsp_servers]]
2231            name = "dup"
2232            language_id = "typescript"
2233            command = "typescript-language-server"
2234        "#;
2235
2236        fs::write(&config_path, toml_content).unwrap();
2237
2238        let result = ServerConfig::load_from(&config_path);
2239        assert!(result.is_ok(), "duplicate name must only warn at load time");
2240    }
2241
2242    #[test]
2243    fn test_workspace_config_defaults() {
2244        let workspace = WorkspaceConfig::default();
2245        assert_eq!(workspace.roots.len(), 0);
2246        assert_eq!(workspace.position_encodings, PositionEncodings::DEFAULT);
2247        assert!(!workspace.language_extensions.is_empty());
2248        assert_eq!(workspace.language_extensions.len(), 30);
2249        assert_eq!(
2250            workspace.heuristics_max_depth.get(),
2251            DEFAULT_HEURISTICS_MAX_DEPTH
2252        );
2253    }
2254
2255    #[test]
2256    fn test_load_multiple_servers() {
2257        let tmp_dir = TempDir::new().unwrap();
2258        let config_path = tmp_dir.path().join("multi.toml");
2259
2260        let toml_content = r#"
2261            [[lsp_servers]]
2262            language_id = "rust"
2263            command = "rust-analyzer"
2264
2265            [[lsp_servers]]
2266            language_id = "python"
2267            command = "pyright-langserver"
2268            args = ["--stdio"]
2269        "#;
2270
2271        fs::write(&config_path, toml_content).unwrap();
2272
2273        let config = ServerConfig::load_from(&config_path).unwrap();
2274        assert_eq!(config.lsp_servers.len(), 2);
2275        assert_eq!(config.lsp_servers[0].language_id, "rust");
2276        assert_eq!(config.lsp_servers[1].language_id, "python");
2277        assert_eq!(config.lsp_servers[1].args, vec!["--stdio"]);
2278    }
2279
2280    #[test]
2281    fn test_deny_unknown_fields() {
2282        let tmp_dir = TempDir::new().unwrap();
2283        let config_path = tmp_dir.path().join("unknown.toml");
2284
2285        let toml_content = r#"
2286            unknown_field = "value"
2287
2288            [workspace]
2289            roots = []
2290        "#;
2291
2292        fs::write(&config_path, toml_content).unwrap();
2293
2294        let result = ServerConfig::load_from(&config_path);
2295        assert!(result.is_err(), "Should reject unknown fields");
2296    }
2297
2298    #[test]
2299    fn test_empty_config_file() {
2300        let tmp_dir = TempDir::new().unwrap();
2301        let config_path = tmp_dir.path().join("empty.toml");
2302
2303        fs::write(&config_path, "").unwrap();
2304
2305        let config = ServerConfig::load_from(&config_path).unwrap();
2306        assert_eq!(config.workspace.roots.len(), 0);
2307        assert!(config.lsp_servers.is_empty());
2308    }
2309
2310    #[test]
2311    fn test_config_with_initialization_options() {
2312        let tmp_dir = TempDir::new().unwrap();
2313        let config_path = tmp_dir.path().join("init_opts.toml");
2314
2315        let toml_content = r#"
2316            [[lsp_servers]]
2317            language_id = "rust"
2318            command = "rust-analyzer"
2319
2320            [lsp_servers.initialization_options]
2321            cargo = { allFeatures = true }
2322        "#;
2323
2324        fs::write(&config_path, toml_content).unwrap();
2325
2326        let config = ServerConfig::load_from(&config_path).unwrap();
2327        assert!(config.lsp_servers[0].initialization_options.is_some());
2328    }
2329
2330    #[test]
2331    fn test_language_extensions_in_config() {
2332        let tmp_dir = TempDir::new().unwrap();
2333        let config_path = tmp_dir.path().join("extensions.toml");
2334
2335        let toml_content = r#"
2336            [[workspace.language_extensions]]
2337            extensions = ["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
2338            language_id = "cpp"
2339
2340            [[workspace.language_extensions]]
2341            extensions = ["nu"]
2342            language_id = "nushell"
2343
2344            [[workspace.language_extensions]]
2345            extensions = ["py", "pyw", "pyi"]
2346            language_id = "python"
2347        "#;
2348
2349        fs::write(&config_path, toml_content).unwrap();
2350
2351        let config = ServerConfig::load_from(&config_path).unwrap();
2352        assert_eq!(config.workspace.language_extensions.len(), 3);
2353
2354        // Check C++ extensions
2355        assert_eq!(config.workspace.language_extensions[0].language_id, "cpp");
2356        assert_eq!(
2357            config.workspace.language_extensions[0].extensions,
2358            vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"]
2359        );
2360
2361        // Check Nushell extension
2362        assert_eq!(
2363            config.workspace.language_extensions[1].language_id,
2364            "nushell"
2365        );
2366        assert_eq!(
2367            config.workspace.language_extensions[1].extensions,
2368            vec!["nu"]
2369        );
2370    }
2371
2372    #[test]
2373    fn test_build_extension_map() {
2374        let workspace = WorkspaceConfig {
2375            roots: vec![],
2376            position_encodings: PositionEncodings::DEFAULT,
2377            language_extensions: vec![
2378                LanguageExtensionMapping {
2379                    extensions: vec![
2380                        FileExtension::from_static("cpp"),
2381                        FileExtension::from_static("cc"),
2382                        FileExtension::from_static("cxx"),
2383                    ],
2384                    language_id: LanguageId::from_static("cpp"),
2385                },
2386                LanguageExtensionMapping {
2387                    extensions: vec![FileExtension::from_static("nu")],
2388                    language_id: LanguageId::from_static("nushell"),
2389                },
2390            ],
2391            heuristics_max_depth: SearchDepth::DEFAULT,
2392            max_documents: DocumentLimit::DEFAULT,
2393            max_file_size: SizeLimit::DEFAULT,
2394            indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
2395            max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
2396        };
2397
2398        let map = workspace.build_extension_map();
2399        assert_eq!(map.get("cpp"), Some(&LanguageId::from_static("cpp")));
2400        assert_eq!(map.get("cc"), Some(&LanguageId::from_static("cpp")));
2401        assert_eq!(map.get("cxx"), Some(&LanguageId::from_static("cpp")));
2402        assert_eq!(map.get("nu"), Some(&LanguageId::from_static("nushell")));
2403        assert_eq!(map.get("unknown"), None);
2404    }
2405
2406    #[test]
2407    fn test_build_effective_language_map_overrides_with_file_patterns() {
2408        let config = ServerConfig {
2409            mcp: McpConfig::default(),
2410            workspace: WorkspaceConfig::default(),
2411            lsp_servers: vec![LspServerConfig {
2412                language_id: LanguageId::from_static("cpp"),
2413                command: ServerCommand::from_static("clangd").into(),
2414                args: vec![],
2415                env: crate::config::ServerEnv::default(),
2416                file_patterns: vec![
2417                    FilePattern::from_static("**/*.c"),
2418                    FilePattern::from_static("**/*.h"),
2419                ],
2420                initialization_options: None,
2421                settings: None,
2422                timeout_seconds: TimeoutSecs::new(30).unwrap(),
2423                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2424                heuristics: None,
2425                name: None,
2426                handles: None,
2427                indexing: crate::bridge::IndexingPolicy::Auto,
2428            }],
2429            project_config_status: ProjectConfigStatus::NotIgnored,
2430            workspace_trust: crate::config::WorkspaceTrust::default(),
2431        };
2432
2433        let map = config.build_effective_language_map();
2434        assert_eq!(map.detect(Path::new("x.c")), "cpp");
2435        assert_eq!(map.detect(Path::new("x.h")), "cpp");
2436    }
2437
2438    #[test]
2439    fn test_build_effective_language_map_derives_tsx_language_id() {
2440        let config = ServerConfig {
2441            mcp: McpConfig::default(),
2442            workspace: WorkspaceConfig::default(),
2443            lsp_servers: vec![LspServerConfig {
2444                language_id: LanguageId::from_static("typescript"),
2445                command: ServerCommand::from_static("tsgo").into(),
2446                args: vec!["--lsp".to_string(), "--stdio".to_string()],
2447                env: crate::config::ServerEnv::default(),
2448                file_patterns: vec![
2449                    FilePattern::from_static("**/*.ts"),
2450                    FilePattern::from_static("**/*.tsx"),
2451                ],
2452                initialization_options: None,
2453                settings: None,
2454                timeout_seconds: TimeoutSecs::new(30).unwrap(),
2455                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2456                heuristics: None,
2457                name: None,
2458                handles: None,
2459                indexing: crate::bridge::IndexingPolicy::Auto,
2460            }],
2461            project_config_status: ProjectConfigStatus::NotIgnored,
2462            workspace_trust: crate::config::WorkspaceTrust::default(),
2463        };
2464
2465        let map = config.build_effective_language_map();
2466        assert_eq!(map.detect(Path::new("x.ts")), "typescript");
2467        assert_eq!(map.detect(Path::new("x.tsx")), "typescriptreact");
2468    }
2469
2470    #[test]
2471    fn test_build_effective_language_map_derives_jsx_language_id() {
2472        let config = ServerConfig {
2473            mcp: McpConfig::default(),
2474            workspace: WorkspaceConfig::default(),
2475            lsp_servers: vec![LspServerConfig {
2476                language_id: LanguageId::from_static("javascript"),
2477                command: ServerCommand::from_static("typescript-language-server").into(),
2478                args: vec!["--stdio".to_string()],
2479                env: crate::config::ServerEnv::default(),
2480                file_patterns: vec![
2481                    FilePattern::from_static("**/*.js"),
2482                    FilePattern::from_static("**/*.jsx"),
2483                ],
2484                initialization_options: None,
2485                settings: None,
2486                timeout_seconds: TimeoutSecs::new(30).unwrap(),
2487                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
2488                heuristics: None,
2489                name: None,
2490                handles: None,
2491                indexing: crate::bridge::IndexingPolicy::Auto,
2492            }],
2493            project_config_status: ProjectConfigStatus::NotIgnored,
2494            workspace_trust: crate::config::WorkspaceTrust::default(),
2495        };
2496
2497        let map = config.build_effective_language_map();
2498        assert_eq!(map.detect(Path::new("x.js")), "javascript");
2499        assert_eq!(map.detect(Path::new("x.jsx")), "javascriptreact");
2500    }
2501
2502    #[test]
2503    fn test_build_effective_language_map_maps_name_patterns() {
2504        let toml = "[[lsp_servers]]\nlanguage_id = \"make\"\ncommand = \"make-lsp\"\n\
2505                    file_patterns = [\"**/Makefile\", \"GNUmakefile\", \"**/*.mk\"]\n";
2506        let config = toml::from_str::<ServerConfig>(toml).unwrap();
2507        let map = config.build_effective_language_map();
2508        assert_eq!(map.detect(Path::new("sub/Makefile")), "make");
2509        assert_eq!(map.detect(Path::new("GNUmakefile")), "make");
2510        assert_eq!(map.detect(Path::new("rules.mk")), "make");
2511        assert_eq!(map.detect(Path::new("Dockerfile")), LanguageId::PLAINTEXT);
2512    }
2513
2514    #[test]
2515    fn test_load_rejects_every_unsupported_file_pattern_form_naming_the_server() {
2516        for &pattern in patterns::UNSUPPORTED_FILE_PATTERNS {
2517            let toml = format!(
2518                "[[lsp_servers]]\nlanguage_id = \"cpp\"\ncommand = \"clangd\"\n\
2519                 file_patterns = [{pattern:?}]\n"
2520            );
2521            let err = toml::from_str::<ServerConfig>(&toml).unwrap_err();
2522            let message = err.message();
2523            assert!(
2524                message.contains("lsp_servers entry 'cpp'"),
2525                "{pattern}: {message}"
2526            );
2527            assert!(
2528                message.contains(&format!("'{pattern}'")),
2529                "{pattern}: {message}"
2530            );
2531            assert!(message.contains("*.EXT"), "{pattern}: {message}");
2532        }
2533    }
2534
2535    #[test]
2536    fn test_unsupported_file_pattern_names_the_explicit_server_name() {
2537        let toml = "[[lsp_servers]]\nlanguage_id = \"python\"\ncommand = \"pylsp\"\n\
2538                    name = \"mine\"\nfile_patterns = [\"**/*.{py,pyi}\"]\n";
2539        let err = toml::from_str::<ServerConfig>(toml).unwrap_err();
2540        assert!(
2541            err.message().contains("lsp_servers entry 'mine'"),
2542            "{}",
2543            err.message()
2544        );
2545    }
2546
2547    #[test]
2548    fn test_language_for_extension() {
2549        let workspace = WorkspaceConfig {
2550            roots: vec![],
2551            position_encodings: PositionEncodings::DEFAULT,
2552            language_extensions: vec![
2553                LanguageExtensionMapping {
2554                    extensions: vec![
2555                        FileExtension::from_static("hpp"),
2556                        FileExtension::from_static("hh"),
2557                    ],
2558                    language_id: LanguageId::from_static("cpp"),
2559                },
2560                LanguageExtensionMapping {
2561                    extensions: vec![FileExtension::from_static("py")],
2562                    language_id: LanguageId::from_static("python"),
2563                },
2564            ],
2565            heuristics_max_depth: SearchDepth::DEFAULT,
2566            max_documents: DocumentLimit::DEFAULT,
2567            max_file_size: SizeLimit::DEFAULT,
2568            indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
2569            max_concurrent_server_starts: ServerStartConcurrency::DEFAULT,
2570        };
2571
2572        let map = workspace.build_extension_map();
2573        assert_eq!(
2574            map.get("hpp").cloned(),
2575            Some(LanguageId::from_static("cpp"))
2576        );
2577        assert_eq!(map.get("hh").cloned(), Some(LanguageId::from_static("cpp")));
2578        assert_eq!(
2579            map.get("py").cloned(),
2580            Some(LanguageId::from_static("python"))
2581        );
2582        assert_eq!(map.get("unknown").cloned(), None);
2583    }
2584
2585    #[test]
2586    fn test_default_language_extensions() {
2587        let workspace = WorkspaceConfig::default();
2588        let map = workspace.build_extension_map();
2589        assert!(!map.is_empty());
2590        assert_eq!(
2591            map.get("rs").cloned(),
2592            Some(LanguageId::from_static("rust"))
2593        );
2594        assert_eq!(
2595            map.get("py").cloned(),
2596            Some(LanguageId::from_static("python"))
2597        );
2598        assert_eq!(
2599            map.get("cpp").cloned(),
2600            Some(LanguageId::from_static("cpp"))
2601        );
2602    }
2603
2604    #[test]
2605    fn test_create_default_config_file() {
2606        let tmp_dir = TempDir::new().unwrap();
2607        let config_path = tmp_dir.path().join("mcpls").join("mcpls.toml");
2608
2609        ServerConfig::create_default_config_file(&config_path).unwrap();
2610
2611        assert!(config_path.exists());
2612
2613        let loaded_config = ServerConfig::load_from(&config_path).unwrap();
2614        assert_eq!(loaded_config.workspace.language_extensions.len(), 30);
2615        assert_eq!(loaded_config.lsp_servers.len(), 6);
2616        assert_eq!(loaded_config.lsp_servers[0].language_id, "rust");
2617    }
2618
2619    #[test]
2620    fn test_load_returns_default_config() {
2621        // When called directly, default() should return config with all language extensions
2622        let config = ServerConfig::default();
2623        assert_eq!(config.workspace.language_extensions.len(), 30);
2624        assert_eq!(config.lsp_servers.len(), 6);
2625        assert_eq!(config.lsp_servers[0].language_id, "rust");
2626    }
2627
2628    // These tests mutate the process-wide CWD via `set_current_dir`, so they
2629    // use the crate-shared `CwdGuard` (see `crate::test_support`) rather
2630    // than a module-local guard: `lib.rs`'s own tests mutate cwd too, and
2631    // both modules' tests compile into the same binary, so a lock scoped to
2632    // just this module would not prevent a cross-module race under a plain
2633    // `cargo test` (nextest runs each test in its own process, so this only
2634    // matters there).
2635    use crate::test_support::CwdGuard;
2636
2637    /// Precondition for tests that assert on `ServerConfig::load_with_trust`'s
2638    /// CWD-local-file branch: a `$MCPLS_CONFIG` set in the ambient
2639    /// environment makes `load_with_trust` return before ever looking at
2640    /// CWD (see its `MCPLS_CONFIG` branch above), which would otherwise fail
2641    /// the test for a reason unrelated to the code under test.
2642    ///
2643    /// Scrubbing the variable for the test's duration would be the more
2644    /// thorough fix, but `std::env::remove_var`/`set_var` are `unsafe`
2645    /// (mutate process-wide state) and this crate denies `unsafe_code`
2646    /// workspace-wide with no existing exception — so this asserts the
2647    /// precondition instead of silently working around it, turning an
2648    /// environment-dependent false failure into an explicit, legible one.
2649    fn assert_mcpls_config_env_unset() {
2650        assert!(
2651            std::env::var_os("MCPLS_CONFIG").is_none(),
2652            "this test requires MCPLS_CONFIG to be unset in the test environment, since \
2653             load_with_trust returns before consulting CWD when it's set"
2654        );
2655    }
2656
2657    #[test]
2658    fn test_load_ignores_untrusted_project_local_config() {
2659        // `ServerConfig::default()` (what untrusted discovery falls back to
2660        // once neither an untrusted local file nor a global config apply)
2661        // still exposes rust-analyzer via built-in project-marker
2662        // heuristics — see `test_default_config` above, which already
2663        // covers this without any filesystem interaction. This test only
2664        // needs to prove the planted attacker file's content never leaks
2665        // through `load()`.
2666        let tmp_dir = TempDir::new().unwrap();
2667        let config_path = tmp_dir.path().join("mcpls.toml");
2668
2669        // A marker language id / root that cannot collide with either the
2670        // built-in defaults or a machine-local global config, so this
2671        // assertion holds regardless of what `load()` actually falls
2672        // through to (built-in defaults on a clean machine, or the
2673        // machine's own customized global config in CI/dev environments).
2674        let custom_toml = r#"
2675            [workspace]
2676            roots = ["/should-never-load-attacker-path"]
2677
2678            [[lsp_servers]]
2679            language_id = "definitely-not-a-real-language-marker"
2680            command = "rm"
2681            args = ["-rf", "/"]
2682        "#;
2683
2684        fs::write(&config_path, custom_toml).unwrap();
2685
2686        let config = {
2687            let _guard = CwdGuard::enter(tmp_dir.path());
2688            ServerConfig::load().unwrap()
2689        };
2690
2691        assert!(
2692            !config
2693                .workspace
2694                .roots
2695                .contains(&ConfiguredRoot::new("/should-never-load-attacker-path").unwrap())
2696        );
2697        assert!(
2698            !config
2699                .lsp_servers
2700                .iter()
2701                .any(|s| s.language_id == "definitely-not-a-real-language-marker")
2702        );
2703    }
2704
2705    #[test]
2706    fn test_load_with_trust_loads_trusted_project_local_config() {
2707        let tmp_dir = TempDir::new().unwrap();
2708        let config_path = tmp_dir.path().join("mcpls.toml");
2709        let custom_root = tmp_dir.path().join("custom");
2710        fs::create_dir(&custom_root).unwrap();
2711        let custom_root_literal = toml_path_literal(&custom_root);
2712
2713        let custom_toml = format!(
2714            r#"
2715            [workspace]
2716            roots = [{custom_root_literal}]
2717
2718            [[lsp_servers]]
2719            language_id = "python"
2720            command = "pyright-langserver"
2721        "#
2722        );
2723
2724        fs::write(&config_path, &custom_toml).unwrap();
2725
2726        let config = {
2727            let _guard = CwdGuard::enter(tmp_dir.path());
2728            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2729        };
2730
2731        assert_eq!(
2732            config.workspace.roots,
2733            vec![ConfiguredRoot::new(custom_root).unwrap()]
2734        );
2735        assert_eq!(config.lsp_servers.len(), 1);
2736        assert_eq!(config.lsp_servers[0].language_id, "python");
2737    }
2738
2739    #[test]
2740    fn test_load_with_trust_untrusted_ignores_workspace_and_servers() {
2741        let tmp_dir = TempDir::new().unwrap();
2742        let config_path = tmp_dir.path().join("mcpls.toml");
2743
2744        let custom_toml = r#"
2745            [workspace]
2746            roots = ["/attacker/controlled"]
2747            heuristics_max_depth = 999999
2748
2749            [[lsp_servers]]
2750            language_id = "evil"
2751            command = "rm"
2752            args = ["-rf", "/"]
2753        "#;
2754
2755        fs::write(&config_path, custom_toml).unwrap();
2756
2757        let config = {
2758            let _guard = CwdGuard::enter(tmp_dir.path());
2759            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2760        };
2761
2762        assert!(
2763            !config
2764                .workspace
2765                .roots
2766                .contains(&ConfiguredRoot::new("/attacker/controlled").unwrap())
2767        );
2768        assert_ne!(config.workspace.heuristics_max_depth.get(), 999_999);
2769        assert!(!config.lsp_servers.iter().any(|s| s.language_id == "evil"));
2770    }
2771
2772    #[test]
2773    fn test_load_with_trust_sets_project_config_status() {
2774        assert_mcpls_config_env_unset();
2775
2776        let tmp_dir = TempDir::new().unwrap();
2777        let config_path = tmp_dir.path().join("mcpls.toml");
2778        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2779
2780        let config = {
2781            let _guard = CwdGuard::enter(tmp_dir.path());
2782            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2783        };
2784        assert_eq!(
2785            config.project_config_status,
2786            ProjectConfigStatus::IgnoredUntrusted
2787        );
2788
2789        let tmp_dir = TempDir::new().unwrap();
2790        let config_path = tmp_dir.path().join("mcpls.toml");
2791        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
2792
2793        let config = {
2794            let _guard = CwdGuard::enter(tmp_dir.path());
2795            ServerConfig::load_with_trust(ProjectConfigTrust::Trusted).unwrap()
2796        };
2797        assert_eq!(
2798            config.project_config_status,
2799            ProjectConfigStatus::NotIgnored
2800        );
2801    }
2802
2803    #[test]
2804    fn test_load_no_local_config_leaves_flag_unset() {
2805        assert_mcpls_config_env_unset();
2806
2807        let tmp_dir = TempDir::new().unwrap();
2808
2809        let config = {
2810            let _guard = CwdGuard::enter(tmp_dir.path());
2811            ServerConfig::load_with_trust(ProjectConfigTrust::Untrusted).unwrap()
2812        };
2813        assert_eq!(
2814            config.project_config_status,
2815            ProjectConfigStatus::NotIgnored
2816        );
2817    }
2818
2819    #[test]
2820    fn test_config_file_creation_with_proper_structure() {
2821        let tmp_dir = TempDir::new().unwrap();
2822        let config_path = tmp_dir.path().join("test_config").join("mcpls.toml");
2823
2824        ServerConfig::create_default_config_file(&config_path).unwrap();
2825
2826        let content = fs::read_to_string(&config_path).unwrap();
2827
2828        assert!(content.contains("[mcp]"));
2829        assert!(content.contains("[workspace]"));
2830        assert!(content.contains("[[workspace.language_extensions]]"));
2831        assert!(content.contains("[[lsp_servers]]"));
2832        assert!(content.contains("language_id = \"rust\""));
2833        assert!(content.contains("extensions = [\"rs\"]"));
2834    }
2835
2836    #[test]
2837    fn test_heuristics_max_depth_default() {
2838        let config = WorkspaceConfig::default();
2839        assert_eq!(config.heuristics_max_depth.get(), 10);
2840    }
2841
2842    #[test]
2843    fn test_heuristics_max_depth_from_config() {
2844        let tmp_dir = TempDir::new().unwrap();
2845        let config_path = tmp_dir.path().join("depth.toml");
2846
2847        let toml_content = r"
2848            [workspace]
2849            heuristics_max_depth = 5
2850        ";
2851
2852        fs::write(&config_path, toml_content).unwrap();
2853
2854        let config = ServerConfig::load_from(&config_path).unwrap();
2855        assert_eq!(config.workspace.heuristics_max_depth.get(), 5);
2856    }
2857
2858    #[test]
2859    fn test_heuristics_max_depth_uses_default_when_not_specified() {
2860        let tmp_dir = TempDir::new().unwrap();
2861        let config_path = tmp_dir.path().join("no_depth.toml");
2862
2863        let toml_content = r"
2864            [workspace]
2865            roots = []
2866        ";
2867
2868        fs::write(&config_path, toml_content).unwrap();
2869
2870        let config = ServerConfig::load_from(&config_path).unwrap();
2871        assert_eq!(
2872            config.workspace.heuristics_max_depth.get(),
2873            DEFAULT_HEURISTICS_MAX_DEPTH
2874        );
2875    }
2876
2877    #[test]
2878    fn test_max_documents_default() {
2879        let config = WorkspaceConfig::default();
2880        assert_eq!(config.max_documents, DocumentLimit::DEFAULT);
2881    }
2882
2883    #[test]
2884    fn test_max_file_size_default() {
2885        let config = WorkspaceConfig::default();
2886        assert_eq!(config.max_file_size, SizeLimit::DEFAULT);
2887    }
2888
2889    #[test]
2890    fn test_max_documents_from_config() {
2891        let tmp_dir = TempDir::new().unwrap();
2892        let config_path = tmp_dir.path().join("limits.toml");
2893
2894        let toml_content = r"
2895            [workspace]
2896            max_documents = 500
2897        ";
2898
2899        fs::write(&config_path, toml_content).unwrap();
2900
2901        let config = ServerConfig::load_from(&config_path).unwrap();
2902        assert_eq!(config.workspace.max_documents, DocumentLimit::new(500));
2903    }
2904
2905    #[test]
2906    fn test_max_file_size_from_config() {
2907        let tmp_dir = TempDir::new().unwrap();
2908        let config_path = tmp_dir.path().join("limits.toml");
2909
2910        let toml_content = r"
2911            [workspace]
2912            max_file_size = 20971520
2913        ";
2914
2915        fs::write(&config_path, toml_content).unwrap();
2916
2917        let config = ServerConfig::load_from(&config_path).unwrap();
2918        assert_eq!(
2919            config.workspace.max_file_size,
2920            SizeLimit::from_static(20_971_520)
2921        );
2922    }
2923
2924    #[test]
2925    fn test_max_documents_uses_default_when_not_specified() {
2926        let tmp_dir = TempDir::new().unwrap();
2927        let config_path = tmp_dir.path().join("no_limits.toml");
2928
2929        let toml_content = r"
2930            [workspace]
2931            roots = []
2932        ";
2933
2934        fs::write(&config_path, toml_content).unwrap();
2935
2936        let config = ServerConfig::load_from(&config_path).unwrap();
2937        assert_eq!(config.workspace.max_documents, DocumentLimit::DEFAULT);
2938        assert_eq!(config.workspace.max_file_size, SizeLimit::DEFAULT);
2939    }
2940
2941    /// `max_file_size = 0` is the documented "unlimited" sentinel (see
2942    /// `ResourceLimits::max_file_size`'s doc comment); config loading must
2943    /// pass it through unchanged rather than treating `0` as "unset".
2944    #[test]
2945    fn test_max_file_size_zero_means_unlimited() {
2946        let tmp_dir = TempDir::new().unwrap();
2947        let config_path = tmp_dir.path().join("unlimited.toml");
2948
2949        let toml_content = r"
2950            [workspace]
2951            max_file_size = 0
2952        ";
2953
2954        fs::write(&config_path, toml_content).unwrap();
2955
2956        let config = ServerConfig::load_from(&config_path).unwrap();
2957        assert_eq!(config.workspace.max_file_size, SizeLimit::UNLIMITED);
2958        assert_eq!(
2959            config.workspace.resource_limits().max_file_size,
2960            SizeLimit::UNLIMITED
2961        );
2962    }
2963
2964    /// #325: `max_documents` is `usize`, so a negative TOML integer must fail
2965    /// `toml::from_str` deserialization cleanly (`Err`), not panic.
2966    #[test]
2967    fn test_max_documents_rejects_negative_value() {
2968        let tmp_dir = TempDir::new().unwrap();
2969        let config_path = tmp_dir.path().join("negative_max_documents.toml");
2970
2971        let toml_content = r"
2972            [workspace]
2973            max_documents = -1
2974        ";
2975
2976        fs::write(&config_path, toml_content).unwrap();
2977
2978        let result = ServerConfig::load_from(&config_path);
2979        // Asserting on the message text (not just the `TomlDe` variant) rules
2980        // out passing for the wrong reason -- `WorkspaceConfig` also has
2981        // `#[serde(deny_unknown_fields)]`, which produces the same variant
2982        // for an unrelated typo'd field name.
2983        if let Err(Error::TomlDe(e)) = &result {
2984            let msg = e.to_string();
2985            assert!(
2986                msg.contains("-1") && msg.contains("usize"),
2987                "expected a type-mismatch message naming the offending value and the \
2988                 expected type, got: {msg}"
2989            );
2990        } else {
2991            panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
2992        }
2993    }
2994
2995    /// #325: `max_file_size` is `u64`, so a string value must fail
2996    /// `toml::from_str` deserialization cleanly (`Err`), not panic.
2997    #[test]
2998    fn test_max_file_size_rejects_string_value() {
2999        let tmp_dir = TempDir::new().unwrap();
3000        let config_path = tmp_dir.path().join("string_max_file_size.toml");
3001
3002        let toml_content = r#"
3003            [workspace]
3004            max_file_size = "10MB"
3005        "#;
3006
3007        fs::write(&config_path, toml_content).unwrap();
3008
3009        let result = ServerConfig::load_from(&config_path);
3010        if let Err(Error::TomlDe(e)) = &result {
3011            let msg = e.to_string();
3012            assert!(
3013                msg.contains("10MB") && msg.contains("u64"),
3014                "expected a type-mismatch message naming the offending value and the \
3015                 expected type, got: {msg}"
3016            );
3017        } else {
3018            panic!("Expected Err(Error::TomlDe(_)), got {result:?}");
3019        }
3020    }
3021
3022    #[test]
3023    fn test_workspace_config_resource_limits_maps_fields() {
3024        let workspace = WorkspaceConfig {
3025            max_documents: DocumentLimit::new(250),
3026            max_file_size: SizeLimit::UNLIMITED,
3027            ..WorkspaceConfig::default()
3028        };
3029
3030        let limits = workspace.resource_limits();
3031        assert_eq!(limits.max_documents, DocumentLimit::new(250));
3032        assert_eq!(limits.max_file_size, SizeLimit::UNLIMITED);
3033    }
3034
3035    #[test]
3036    fn test_workspace_config_toml_round_trip() {
3037        let original = WorkspaceConfig {
3038            roots: vec![ConfiguredRoot::new("/tmp/round-trip").unwrap()],
3039            position_encodings: PositionEncodings::new(vec![PositionEncoding::Utf8]).unwrap(),
3040            language_extensions: vec![LanguageExtensionMapping {
3041                extensions: vec![FileExtension::from_static("nu")],
3042                language_id: LanguageId::from_static("nushell"),
3043            }],
3044            heuristics_max_depth: SearchDepth::new(5).unwrap(),
3045            max_documents: DocumentLimit::new(500),
3046            max_file_size: SizeLimit::UNLIMITED,
3047            indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::new(45).unwrap(),
3048            max_concurrent_server_starts: ServerStartConcurrency::new(3).unwrap(),
3049        };
3050
3051        let toml_content = toml::to_string_pretty(&original).unwrap();
3052        let round_tripped: WorkspaceConfig = toml::from_str(&toml_content).unwrap();
3053
3054        assert_eq!(round_tripped.roots, original.roots);
3055        assert_eq!(
3056            round_tripped.position_encodings,
3057            original.position_encodings
3058        );
3059        assert_eq!(
3060            round_tripped.language_extensions.len(),
3061            original.language_extensions.len()
3062        );
3063        assert_eq!(
3064            round_tripped.language_extensions[0].extensions,
3065            original.language_extensions[0].extensions
3066        );
3067        assert_eq!(
3068            round_tripped.language_extensions[0].language_id,
3069            original.language_extensions[0].language_id
3070        );
3071        assert_eq!(
3072            round_tripped.heuristics_max_depth,
3073            original.heuristics_max_depth
3074        );
3075        assert_eq!(round_tripped.max_documents, original.max_documents);
3076        assert_eq!(round_tripped.max_file_size, original.max_file_size);
3077        assert_eq!(
3078            round_tripped.indexing_ready_timeout_seconds,
3079            original.indexing_ready_timeout_seconds
3080        );
3081    }
3082
3083    #[test]
3084    fn test_mcp_config_parses_from_toml_section() {
3085        let tmp_dir = TempDir::new().unwrap();
3086        let config_path = tmp_dir.path().join("config.toml");
3087
3088        let toml_content = r#"
3089            [mcp]
3090            title = "Custom Title"
3091            description = "Custom description"
3092            instructions = "Custom instructions."
3093        "#;
3094
3095        fs::write(&config_path, toml_content).unwrap();
3096
3097        let config = ServerConfig::load_from(&config_path).unwrap();
3098        assert_eq!(config.mcp.title.unwrap(), "Custom Title");
3099        assert_eq!(config.mcp.description.unwrap(), "Custom description");
3100        assert_eq!(config.mcp.instructions.unwrap(), "Custom instructions.");
3101    }
3102
3103    #[test]
3104    fn test_mcp_config_defaults_to_none_when_section_absent() {
3105        let tmp_dir = TempDir::new().unwrap();
3106        let config_path = tmp_dir.path().join("config.toml");
3107
3108        fs::write(&config_path, "[workspace]\nroots = []\n").unwrap();
3109
3110        let config = ServerConfig::load_from(&config_path).unwrap();
3111        assert_eq!(config.mcp.title, None);
3112        assert_eq!(config.mcp.description, None);
3113        assert_eq!(config.mcp.instructions, None);
3114    }
3115
3116    #[test]
3117    fn test_mcp_config_rejects_unknown_field() {
3118        let tmp_dir = TempDir::new().unwrap();
3119        let config_path = tmp_dir.path().join("config.toml");
3120
3121        fs::write(&config_path, "[mcp]\nbogus_field = \"x\"\n").unwrap();
3122
3123        let result = ServerConfig::load_from(&config_path);
3124        assert_matches!(result, Err(Error::TomlDe(_)));
3125    }
3126
3127    #[test]
3128    fn test_validate_rejects_empty_mcp_title() {
3129        let tmp_dir = TempDir::new().unwrap();
3130        let config_path = tmp_dir.path().join("config.toml");
3131
3132        fs::write(&config_path, "[mcp]\ntitle = \"\"\n").unwrap();
3133
3134        let result = ServerConfig::load_from(&config_path);
3135        if let Err(Error::TomlDe(e)) = result {
3136            let msg = e.message();
3137            assert_eq!(msg, "cannot be empty");
3138        } else {
3139            panic!("Expected TomlDe error, got {result:?}");
3140        }
3141    }
3142
3143    /// A whitespace-only value must report as *empty*, not over-length --
3144    /// the trim-empty check must run before the length check.
3145    #[test]
3146    fn test_validate_rejects_whitespace_only_mcp_title_as_empty() {
3147        let tmp_dir = TempDir::new().unwrap();
3148        let config_path = tmp_dir.path().join("config.toml");
3149
3150        fs::write(&config_path, "[mcp]\ntitle = \"   \"\n").unwrap();
3151
3152        let result = ServerConfig::load_from(&config_path);
3153        if let Err(Error::TomlDe(e)) = result {
3154            let msg = e.message();
3155            assert!(msg.contains("cannot be empty"));
3156        } else {
3157            panic!("Expected TomlDe error, got {result:?}");
3158        }
3159    }
3160
3161    #[test]
3162    fn test_validate_rejects_empty_mcp_description() {
3163        let tmp_dir = TempDir::new().unwrap();
3164        let config_path = tmp_dir.path().join("config.toml");
3165
3166        fs::write(&config_path, "[mcp]\ndescription = \"\"\n").unwrap();
3167
3168        let result = ServerConfig::load_from(&config_path);
3169        if let Err(Error::TomlDe(e)) = result {
3170            let msg = e.message();
3171            assert_eq!(msg, "cannot be empty");
3172        } else {
3173            panic!("Expected TomlDe error, got {result:?}");
3174        }
3175    }
3176
3177    #[test]
3178    fn test_validate_rejects_empty_mcp_instructions() {
3179        let tmp_dir = TempDir::new().unwrap();
3180        let config_path = tmp_dir.path().join("config.toml");
3181
3182        fs::write(&config_path, "[mcp]\ninstructions = \"\"\n").unwrap();
3183
3184        let result = ServerConfig::load_from(&config_path);
3185        if let Err(Error::TomlDe(e)) = result {
3186            let msg = e.message();
3187            assert_eq!(msg, "cannot be empty");
3188        } else {
3189            panic!("Expected TomlDe error, got {result:?}");
3190        }
3191    }
3192
3193    #[test]
3194    fn test_validate_rejects_over_length_mcp_title() {
3195        let tmp_dir = TempDir::new().unwrap();
3196        let config_path = tmp_dir.path().join("config.toml");
3197
3198        let title = "a".repeat(MAX_MCP_TITLE_BYTES + 1);
3199        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3200
3201        let result = ServerConfig::load_from(&config_path);
3202        if let Err(Error::TomlDe(e)) = result {
3203            let msg = e.message();
3204            assert_eq!(
3205                msg,
3206                format!(
3207                    "exceeds the maximum of {MAX_MCP_TITLE_BYTES} bytes ({} given)",
3208                    MAX_MCP_TITLE_BYTES + 1
3209                )
3210            );
3211        } else {
3212            panic!("Expected TomlDe error, got {result:?}");
3213        }
3214    }
3215
3216    #[test]
3217    fn test_validate_accepts_mcp_title_at_exact_cap() {
3218        let tmp_dir = TempDir::new().unwrap();
3219        let config_path = tmp_dir.path().join("config.toml");
3220
3221        let title = "a".repeat(MAX_MCP_TITLE_BYTES);
3222        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3223
3224        let result = ServerConfig::load_from(&config_path);
3225        assert!(result.is_ok(), "expected Ok, got {result:?}");
3226    }
3227
3228    #[test]
3229    fn test_validate_rejects_over_length_mcp_description() {
3230        let tmp_dir = TempDir::new().unwrap();
3231        let config_path = tmp_dir.path().join("config.toml");
3232
3233        let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES + 1);
3234        fs::write(
3235            &config_path,
3236            format!("[mcp]\ndescription = \"{description}\"\n"),
3237        )
3238        .unwrap();
3239
3240        let result = ServerConfig::load_from(&config_path);
3241        if let Err(Error::TomlDe(e)) = result {
3242            let msg = e.message();
3243            assert!(msg.contains("exceeds the maximum"));
3244            assert!(msg.contains(&(MAX_MCP_DESCRIPTION_BYTES + 1).to_string()));
3245        } else {
3246            panic!("Expected TomlDe error, got {result:?}");
3247        }
3248    }
3249
3250    #[test]
3251    fn test_validate_accepts_mcp_description_at_exact_cap() {
3252        let tmp_dir = TempDir::new().unwrap();
3253        let config_path = tmp_dir.path().join("config.toml");
3254
3255        let description = "a".repeat(MAX_MCP_DESCRIPTION_BYTES);
3256        fs::write(
3257            &config_path,
3258            format!("[mcp]\ndescription = \"{description}\"\n"),
3259        )
3260        .unwrap();
3261
3262        let result = ServerConfig::load_from(&config_path);
3263        assert!(result.is_ok(), "expected Ok, got {result:?}");
3264    }
3265
3266    /// Pins the documented "bytes, not chars" contract: `é` is 1 char but 2
3267    /// UTF-8 bytes, so a naive `.chars().count()` cap would accept both
3268    /// cases below. `MAX_MCP_TITLE_BYTES` (128) is even, so 64 `é`s land
3269    /// exactly at the byte cap and 65 land one byte over it.
3270    #[test]
3271    fn test_validate_rejects_multibyte_title_over_byte_cap_though_under_char_cap() {
3272        let tmp_dir = TempDir::new().unwrap();
3273        let config_path = tmp_dir.path().join("config.toml");
3274
3275        let title = "é".repeat(65);
3276        assert_eq!(title.len(), MAX_MCP_TITLE_BYTES + 2);
3277        assert_eq!(title.chars().count(), 65);
3278        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3279
3280        let result = ServerConfig::load_from(&config_path);
3281        if let Err(Error::TomlDe(e)) = result {
3282            let msg = e.message();
3283            assert!(msg.contains("exceeds the maximum"));
3284        } else {
3285            panic!("Expected TomlDe error, got {result:?}");
3286        }
3287    }
3288
3289    #[test]
3290    fn test_validate_accepts_multibyte_title_at_exact_byte_cap() {
3291        let tmp_dir = TempDir::new().unwrap();
3292        let config_path = tmp_dir.path().join("config.toml");
3293
3294        let title = "é".repeat(64);
3295        assert_eq!(title.len(), MAX_MCP_TITLE_BYTES);
3296        fs::write(&config_path, format!("[mcp]\ntitle = \"{title}\"\n")).unwrap();
3297
3298        let result = ServerConfig::load_from(&config_path);
3299        assert!(result.is_ok(), "expected Ok, got {result:?}");
3300    }
3301
3302    #[test]
3303    fn test_validate_rejects_over_length_mcp_instructions() {
3304        let tmp_dir = TempDir::new().unwrap();
3305        let config_path = tmp_dir.path().join("config.toml");
3306
3307        let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES + 1);
3308        fs::write(
3309            &config_path,
3310            format!("[mcp]\ninstructions = \"{instructions}\"\n"),
3311        )
3312        .unwrap();
3313
3314        let result = ServerConfig::load_from(&config_path);
3315        if let Err(Error::TomlDe(e)) = result {
3316            let msg = e.message();
3317            assert!(msg.contains("exceeds the maximum"));
3318            assert!(msg.contains(&(MAX_MCP_INSTRUCTIONS_BYTES + 1).to_string()));
3319        } else {
3320            panic!("Expected TomlDe error, got {result:?}");
3321        }
3322    }
3323
3324    #[test]
3325    fn test_validate_accepts_mcp_instructions_at_exact_cap() {
3326        let tmp_dir = TempDir::new().unwrap();
3327        let config_path = tmp_dir.path().join("config.toml");
3328
3329        let instructions = "a".repeat(MAX_MCP_INSTRUCTIONS_BYTES);
3330        fs::write(
3331            &config_path,
3332            format!("[mcp]\ninstructions = \"{instructions}\"\n"),
3333        )
3334        .unwrap();
3335
3336        let result = ServerConfig::load_from(&config_path);
3337        assert!(result.is_ok(), "expected Ok, got {result:?}");
3338    }
3339
3340    // ------------------------------------------------------------------
3341    // ToolPrefix tests
3342    // ------------------------------------------------------------------
3343
3344    #[test]
3345    fn test_tool_prefix_accepts_valid_and_round_trips() {
3346        let tmp_dir = TempDir::new().unwrap();
3347        let config_path = tmp_dir.path().join("config.toml");
3348        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics\"\n").unwrap();
3349
3350        let config = ServerConfig::load_from(&config_path).unwrap();
3351        assert_eq!(config.mcp.tool_prefix.as_ref().unwrap().as_str(), "optics");
3352
3353        let serialized = toml::to_string_pretty(&config).unwrap();
3354        let round_tripped: ServerConfig = toml::from_str(&serialized).unwrap();
3355        assert_eq!(round_tripped.mcp.tool_prefix, config.mcp.tool_prefix);
3356    }
3357
3358    #[test]
3359    fn test_tool_prefix_accepts_digits_and_mixed_case() {
3360        let prefix: ToolPrefix = "Optics2".parse().unwrap();
3361        assert_eq!(prefix.as_str(), "Optics2");
3362    }
3363
3364    #[test]
3365    fn test_tool_prefix_accepts_single_alphanumeric_char() {
3366        assert!("x".parse::<ToolPrefix>().is_ok());
3367        assert!("9".parse::<ToolPrefix>().is_ok());
3368    }
3369
3370    #[test]
3371    fn test_tool_prefix_rejects_empty() {
3372        let err = "".parse::<ToolPrefix>().unwrap_err();
3373        assert_eq!(err, InvalidToolPrefix::Empty);
3374        assert_eq!(
3375            err.to_string(),
3376            "mcp.tool_prefix cannot be empty (omit `tool_prefix` from the `[mcp]` section to \
3377             use unprefixed tool names)"
3378        );
3379    }
3380
3381    #[test]
3382    fn test_tool_prefix_rejects_whitespace_only() {
3383        let err = "   ".parse::<ToolPrefix>().unwrap_err();
3384        assert_eq!(err, InvalidToolPrefix::Empty);
3385    }
3386
3387    #[test]
3388    fn test_tool_prefix_rejects_leading_separator() {
3389        for bad in ["_optics", "-optics"] {
3390            let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
3391            assert!(
3392                err.contains("cannot start with"),
3393                "for input {bad:?}: {err}"
3394            );
3395        }
3396    }
3397
3398    #[test]
3399    fn test_tool_prefix_rejects_trailing_separator() {
3400        for bad in ["optics_", "optics-"] {
3401            let err = bad.parse::<ToolPrefix>().unwrap_err().to_string();
3402            assert!(err.contains("cannot end with"), "for input {bad:?}: {err}");
3403            assert!(err.contains("inserted automatically"));
3404        }
3405    }
3406
3407    #[test]
3408    fn test_tool_prefix_rejects_dot() {
3409        let err = "op.tics".parse::<ToolPrefix>().unwrap_err().to_string();
3410        assert!(err.contains("invalid character '.'"));
3411    }
3412
3413    #[test]
3414    fn test_tool_prefix_rejects_space() {
3415        let err = "op tics".parse::<ToolPrefix>().unwrap_err().to_string();
3416        assert!(err.contains("invalid character ' '"));
3417    }
3418
3419    #[test]
3420    fn test_tool_prefix_rejects_non_ascii_and_names_the_character() {
3421        let err = "optiсs".parse::<ToolPrefix>().unwrap_err().to_string();
3422        assert!(err.contains("invalid character 'с'"), "{err}");
3423    }
3424
3425    #[test]
3426    fn test_tool_prefix_rejects_over_length() {
3427        let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES + 1);
3428        let err = prefix.parse::<ToolPrefix>().unwrap_err();
3429        assert_eq!(
3430            err,
3431            InvalidToolPrefix::TooLong {
3432                len: MAX_MCP_TOOL_PREFIX_BYTES + 1
3433            }
3434        );
3435    }
3436
3437    #[test]
3438    fn test_tool_prefix_accepts_exact_length_cap() {
3439        let prefix = "a".repeat(MAX_MCP_TOOL_PREFIX_BYTES);
3440        assert!(prefix.parse::<ToolPrefix>().is_ok());
3441    }
3442
3443    #[test]
3444    fn test_tool_prefix_from_str_shares_config_validator() {
3445        // Proves the programmatic path (`ToolPrefix::from_str`, used by
3446        // `serve_with` callers that build `McpConfig` directly) is held to
3447        // the same rules as the TOML deserialization path, not a separate,
3448        // looser check.
3449        assert!("optics_".parse::<ToolPrefix>().is_err());
3450        let tmp_dir = TempDir::new().unwrap();
3451        let config_path = tmp_dir.path().join("config.toml");
3452        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3453        assert_matches!(ServerConfig::load_from(&config_path), Err(Error::TomlDe(_)));
3454    }
3455
3456    /// Pins the actual behavior of a `serde::de::Error::custom` raised from
3457    /// inside `ToolPrefix`'s hand-written `Deserialize` impl, since the
3458    /// `toml` crate's line-reference attachment for such errors is not
3459    /// guaranteed by its public API and must be verified empirically rather
3460    /// than assumed (see [`ToolPrefix`]'s doc comment on the `Error::TomlDe`
3461    /// trade-off).
3462    #[test]
3463    fn test_invalid_tool_prefix_error_names_field_and_offending_character() {
3464        let tmp_dir = TempDir::new().unwrap();
3465        let config_path = tmp_dir.path().join("config.toml");
3466        fs::write(&config_path, "[mcp]\ntool_prefix = \"optics_\"\n").unwrap();
3467
3468        let result = ServerConfig::load_from(&config_path);
3469        let Err(Error::TomlDe(err)) = result else {
3470            panic!("Expected TomlDe error, got {result:?}");
3471        };
3472        let msg = err.to_string();
3473        assert!(msg.contains("mcp.tool_prefix"), "{msg}");
3474        assert!(msg.contains("cannot end with"), "{msg}");
3475        // Empirically confirmed (not merely assumed): `toml` attaches a
3476        // "line N, column M" reference to a `serde::de::Error::custom`
3477        // raised from within a field's `Deserialize` impl, giving this
3478        // error strictly more location information than the sibling
3479        // `title`/`description`/`instructions` fields get.
3480        assert!(msg.contains("line 2"), "{msg}");
3481    }
3482
3483    mod trust_tests {
3484        use super::*;
3485
3486        fn config_with_servers(trust: WorkspaceTrust) -> ServerConfig {
3487            ServerConfig {
3488                workspace_trust: trust,
3489                ..ServerConfig::default()
3490            }
3491        }
3492
3493        #[test]
3494        fn test_validate_accepts_allowed_ids_of_configured_servers() {
3495            let config =
3496                config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rust")]));
3497            assert!(config.validate().is_ok());
3498        }
3499
3500        #[test]
3501        fn test_validate_rejects_an_allowed_id_that_names_no_configured_server() {
3502            let config =
3503                config_with_servers(WorkspaceTrust::untrusted([ServerId::from_static("rsut")]));
3504            let err = config.validate().unwrap_err();
3505            let text = err.to_string();
3506            assert_matches!(
3507                err,
3508                Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
3509                    if server.as_str() == "rsut"
3510            );
3511            assert!(text.contains("'rsut'") && text.contains("rust"), "{text}");
3512        }
3513
3514        #[test]
3515        fn test_apply_workspace_trust_rejects_an_unknown_allowed_id_and_keeps_the_trust() {
3516            let mut config = ServerConfig::default();
3517            let err = config
3518                .apply_workspace_trust(
3519                    &WorkspaceTrust::untrusted([ServerId::from_static("rsut")]),
3520                    None,
3521                )
3522                .unwrap_err();
3523            assert_matches!(
3524                err,
3525                Error::Config(ConfigError::UnknownAllowedServer { ref server, .. })
3526                    if server.as_str() == "rsut"
3527            );
3528            assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
3529        }
3530
3531        #[test]
3532        fn test_validate_ignores_the_allowlist_when_trusted() {
3533            assert!(
3534                config_with_servers(WorkspaceTrust::Trusted)
3535                    .validate()
3536                    .is_ok()
3537            );
3538        }
3539
3540        #[test]
3541        fn test_workspace_trust_is_never_read_from_toml() {
3542            let config: ServerConfig = toml::from_str("").unwrap();
3543            assert_eq!(config.workspace_trust, WorkspaceTrust::Trusted);
3544            let err = toml::from_str::<ServerConfig>("workspace_trust = \"untrusted\"");
3545            assert!(err.is_err(), "an unknown key is rejected");
3546            assert!(!toml::to_string(&config).unwrap().contains("trust"));
3547        }
3548
3549        fn temp_roots_config(root: &Path) -> ServerConfig {
3550            let mut config = ServerConfig::default();
3551            config.workspace.roots = vec![ConfiguredRoot::new(root).unwrap()];
3552            config
3553        }
3554
3555        #[test]
3556        fn test_ensure_outside_workspace_rejects_a_config_inside_a_root() {
3557            let dir = TempDir::new().unwrap();
3558            let file = dir.path().join("mcpls.toml");
3559            fs::write(&file, "").unwrap();
3560            let err = temp_roots_config(dir.path())
3561                .ensure_outside_workspace(&file, ConfigOrigin::Argument)
3562                .unwrap_err();
3563            assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3564        }
3565
3566        #[test]
3567        fn test_ensure_outside_workspace_rejects_a_config_inside_the_process_cwd() {
3568            let cwd = std::env::current_dir().unwrap();
3569            let dir = tempfile::tempdir_in(&cwd).unwrap();
3570            let file = dir.path().join("mcpls.toml");
3571            fs::write(&file, "").unwrap();
3572            let elsewhere = TempDir::new().unwrap();
3573            let config = temp_roots_config(elsewhere.path());
3574            let err = config
3575                .ensure_outside_workspace(&file, ConfigOrigin::Environment)
3576                .unwrap_err();
3577            assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3578            assert!(
3579                config
3580                    .ensure_outside_workspace(&file, ConfigOrigin::Argument)
3581                    .is_ok(),
3582                "an absolute path given as an argument is only checked against the roots"
3583            );
3584        }
3585
3586        #[test]
3587        fn test_ensure_outside_workspace_checks_cwd_for_a_relative_path() {
3588            let cwd = std::env::current_dir().unwrap();
3589            let dir = tempfile::tempdir_in(&cwd).unwrap();
3590            let file = dir.path().join("mcpls.toml");
3591            fs::write(&file, "").unwrap();
3592            let relative = file.strip_prefix(&cwd).unwrap();
3593            let elsewhere = TempDir::new().unwrap();
3594            let err = temp_roots_config(elsewhere.path())
3595                .ensure_outside_workspace(relative, ConfigOrigin::Argument)
3596                .unwrap_err();
3597            assert_matches!(err, Error::ConfigInsideWorkspace { .. });
3598        }
3599
3600        #[test]
3601        fn test_ensure_outside_workspace_admits_a_config_outside_roots_and_cwd() {
3602            let roots = TempDir::new().unwrap();
3603            let outside = TempDir::new().unwrap();
3604            let file = outside.path().join("mcpls.toml");
3605            fs::write(&file, "").unwrap();
3606            assert!(
3607                temp_roots_config(roots.path())
3608                    .ensure_outside_workspace(&file, ConfigOrigin::Environment)
3609                    .is_ok()
3610            );
3611        }
3612    }
3613}