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