Skip to main content

mcpls_core/
error.rs

1//! Error types for mcpls-core.
2//!
3//! This module defines the canonical error type for the library,
4//! following the Microsoft Rust Guidelines for error handling.
5
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::Serialize;
12
13use crate::bridge::resources::ResourceUriError;
14use crate::bridge::{
15    Capability, InvalidClientPath, InvalidHierarchyItem, InvalidPosition, InvalidRange,
16};
17use crate::config::{
18    BuiltinServer, DuplicateEnvKey, EntrySummary, EnvKey, EnvTooLarge, FileKey, FilePattern,
19    InvalidAutoSelection, InvalidEnvValue, InvalidServerArgs, LanguageId, ServerCommand, ServerId,
20    ToolKind, UnsupportedFilePattern,
21};
22use crate::lsp::JsonRpcError;
23pub use crate::redaction::RedactedText;
24use crate::redaction::Redactions;
25pub use crate::util::SizeExceeded;
26use crate::util::{
27    MAX_ECHOED_NAME_BYTES, MAX_ECHOED_PATH_BYTES, MAX_ERROR_MESSAGE_CALLER_BYTES,
28    TRUNCATION_MARKER, escape_bounded, escape_control, truncate_str,
29};
30
31/// Explains a `plaintext` routing failure: which extension or file name had no
32/// mapping and which `file_patterns` were configured. Empty for any other
33/// language.
34fn no_server_detail(language: &LanguageId, file: &FileKey, patterns: &[FilePattern]) -> String {
35    if *language != LanguageId::PLAINTEXT {
36        return String::new();
37    }
38    let (subject, remedy) = match file {
39        FileKey::Extension(ext) => (
40            format!("file extension '{ext}' is not mapped to any language"),
41            "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
42        ),
43        FileKey::Name(name) => (
44            format!("file name '{name}' is not mapped to any language"),
45            format!("a `**/{name}` file_patterns entry"),
46        ),
47        FileKey::Unmappable => (
48            "the file has no usable extension or name".to_owned(),
49            "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
50        ),
51    };
52    let configured = if patterns.is_empty() {
53        "no file_patterns are configured".to_owned()
54    } else {
55        let list: Vec<&str> = patterns.iter().map(FilePattern::as_str).collect();
56        format!("configured file_patterns: {}", list.join(", "))
57    };
58    format!(" ({subject}; {configured}; map it with {remedy})")
59}
60
61/// Host platform, as far as [`NotFoundGuidance`] cares.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum Platform {
64    Windows,
65    Other,
66}
67
68impl Platform {
69    const CURRENT: Self = if cfg!(windows) {
70        Self::Windows
71    } else {
72        Self::Other
73    };
74}
75
76/// Display suffix explaining how to fix a missing LSP server executable.
77struct NotFoundGuidance<'a>(&'a str, Platform);
78
79impl fmt::Display for NotFoundGuidance<'_> {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        let Self(command, platform) = *self;
82        if Path::new(command).parent().is_some_and(|p| !p.is_empty()) {
83            return f.write_str("; check that the configured path exists");
84        }
85        write!(
86            f,
87            "; '{command}' is not on the PATH mcpls runs with -- if it is installed, add its \
88             directory to the MCP client's PATH or set `command` to an absolute path"
89        )?;
90        let Some(builtin) = BuiltinServer::from_command(command) else {
91            return Ok(());
92        };
93        if platform == Platform::Windows && builtin.is_npm_package() {
94            write!(
95                f,
96                " (npm-installed servers need the `.cmd` name, e.g. `{command}.cmd`)"
97            )?;
98        }
99        write!(f, "; otherwise install it: {}", builtin.install_hint())
100    }
101}
102
103/// Substring rust-analyzer's raw error text carries when a position-based
104/// request's `line`/`character` falls outside the target document. Shared
105/// between [`sanitize_lsp_server_message`] (rewrites the message shown to the
106/// caller) and [`Error::mcp_error_kind`] (classifies this shape of
107/// [`Error::LspServerError`] as caller-fault) so the two stay in sync.
108const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
109
110/// Whether `response` is rust-analyzer's "position out of range" internal
111/// error, which [`Error::mcp_error_kind`] classifies as caller-fault.
112pub(crate) fn is_invalid_offset(response: &JsonRpcError) -> bool {
113    response.message.contains(INVALID_OFFSET_MARKER)
114}
115
116/// Rewrites an LSP server's raw error message for display to the MCP caller,
117/// replacing rust-analyzer's "Invalid offset" internal error with a clean,
118/// client-appropriate message.
119///
120/// rust-analyzer returns this `Debug`-formatted internal error (embedding its
121/// `LineCol` struct and the line index's byte length, e.g. `"Invalid offset
122/// LineCol { line: 2291, col: 0 } (line index length: 100417)"`) when a
123/// position-based request's `line` or `character` falls outside the target
124/// document. Every other [`Error`] variant produces a clean message; this
125/// function keeps [`Error::LspServerError`]'s `Display` impl consistent with
126/// that convention instead of forwarding the upstream server's internals
127/// verbatim.
128///
129/// Matches via `contains` rather than `starts_with`: rust-analyzer's error
130/// travels through `anyhow`/`lsp_server` before reaching mcpls, so a future
131/// upstream `.context(...)` wrapper (or a truncation prefix added on the
132/// mcpls side) could prepend text ahead of `"Invalid offset LineCol"` without
133/// mcpls's control -- `contains` keeps the guard robust to that at no extra
134/// cost. Deliberately not also gated on the JSON-RPC error `code`: this error
135/// class has been observed under both `-32603` (internal error) and `-32803`
136/// (`RequestFailed`) across rust-analyzer versions, so a code condition would
137/// make the guard more fragile, not less.
138fn sanitize_lsp_server_message(message: &str) -> String {
139    if message.contains(INVALID_OFFSET_MARKER) {
140        "position out of range for this document".to_string()
141    } else {
142        escape_control(message).into_owned()
143    }
144}
145
146/// The raw LSP error behind a position-out-of-range rewrite, carried as the
147/// JSON-RPC `data` of the resulting `-32602` error.
148///
149/// [`Error::LspServerError`]'s `Display` replaces rust-analyzer's internal
150/// "Invalid offset" text with a clean message; this keeps the original
151/// reachable for the caller. The fields are private so the message can only
152/// be built through [`Self::new`], which bounds it.
153///
154/// # Examples
155///
156/// ```
157/// use mcpls_core::error::RewrittenServerError;
158///
159/// let raw = RewrittenServerError::new(-32603, "Invalid offset LineCol { line: 9, col: 0 }");
160/// assert_eq!(raw.code(), -32603);
161/// assert_eq!(
162///     serde_json::to_value(&raw).unwrap(),
163///     serde_json::json!({"code": -32603, "raw_message": "Invalid offset LineCol { line: 9, col: 0 }"})
164/// );
165/// ```
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167pub struct RewrittenServerError {
168    code: i32,
169    raw_message: String,
170}
171
172impl RewrittenServerError {
173    /// Build from the server's JSON-RPC `code` and raw message, truncating
174    /// the message to the budget used for text forwarded to MCP callers.
175    #[must_use]
176    pub fn new(code: i32, raw_message: &str) -> Self {
177        Self {
178            code,
179            raw_message: truncate_str(raw_message, MAX_ERROR_MESSAGE_CALLER_BYTES),
180        }
181    }
182
183    /// The server's JSON-RPC error code.
184    #[must_use]
185    pub const fn code(&self) -> i32 {
186        self.code
187    }
188
189    /// The server's raw message, bounded by [`Self::new`].
190    #[must_use]
191    pub fn raw_message(&self) -> &str {
192        &self.raw_message
193    }
194}
195
196/// Why a configured LSP server never registered during startup.
197///
198/// # Examples
199///
200/// ```
201/// use mcpls_core::error::StartupFailure;
202///
203/// assert!(StartupFailure::InitTaskPanicked.to_string().contains("panicked"));
204/// ```
205#[derive(Debug, Clone)]
206#[non_exhaustive]
207pub enum StartupFailure {
208    /// Spawning or initializing the server failed.
209    Spawn(Arc<Error>),
210    /// Starting this server, or the background initialization task as a whole,
211    /// panicked before the server registered.
212    InitTaskPanicked,
213    /// The workspace is untrusted and this server was not started.
214    RefusedUntrustedWorkspace(UntrustedRefusal),
215}
216
217/// An environment variable that names the user's home directory.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219#[non_exhaustive]
220pub enum HomeVariable {
221    /// `HOME`.
222    Home,
223    /// `USERPROFILE`.
224    UserProfile,
225}
226
227impl HomeVariable {
228    /// Every home variable untrusted mode manages.
229    pub const ALL: [Self; 2] = [Self::Home, Self::UserProfile];
230
231    /// The variable's name in the environment.
232    #[must_use]
233    pub const fn name(self) -> &'static str {
234        match self {
235            Self::Home => "HOME",
236            Self::UserProfile => "USERPROFILE",
237        }
238    }
239}
240
241impl fmt::Display for HomeVariable {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        f.write_str(self.name())
244    }
245}
246
247/// What untrusted-workspace mode resolved to a path it could not pass on.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum ResolvedItem {
251    /// The server's executable.
252    Executable,
253    /// The `PATH` handed to the server.
254    SearchPath,
255}
256
257impl fmt::Display for ResolvedItem {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.write_str(match self {
260            Self::Executable => "executable",
261            Self::SearchPath => "PATH",
262        })
263    }
264}
265
266/// A piece of configured launch text that may be shown to callers: an option
267/// name or a program name, never a value, an inline program or an
268/// assignment's value.
269///
270/// The text is cut at the first `=` or whitespace, escaped and bounded to
271/// 256 bytes (the escaped text is what is bounded), because
272/// configured arguments can hold secrets and reach MCP clients unredacted.
273///
274/// # Examples
275///
276/// ```
277/// use mcpls_core::error::EchoedArgument;
278///
279/// assert_eq!(EchoedArgument::name("--token=abc").as_str(), "--token");
280/// assert_eq!(EchoedArgument::name("API_KEY=abc").as_str(), "API_KEY");
281/// ```
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct EchoedArgument(String);
284
285impl EchoedArgument {
286    /// The option or program name `arg` spells: the text before the first `=`
287    /// or whitespace, so an assignment's value or an argument glued to the
288    /// name is not echoed. A short cluster is named by its offending letter
289    /// (`-x`), built by the caller.
290    #[must_use]
291    pub fn name(arg: &str) -> Self {
292        let name = arg
293            .split_once(|c: char| c == '=' || c.is_whitespace())
294            .map_or(arg, |(name, _)| name);
295        Self(escape_bounded(name, MAX_ECHOED_NAME_BYTES))
296    }
297
298    /// The echoed text.
299    #[must_use]
300    pub fn as_str(&self) -> &str {
301        &self.0
302    }
303}
304
305/// A path the workspace names, shown whole, escaped and bounded.
306///
307/// The workspace names a program a launcher starts or a symlink target's final
308/// component. Unlike [`EchoedArgument`], the text is never cut at a space or an
309/// `=`, because a path is not an option with a value. It is bounded to
310/// 1024 bytes of escaped text.
311///
312/// # Examples
313///
314/// ```
315/// use std::path::Path;
316///
317/// use mcpls_core::error::EchoedPath;
318///
319/// let path = EchoedPath::new(Path::new("/opt/my tools/a=b/srv"));
320/// assert_eq!(path.as_str(), "/opt/my tools/a=b/srv");
321/// ```
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct EchoedPath(String);
324
325impl EchoedPath {
326    /// The whole of `path`, escaped and bounded.
327    #[must_use]
328    pub fn new(path: &Path) -> Self {
329        Self(escape_bounded(
330            &path.to_string_lossy(),
331            MAX_ECHOED_PATH_BYTES,
332        ))
333    }
334
335    /// The program an argument names: its whole path, unless the text reads
336    /// as an assignment (an `=` before any path separator), which is cut at
337    /// the `=` like [`EchoedArgument`] so a value that is not a program is not
338    /// echoed.
339    #[must_use]
340    pub fn program(arg: &str) -> Self {
341        let assignment = arg
342            .split_once('=')
343            .is_some_and(|(name, _)| !name.contains(std::path::is_separator));
344        if assignment {
345            Self(EchoedArgument::name(arg).0)
346        } else {
347            Self::new(Path::new(arg))
348        }
349    }
350
351    /// The echoed text.
352    #[must_use]
353    pub fn as_str(&self) -> &str {
354        &self.0
355    }
356}
357
358/// Renders a path as `{path:?}` does, cut after about 1024 bytes of that text.
359///
360/// The quoted form is for the paths a caller supplied or a file operation
361/// failed on ([`Error::MalformedPath`], [`Error::FileIo`]), where the quotes
362/// keep an empty or spaced path unambiguous and non-UTF-8 bytes visible.
363/// [`EchoedPath`] is the bare form for paths that read inside a sentence
364/// (`NotARegularFile`, `DocumentNotFound`, `PathOutsideWorkspace`,
365/// `NoWorkspaceRoots`), lossy for non-UTF-8 bytes. The two differ on purpose.
366///
367/// The cut is made while the debug text is produced, so the work is bounded by
368/// the cut and not by the path, and it never leaves half an escape sequence
369/// (`\u{..}`, `\x..`) in front of the truncation marker. Text within the bound
370/// is byte-identical to the plain debug form.
371struct BoundedDebugPath<'a>(&'a Path);
372
373/// Collects text until `left` bytes are used, then refuses the rest.
374struct CutWriter {
375    text: String,
376    left: usize,
377    cut: bool,
378}
379
380impl fmt::Write for CutWriter {
381    fn write_str(&mut self, piece: &str) -> fmt::Result {
382        if self.cut {
383            return Err(fmt::Error);
384        }
385        if piece.len() <= self.left {
386            self.text.push_str(piece);
387            self.left = self.left.saturating_sub(piece.len());
388            return Ok(());
389        }
390        let keep = piece.floor_char_boundary(self.left);
391        self.text.push_str(piece.get(..keep).unwrap_or_default());
392        self.cut = true;
393        Err(fmt::Error)
394    }
395}
396
397/// The length of `text` without a trailing incomplete debug escape (`\u{1`,
398/// `\x4`, a lone `\`).
399fn complete_escapes_len(text: &str) -> usize {
400    let mut at = 0;
401    while let Some(offset) = text.get(at..).and_then(|tail| tail.find('\\')) {
402        let start = at.saturating_add(offset);
403        let after = start.saturating_add(1);
404        let rest = text.get(after..).unwrap_or_default();
405        let escape_len = match rest.chars().next() {
406            Some('u') => rest.find('}').map(|end| end.saturating_add(1)),
407            Some('x') => (rest.len() >= 3).then_some(3),
408            Some(other) => Some(other.len_utf8()),
409            None => None,
410        };
411        let Some(escape_len) = escape_len else {
412            return start;
413        };
414        at = after.saturating_add(escape_len);
415    }
416    text.len()
417}
418
419impl fmt::Display for BoundedDebugPath<'_> {
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        use fmt::Write as _;
422
423        let mut writer = CutWriter {
424            text: String::new(),
425            left: MAX_ECHOED_PATH_BYTES,
426            cut: false,
427        };
428        // An error only means the writer refused text after the cut.
429        #[expect(
430            clippy::unnecessary_debug_formatting,
431            reason = "the quoted, escaped debug form is the message format"
432        )]
433        let formatted = write!(writer, "{:?}", self.0);
434        debug_assert!(formatted.is_ok() || writer.cut);
435        if !writer.cut {
436            return f.write_str(&writer.text);
437        }
438        let whole = complete_escapes_len(&writer.text);
439        f.write_str(writer.text.get(..whole).unwrap_or_default())?;
440        f.write_str(TRUNCATION_MARKER)
441    }
442}
443
444impl fmt::Display for EchoedPath {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        f.write_str(&self.0)
447    }
448}
449
450impl fmt::Display for EchoedArgument {
451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452        f.write_str(&self.0)
453    }
454}
455
456/// Declares a closed enum of spellings: the variants, `ALL`, `as_str` and
457/// `Display`, so a spelling is written once.
458macro_rules! spelled_enum {
459    (
460        $(#[$meta:meta])*
461        $name:ident {
462            $($(#[$variant_meta:meta])* $variant:ident => $spelling:expr),+ $(,)?
463        }
464    ) => {
465        $(#[$meta])*
466        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
467        #[non_exhaustive]
468        pub enum $name {
469            $($(#[$variant_meta])* $variant),+
470        }
471
472        impl $name {
473            /// Every variant, in declaration order.
474            pub const ALL: [Self; [$(Self::$variant),+].len()] = [$(Self::$variant),+];
475
476            /// The spelling as written on the wire or a command line.
477            #[must_use]
478            pub const fn as_str(self) -> &'static str {
479                match self {
480                    $(Self::$variant => $spelling),+
481                }
482            }
483        }
484
485        impl ::std::fmt::Display for $name {
486            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
487                f.write_str(self.as_str())
488            }
489        }
490    };
491}
492
493pub(crate) use spelled_enum;
494
495spelled_enum! {
496    /// A subcommand of a runner that starts a program the workspace chooses.
497    RunnerSubcommand {
498        /// `run`.
499        Run => "run",
500        /// `x`.
501        X => "x",
502        /// `task`.
503        Task => "task",
504        /// `eval`.
505        Eval => "eval",
506        /// `repl`.
507        Repl => "repl",
508        /// `tool`.
509        Tool => "tool",
510        /// `exec`.
511        Exec => "exec",
512        /// cabal's `v1-run`.
513        V1Run => "v1-run",
514        /// cabal's `v2-run`.
515        V2Run => "v2-run",
516        /// cabal's `new-run`.
517        NewRun => "new-run",
518        /// cabal's `v1-exec`.
519        V1Exec => "v1-exec",
520        /// cabal's `v2-exec`.
521        V2Exec => "v2-exec",
522        /// cabal's `new-exec`.
523        NewExec => "new-exec",
524        /// stack's `runghc`.
525        Runghc => "runghc",
526        /// stack's `runhaskell`.
527        Runhaskell => "runhaskell",
528        /// stack's `script`.
529        Script => "script",
530        /// cargo's `r` shorthand for `run`.
531        R => "r",
532        /// cargo's `t` shorthand for `test`.
533        T => "t",
534        /// `test`, which runs the project's tests.
535        Test => "test",
536        /// cargo's `bench`.
537        Bench => "bench",
538        /// stack's `ghci`.
539        Ghci => "ghci",
540        /// cabal's `v1-repl`.
541        V1Repl => "v1-repl",
542        /// cabal's `v2-repl`.
543        V2Repl => "v2-repl",
544        /// cabal's `new-repl`.
545        NewRepl => "new-repl",
546    }
547}
548
549spelled_enum! {
550    /// The spelling of a long flag that gives a shell a command string.
551    LongCommandName {
552        /// `--command`.
553        Command => "--command",
554        /// `--commands`.
555        Commands => "--commands",
556        /// `--init-command`, which fish runs as a command string.
557        InitCommand => "--init-command",
558        /// `--execute`, which nushell runs as a command string.
559        Execute => "--execute",
560    }
561}
562
563spelled_enum! {
564    /// A PowerShell parameter that gives it a command, however abbreviated.
565    PowerShellParameter {
566        /// `-Command`.
567        Command => "-Command",
568        /// `-CommandWithArgs`.
569        CommandWithArgs => "-CommandWithArgs",
570        /// `-EncodedCommand`.
571        EncodedCommand => "-EncodedCommand",
572    }
573}
574
575/// The flag that gives a shell a command string, as the shell's family
576/// spells it.
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578#[non_exhaustive]
579pub enum ShellFlag {
580    /// `-c`, alone or inside a cluster such as `-lc`.
581    DashC,
582    /// `+c`, which POSIX shells read like `-c`.
583    PlusC,
584    /// fish's `-C`, which runs an init command string.
585    DashCapitalC,
586    /// nushell's `-e`, which executes a command string.
587    DashE,
588    /// A long flag such as `--command`.
589    LongCommand(LongCommandName),
590    /// A PowerShell parameter or an abbreviation or alias of it.
591    PowerShell(PowerShellParameter),
592}
593
594/// How a [`ShellFlag`] is written on a command line.
595enum FlagSpelling {
596    /// A prefix character and a letter (`-c`, `+c`).
597    Short(char, char),
598    /// A whole word (`--command`, `-Command`).
599    Word(&'static str),
600}
601
602impl ShellFlag {
603    const fn spelling(self) -> FlagSpelling {
604        match self {
605            Self::DashC => FlagSpelling::Short('-', 'c'),
606            Self::PlusC => FlagSpelling::Short('+', 'c'),
607            Self::DashCapitalC => FlagSpelling::Short('-', 'C'),
608            Self::DashE => FlagSpelling::Short('-', 'e'),
609            Self::LongCommand(name) => FlagSpelling::Word(name.as_str()),
610            Self::PowerShell(parameter) => FlagSpelling::Word(parameter.as_str()),
611        }
612    }
613
614    /// The prefix character and letter of a short flag, or `None` for a flag
615    /// written as a whole word.
616    ///
617    /// This is the one place a short flag's spelling is written, so a flag
618    /// cannot be reported with a prefix the user did not use.
619    #[must_use]
620    pub(crate) const fn short_spelling(self) -> Option<(char, char)> {
621        match self.spelling() {
622            FlagSpelling::Short(prefix, letter) => Some((prefix, letter)),
623            FlagSpelling::Word(_) => None,
624        }
625    }
626}
627
628impl fmt::Display for ShellFlag {
629    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
630        match self.spelling() {
631            FlagSpelling::Short(prefix, letter) => write!(f, "{prefix}{letter}"),
632            FlagSpelling::Word(word) => f.write_str(word),
633        }
634    }
635}
636
637spelled_enum! {
638    /// The spelling of a long flag that gives an interpreter a program to run.
639    LongInlineFlag {
640        /// `--eval`.
641        Eval => "--eval",
642        /// `--print`.
643        Print => "--print",
644        /// `--import`, given a `data:` URL.
645        Import => "--import",
646        /// `--loader`, given a `data:` URL.
647        Loader => "--loader",
648        /// `--experimental-loader`, given a `data:` URL.
649        ExperimentalLoader => "--experimental-loader",
650        /// php's `--run`, the long form of `-r`.
651        Run => "--run",
652        /// php's `--process-begin`, the long form of `-B`.
653        ProcessBegin => "--process-begin",
654        /// php's `--process-code`, the long form of `-R`.
655        ProcessCode => "--process-code",
656        /// php's `--process-end`, the long form of `-E`.
657        ProcessEnd => "--process-end",
658        /// php's `--process-file`, the long form of `-F`.
659        ProcessFile => "--process-file",
660    }
661}
662
663/// The flag that gives an interpreter a program to run inline.
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665#[non_exhaustive]
666pub enum InlineFlag {
667    /// A short flag letter (`-e`, `-c`), alone or inside a cluster.
668    Short(char),
669    /// A long flag such as `--eval`.
670    Long(LongInlineFlag),
671}
672
673impl fmt::Display for InlineFlag {
674    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
675        match self {
676            Self::Short(letter) => write!(f, "-{letter}"),
677            Self::Long(flag) => f.write_str(flag.as_str()),
678        }
679    }
680}
681
682/// A positional operand a wrapper takes before the command.
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684#[non_exhaustive]
685pub enum OperandKind {
686    /// `timeout`'s `DURATION`.
687    Duration,
688}
689
690impl fmt::Display for OperandKind {
691    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
692        f.write_str(match self {
693            Self::Duration => "DURATION",
694        })
695    }
696}
697
698/// Why a shell or interpreter would read its program from standard input,
699/// which carries the LSP frames mcpls writes to the server.
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701#[non_exhaustive]
702pub enum StdinSource {
703    /// No script, module or other program source is given.
704    NoProgram,
705    /// `-s`, alone or inside a cluster.
706    DashS,
707    /// The script operand is `-`.
708    DashOperand,
709    /// The script operand lies under `/dev/` or `/proc/`, where a path can
710    /// name the process's standard input.
711    DeviceOperand,
712    /// The script operand has a `..` component, which can reach a device path.
713    ParentTraversal,
714    /// The script operand resolves, where the server starts, to a device,
715    /// FIFO or socket, so it may be an alias of a device path.
716    SpecialFile,
717}
718
719impl fmt::Display for StdinSource {
720    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
721        f.write_str(match self {
722            Self::NoProgram => "no script is given, so it reads its program from standard input",
723            Self::DashS => "`-s` makes it read its program from standard input",
724            Self::DashOperand => "a `-` operand makes it read its program from standard input",
725            Self::DeviceOperand => {
726                "its script is under /dev or /proc, where a path can name standard input"
727            }
728            Self::ParentTraversal => "its script path has a `..` component, which can reach /dev",
729            Self::SpecialFile => {
730                "its script is a device, FIFO or socket, which may be an alias of standard input"
731            }
732        })
733    }
734}
735
736/// What about a launcher lets the workspace choose the program that runs.
737///
738/// The values are matched from closed tables, never from configured text.
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740#[non_exhaustive]
741pub enum LaunchTrigger {
742    /// Every use of the program does (a package or task runner, or a wrapper
743    /// whose options are not analyzed).
744    Always,
745    /// This subcommand does.
746    Subcommand(RunnerSubcommand),
747    /// This flag gives it a command string.
748    CommandString(ShellFlag),
749    /// A bare argument is read as its command string (Windows PowerShell).
750    ImplicitCommandString,
751    /// This flag gives an interpreter a program to run inline.
752    InlineProgram(InlineFlag),
753    /// An argument names an npm package to run (`npm:`).
754    NpmSpecifier,
755    /// It reads its program from standard input, which carries LSP frames.
756    ProgramFromStdin(StdinSource),
757}
758
759impl fmt::Display for LaunchTrigger {
760    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761        match self {
762            Self::Always => f.write_str("every use of it does"),
763            Self::Subcommand(name) => write!(f, "its `{name}` subcommand does"),
764            Self::CommandString(flag) => {
765                write!(
766                    f,
767                    "`{flag}` gives it a command string, which cannot be analyzed"
768                )
769            }
770            Self::ImplicitCommandString => f.write_str(
771                "it reads a bare argument as a command string, which cannot be analyzed",
772            ),
773            Self::InlineProgram(flag) => write!(f, "`{flag}` gives it a program to run"),
774            Self::NpmSpecifier => f.write_str("an `npm:` argument names a package to run"),
775            Self::ProgramFromStdin(source) => write!(f, "{source}"),
776        }
777    }
778}
779
780/// Why a launch cannot be analyzed, so untrusted mode cannot tell which
781/// program it starts.
782#[derive(Debug, Clone, PartialEq, Eq)]
783#[non_exhaustive]
784pub enum UnanalyzableLaunch {
785    /// The wrapper has an option its option table does not list.
786    UnknownOption(EchoedArgument),
787    /// A listed option that takes a value is the last argument.
788    MissingValue(EchoedArgument),
789    /// No command follows the wrapper's options.
790    MissingCommand,
791    /// A positional operand before the command is not of the expected shape.
792    MalformedOperand(OperandKind),
793    /// `PATH` is assigned, which bypasses the sanitized search path.
794    PathAssignment,
795    /// A string is split into arguments (`env -S`).
796    SplitString,
797    /// A relative program follows a directory change.
798    RelativeProgramAfterChdir,
799    /// A relative script operand follows a directory change.
800    RelativeOperandAfterChdir,
801    /// The program name is empty.
802    BlankProgram,
803}
804
805impl fmt::Display for UnanalyzableLaunch {
806    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
807        match self {
808            Self::UnknownOption(option) => write!(f, "unknown option '{option}'"),
809            Self::MissingValue(option) => write!(f, "option '{option}' has no value"),
810            Self::MissingCommand => f.write_str("no command follows its options"),
811            Self::MalformedOperand(name) => write!(f, "its {name} operand is malformed"),
812            Self::PathAssignment => f.write_str("it assigns PATH"),
813            Self::SplitString => f.write_str("it splits a string into arguments"),
814            Self::RelativeProgramAfterChdir => {
815                f.write_str("it starts a relative program after changing directory")
816            }
817            Self::RelativeOperandAfterChdir => {
818                f.write_str("it reads a relative script after changing directory")
819            }
820            Self::BlankProgram => f.write_str("the program name is empty"),
821        }
822    }
823}
824
825/// Why a launcher lets the workspace choose the program that runs.
826#[derive(Debug, Clone, PartialEq, Eq)]
827#[non_exhaustive]
828pub enum LauncherRefusal {
829    /// A launcher selects workspace code.
830    SelectsWorkspaceCode {
831        /// The launcher's name.
832        program: EchoedPath,
833        /// What about it does.
834        trigger: LaunchTrigger,
835    },
836    /// The launch cannot be analyzed, which untrusted mode treats as unsafe.
837    Unanalyzable {
838        /// The launcher's name.
839        program: EchoedPath,
840        /// Why it cannot be analyzed.
841        reason: UnanalyzableLaunch,
842    },
843    /// Wrappers are nested deeper than the analysis follows.
844    TooDeep,
845}
846
847impl fmt::Display for LauncherRefusal {
848    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
849        match self {
850            Self::SelectsWorkspaceCode { program, trigger } => write!(
851                f,
852                "its launcher '{program}' chooses the server from files in the workspace \
853                 ({trigger}), which untrusted mode never runs; install the server globally \
854                 and give its absolute path as `command`"
855            ),
856            Self::Unanalyzable { program, reason } => write!(
857                f,
858                "its launcher '{program}' cannot be analyzed ({reason}), so untrusted mode \
859                 cannot tell which program it starts; give the absolute path of the server \
860                 as `command`"
861            ),
862            Self::TooDeep => f.write_str(
863                "its launchers are nested too deeply to analyze, so untrusted mode cannot \
864                 tell which program they start; give the absolute path of the server as \
865                 `command`",
866            ),
867        }
868    }
869}
870
871/// Why untrusted-workspace mode refused to start a server.
872///
873/// # Examples
874///
875/// ```
876/// use mcpls_core::config::BuiltinServer;
877/// use mcpls_core::error::UntrustedRefusal;
878///
879/// let refusal = UntrustedRefusal::NotAllowed {
880///     builtin: Some(BuiltinServer::RustAnalyzer),
881/// };
882/// assert!(refusal.to_string().contains("Cargo build scripts"));
883/// ```
884#[derive(Debug, Clone, PartialEq, Eq)]
885#[non_exhaustive]
886pub enum UntrustedRefusal {
887    /// The server was not named with `--allow-server`.
888    NotAllowed {
889        /// The built-in server this is, whose workspace-code class is named
890        /// in the message; `None` for a custom server.
891        builtin: Option<BuiltinServer>,
892    },
893    /// The server's executable lies inside the workspace. Naming the server
894    /// does not consent to running a binary the workspace supplies.
895    WorkspaceExecutable {
896        /// The canonical path of the executable.
897        executable: PathBuf,
898    },
899    /// The executable was not found on a search path outside the workspace,
900    /// so untrusted mode cannot tell what would run.
901    UnresolvedExecutable {
902        /// The configured `command`.
903        command: ServerCommand,
904    },
905    /// The auto-selected TypeScript command resolved to an executable that is
906    /// not typescript-language-server (a `PATH` entry symlinked to another
907    /// program), so it cannot be hardened without dropping the tsserver pin.
908    AutoSelectionTarget {
909        /// The resolved executable the command would have been replaced with.
910        executable: ServerCommand,
911        /// Why the selection cannot follow the resolved executable.
912        cause: InvalidAutoSelection,
913    },
914    /// The login home directory is unknown and the inherited `HOME` lies
915    /// inside the workspace, where rustup, cargo and npm would read their
916    /// configuration from.
917    WorkspaceHome {
918        /// The variable that names the home directory.
919        variable: HomeVariable,
920        /// The path the server would inherit, inside the workspace.
921        home: PathBuf,
922    },
923    /// The login home directory is unknown and a home variable is empty, so
924    /// tools resolve it against the working directory, which is the checkout.
925    EmptyHome {
926        /// The variable that is empty.
927        variable: HomeVariable,
928    },
929    /// The login home directory is unknown and `HOME` is not set, so tools
930    /// would resolve `~` against the working directory, which is the
931    /// checkout.
932    UnknownHome,
933    /// The tsserver the TypeScript server would use lies inside the
934    /// workspace.
935    WorkspaceTsserver {
936        /// The canonical path of the tsserver.
937        tsserver: PathBuf,
938    },
939    /// The configured command is a launcher that chooses the server from
940    /// files in the workspace (a package runner, task runner or toolchain
941    /// wrapper), so what would run is under the workspace's control.
942    ProjectLauncher {
943        /// The configured `command`.
944        command: ServerCommand,
945        /// What about the launch the analysis refused.
946        cause: LauncherRefusal,
947    },
948    /// An argument of the configured command names an executable file inside
949    /// the workspace, which whatever launcher precedes it would start.
950    WorkspaceExecutableArgument {
951        /// The position of the argument in the configured `args`.
952        index: usize,
953        /// The canonical path of the executable.
954        executable: PathBuf,
955    },
956    /// An argument of the configured command lies inside the workspace and
957    /// cannot be read, so untrusted mode cannot tell whether it is an
958    /// executable.
959    UnreadableWorkspaceArgument {
960        /// The position of the argument in the configured `args`.
961        index: usize,
962        /// The path the argument names, lexically normalized.
963        path: PathBuf,
964    },
965    /// A program an exec wrapper or `env` starts was not found on a search
966    /// path outside the workspace, so untrusted mode cannot tell what would
967    /// run.
968    UnresolvedWrappedProgram {
969        /// The wrapped program, as configured.
970        program: EchoedPath,
971    },
972    /// The vetted path of a program that `env` starts contains `=`, which
973    /// `env` would read as an assignment, so it cannot be passed on unchanged.
974    WrappedProgramPathContainsEquals {
975        /// The wrapped program, as configured.
976        program: EchoedPath,
977    },
978    /// The configured command starts the TypeScript server through a launcher
979    /// that untrusted mode cannot pin to a binary outside the workspace.
980    UnpinnedTypescriptLauncher {
981        /// The configured `command`.
982        command: ServerCommand,
983    },
984    /// A resolved path is not valid UTF-8, so untrusted mode cannot pass it
985    /// on unchanged.
986    NonUtf8Path {
987        /// What the path belongs to.
988        what: ResolvedItem,
989        /// The path, as resolved.
990        path: PathBuf,
991    },
992    /// A resolved path is blank, so there is nothing to start.
993    BlankResolvedPath {
994        /// What the path belongs to.
995        what: ResolvedItem,
996    },
997    /// A resolved path contains a NUL, which the operating system cannot pass
998    /// in a path or environment variable.
999    NulInResolvedPath {
1000        /// What the path belongs to.
1001        what: ResolvedItem,
1002    },
1003    /// No directory outside the workspace is available for the server to
1004    /// start in.
1005    NoSafeWorkingDirectory,
1006    /// The variables untrusted mode sets for the server do not fit in its
1007    /// `env` table.
1008    EnvTooLarge {
1009        /// Which limit the table would be over.
1010        error: EnvTooLarge,
1011    },
1012}
1013
1014impl fmt::Display for UntrustedRefusal {
1015    #[allow(
1016        clippy::too_many_lines,
1017        reason = "one message per refusal, and the refusals are the table"
1018    )]
1019    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1020        match self {
1021            Self::NotAllowed {
1022                builtin: Some(builtin),
1023            } => write!(
1024                f,
1025                "the workspace is untrusted and it may run workspace code ({})",
1026                builtin.workspace_code()
1027            ),
1028            Self::NotAllowed { builtin: None } => f.write_str(
1029                "the workspace is untrusted and it is not a built-in server, so the workspace \
1030                 code it may run is unknown",
1031            ),
1032            Self::WorkspaceExecutable { executable } => write!(
1033                f,
1034                "its executable {} lies inside the workspace, which untrusted mode never runs",
1035                EchoedPath::new(executable)
1036            ),
1037            Self::WorkspaceExecutableArgument { index, executable } => write!(
1038                f,
1039                "its argument at index {index}, {}, is an executable inside the workspace, \
1040                 which untrusted mode never runs",
1041                EchoedPath::new(executable)
1042            ),
1043            Self::UnreadableWorkspaceArgument { index, path } => write!(
1044                f,
1045                "its argument at index {index}, {}, lies inside the workspace and cannot be \
1046                 read, so untrusted mode cannot tell whether it is an executable",
1047                EchoedPath::new(path)
1048            ),
1049            Self::UnresolvedExecutable { command } => write!(
1050                f,
1051                "its executable '{command}' was not found on a PATH outside the workspace, \
1052                 which untrusted mode requires"
1053            ),
1054            Self::AutoSelectionTarget { executable, cause } => write!(
1055                f,
1056                "its executable resolved to '{executable}' ({cause}), so untrusted mode \
1057                 cannot pin it; give the absolute path of the server as `command`"
1058            ),
1059            Self::UnknownHome => f.write_str(
1060                "HOME is not set and the login home directory is unknown, so untrusted mode \
1061                 cannot give it a safe one",
1062            ),
1063            Self::EmptyHome { variable } => write!(
1064                f,
1065                "its {variable} is empty, which resolves inside the workspace, and the login \
1066                 home directory is unknown, so untrusted mode cannot give it a safe one"
1067            ),
1068            Self::WorkspaceHome { variable, home } => write!(
1069                f,
1070                "its {variable}, {}, lies inside the workspace and the login home directory is \
1071                 unknown, so untrusted mode cannot give it a safe one",
1072                EchoedPath::new(home)
1073            ),
1074            Self::WorkspaceTsserver { tsserver } => write!(
1075                f,
1076                "the tsserver it would use, {}, lies inside the workspace, which untrusted \
1077                 mode never runs",
1078                EchoedPath::new(tsserver)
1079            ),
1080            Self::ProjectLauncher { cause, .. } => cause.fmt(f),
1081            Self::UnresolvedWrappedProgram { program } => write!(
1082                f,
1083                "the program '{program}' its launcher starts was not found where the child \
1084                 would look it up, which untrusted mode requires"
1085            ),
1086            Self::WrappedProgramPathContainsEquals { program } => write!(
1087                f,
1088                "the program '{program}' its launcher starts resolves to a path containing \
1089                 '=', which env reads as an assignment, so untrusted mode cannot pin it; give \
1090                 a program whose path has no '='"
1091            ),
1092            Self::UnpinnedTypescriptLauncher { command } => write!(
1093                f,
1094                "its launcher '{command}' starts the TypeScript server in a way untrusted \
1095                 mode cannot pin to a binary outside the workspace; install the server \
1096                 globally and give its absolute path as `command`"
1097            ),
1098            Self::NonUtf8Path { what, path } => write!(
1099                f,
1100                "its {what}, {}, is not valid UTF-8, so untrusted mode cannot pass it on",
1101                EchoedPath::new(path)
1102            ),
1103            Self::BlankResolvedPath { what } => write!(
1104                f,
1105                "its {what} resolved to a blank path, which cannot be started"
1106            ),
1107            Self::NulInResolvedPath { what } => {
1108                write!(
1109                    f,
1110                    "its {what} resolved to a path containing NUL, which cannot be passed on"
1111                )
1112            }
1113            Self::NoSafeWorkingDirectory => f.write_str(
1114                "no directory outside the workspace is available to start it in, which \
1115                 untrusted mode requires",
1116            ),
1117            Self::EnvTooLarge { error } => write!(
1118                f,
1119                "the variables untrusted mode sets do not fit in its environment: {error}"
1120            ),
1121        }
1122    }
1123}
1124
1125impl fmt::Display for StartupFailure {
1126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1127        match self {
1128            Self::Spawn(error) => ErrorChain(error.as_ref()).fmt(f),
1129            Self::InitTaskPanicked => {
1130                f.write_str("the initialization task panicked (see the mcpls log)")
1131            }
1132            Self::RefusedUntrustedWorkspace(refusal) => refusal.fmt(f),
1133        }
1134    }
1135}
1136
1137/// Details of a single server spawn failure.
1138#[derive(Debug, Clone)]
1139pub struct ServerSpawnFailure {
1140    /// Routing identity of the failed server.
1141    pub server_id: ServerId,
1142    /// Language ID of the failed server.
1143    pub language_id: LanguageId,
1144    /// Command that was attempted.
1145    pub command: ServerCommand,
1146    /// Why the server never registered.
1147    pub reason: StartupFailure,
1148}
1149
1150impl fmt::Display for ServerSpawnFailure {
1151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1152        write!(
1153            f,
1154            "{} [{}] ({}): {}",
1155            self.server_id, self.language_id, self.command, self.reason
1156        )
1157    }
1158}
1159
1160impl ServerSpawnFailure {
1161    /// The caller-facing text of [`Error::ServerFailedToStart`]. A refusal
1162    /// was a decision, not a failure, so it names the remedy instead of the
1163    /// startup-failure tail.
1164    pub(crate) const fn failed_to_start(&self) -> FailedToStart<'_> {
1165        FailedToStart(self)
1166    }
1167}
1168
1169/// `Display` adapter for [`ServerSpawnFailure::failed_to_start`].
1170pub(crate) struct FailedToStart<'a>(&'a ServerSpawnFailure);
1171
1172impl fmt::Display for FailedToStart<'_> {
1173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1174        let failure = self.0;
1175        let (id, language) = (&failure.server_id, &failure.language_id);
1176        match &failure.reason {
1177            StartupFailure::RefusedUntrustedWorkspace(refusal) => {
1178                write!(
1179                    f,
1180                    "LSP server '{id}' for language '{language}' was not started: {refusal}"
1181                )?;
1182                match refusal {
1183                    UntrustedRefusal::NotAllowed { .. } => {
1184                        write!(f, "; restart mcpls with `--allow-server {id}` to start it")
1185                    }
1186                    UntrustedRefusal::WorkspaceExecutable { .. }
1187                    | UntrustedRefusal::WorkspaceExecutableArgument { .. }
1188                    | UntrustedRefusal::UnreadableWorkspaceArgument { .. }
1189                    | UntrustedRefusal::UnresolvedExecutable { .. }
1190                    | UntrustedRefusal::AutoSelectionTarget { .. }
1191                    | UntrustedRefusal::WorkspaceHome { .. }
1192                    | UntrustedRefusal::EmptyHome { .. }
1193                    | UntrustedRefusal::UnknownHome
1194                    | UntrustedRefusal::WorkspaceTsserver { .. }
1195                    | UntrustedRefusal::ProjectLauncher { .. }
1196                    | UntrustedRefusal::UnresolvedWrappedProgram { .. }
1197                    | UntrustedRefusal::WrappedProgramPathContainsEquals { .. }
1198                    | UntrustedRefusal::UnpinnedTypescriptLauncher { .. }
1199                    | UntrustedRefusal::NonUtf8Path { .. }
1200                    | UntrustedRefusal::BlankResolvedPath { .. }
1201                    | UntrustedRefusal::NulInResolvedPath { .. }
1202                    | UntrustedRefusal::NoSafeWorkingDirectory
1203                    | UntrustedRefusal::EnvTooLarge { .. } => Ok(()),
1204                }
1205            }
1206            reason @ (StartupFailure::Spawn(_) | StartupFailure::InitTaskPanicked) => write!(
1207                f,
1208                "LSP server '{id}' for language '{language}' failed to start: {reason}; restart \
1209                 mcpls after fixing it (startup failures are not retried)"
1210            ),
1211        }
1212    }
1213}
1214
1215/// `Display` adapter listing every failure, separated by `; `.
1216struct FailureList<'a>(&'a [ServerSpawnFailure]);
1217
1218impl fmt::Display for FailureList<'_> {
1219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1220        for (index, failure) in self.0.iter().enumerate() {
1221            if index > 0 {
1222                f.write_str("; ")?;
1223            }
1224            failure.fmt(f)?;
1225        }
1226        Ok(())
1227    }
1228}
1229
1230/// Maximum candidates named in the `Display` text of an ambiguity error;
1231/// the structured payload carries all of them.
1232const MAX_DISPLAYED_CANDIDATES: usize = 10;
1233
1234/// One symbol a name resolved to, with enough context to tell it apart from
1235/// its namesakes and to address it by position instead.
1236///
1237/// # Examples
1238///
1239/// ```
1240/// use mcpls_core::error::SymbolCandidate;
1241///
1242/// let candidate = SymbolCandidate {
1243///     name: "new".to_string(),
1244///     kind: 6,
1245///     kind_name: "Method".to_string(),
1246///     container: Some("Config".to_string()),
1247///     line: 12,
1248///     character: 8,
1249/// };
1250/// assert_eq!(candidate.to_string(), "Method `new` in `Config` at 12:8");
1251/// ```
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1253pub struct SymbolCandidate {
1254    /// The symbol's name as the server reports it.
1255    pub name: String,
1256    /// Numeric LSP `SymbolKind`.
1257    pub kind: u32,
1258    /// Readable name of [`Self::kind`].
1259    pub kind_name: String,
1260    /// Enclosing symbol, when the server reports one.
1261    pub container: Option<String>,
1262    /// 1-based line of the identifier.
1263    pub line: u32,
1264    /// 1-based character of the identifier.
1265    pub character: u32,
1266}
1267
1268impl fmt::Display for SymbolCandidate {
1269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270        write!(f, "{} `{}`", self.kind_name, self.name)?;
1271        if let Some(container) = &self.container {
1272            write!(f, " in `{container}`")?;
1273        }
1274        write!(f, " at {}:{}", self.line, self.character)
1275    }
1276}
1277
1278/// Why a symbol name could not be resolved to exactly one position.
1279///
1280/// Carried as the structured `data` of the `INVALID_PARAMS` error, tagged by
1281/// `resolution`.
1282///
1283/// # Examples
1284///
1285/// ```
1286/// use mcpls_core::error::SymbolResolutionData;
1287///
1288/// let missing = SymbolResolutionData::NotDefinedInFile { name: "Config".to_string() };
1289/// assert_eq!(
1290///     serde_json::to_value(&missing).unwrap(),
1291///     serde_json::json!({"resolution": "not_defined_in_file", "name": "Config"})
1292/// );
1293/// ```
1294#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1295#[serde(tag = "resolution", rename_all = "snake_case")]
1296pub enum SymbolResolutionData {
1297    /// The name matches several symbols; nothing was picked.
1298    Ambiguous {
1299        /// The requested name.
1300        name: String,
1301        /// The matching symbols, capped.
1302        candidates: Vec<SymbolCandidate>,
1303        /// More symbols match than `candidates` lists.
1304        truncated: bool,
1305    },
1306    /// No symbol of that name (and kind/container) is defined in the file.
1307    NotFound {
1308        /// The requested name.
1309        name: String,
1310        /// Symbols of that name excluded by the kind or container filter.
1311        excluded_by_filters: usize,
1312    },
1313    /// The name occurs in the file but no symbol defines it there (an import
1314    /// or a plain reference).
1315    NotDefinedInFile {
1316        /// The requested name.
1317        name: String,
1318    },
1319    /// A symbol matched but its identifier position could not be verified
1320    /// against the document text, so no query was made.
1321    PositionUnverified {
1322        /// The requested name.
1323        name: String,
1324        /// The symbol that matched.
1325        candidate: SymbolCandidate,
1326    },
1327}
1328
1329impl fmt::Display for SymbolResolutionData {
1330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1331        match self {
1332            Self::Ambiguous {
1333                name,
1334                candidates,
1335                truncated,
1336            } => {
1337                write!(
1338                    f,
1339                    "symbol `{name}` is ambiguous ({} candidates{}): ",
1340                    candidates.len(),
1341                    if *truncated { ", more not listed" } else { "" }
1342                )?;
1343                for (index, candidate) in
1344                    candidates.iter().take(MAX_DISPLAYED_CANDIDATES).enumerate()
1345                {
1346                    if index > 0 {
1347                        f.write_str("; ")?;
1348                    }
1349                    candidate.fmt(f)?;
1350                }
1351                if candidates.len() > MAX_DISPLAYED_CANDIDATES {
1352                    f.write_str("; ...")?;
1353                }
1354                f.write_str(
1355                    ". Narrow it with `symbol_kind` or `container`, or address it by line and character",
1356                )
1357            }
1358            Self::NotFound {
1359                name,
1360                excluded_by_filters,
1361            } => {
1362                write!(f, "no symbol named `{name}` is defined in this file")?;
1363                if *excluded_by_filters > 0 {
1364                    write!(
1365                        f,
1366                        " ({excluded_by_filters} with that name excluded by `symbol_kind`/`container`)"
1367                    )?;
1368                }
1369                f.write_str("; try `workspace_symbol_search` or address it by line and character")
1370            }
1371            Self::NotDefinedInFile { name } => write!(
1372                f,
1373                "`{name}` is not defined in this file (it only occurs as a reference or import); \
1374                 find its definition with `workspace_symbol_search` or address it by line and character"
1375            ),
1376            Self::PositionUnverified { name, candidate } => write!(
1377                f,
1378                "could not verify where the identifier `{name}` is ({candidate}), so nothing was \
1379                 queried; address it by line and character"
1380            ),
1381        }
1382    }
1383}
1384
1385/// `Display` adapter listing server ids, separated by `, `.
1386struct IdList<'a>(&'a [ServerId]);
1387
1388impl fmt::Display for IdList<'_> {
1389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390        for (index, id) in self.0.iter().enumerate() {
1391            if index > 0 {
1392                f.write_str(", ")?;
1393            }
1394            write!(f, "'{id}'")?;
1395        }
1396        Ok(())
1397    }
1398}
1399
1400/// What a failed server startup wrote to stderr, bounded and sanitized.
1401///
1402/// Holds the first and last bytes of the output (or all of it when short), so
1403/// both the opening banner and the final error survive.
1404///
1405/// # Sanitizing
1406///
1407/// Control characters other than newline and tab are dropped, as are
1408/// characters that forge or reorder text in logs and terminals: the Unicode
1409/// line and paragraph separators, bidirectional controls and zero-width
1410/// characters.
1411///
1412/// # Redaction
1413///
1414/// Values that look secret are replaced with `[redacted:NAME]` before the text
1415/// leaves mcpls. A value counts as secret (and must be at least 8 bytes) when
1416/// it is:
1417/// - an environment variable value, from the server's configured `env` or
1418///   mcpls's own environment, whose name contains `TOKEN`, `KEY`, `SECRET`,
1419///   `PASSW`, `CRED` or `AUTH` (case-insensitive);
1420/// - the value of a `--flag=value` or `--flag value` argument whose flag name
1421///   matches the same patterns;
1422/// - a string under a matching key of `initialization_options`.
1423///
1424/// Other values, such as `RUSTUP_TOOLCHAIN`, stay visible so the cause of a
1425/// failure can still be read. Matching is by exact value: encoded forms (URL,
1426/// base64, JSON-escaped) are not found. A secret cut by the head/tail elision
1427/// is hidden only for the fragment of at least 4 bytes on either side of the
1428/// cut. Put secrets in a name-matching `env` entry rather than in other
1429/// places.
1430///
1431/// `Display` renders one line: trimmed non-empty lines joined with ` | `,
1432/// with `...` between head and tail when elided. The raw multi-line text is
1433/// available through [`Self::head`] and [`Self::tail`].
1434///
1435/// # Examples
1436///
1437/// ```
1438/// use mcpls_core::Error;
1439/// use mcpls_core::error::InitPhase;
1440///
1441/// fn server_output(err: &Error) -> Option<String> {
1442///     match err {
1443///         Error::LspInitFailed { stderr: Some(stderr), .. }
1444///         | Error::ServerExitedDuringInit { stderr: Some(stderr), .. } => {
1445///             Some(stderr.to_string())
1446///         }
1447///         _ => None,
1448///     }
1449/// }
1450///
1451/// let err = Error::LspInitFailed {
1452///     phase: InitPhase::Initialize,
1453///     cause: Box::new(Error::ServerTerminated),
1454///     hint: None,
1455///     stderr: None,
1456/// };
1457/// assert_eq!(server_output(&err), None);
1458/// ```
1459#[derive(Debug, Clone, PartialEq, Eq)]
1460pub struct StderrExcerpt {
1461    body: ExcerptBody,
1462}
1463
1464#[derive(Debug, Clone, PartialEq, Eq)]
1465enum ExcerptBody {
1466    Complete(Box<str>),
1467    Elided { head: Box<str>, tail: Box<str> },
1468}
1469
1470impl StderrExcerpt {
1471    /// Excerpt of output that fit entirely in the capture buffer; `None` when
1472    /// it holds no visible text.
1473    pub(crate) fn complete(bytes: &[u8], redactions: &Redactions) -> Option<Self> {
1474        let text = clean_stderr(&String::from_utf8_lossy(bytes), redactions);
1475        (!text.is_empty()).then(|| Self {
1476            body: ExcerptBody::Complete(text.into()),
1477        })
1478    }
1479
1480    /// Excerpt of output whose middle was dropped. The cut can split a UTF-8
1481    /// sequence at the end of `head` and at the start of `tail`; both partial
1482    /// sequences are discarded rather than shown as replacement characters.
1483    /// A secret split by the cut is masked as far as `redactions` allows.
1484    pub(crate) fn elided(head: &[u8], tail: &[u8], redactions: &Redactions) -> Option<Self> {
1485        let head = clean_stderr(
1486            &redactions.mask_cut_head(&decode_cut_head(head)),
1487            redactions,
1488        );
1489        let tail_start = tail
1490            .iter()
1491            .position(|byte| byte & 0xC0 != 0x80)
1492            .unwrap_or(tail.len());
1493        let tail = clean_stderr(
1494            &redactions.mask_cut_tail(&String::from_utf8_lossy(
1495                tail.get(tail_start..).unwrap_or_default(),
1496            )),
1497            redactions,
1498        );
1499        match (head.is_empty(), tail.is_empty()) {
1500            (true, true) => None,
1501            (false, true) => Some(Self {
1502                body: ExcerptBody::Complete(head.into()),
1503            }),
1504            (true, false) => Some(Self {
1505                body: ExcerptBody::Complete(tail.into()),
1506            }),
1507            (false, false) => Some(Self {
1508                body: ExcerptBody::Elided {
1509                    head: head.into(),
1510                    tail: tail.into(),
1511                },
1512            }),
1513        }
1514    }
1515
1516    /// The start of the output, or all of it when nothing was dropped.
1517    #[must_use]
1518    pub fn head(&self) -> &str {
1519        match &self.body {
1520            ExcerptBody::Complete(text) => text,
1521            ExcerptBody::Elided { head, .. } => head,
1522        }
1523    }
1524
1525    /// The end of the output, present only when the middle was dropped.
1526    #[must_use]
1527    pub fn tail(&self) -> Option<&str> {
1528        match &self.body {
1529            ExcerptBody::Complete(_) => None,
1530            ExcerptBody::Elided { tail, .. } => Some(tail),
1531        }
1532    }
1533
1534    /// Whether the middle of the output was dropped.
1535    #[must_use]
1536    pub const fn is_elided(&self) -> bool {
1537        matches!(self.body, ExcerptBody::Elided { .. })
1538    }
1539}
1540
1541impl fmt::Display for StderrExcerpt {
1542    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1543        fn visible_lines(text: &str) -> impl Iterator<Item = &str> {
1544            text.lines().map(str::trim).filter(|line| !line.is_empty())
1545        }
1546
1547        let mut parts: Vec<&str> = visible_lines(self.head()).collect();
1548        if let Some(tail) = self.tail() {
1549            parts.push("...");
1550            parts.extend(visible_lines(tail));
1551        }
1552        f.write_str(&parts.join(" | "))
1553    }
1554}
1555
1556/// Decodes `head`, dropping a UTF-8 sequence cut off at its end.
1557fn decode_cut_head(head: &[u8]) -> String {
1558    match std::str::from_utf8(head) {
1559        Ok(text) => text.to_owned(),
1560        Err(e) if e.error_len().is_none() => {
1561            String::from_utf8_lossy(head.get(..e.valid_up_to()).unwrap_or_default()).into_owned()
1562        }
1563        Err(_) => String::from_utf8_lossy(head).into_owned(),
1564    }
1565}
1566
1567/// Redacts secrets, drops control and deceptive format characters (other than
1568/// `\n`/`\t`: they could forge log lines or drive a terminal) and trims.
1569/// Redaction runs before and after the filter so a dropped character cannot
1570/// split a secret into a form that escapes it.
1571fn clean_stderr(text: &str, redactions: &Redactions) -> String {
1572    let filtered: String = redactions
1573        .apply(text)
1574        .chars()
1575        .filter(|c| {
1576            (!c.is_control() || matches!(c, '\n' | '\t'))
1577                && !crate::util::is_deceptive_format_char(*c)
1578        })
1579        .collect();
1580    redactions.apply(&filtered).trim().to_owned()
1581}
1582
1583/// `Display` suffix appending a server's stderr excerpt to a startup error.
1584struct StderrSuffix<'a>(&'a Option<StderrExcerpt>);
1585
1586impl fmt::Display for StderrSuffix<'_> {
1587    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1588        self.0
1589            .as_ref()
1590            .map_or(Ok(()), |excerpt| write!(f, "; stderr: {excerpt}"))
1591    }
1592}
1593
1594/// Guidance attached to an `initialize` failure whose likely cause is known.
1595///
1596/// # Examples
1597///
1598/// ```
1599/// use mcpls_core::error::InitFailureHint;
1600///
1601/// let text = InitFailureHint::NativeTypescriptOnly.to_string();
1602/// assert!(text.contains("TypeScript 7"));
1603/// ```
1604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1605#[non_exhaustive]
1606pub enum InitFailureHint {
1607    /// typescript-language-server found only TypeScript 7 or later, which
1608    /// ships no `tsserver` for it to start.
1609    NativeTypescriptOnly,
1610}
1611
1612impl fmt::Display for InitFailureHint {
1613    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1614        match self {
1615            Self::NativeTypescriptOnly => write!(
1616                f,
1617                "typescript-language-server found only TypeScript 7 or later, which ships no \
1618                 tsserver; install the TypeScript version the server supports next to it (for a \
1619                 global install: `{}`), or change the `command` of your existing `typescript` \
1620                 server entry to the absolute path of the `tsc` of a TypeScript 7 install \
1621                 outside the workspace, with `args = [\"--lsp\", \"--stdio\"]` (a `tsc` from the \
1622                 workspace, or a bare `tsc` that PATH may resolve into the workspace, is \
1623                 workspace-supplied code)",
1624                BuiltinServer::TypescriptLanguageServer.install_hint()
1625            ),
1626        }
1627    }
1628}
1629
1630/// `Display` suffix appending an [`InitFailureHint`] to a startup error.
1631struct HintSuffix<'a>(&'a Option<InitFailureHint>);
1632
1633impl fmt::Display for HintSuffix<'_> {
1634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1635        self.0.map_or(Ok(()), |hint| write!(f, "; {hint}"))
1636    }
1637}
1638
1639/// `Display` suffix for [`Error::ServerExitedDuringInit`] carrying the exit
1640/// status and, for builtin servers with a known early-exit cause, a hint.
1641struct EarlyExitDetail<'a>(&'a str, Option<i32>);
1642
1643impl fmt::Display for EarlyExitDetail<'_> {
1644    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1645        let Self(command, exit_code) = *self;
1646        match exit_code {
1647            Some(code) => write!(f, " with exit code {code}")?,
1648            None => f.write_str(" (terminated by a signal)")?,
1649        }
1650        BuiltinServer::from_command(command)
1651            .and_then(BuiltinServer::early_exit_hint)
1652            .map_or(Ok(()), |hint| write!(f, "; {hint}"))
1653    }
1654}
1655
1656/// A background task whose failure surfaces as [`Error::TaskFailed`] or is
1657/// reported by its supervisor.
1658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1659#[non_exhaustive]
1660pub enum BackgroundTask {
1661    /// The MCP service loop that serves one client connection.
1662    McpService,
1663    /// The task that reads an LSP server's messages.
1664    LspReceiver,
1665    /// The blocking resolution of `subscriptions/listen` URIs.
1666    ListenResolution,
1667    /// The blocking canonicalization of a client path.
1668    PathValidation,
1669    /// Opening and verifying a file on the blocking pool.
1670    FileOpen,
1671    /// Planning which configured servers to start, on the blocking pool.
1672    ServerPlanning,
1673    /// The reaper that expires idle HTTP sessions.
1674    IdleSessionReaper,
1675}
1676
1677impl fmt::Display for BackgroundTask {
1678    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1679        f.write_str(match self {
1680            Self::McpService => "MCP service",
1681            Self::LspReceiver => "LSP receiver",
1682            Self::ListenResolution => "listen URI resolution",
1683            Self::PathValidation => "path validation",
1684            Self::FileOpen => "file open",
1685            Self::ServerPlanning => "server start planning",
1686            Self::IdleSessionReaper => "idle HTTP session reaper",
1687        })
1688    }
1689}
1690
1691/// A standard stream of a spawned LSP server process.
1692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693#[non_exhaustive]
1694pub enum StdioStream {
1695    /// Standard input.
1696    Stdin,
1697    /// Standard output.
1698    Stdout,
1699    /// Standard error.
1700    Stderr,
1701}
1702
1703impl fmt::Display for StdioStream {
1704    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1705        f.write_str(match self {
1706            Self::Stdin => "stdin",
1707            Self::Stdout => "stdout",
1708            Self::Stderr => "stderr",
1709        })
1710    }
1711}
1712
1713/// A configuration value that is invalid in the context of the entry that holds it.
1714///
1715/// Field-level rules live in the types of the fields (a rejected value cannot be
1716/// built); this enum carries what only the surrounding entry or the whole
1717/// configuration can add, such as which server a bad value belongs to.
1718#[derive(Debug, thiserror::Error)]
1719#[non_exhaustive]
1720pub enum ConfigError {
1721    /// A server entry lists a `file_patterns` form that maps to no extension.
1722    #[error("lsp_servers entry '{server}'")]
1723    UnsupportedFilePattern {
1724        /// The id of the entry that holds the pattern.
1725        server: ServerId,
1726        /// The rejected pattern and the supported forms.
1727        #[source]
1728        pattern: UnsupportedFilePattern,
1729    },
1730
1731    /// The config file's bytes are not UTF-8.
1732    #[error("config file is not valid UTF-8")]
1733    NotUtf8(#[source] std::string::FromUtf8Error),
1734
1735    /// The config file is over its fixed size limit, which is not configurable.
1736    #[error("config file is {} bytes, over the fixed {} byte limit", .0.size, .0.max)]
1737    FileTooLarge(SizeExceeded),
1738
1739    /// A server's `env` table sets one variable twice, which the host treats as
1740    /// one name.
1741    #[error("lsp_servers entry '{server}'")]
1742    DuplicateEnvKey {
1743        /// The id of the entry that holds the table.
1744        server: ServerId,
1745        /// The colliding keys.
1746        #[source]
1747        error: DuplicateEnvKey,
1748    },
1749
1750    /// A server's `env` table holds a value the operating system cannot pass.
1751    #[error("lsp_servers entry '{server}': environment variable '{key}'")]
1752    InvalidEnvValue {
1753        /// The id of the entry that holds the table.
1754        server: ServerId,
1755        /// The variable whose value is invalid.
1756        key: EnvKey,
1757        /// Why the value is invalid.
1758        #[source]
1759        error: InvalidEnvValue,
1760    },
1761
1762    /// A server's `env` table is too large to pass to a process.
1763    #[error("lsp_servers entry '{server}'")]
1764    EnvTooLarge {
1765        /// The id of the entry that holds the table.
1766        server: ServerId,
1767        /// Which size limit the table is over.
1768        #[source]
1769        error: EnvTooLarge,
1770    },
1771
1772    /// An entry of a server's `args` holds a NUL, which the operating system
1773    /// cannot pass in an argument.
1774    #[error("lsp_servers entry '{server}'")]
1775    ArgContainsNul {
1776        /// The id of the entry that holds the argument.
1777        server: ServerId,
1778        /// Which argument holds the NUL.
1779        #[source]
1780        error: InvalidServerArgs,
1781    },
1782
1783    /// A config path has no directory to resolve relative entries against.
1784    #[error("configuration path has no parent directory: {}", .path.display())]
1785    NoParentDirectory {
1786        /// The config path.
1787        path: PathBuf,
1788    },
1789
1790    /// `selection = "auto"` on an entry that is not typescript-language-server.
1791    #[error(
1792        "selection = \"auto\" is only valid for typescript-language-server entries (language \
1793         '{language}'); remove `selection` to use `command` as written"
1794    )]
1795    SelectionAutoOnNonTypescript {
1796        /// The language of the offending entry.
1797        language: LanguageId,
1798    },
1799
1800    /// An allowed server id names no configured server.
1801    #[error(
1802        "allowed server '{server}' is not a configured server (configured: {})",
1803        IdList(configured)
1804    )]
1805    UnknownAllowedServer {
1806        /// The allowed id that matches nothing.
1807        server: ServerId,
1808        /// The ids of the configured servers.
1809        configured: Vec<ServerId>,
1810    },
1811
1812    /// Two applicable entries share one server id.
1813    #[error(
1814        "duplicate server id '{id}' in this workspace (used by both an entry with {first} and \
1815         one with {second}); add a unique `name` to each `[[lsp_servers]]` entry"
1816    )]
1817    DuplicateServerId {
1818        /// The shared id.
1819        id: ServerId,
1820        /// The first entry.
1821        first: Box<EntrySummary>,
1822        /// The second entry.
1823        second: Box<EntrySummary>,
1824    },
1825
1826    /// A language has two servers that both omit `handles`.
1827    #[error(
1828        "language '{language}' has two catch-all servers ('{existing}' and '{id}'); at most one \
1829         server per language may omit `handles`"
1830    )]
1831    TwoCatchAllServers {
1832        /// The language both serve.
1833        language: LanguageId,
1834        /// The server registered first.
1835        existing: ServerId,
1836        /// The server that collides with it.
1837        id: ServerId,
1838    },
1839
1840    /// Two servers of one language claim the same tool in `handles`.
1841    #[error("tool '{tool}' for language '{language}' is claimed by both '{existing}' and '{id}'")]
1842    ToolClaimedTwice {
1843        /// The contested tool.
1844        tool: ToolKind,
1845        /// The language both serve.
1846        language: LanguageId,
1847        /// The server registered first.
1848        existing: ServerId,
1849        /// The server that collides with it.
1850        id: ServerId,
1851    },
1852
1853    /// A configured workspace root cannot be canonicalized.
1854    #[error(
1855        "workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized",
1856        .written.display(), .base_dir.display(), .probe.display()
1857    )]
1858    UnresolvableWorkspaceRoot {
1859        /// The root as written in the config.
1860        written: PathBuf,
1861        /// The directory a relative root is resolved against.
1862        base_dir: PathBuf,
1863        /// The path that was canonicalized.
1864        probe: PathBuf,
1865        /// Why canonicalization failed.
1866        #[source]
1867        source: std::io::Error,
1868    },
1869}
1870
1871/// A step of the LSP initialization handshake.
1872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1873#[non_exhaustive]
1874pub enum InitPhase {
1875    /// The `initialize` request.
1876    Initialize,
1877    /// The `initialized` notification.
1878    Initialized,
1879    /// The `workspace/didChangeConfiguration` notification.
1880    DidChangeConfiguration,
1881}
1882
1883impl fmt::Display for InitPhase {
1884    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1885        f.write_str(match self {
1886            Self::Initialize => "Initialize request failed",
1887            Self::Initialized => "initialized notification failed",
1888            Self::DidChangeConfiguration => "workspace/didChangeConfiguration notification failed",
1889        })
1890    }
1891}
1892
1893/// The main error type for mcpls-core operations.
1894///
1895/// This enum is `#[non_exhaustive]`: downstream crates that match on it must
1896/// include a wildcard arm. New variants (such as [`Error::ServerInitializing`])
1897/// can then be added without further breaking changes.
1898#[derive(Debug, thiserror::Error)]
1899#[non_exhaustive]
1900pub enum Error {
1901    /// LSP server failed to initialize.
1902    #[error("LSP server initialization failed: {phase}: {}{}{}", ErrorChain(.cause.as_ref()), HintSuffix(.hint), StderrSuffix(.stderr))]
1903    LspInitFailed {
1904        /// The handshake step that failed.
1905        phase: InitPhase,
1906        /// Why the step failed, so a timeout and a rejection by the server
1907        /// stay distinguishable. Rendered by `Display` through
1908        /// [`ErrorChain`] instead of being exposed through `source()`, so the
1909        /// chain is not printed twice.
1910        cause: Box<Self>,
1911        /// The likely cause and remedy, when one is known.
1912        hint: Option<InitFailureHint>,
1913        /// What the server wrote to stderr before failing, if anything.
1914        stderr: Option<StderrExcerpt>,
1915    },
1916
1917    /// LSP server returned an error response.
1918    ///
1919    /// The payload keeps the message verbatim for diagnostics (logging,
1920    /// `Debug`, pattern matching). The `Display` impl rewrites known-internal
1921    /// upstream text before it reaches an MCP caller, so `message` is not
1922    /// always what the caller sees.
1923    #[error("LSP server error: {} - {}", .0.code, sanitize_lsp_server_message(&.0.message))]
1924    LspServerError(JsonRpcError),
1925
1926    /// The MCP server could not complete its handshake with the client.
1927    #[error("failed to start MCP server")]
1928    McpServerStart(#[source] Box<dyn std::error::Error + Send + Sync>),
1929
1930    /// A background task panicked or was cancelled.
1931    #[error("{task} task failed")]
1932    TaskFailed {
1933        /// The task that failed.
1934        task: BackgroundTask,
1935        /// The join failure, carrying the panic payload or cancellation.
1936        #[source]
1937        source: tokio::task::JoinError,
1938    },
1939
1940    /// A standard stream of the spawned LSP server could not be captured.
1941    #[error("failed to capture LSP server {0}")]
1942    StdioCapture(StdioStream),
1943
1944    /// The HTTP transport could not bind its listener.
1945    #[error("failed to bind HTTP listener on {addr}")]
1946    HttpBind {
1947        /// Address the listener was asked to bind.
1948        addr: std::net::SocketAddr,
1949        /// Underlying I/O error, e.g. `AddrInUse`.
1950        #[source]
1951        source: std::io::Error,
1952    },
1953
1954    /// Document was not found or could not be opened.
1955    #[error("document not found: {}", EchoedPath::new(.0))]
1956    DocumentNotFound(PathBuf),
1957
1958    /// No LSP server configured for the given language.
1959    #[error(
1960        "no LSP server configured for language: {language}{}",
1961        no_server_detail(.language, .file, .patterns)
1962    )]
1963    NoServerForLanguage {
1964        /// The language detected for the file.
1965        language: LanguageId,
1966        /// What identifies the file to the language map: its extension, its
1967        /// extensionless name, or [`FileKey::Unmappable`].
1968        file: FileKey,
1969        /// The `file_patterns` configured across all servers, so the error can
1970        /// show what was available to map the extension.
1971        patterns: Arc<[FilePattern]>,
1972    },
1973
1974    /// A server is configured for the language, but no server claims this
1975    /// specific tool (either no server lists it in `handles` and there is no
1976    /// catch-all, or the server that claimed it failed to spawn with no live
1977    /// catch-all to rebind to).
1978    #[error("no server handles tool '{tool}' for language '{language_id}'")]
1979    NoServerForTool {
1980        /// Language ID the request was for.
1981        language_id: LanguageId,
1982        /// Tool that no server claims.
1983        tool: ToolKind,
1984    },
1985
1986    /// The server configured for this request failed to start and, because
1987    /// startup failures are never retried, will not become available until
1988    /// mcpls is restarted.
1989    ///
1990    /// Boxed to keep [`Error`] small.
1991    #[error("{}", .0.failed_to_start())]
1992    ServerFailedToStart(Box<ServerSpawnFailure>),
1993
1994    /// LSP server for the language is configured but still initializing.
1995    #[error(
1996        "LSP server '{server_id}' is still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
1997    )]
1998    ServerInitializing {
1999        /// Routing identity of the server that has not yet registered.
2000        server_id: ServerId,
2001    },
2002
2003    /// The server was restarted on request while this call was in flight or
2004    /// being routed; the request never reached the replacement.
2005    #[error(
2006        "LSP server '{server_id}' was restarted while this request was in flight; retry the request"
2007    )]
2008    ServerRestarted {
2009        /// Routing identity of the restarted server.
2010        server_id: ServerId,
2011    },
2012
2013    /// A symbol name could not be resolved to exactly one position.
2014    ///
2015    /// Boxed to keep [`Error`] small.
2016    #[error("{0}")]
2017    SymbolResolution(Box<SymbolResolutionData>),
2018
2019    /// A restart request named servers that are not configured.
2020    #[error(
2021        "unknown LSP server(s) {}; configured servers: {}",
2022        IdList(unknown),
2023        IdList(configured)
2024    )]
2025    UnknownServers {
2026        /// The requested ids that matched no configured server.
2027        unknown: Vec<ServerId>,
2028        /// Every configured server id.
2029        configured: Vec<ServerId>,
2030    },
2031
2032    /// A workspace-wide tool (one with no file to resolve a language from,
2033    /// e.g. `workspace_symbol_search`) could not be routed because at least
2034    /// one expected LSP server has not registered yet. Unlike
2035    /// [`Error::ServerInitializing`], resolution never narrowed down to a
2036    /// single candidate server, so no `server_id` is available.
2037    #[error(
2038        "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
2039    )]
2040    WorkspaceServersInitializing,
2041
2042    /// No LSP server is currently configured.
2043    #[error("no LSP server configured")]
2044    NoServerConfigured,
2045
2046    /// At least one server is configured somewhere in the workspace, but
2047    /// none of them claims a workspace-wide tool that has no file to
2048    /// resolve a language from (e.g. `workspace_symbol_search`). The
2049    /// language-less counterpart of [`Error::NoServerForTool`].
2050    #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
2051    NoServerForWorkspaceTool {
2052        /// Tool that no server claims anywhere in the workspace.
2053        tool: ToolKind,
2054    },
2055
2056    /// Configuration file not found.
2057    #[error("configuration file not found: {0}")]
2058    ConfigNotFound(PathBuf),
2059
2060    /// Untrusted-workspace mode refused a config file that lies inside the
2061    /// workspace or the current directory, because the analyzed checkout
2062    /// controls it. A startup error; never reaches a tool response.
2063    #[error(
2064        "config file {} lies inside the workspace or the current directory, which untrusted mode does not trust; move it elsewhere",
2065        .path.display()
2066    )]
2067    ConfigInsideWorkspace {
2068        /// The canonical path of the config file.
2069        path: PathBuf,
2070    },
2071
2072    /// Invalid configuration.
2073    #[error(transparent)]
2074    Config(#[from] ConfigError),
2075
2076    /// I/O error; displays the OS text once, with no prefix.
2077    #[error(transparent)]
2078    Io(#[from] std::io::Error),
2079
2080    /// JSON serialization/deserialization error.
2081    #[error("JSON error")]
2082    Json(#[from] serde_json::Error),
2083
2084    /// TOML deserialization error.
2085    ///
2086    /// Displays the parser's message and position, never the source line it
2087    /// prints: that line can hold a secret from an `env` table. The parser
2088    /// error is deliberately not a `source`, so no error chain prints it
2089    /// either.
2090    #[error("{0}")]
2091    TomlDe(TomlParseError),
2092
2093    /// TOML serialization error.
2094    #[error(transparent)]
2095    TomlSer(#[from] toml::ser::Error),
2096
2097    /// Request timeout, carrying the elapsed limit.
2098    #[error("request timed out after {0:?}")]
2099    Timeout(Duration),
2100
2101    /// LSP server failed to spawn.
2102    #[error("failed to spawn LSP server '{command}'")]
2103    ServerSpawnFailed {
2104        /// Command that failed to spawn.
2105        command: ServerCommand,
2106        /// Underlying IO error.
2107        #[source]
2108        source: std::io::Error,
2109    },
2110
2111    /// LSP server executable was not found.
2112    ///
2113    /// Distinct from [`Error::ServerSpawnFailed`] so the message can carry
2114    /// PATH and install guidance.
2115    #[error("failed to spawn LSP server '{command}': {io}{}", NotFoundGuidance(.command.as_str(), Platform::CURRENT))]
2116    ServerNotFound {
2117        /// Command that could not be found.
2118        command: ServerCommand,
2119        /// Underlying IO error. Not a `source()`: the guidance text follows
2120        /// it, so it is rendered inline to keep the cause before the advice.
2121        io: std::io::Error,
2122    },
2123
2124    /// LSP protocol error during message parsing.
2125    #[error("LSP protocol error: {}", escape_control(.0.as_str()))]
2126    LspProtocolError(RedactedText),
2127
2128    /// None of the resource URIs a `subscriptions/listen` request named
2129    /// resolves inside the workspace.
2130    #[error("none of the requested resource URIs resolve inside the workspace")]
2131    NoResolvableListenUris,
2132
2133    /// A client-supplied position is out of bounds.
2134    #[error(transparent)]
2135    InvalidPositionInput(#[from] InvalidPosition),
2136
2137    /// A client-supplied range is malformed or too large.
2138    #[error(transparent)]
2139    InvalidRangeInput(#[from] InvalidRange),
2140
2141    /// A client-supplied hierarchy item has an invalid range.
2142    #[error(transparent)]
2143    InvalidHierarchyItemInput(#[from] InvalidHierarchyItem),
2144
2145    /// A client-supplied line lies beyond the end of the tracked document.
2146    ///
2147    /// Only the line is checked: an LSP server clamps a character past the
2148    /// end of its line to the line length (LSP 3.17, `Position`), so such a
2149    /// character is forwarded unchanged.
2150    #[error(
2151        "line {line} is beyond the end of the document (the document ends at line {last_line})"
2152    )]
2153    PositionBeyondDocument {
2154        /// The 1-based line the client supplied.
2155        line: std::num::NonZeroU32,
2156        /// The 1-based number of the document's last line.
2157        last_line: std::num::NonZeroU32,
2158    },
2159
2160    /// A client-supplied `lsp-diagnostics://` resource URI was rejected.
2161    #[error(transparent)]
2162    ResourceUri(#[from] ResourceUriError),
2163
2164    /// A path mcpls itself derived could not be represented as a URI.
2165    #[error("cannot convert path to URI: {}", EchoedPath::new(.0))]
2166    PathToUri(PathBuf),
2167
2168    /// Server process terminated unexpectedly.
2169    #[error("LSP server process terminated unexpectedly")]
2170    ServerTerminated,
2171
2172    /// LSP server shutdown did not complete before its deadline.
2173    #[error("LSP server shutdown did not complete before its deadline")]
2174    ShutdownTimeout,
2175
2176    /// LSP server process exited before completing the `initialize`
2177    /// handshake.
2178    #[error("LSP server '{command}' exited during initialization{}{}{}", EarlyExitDetail(.command.as_str(), *.exit_code), HintSuffix(.hint), StderrSuffix(.stderr))]
2179    ServerExitedDuringInit {
2180        /// Command that was spawned.
2181        command: ServerCommand,
2182        /// Exit code, or `None` if the process was terminated by a signal.
2183        exit_code: Option<i32>,
2184        /// The likely cause and remedy, when one is known.
2185        hint: Option<InitFailureHint>,
2186        /// What the server wrote to stderr before exiting, if anything.
2187        stderr: Option<StderrExcerpt>,
2188    },
2189
2190    /// A crashed server could not be automatically respawned.
2191    ///
2192    /// Distinct from [`Self::ServerTerminated`] so a caller (or a log
2193    /// reader) can tell "the connection just died" apart from "mcpls tried
2194    /// to bring it back and could not" -- it is crash-looping and is being
2195    /// backed off.
2196    #[error(
2197        "LSP server '{server_id}' is unavailable: crash-looping, retry in {:.1}s",
2198        .retry_in.as_secs_f64()
2199    )]
2200    ServerUnavailable {
2201        /// Routing identity of the server that could not be respawned.
2202        server_id: ServerId,
2203        /// Remaining backoff before the next respawn attempt.
2204        retry_in: Duration,
2205    },
2206
2207    /// Invalid tool parameters provided.
2208    #[error("invalid tool parameters: {0}")]
2209    InvalidToolParams(String),
2210
2211    /// A client-supplied file path is empty or contains a NUL byte.
2212    #[error(transparent)]
2213    InvalidClientPath(#[from] InvalidClientPath),
2214
2215    /// A client-supplied file path is malformed for the filesystem: it runs
2216    /// through a regular file, or has an invalid or over-long name.
2217    ///
2218    /// Produced only while validating the client path
2219    /// (`WorkspaceRoots::validate`); the same IO kinds raised later, while
2220    /// reading or opening an already validated file, stay [`Error::FileIo`]
2221    /// because there they are environmental.
2222    #[error("malformed file path {}", BoundedDebugPath(.path))]
2223    MalformedPath {
2224        /// The path as supplied by the client.
2225        path: PathBuf,
2226        /// Underlying I/O error.
2227        #[source]
2228        source: std::io::Error,
2229    },
2230
2231    /// File I/O error occurred.
2232    ///
2233    /// See [`Error::mcp_error_kind`] for the JSON-RPC classification: a
2234    /// `source.kind() == ErrorKind::NotFound` failure -- whether `path` was
2235    /// freshly supplied in this request or was tracked from an earlier one
2236    /// and has since been deleted/moved on disk -- is caller-fault; any
2237    /// other IO failure is not.
2238    #[error("file I/O error for {}", BoundedDebugPath(.path))]
2239    FileIo {
2240        /// Path to the file.
2241        path: PathBuf,
2242        /// Underlying I/O error.
2243        #[source]
2244        source: std::io::Error,
2245    },
2246
2247    /// Path is outside allowed workspace boundaries.
2248    #[error("path outside workspace: {}", EchoedPath::new(.0))]
2249    PathOutsideWorkspace(PathBuf),
2250
2251    /// No workspace roots are configured, so path-taking operations are
2252    /// rejected outright rather than allowed unrestricted (fail closed).
2253    #[error("no workspace roots configured: refusing access to {}", EchoedPath::new(.0))]
2254    NoWorkspaceRoots(PathBuf),
2255
2256    /// Document limit exceeded.
2257    #[error(
2258        "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
2259    )]
2260    DocumentLimitExceeded {
2261        /// Current number of documents.
2262        current: usize,
2263        /// Maximum allowed documents.
2264        max: std::num::NonZeroUsize,
2265    },
2266
2267    /// Resource-subscription limit exceeded for the session.
2268    ///
2269    /// See [`Error::mcp_error_kind`] for the JSON-RPC classification: same
2270    /// shape as [`Self::DocumentLimitExceeded`] -- fires on aggregate
2271    /// per-session tracker state, not this request's params -- so it is
2272    /// classified the same way, not `InvalidParams`.
2273    #[error("subscription limit of {max} reached")]
2274    SubscriptionLimitReached {
2275        /// Maximum number of subscriptions allowed per session.
2276        max: usize,
2277    },
2278
2279    /// Too many concurrent `subscriptions/listen` streams are open.
2280    ///
2281    /// Transient: a stream closing frees a slot, so it is classified as
2282    /// retryable (see [`Error::mcp_error_kind`]).
2283    #[error("listen stream limit of {max} reached; retry once another stream closes")]
2284    ListenStreamsExhausted {
2285        /// Maximum number of concurrent listen streams.
2286        max: usize,
2287    },
2288
2289    /// A `subscriptions/listen` request asked for more resource URIs than a
2290    /// single stream may watch.
2291    #[error(
2292        "subscriptions/listen request exceeds the limit of {max} resource URIs or their total size budget"
2293    )]
2294    ListenFilterTooLarge {
2295        /// Maximum number of resource URIs per listen stream.
2296        max: usize,
2297    },
2298
2299    /// File size limit exceeded.
2300    #[error(
2301        "file size limit exceeded: {} bytes, max {} bytes (raise workspace.max_file_size in config to increase this)",
2302        .0.size,
2303        .0.max
2304    )]
2305    FileSizeLimitExceeded(SizeExceeded),
2306
2307    /// Path exists but does not refer to a regular file (e.g. a FIFO or a
2308    /// character/block device).
2309    ///
2310    /// mcpls refuses to read such paths: their reported size does not bound
2311    /// how much data reading them could produce, and opening some of them
2312    /// for reading can block indefinitely waiting for a peer. A Unix domain
2313    /// socket special file is not covered by this variant -- `open(2)` on
2314    /// one fails outright (`ENXIO`) before the file-type check that produces
2315    /// this error ever runs, so it surfaces as [`Self::FileIo`] from a
2316    /// document read and as [`Self::Io`] from the config loader.
2317    #[error("not a regular file: {}", EchoedPath::new(.0))]
2318    NotARegularFile(PathBuf),
2319
2320    /// All configured LSP servers failed to initialize.
2321    #[error("all LSP servers failed to initialize: {}", FailureList(failures))]
2322    AllServersFailedToInit {
2323        /// Details of each failure.
2324        failures: Vec<ServerSpawnFailure>,
2325    },
2326
2327    /// The server routed for this request does not advertise support for the
2328    /// requested LSP capability (e.g. no `renameProvider` in its
2329    /// `ServerCapabilities`).
2330    #[error("server '{server_id}' does not support capability '{capability}'")]
2331    CapabilityNotSupported {
2332        /// Routing identity of the server that lacks the capability.
2333        server_id: ServerId,
2334        /// The missing LSP capability, naming the `ServerCapabilities` field
2335        /// mcpls checked.
2336        capability: Capability,
2337    },
2338
2339    /// The routed server has an active signal indicating its initial
2340    /// workspace-load/indexing phase is still in progress, and the bounded
2341    /// wait for it to finish elapsed before it completed. Returned instead
2342    /// of an unqualified empty/`null` result so a caller cannot mistake
2343    /// "index not ready yet" for "this position/symbol genuinely has
2344    /// nothing here".
2345    #[error(
2346        "LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
2347    )]
2348    WorkspaceIndexing {
2349        /// Routing identity of the server still indexing.
2350        server_id: ServerId,
2351        /// How long mcpls waited for readiness before giving up.
2352        elapsed_secs: u64,
2353    },
2354}
2355
2356/// Bespoke JSON-RPC code for [`Error::WorkspaceIndexing`].
2357///
2358/// Picked clear of rmcp's `-32002`/`-32020..-32022`; the range is convention, not a registry.
2359///
2360/// # Examples
2361///
2362/// ```
2363/// use mcpls_core::error::WORKSPACE_INDEXING_ERROR_CODE;
2364///
2365/// assert_eq!(WORKSPACE_INDEXING_ERROR_CODE, -32050);
2366/// ```
2367pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
2368
2369/// Bespoke JSON-RPC code for [`Error::ServerInitializing`].
2370///
2371/// Distinct from [`WORKSPACE_INDEXING_ERROR_CODE`] so a client can tell "the
2372/// server hasn't registered yet" apart from "the server registered but is
2373/// still indexing" -- both retryable, but for different reasons.
2374///
2375/// # Examples
2376///
2377/// ```
2378/// use mcpls_core::error::{SERVER_INITIALIZING_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE};
2379///
2380/// assert_eq!(SERVER_INITIALIZING_ERROR_CODE, -32051);
2381/// assert_ne!(SERVER_INITIALIZING_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE);
2382/// ```
2383pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
2384
2385/// Bespoke JSON-RPC code for a resource subscription request rejected
2386/// because it was served over rmcp's stateless per-request HTTP path (#482).
2387///
2388/// Same convention range as [`WORKSPACE_INDEXING_ERROR_CODE`]/
2389/// [`SERVER_INITIALIZING_ERROR_CODE`], next unused slot.
2390///
2391/// # Examples
2392///
2393/// ```
2394/// use mcpls_core::error::{
2395///     SERVER_INITIALIZING_ERROR_CODE, STATELESS_SUBSCRIPTION_ERROR_CODE,
2396///     WORKSPACE_INDEXING_ERROR_CODE,
2397/// };
2398///
2399/// assert_eq!(STATELESS_SUBSCRIPTION_ERROR_CODE, -32052);
2400/// assert_ne!(STATELESS_SUBSCRIPTION_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE);
2401/// assert_ne!(STATELESS_SUBSCRIPTION_ERROR_CODE, SERVER_INITIALIZING_ERROR_CODE);
2402/// ```
2403pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
2404
2405/// Bespoke JSON-RPC code for [`Error::ListenStreamsExhausted`].
2406///
2407/// Same convention range as [`WORKSPACE_INDEXING_ERROR_CODE`], next unused
2408/// slot after [`STATELESS_SUBSCRIPTION_ERROR_CODE`].
2409///
2410/// # Examples
2411///
2412/// ```
2413/// use mcpls_core::error::{
2414///     LISTEN_STREAMS_EXHAUSTED_ERROR_CODE, STATELESS_SUBSCRIPTION_ERROR_CODE,
2415/// };
2416///
2417/// assert_eq!(LISTEN_STREAMS_EXHAUSTED_ERROR_CODE, -32053);
2418/// assert_ne!(LISTEN_STREAMS_EXHAUSTED_ERROR_CODE, STATELESS_SUBSCRIPTION_ERROR_CODE);
2419/// ```
2420pub const LISTEN_STREAMS_EXHAUSTED_ERROR_CODE: i32 = -32053;
2421
2422/// Bespoke JSON-RPC code for [`Error::ServerRestarted`].
2423///
2424/// Same convention range as [`WORKSPACE_INDEXING_ERROR_CODE`], next unused
2425/// slot after [`LISTEN_STREAMS_EXHAUSTED_ERROR_CODE`].
2426///
2427/// # Examples
2428///
2429/// ```
2430/// use mcpls_core::error::{LISTEN_STREAMS_EXHAUSTED_ERROR_CODE, SERVER_RESTARTED_ERROR_CODE};
2431///
2432/// assert_eq!(SERVER_RESTARTED_ERROR_CODE, -32054);
2433/// assert_ne!(SERVER_RESTARTED_ERROR_CODE, LISTEN_STREAMS_EXHAUSTED_ERROR_CODE);
2434/// ```
2435pub const SERVER_RESTARTED_ERROR_CODE: i32 = -32054;
2436
2437/// Structured `data` payload of a retryable JSON-RPC error.
2438///
2439/// Each variant pairs a bespoke error code ([`Self::code`]) with its own
2440/// payload, so a code can never be sent with another variant's data. Keys
2441/// are `snake_case`; the wire form is the variant's fields as a bare object.
2442///
2443/// # Examples
2444///
2445/// ```
2446/// use mcpls_core::config::ServerId;
2447/// use mcpls_core::error::{RetryableErrorData, WORKSPACE_INDEXING_ERROR_CODE};
2448///
2449/// let data = RetryableErrorData::WorkspaceIndexing {
2450///     server_id: ServerId::from_static("rust"),
2451///     elapsed_secs: 30,
2452/// };
2453/// assert_eq!(data.code(), WORKSPACE_INDEXING_ERROR_CODE);
2454/// assert_eq!(
2455///     serde_json::to_value(&data).unwrap(),
2456///     serde_json::json!({"server_id": "rust", "elapsed_secs": 30})
2457/// );
2458/// ```
2459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2460#[serde(untagged)]
2461pub enum RetryableErrorData {
2462    /// The routed server is still indexing the workspace.
2463    WorkspaceIndexing {
2464        /// Server that is indexing.
2465        server_id: ServerId,
2466        /// Seconds spent waiting for indexing to finish before giving up.
2467        elapsed_secs: u64,
2468    },
2469    /// The routed server has not finished initializing.
2470    ServerInitializing {
2471        /// Server that is initializing.
2472        server_id: ServerId,
2473    },
2474    /// An expected server has not registered yet and no single candidate
2475    /// could be narrowed down, so there is no server to name. The braces
2476    /// keep the wire value an empty object rather than `null`.
2477    WorkspaceServersInitializing {},
2478    /// The concurrent `subscriptions/listen` stream limit is reached.
2479    ListenStreamsExhausted {
2480        /// Maximum number of concurrent listen streams.
2481        max_listen_streams: usize,
2482    },
2483    /// The routed server was restarted while the request was in flight.
2484    ServerRestarted {
2485        /// Server that was restarted.
2486        server_id: ServerId,
2487    },
2488}
2489
2490impl RetryableErrorData {
2491    /// The bespoke JSON-RPC error code for this retryable condition.
2492    #[must_use]
2493    pub const fn code(&self) -> i32 {
2494        match self {
2495            Self::WorkspaceIndexing { .. } => WORKSPACE_INDEXING_ERROR_CODE,
2496            Self::ServerInitializing { .. } | Self::WorkspaceServersInitializing {} => {
2497                SERVER_INITIALIZING_ERROR_CODE
2498            }
2499            Self::ListenStreamsExhausted { .. } => LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
2500            Self::ServerRestarted { .. } => SERVER_RESTARTED_ERROR_CODE,
2501        }
2502    }
2503}
2504
2505/// JSON-RPC error-code classification for an [`Error`], returned by
2506/// [`Error::mcp_error_kind`].
2507///
2508/// mcpls-core has no dependency on the MCP transport crate, so this carries
2509/// only plain data; `crate::mcp` is responsible for turning it into the
2510/// actual wire-level error type.
2511///
2512/// # Examples
2513///
2514/// ```
2515/// use mcpls_core::error::{Error, McpErrorKind};
2516///
2517/// let err = Error::InvalidToolParams("missing `file_path`".to_string());
2518/// assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
2519/// ```
2520///
2521/// This enum is `#[non_exhaustive]`: match it with a wildcard arm.
2522#[derive(Debug, Clone, PartialEq, Eq)]
2523#[non_exhaustive]
2524pub enum McpErrorKind {
2525    /// Caller-fault: the request itself was invalid. Maps to JSON-RPC
2526    /// `-32602` (`INVALID_PARAMS`).
2527    InvalidParams,
2528    /// Caller-fault: the position fell outside the document and the server's
2529    /// raw error was rewritten for display. Maps to `-32602` with the
2530    /// original error as `data`.
2531    InvalidPosition(RewrittenServerError),
2532    /// Caller-fault: a symbol name did not resolve to exactly one position.
2533    /// Maps to `-32602` with the structured resolution outcome as `data`.
2534    SymbolResolution(SymbolResolutionData),
2535    /// A transient, retryable server-side condition, distinct from a crash.
2536    /// Maps to a bespoke JSON-RPC `code` with a structured `data` payload a
2537    /// caller can act on mechanically, rather than the generic
2538    /// `INTERNAL_ERROR`.
2539    Retryable(RetryableErrorData),
2540    /// An unexpected server-side failure. Maps to JSON-RPC `-32603`
2541    /// (`INTERNAL_ERROR`).
2542    Internal,
2543}
2544
2545impl Error {
2546    /// Classify this error for JSON-RPC error-code mapping.
2547    ///
2548    /// Matched exhaustively with no wildcard arm: a newly added [`Error`]
2549    /// variant must be given an explicit classification here instead of
2550    /// silently defaulting to [`McpErrorKind::Internal`].
2551    ///
2552    /// # Examples
2553    ///
2554    /// ```
2555    /// use mcpls_core::config::ServerId;
2556    /// use mcpls_core::error::{Error, McpErrorKind};
2557    ///
2558    /// let err = Error::WorkspaceIndexing {
2559    ///     server_id: ServerId::from_static("rust"),
2560    ///     elapsed_secs: 30,
2561    /// };
2562    /// let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
2563    ///     panic!("expected a retryable classification");
2564    /// };
2565    /// assert_eq!(data.code(), mcpls_core::error::WORKSPACE_INDEXING_ERROR_CODE);
2566    /// ```
2567    #[must_use]
2568    pub fn mcp_error_kind(&self) -> McpErrorKind {
2569        match self {
2570            Self::InvalidToolParams(_)
2571            | Self::UnknownServers { .. }
2572            | Self::InvalidClientPath(_)
2573            | Self::MalformedPath { .. }
2574            | Self::PathOutsideWorkspace(_)
2575            | Self::NotARegularFile(_)
2576            | Self::NoResolvableListenUris
2577            | Self::ResourceUri(_)
2578            | Self::InvalidPositionInput(_)
2579            | Self::InvalidRangeInput(_)
2580            | Self::InvalidHierarchyItemInput(_)
2581            | Self::PositionBeyondDocument { .. }
2582            | Self::ListenFilterTooLarge { .. }
2583            | Self::DocumentNotFound(_)
2584            | Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
2585
2586            // Frees up as soon as another listen stream closes.
2587            Self::ListenStreamsExhausted { max } => {
2588                McpErrorKind::Retryable(RetryableErrorData::ListenStreamsExhausted {
2589                    max_listen_streams: *max,
2590                })
2591            }
2592
2593            // A path that doesn't exist -- whether freshly supplied in this
2594            // request or tracked from an earlier request and then
2595            // deleted/moved on disk since -- is caller-fault, same as
2596            // `DocumentNotFound`, and matches the MCP spec's expectation
2597            // that resource-not-found map to INVALID_PARAMS, not
2598            // INTERNAL_ERROR (rmcp's `read_resource` handling, SEP-2164).
2599            // Any other IO failure (permission denied, an invalid-input error
2600            // while reading an already validated file, etc.) reaching here is
2601            // a genuine server-side problem the caller cannot fix by changing
2602            // their request. A malformed client path is `MalformedPath`.
2603            Self::FileIo { source, .. } => {
2604                if source.kind() == std::io::ErrorKind::NotFound {
2605                    McpErrorKind::InvalidParams
2606                } else {
2607                    McpErrorKind::Internal
2608                }
2609            }
2610
2611            Self::WorkspaceIndexing {
2612                server_id,
2613                elapsed_secs,
2614            } => McpErrorKind::Retryable(RetryableErrorData::WorkspaceIndexing {
2615                server_id: server_id.clone(),
2616                elapsed_secs: *elapsed_secs,
2617            }),
2618            Self::ServerInitializing { server_id } => {
2619                McpErrorKind::Retryable(RetryableErrorData::ServerInitializing {
2620                    server_id: server_id.clone(),
2621                })
2622            }
2623            Self::SymbolResolution(data) => McpErrorKind::SymbolResolution((**data).clone()),
2624            Self::ServerRestarted { server_id } => {
2625                McpErrorKind::Retryable(RetryableErrorData::ServerRestarted {
2626                    server_id: server_id.clone(),
2627                })
2628            }
2629            // Same condition as `ServerInitializing` -- an expected LSP
2630            // server hasn't registered yet, retry -- just without a single
2631            // candidate server narrowed down (see the variant's doc), so
2632            // there's no `server_id` to report.
2633            Self::WorkspaceServersInitializing => {
2634                McpErrorKind::Retryable(RetryableErrorData::WorkspaceServersInitializing {})
2635            }
2636
2637            // Same recognized shape `sanitize_lsp_server_message` rewrites
2638            // for display: rust-analyzer reports this when a position-based
2639            // request's line/character falls outside the target document --
2640            // caller-fault. Every other `LspServerError` shape is a genuine
2641            // server-side problem and stays `Internal`.
2642            Self::LspServerError(response) if is_invalid_offset(response) => {
2643                McpErrorKind::InvalidPosition(RewrittenServerError::new(
2644                    response.code,
2645                    &response.message,
2646                ))
2647            }
2648
2649            Self::LspInitFailed { .. }
2650            | Self::LspServerError(..)
2651            | Self::McpServerStart(_)
2652            | Self::TaskFailed { .. }
2653            | Self::StdioCapture(_)
2654            | Self::PathToUri(_)
2655            | Self::HttpBind { .. }
2656            | Self::NoServerForLanguage { .. }
2657            | Self::NoServerForTool { .. }
2658            | Self::NoServerConfigured
2659            | Self::NoServerForWorkspaceTool { .. }
2660            | Self::ConfigNotFound(_)
2661            | Self::ConfigInsideWorkspace { .. }
2662            | Self::Config(_)
2663            | Self::Io(_)
2664            | Self::Json(_)
2665            | Self::TomlDe(_)
2666            | Self::TomlSer(_)
2667            | Self::Timeout(_)
2668            | Self::ServerSpawnFailed { .. }
2669            | Self::ServerNotFound { .. }
2670            | Self::LspProtocolError(_)
2671            | Self::ServerTerminated
2672            | Self::ShutdownTimeout
2673            | Self::ServerUnavailable { .. }
2674            | Self::ServerFailedToStart(_)
2675            | Self::ServerExitedDuringInit { .. }
2676            | Self::NoWorkspaceRoots(_)
2677            // Unlike `FileSizeLimitExceeded`, this fires on aggregate tracker
2678            // state, not this request's params -- it can succeed unchanged
2679            // once other documents close, so `InvalidParams` is wrong; not
2680            // `Retryable` either, since nothing evicts documents on a timer.
2681            | Self::DocumentLimitExceeded { .. }
2682            // Same shape as `DocumentLimitExceeded` above -- see this
2683            // variant's doc comment.
2684            | Self::SubscriptionLimitReached { .. }
2685            | Self::AllServersFailedToInit { .. }
2686            | Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
2687        }
2688    }
2689
2690    /// A well-formed resource URI whose path cannot be resolved (deleted, outside
2691    /// the workspace): unsubscribing falls back to the recorded alias (#499).
2692    /// Every other failure is a malformed URI or an internal fault.
2693    pub(crate) const fn is_unresolvable_resource(&self) -> bool {
2694        match self {
2695            Self::MalformedPath { .. }
2696            | Self::FileIo { .. }
2697            | Self::PathOutsideWorkspace(..)
2698            | Self::NoWorkspaceRoots(..) => true,
2699            Self::LspInitFailed { .. }
2700            | Self::LspServerError(..)
2701            | Self::McpServerStart(..)
2702            | Self::TaskFailed { .. }
2703            | Self::StdioCapture(..)
2704            | Self::HttpBind { .. }
2705            | Self::DocumentNotFound(..)
2706            | Self::NoServerForLanguage { .. }
2707            | Self::NoServerForTool { .. }
2708            | Self::ServerFailedToStart(..)
2709            | Self::ServerInitializing { .. }
2710            | Self::ServerRestarted { .. }
2711            | Self::SymbolResolution(..)
2712            | Self::UnknownServers { .. }
2713            | Self::WorkspaceServersInitializing
2714            | Self::NoServerConfigured
2715            | Self::NoServerForWorkspaceTool { .. }
2716            | Self::ConfigNotFound(..)
2717            | Self::ConfigInsideWorkspace { .. }
2718            | Self::Config(..)
2719            | Self::Io(..)
2720            | Self::Json(..)
2721            | Self::TomlDe(..)
2722            | Self::TomlSer(..)
2723            | Self::Timeout(..)
2724            | Self::ServerSpawnFailed { .. }
2725            | Self::ServerNotFound { .. }
2726            | Self::LspProtocolError(..)
2727            | Self::NoResolvableListenUris
2728            | Self::InvalidPositionInput(..)
2729            | Self::InvalidRangeInput(..)
2730            | Self::InvalidHierarchyItemInput(..)
2731            | Self::PositionBeyondDocument { .. }
2732            | Self::ResourceUri(..)
2733            | Self::PathToUri(..)
2734            | Self::ServerTerminated
2735            | Self::ShutdownTimeout
2736            | Self::ServerExitedDuringInit { .. }
2737            | Self::ServerUnavailable { .. }
2738            | Self::InvalidToolParams(..)
2739            | Self::InvalidClientPath(..)
2740            | Self::DocumentLimitExceeded { .. }
2741            | Self::SubscriptionLimitReached { .. }
2742            | Self::ListenStreamsExhausted { .. }
2743            | Self::ListenFilterTooLarge { .. }
2744            | Self::FileSizeLimitExceeded { .. }
2745            | Self::NotARegularFile(..)
2746            | Self::AllServersFailedToInit { .. }
2747            | Self::CapabilityNotSupported { .. }
2748            | Self::WorkspaceIndexing { .. } => false,
2749        }
2750    }
2751}
2752
2753/// A TOML parse error that names where it happened without quoting the source.
2754///
2755/// The parser's own `Display` quotes the offending source line, which can hold
2756/// a secret from an `env` table. This type shows the parser's message and the
2757/// line and column of the error, or its byte span when the source is not at
2758/// hand.
2759#[derive(Debug, Clone)]
2760pub struct TomlParseError {
2761    error: toml::de::Error,
2762    line_column: Option<(usize, usize)>,
2763}
2764
2765impl TomlParseError {
2766    /// Wraps `error`, locating it in the `source` text that was parsed.
2767    ///
2768    /// # Examples
2769    ///
2770    /// ```
2771    /// use mcpls_core::error::TomlParseError;
2772    ///
2773    /// let src = "a = = 1";
2774    /// let err = toml::from_str::<toml::Table>(src).unwrap_err();
2775    /// let shown = TomlParseError::new(err, src).to_string();
2776    /// assert!(shown.contains("line 1"));
2777    /// assert!(!shown.contains(src));
2778    /// ```
2779    #[must_use]
2780    pub fn new(error: toml::de::Error, source: &str) -> Self {
2781        let line_column = error
2782            .span()
2783            .and_then(|span| source.get(..span.start))
2784            .map(|before| {
2785                let line = before.matches('\n').count().saturating_add(1);
2786                let column = before
2787                    .rsplit('\n')
2788                    .next()
2789                    .map_or_default(|last| last.chars().count())
2790                    .saturating_add(1);
2791                (line, column)
2792            });
2793        Self { error, line_column }
2794    }
2795
2796    /// The parser's message, without the source line.
2797    #[must_use]
2798    pub fn message(&self) -> &str {
2799        self.error.message()
2800    }
2801}
2802
2803impl fmt::Display for TomlParseError {
2804    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2805        f.write_str(self.message())?;
2806        if let Some((line, column)) = self.line_column {
2807            write!(f, " (line {line}, column {column})")
2808        } else if let Some(span) = self.error.span() {
2809            write!(f, " (at bytes {}..{})", span.start, span.end)
2810        } else {
2811            Ok(())
2812        }
2813    }
2814}
2815
2816/// A specialized Result type for mcpls-core operations.
2817pub type Result<T> = std::result::Result<T, Error>;
2818
2819/// Renders an error followed by every cause in its
2820/// [`source`](std::error::Error::source) chain, `outer: cause: root`.
2821///
2822/// An error type's own message names what failed and leaves the cause to
2823/// [`std::error::Error::source`]; this is where a reader that wants the whole
2824/// story (an MCP client, a log line) joins them once. No cause is skipped, so
2825/// a type must not also print its source in its own message.
2826///
2827/// # Examples
2828///
2829/// ```
2830/// use std::path::PathBuf;
2831///
2832/// use mcpls_core::error::{Error, ErrorChain};
2833///
2834/// let error = Error::FileIo {
2835///     path: PathBuf::from("a.rs"),
2836///     source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
2837/// };
2838///
2839/// assert_eq!(ErrorChain(&error).to_string(), "file I/O error for \"a.rs\": gone");
2840/// ```
2841#[derive(Debug, Clone, Copy)]
2842pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static));
2843
2844impl fmt::Display for ErrorChain<'_> {
2845    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2846        write!(f, "{}", self.0)?;
2847        let mut cause = self.0.source();
2848        while let Some(error) = cause {
2849            write!(f, ": {error}")?;
2850            cause = error.source();
2851        }
2852        Ok(())
2853    }
2854}
2855
2856/// An error that renders its whole [`ErrorChain`] from `Display`, for a
2857/// consumer that prints only the outermost error (serde's `try_from` turns the
2858/// conversion error into a message with `Display`).
2859#[derive(Debug)]
2860pub(crate) struct ChainedError<E>(pub(crate) E);
2861
2862impl<E> From<E> for ChainedError<E> {
2863    fn from(error: E) -> Self {
2864        Self(error)
2865    }
2866}
2867
2868impl<E: std::error::Error + 'static> fmt::Display for ChainedError<E> {
2869    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2870        ErrorChain(&self.0).fmt(f)
2871    }
2872}
2873
2874#[cfg(test)]
2875mod tests {
2876    use std::assert_matches;
2877
2878    use super::*;
2879    use crate::config::{FileExtension, FileName};
2880    use crate::util::TRUNCATION_MARKER;
2881
2882    #[test]
2883    fn test_all_servers_failed_to_init_error() {
2884        let failures = vec![
2885            ServerSpawnFailure {
2886                server_id: ServerId::from_static("rust"),
2887                language_id: LanguageId::from_static("rust"),
2888                command: ServerCommand::from_static("rust-analyzer"),
2889                reason: StartupFailure::InitTaskPanicked,
2890            },
2891            ServerSpawnFailure {
2892                server_id: ServerId::from_static("python"),
2893                language_id: LanguageId::from_static("python"),
2894                command: ServerCommand::from_static("pyright"),
2895                reason: StartupFailure::InitTaskPanicked,
2896            },
2897        ];
2898
2899        let err = Error::AllServersFailedToInit { failures };
2900
2901        assert!(err.to_string().contains("all LSP servers failed"));
2902
2903        // Verify failures are preserved
2904        if let Error::AllServersFailedToInit { failures: f } = err {
2905            assert_eq!(f.len(), 2);
2906            assert_eq!(f[0].language_id, "rust");
2907            assert_eq!(f[1].language_id, "python");
2908        } else {
2909            panic!("Expected AllServersFailedToInit error");
2910        }
2911    }
2912
2913    #[test]
2914    fn test_server_spawn_failure_display_names_init_panic() {
2915        let failure = ServerSpawnFailure {
2916            server_id: ServerId::from_static("typescript"),
2917            language_id: LanguageId::from_static("typescript"),
2918            command: ServerCommand::from_static("tsserver"),
2919            reason: StartupFailure::InitTaskPanicked,
2920        };
2921
2922        let display = failure.to_string();
2923        assert!(display.contains("typescript"));
2924        assert!(display.contains("tsserver"));
2925        assert!(display.contains("panicked"));
2926    }
2927
2928    #[test]
2929    fn test_ambiguity_display_lists_at_most_ten_candidates_and_the_hint() {
2930        let candidates: Vec<SymbolCandidate> = (1..=12)
2931            .map(|line| SymbolCandidate {
2932                name: "new".to_string(),
2933                kind: 6,
2934                kind_name: "Method".to_string(),
2935                container: Some("Foo".to_string()),
2936                line,
2937                character: 1,
2938            })
2939            .collect();
2940        let text = SymbolResolutionData::Ambiguous {
2941            name: "new".to_string(),
2942            candidates,
2943            truncated: false,
2944        }
2945        .to_string();
2946
2947        assert!(text.contains("12 candidates"), "{text}");
2948        assert!(
2949            text.contains("at 10:1") && !text.contains("at 11:1"),
2950            "{text}"
2951        );
2952        assert!(text.contains("; ..."), "{text}");
2953        assert!(text.contains("`symbol_kind` or `container`"), "{text}");
2954    }
2955
2956    #[test]
2957    fn test_error_display_lsp_init_failed() {
2958        let err = Error::LspInitFailed {
2959            phase: InitPhase::Initialize,
2960            cause: Box::new(Error::Io(std::io::Error::other("server not found"))),
2961            hint: None,
2962            stderr: None,
2963        };
2964        assert_eq!(
2965            err.to_string(),
2966            "LSP server initialization failed: Initialize request failed: server not found"
2967        );
2968    }
2969
2970    #[test]
2971    fn test_stderr_excerpt_none_when_nothing_visible() {
2972        assert_eq!(
2973            StderrExcerpt::complete(b"  \n\x1b\r\n ", &Redactions::default()),
2974            None
2975        );
2976        assert_eq!(StderrExcerpt::complete(b"", &Redactions::default()), None);
2977    }
2978
2979    #[test]
2980    fn test_stderr_excerpt_strips_control_characters_but_keeps_newline_and_tab() {
2981        let excerpt =
2982            StderrExcerpt::complete(b"a\x1b[31mb\x00\r\nc\td", &Redactions::default()).unwrap();
2983        assert_eq!(excerpt.head(), "a[31mb\nc\td");
2984    }
2985
2986    #[test]
2987    fn test_stderr_excerpt_display_is_single_line() {
2988        let excerpt =
2989            StderrExcerpt::complete(b"first\n\n  second  \nthird\n", &Redactions::default())
2990                .unwrap();
2991        assert_eq!(excerpt.to_string(), "first | second | third");
2992    }
2993
2994    #[test]
2995    fn test_stderr_excerpt_elided_display_marks_the_gap() {
2996        let excerpt =
2997            StderrExcerpt::elided(b"start\nbanner", b"last\nerror", &Redactions::default())
2998                .unwrap();
2999        assert!(excerpt.is_elided());
3000        assert_eq!(excerpt.tail(), Some("last\nerror"));
3001        assert_eq!(excerpt.to_string(), "start | banner | ... | last | error");
3002    }
3003
3004    #[test]
3005    fn test_stderr_excerpt_elided_drops_utf8_sequences_cut_by_the_split() {
3006        let text = "é".as_bytes();
3007        let head = [b"ok ".as_slice(), &text[..1]].concat();
3008        let tail = [&text[1..], b" end".as_slice()].concat();
3009
3010        let excerpt = StderrExcerpt::elided(&head, &tail, &Redactions::default()).unwrap();
3011
3012        assert_eq!(excerpt.head(), "ok");
3013        assert_eq!(excerpt.tail(), Some("end"));
3014    }
3015
3016    fn secrets(pairs: &[(&str, &str)]) -> Redactions {
3017        Redactions::new(
3018            pairs
3019                .iter()
3020                .map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
3021        )
3022    }
3023
3024    #[test]
3025    fn test_stderr_excerpt_redacts_secrets_with_their_label() {
3026        let excerpt = StderrExcerpt::complete(
3027            b"token=hunter2hunter2 id=abc",
3028            &secrets(&[("API_TOKEN", "hunter2hunter2"), ("SHORT_KEY", "abc")]),
3029        )
3030        .unwrap();
3031        assert_eq!(excerpt.head(), "token=[redacted:API_TOKEN] id=abc");
3032    }
3033
3034    #[test]
3035    fn test_stderr_excerpt_redacts_longest_overlapping_secret_whole() {
3036        let excerpt = StderrExcerpt::complete(
3037            b"v=abcdefghXYZ12345",
3038            &secrets(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]),
3039        )
3040        .unwrap();
3041        assert_eq!(excerpt.head(), "v=[redacted:B_KEY]");
3042    }
3043
3044    #[test]
3045    fn test_stderr_excerpt_keeps_non_secret_values_visible() {
3046        let excerpt = StderrExcerpt::complete(
3047            b"toolchain 'nightly-2024-01-01' is not installed",
3048            &secrets(&[("API_TOKEN", "hunter2hunter2")]),
3049        )
3050        .unwrap();
3051        assert_eq!(
3052            excerpt.head(),
3053            "toolchain 'nightly-2024-01-01' is not installed"
3054        );
3055    }
3056
3057    #[test]
3058    fn test_stderr_excerpt_redacts_a_secret_split_by_a_control_character() {
3059        let excerpt = StderrExcerpt::complete(
3060            "tok=hunter2\u{200B}hunter2".as_bytes(),
3061            &secrets(&[("API_TOKEN", "hunter2hunter2")]),
3062        )
3063        .unwrap();
3064        assert_eq!(excerpt.head(), "tok=[redacted:API_TOKEN]");
3065    }
3066
3067    /// A secret cut in two by the elision boundary: the fragments of at least
3068    /// four bytes on each side are masked.
3069    #[test]
3070    fn test_stderr_excerpt_masks_a_secret_split_across_the_elision_boundary() {
3071        let redactions = secrets(&[("API_TOKEN", "supersecretvalue")]);
3072
3073        let excerpt =
3074            StderrExcerpt::elided(b"token=supersec", b"retvalue done", &redactions).unwrap();
3075
3076        let shown = excerpt.to_string();
3077        assert!(!shown.contains("supersec"), "{shown}");
3078        assert!(!shown.contains("retvalue"), "{shown}");
3079        assert!(shown.contains("[redacted:API_TOKEN]"), "{shown}");
3080    }
3081
3082    #[test]
3083    fn test_stderr_excerpt_strips_separators_bidi_and_zero_width_characters() {
3084        let text = "a\u{2028}b\u{2029}c\u{202E}d\u{2066}e\u{200B}f\u{FEFF}g\u{200F}h";
3085
3086        let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
3087
3088        assert_eq!(excerpt.head(), "abcdefgh");
3089    }
3090
3091    #[test]
3092    fn test_escape_control_and_stderr_agree_on_deceptive_characters() {
3093        let deceptive = [
3094            '\u{061C}',
3095            '\u{200B}',
3096            '\u{200C}',
3097            '\u{200D}',
3098            '\u{200E}',
3099            '\u{200F}',
3100            '\u{2028}',
3101            '\u{2029}',
3102            '\u{202A}',
3103            '\u{202B}',
3104            '\u{202C}',
3105            '\u{202D}',
3106            '\u{202E}',
3107            '\u{2060}',
3108            '\u{2066}',
3109            '\u{2067}',
3110            '\u{2068}',
3111            '\u{2069}',
3112            '\u{FEFF}',
3113            '\u{00AD}',
3114            '\u{180E}',
3115            '\u{2061}',
3116            '\u{2064}',
3117            '\u{206A}',
3118            '\u{206F}',
3119            '\u{FFF9}',
3120            '\u{FFFB}',
3121            '\u{E0000}',
3122            '\u{E0041}',
3123            '\u{E007F}',
3124        ];
3125        for c in deceptive {
3126            let text = format!("a{c}b");
3127            let escaped = crate::util::escape_control(&text);
3128            assert!(
3129                !escaped.contains(c),
3130                "escape_control kept U+{:04X}",
3131                c as u32
3132            );
3133            let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
3134            assert_eq!(excerpt.head(), "ab", "stderr kept U+{:04X}", c as u32);
3135        }
3136    }
3137
3138    #[test]
3139    fn test_init_errors_append_stderr_to_display() {
3140        let stderr = StderrExcerpt::complete(b"fatal: bad config", &Redactions::default());
3141        let failed = Error::LspInitFailed {
3142            phase: InitPhase::Initialize,
3143            cause: Box::new(Error::Io(std::io::Error::other("boom"))),
3144            hint: None,
3145            stderr: stderr.clone(),
3146        };
3147        assert_eq!(
3148            failed.to_string(),
3149            "LSP server initialization failed: Initialize request failed: boom; stderr: fatal: bad config"
3150        );
3151        let exited = Error::ServerExitedDuringInit {
3152            command: crate::config::ServerCommand::from_static("gopls"),
3153            exit_code: Some(2),
3154            hint: None,
3155            stderr,
3156        };
3157        assert_eq!(
3158            exited.to_string(),
3159            "LSP server 'gopls' exited during initialization with exit code 2; stderr: fatal: bad config"
3160        );
3161    }
3162
3163    #[test]
3164    fn test_error_display_lsp_server_error() {
3165        let err = Error::LspServerError(JsonRpcError {
3166            code: -32600,
3167            message: "Invalid request".to_string(),
3168            data: None,
3169        });
3170        assert_eq!(
3171            err.to_string(),
3172            "LSP server error: -32600 - Invalid request"
3173        );
3174    }
3175
3176    #[test]
3177    fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
3178        let err = Error::LspServerError(JsonRpcError {
3179            code: -32603,
3180            message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
3181                .to_string(),
3182            data: None,
3183        });
3184        assert_eq!(
3185            err.to_string(),
3186            "LSP server error: -32603 - position out of range for this document"
3187        );
3188    }
3189
3190    #[test]
3191    fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
3192        // Guards the `contains` (not `starts_with`) match: an upstream
3193        // wrapper (e.g. an `anyhow::Context`) or a future mcpls-side prefix
3194        // could prepend text ahead of rust-analyzer's raw message.
3195        let err = Error::LspServerError(JsonRpcError {
3196            code: -32803,
3197            message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
3198                      (line index length: 3)"
3199                .to_string(),
3200            data: None,
3201        });
3202        assert_eq!(
3203            err.to_string(),
3204            "LSP server error: -32803 - position out of range for this document"
3205        );
3206    }
3207
3208    #[test]
3209    fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
3210        let err = Error::LspServerError(JsonRpcError {
3211            code: -32602,
3212            message: "Invalid params: expected object".to_string(),
3213            data: None,
3214        });
3215        assert_eq!(
3216            err.to_string(),
3217            "LSP server error: -32602 - Invalid params: expected object"
3218        );
3219    }
3220
3221    #[test]
3222    fn test_error_display_document_not_found() {
3223        let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
3224        assert!(err.to_string().contains("document not found"));
3225        assert!(err.to_string().contains("file.rs"));
3226    }
3227
3228    #[test]
3229    fn test_error_display_no_server_for_language() {
3230        let err = no_server_for_language("rust");
3231        assert_eq!(
3232            err.to_string(),
3233            "no LSP server configured for language: rust"
3234        );
3235    }
3236
3237    #[test]
3238    fn test_error_display_workspace_servers_initializing() {
3239        let err = Error::WorkspaceServersInitializing;
3240        assert!(err.to_string().contains("still initializing"));
3241    }
3242
3243    #[test]
3244    fn test_error_display_no_server_for_workspace_tool() {
3245        let err = Error::NoServerForWorkspaceTool {
3246            tool: crate::config::ToolKind::WorkspaceSymbols,
3247        };
3248        assert!(err.to_string().contains("workspace_symbols"));
3249        assert!(err.to_string().contains("no server's `handles` list"));
3250    }
3251
3252    #[test]
3253    fn test_error_display_timeout() {
3254        assert_eq!(
3255            Error::Timeout(Duration::from_secs(30)).to_string(),
3256            "request timed out after 30s"
3257        );
3258        assert_eq!(
3259            Error::Timeout(Duration::from_millis(500)).to_string(),
3260            "request timed out after 500ms"
3261        );
3262    }
3263
3264    #[test]
3265    fn test_error_display_document_limit() {
3266        let err = Error::DocumentLimitExceeded {
3267            current: 150,
3268            max: std::num::NonZeroUsize::new(100).unwrap(),
3269        };
3270        assert_eq!(
3271            err.to_string(),
3272            "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
3273        );
3274    }
3275
3276    #[test]
3277    fn test_error_display_file_size_limit() {
3278        let err = Error::FileSizeLimitExceeded(SizeExceeded {
3279            size: 20_000_000,
3280            max: std::num::NonZeroU64::new(10_000_000).unwrap(),
3281        });
3282        assert_eq!(
3283            err.to_string(),
3284            "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
3285        );
3286    }
3287
3288    #[test]
3289    fn test_error_display_not_a_regular_file() {
3290        let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
3291        assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
3292    }
3293
3294    #[test]
3295    fn test_error_from_io() {
3296        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
3297        let err: Error = io_err.into();
3298        assert_matches!(err, Error::Io(_));
3299    }
3300
3301    #[test]
3302    fn test_error_from_json() {
3303        let json_str = "{invalid json}";
3304        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
3305        let err: Error = json_err.into();
3306        assert_matches!(err, Error::Json(_));
3307    }
3308
3309    fn assert_rendered_once(err: &(dyn std::error::Error + 'static), marker: &str) {
3310        let text = ErrorChain(err).to_string();
3311        assert_eq!(text.matches(marker).count(), 1, "{text}");
3312        let segments: Vec<&str> = text.split(": ").collect();
3313        let distinct: std::collections::HashSet<&str> = segments.iter().copied().collect();
3314        assert_eq!(distinct.len(), segments.len(), "repeated segment in {text}");
3315    }
3316
3317    fn io_sourced_cases() -> Vec<(Error, String)> {
3318        let io = |marker: &str| std::io::Error::other(marker.to_owned());
3319        let command = ServerCommand::from_static("rust-analyzer");
3320        vec![
3321            (
3322                Error::HttpBind {
3323                    addr: std::net::SocketAddr::from(([127, 0, 0, 1], 1)),
3324                    source: io("bind-marker"),
3325                },
3326                "bind-marker".to_owned(),
3327            ),
3328            (
3329                Error::ServerSpawnFailed {
3330                    command: command.clone(),
3331                    source: io("spawn-marker"),
3332                },
3333                "spawn-marker".to_owned(),
3334            ),
3335            (
3336                Error::ServerNotFound {
3337                    command: command.clone(),
3338                    io: io("missing-marker"),
3339                },
3340                "missing-marker".to_owned(),
3341            ),
3342            (
3343                Error::LspInitFailed {
3344                    phase: InitPhase::Initialize,
3345                    cause: Box::new(Error::ServerSpawnFailed {
3346                        command,
3347                        source: io("init-marker"),
3348                    }),
3349                    hint: None,
3350                    stderr: None,
3351                },
3352                "init-marker".to_owned(),
3353            ),
3354            (
3355                Error::from(ConfigError::UnresolvableWorkspaceRoot {
3356                    written: PathBuf::from("root"),
3357                    base_dir: PathBuf::from("base"),
3358                    probe: PathBuf::from("base/root"),
3359                    source: io("root-marker"),
3360                }),
3361                "root-marker".to_owned(),
3362            ),
3363            (Error::from(io("io-marker")), "io-marker".to_owned()),
3364            (
3365                Error::FileIo {
3366                    path: PathBuf::from("a.rs"),
3367                    source: io("file-marker"),
3368                },
3369                "file-marker".to_owned(),
3370            ),
3371            (
3372                Error::MalformedPath {
3373                    path: PathBuf::from("b.rs"),
3374                    source: io("path-marker"),
3375                },
3376                "path-marker".to_owned(),
3377            ),
3378        ]
3379    }
3380
3381    /// #735: every variant that carries a cause renders it exactly once.
3382    #[tokio::test]
3383    async fn test_error_chain_renders_each_cause_exactly_once() {
3384        let toml_text = "[invalid toml";
3385        let toml_err = toml::from_str::<toml::Value>(toml_text).unwrap_err();
3386        let json_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
3387        let json_text = json_err.to_string();
3388        let join_error = tokio::spawn(async { panic!("task-marker") })
3389            .await
3390            .unwrap_err();
3391        let url_err = url::Url::parse("http://[").unwrap_err();
3392        let non_utf8 = String::from_utf8(vec![0xff]).unwrap_err();
3393
3394        let mut cases = io_sourced_cases();
3395        cases.extend([
3396            (
3397                Error::TomlDe(TomlParseError::new(toml_err.clone(), toml_text)),
3398                toml_err.message().to_owned(),
3399            ),
3400            (Error::from(json_err), json_text),
3401            (
3402                Error::McpServerStart("mcp-marker".into()),
3403                "mcp-marker".to_owned(),
3404            ),
3405            (
3406                Error::TaskFailed {
3407                    task: BackgroundTask::McpService,
3408                    source: join_error,
3409                },
3410                "task-marker".to_owned(),
3411            ),
3412            (
3413                Error::from(ConfigError::UnsupportedFilePattern {
3414                    server: ServerId::from_static("rust"),
3415                    pattern: FilePattern::parse("zz/yy").unwrap_err(),
3416                }),
3417                "'zz/yy'".to_owned(),
3418            ),
3419            (
3420                Error::from(ConfigError::NotUtf8(non_utf8.clone())),
3421                non_utf8.to_string(),
3422            ),
3423            (
3424                Error::from(ResourceUriError::UrlParse(url_err)),
3425                url_err.to_string(),
3426            ),
3427            (
3428                Error::from(ResourceUriError::ClientPath(InvalidClientPath::Empty)),
3429                InvalidClientPath::Empty.to_string(),
3430            ),
3431        ]);
3432        for (err, marker) in &cases {
3433            assert_rendered_once(err, marker);
3434        }
3435    }
3436
3437    #[test]
3438    fn test_startup_failure_spawn_renders_the_cause_once() {
3439        let failure = StartupFailure::Spawn(Arc::new(Error::ServerSpawnFailed {
3440            command: ServerCommand::from_static("rust-analyzer"),
3441            source: std::io::Error::other("spawn-marker"),
3442        }));
3443        let text = failure.to_string();
3444        assert_eq!(text.matches("spawn-marker").count(), 1, "{text}");
3445    }
3446
3447    /// The docs of the echo types state the bounds as numbers (the constants
3448    /// live in a private module and cannot be linked); this keeps them in step.
3449    #[test]
3450    fn test_echo_bound_docs_state_the_constants() {
3451        let source = include_str!("error.rs");
3452        for (bytes, doc_marker) in [
3453            (MAX_ECHOED_NAME_BYTES, "pub struct EchoedArgument"),
3454            (MAX_ECHOED_PATH_BYTES, "pub struct EchoedPath"),
3455        ] {
3456            let before = source.split(doc_marker).next().unwrap();
3457            let doc = before.rsplit("\n\n").next().unwrap();
3458            assert!(
3459                doc.contains(&format!("{bytes} bytes")),
3460                "{doc_marker}: {doc}"
3461            );
3462        }
3463        let debug_doc = source
3464            .split("struct BoundedDebugPath")
3465            .next()
3466            .and_then(|before| before.rsplit("\n\n").next())
3467            .unwrap();
3468        assert!(
3469            debug_doc.contains(&format!("{MAX_ECHOED_PATH_BYTES} bytes")),
3470            "{debug_doc}"
3471        );
3472    }
3473
3474    fn debug_of(path: &str) -> String {
3475        BoundedDebugPath(Path::new(path)).to_string()
3476    }
3477
3478    /// #749 FR-013: the debug-quoted path is cut at the bound, on a character
3479    /// boundary, and within the bound it is the plain `{:?}` text.
3480    #[test]
3481    fn test_bounded_debug_path_cuts_at_the_bound() {
3482        let at_bound = "a".repeat(MAX_ECHOED_PATH_BYTES - 2);
3483        assert_eq!(debug_of(&at_bound), format!("{:?}", Path::new(&at_bound)));
3484        assert!(!debug_of(&at_bound).ends_with(TRUNCATION_MARKER));
3485
3486        let over = "a".repeat(MAX_ECHOED_PATH_BYTES - 1);
3487        let cut = debug_of(&over);
3488        assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
3489        assert_eq!(cut.len(), MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
3490
3491        let straddling = "é".repeat(MAX_ECHOED_PATH_BYTES);
3492        let cut = debug_of(&straddling);
3493        assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
3494        assert!(cut.len() <= MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
3495
3496        let honest = debug_of("/ws/quote\"d\\ name.rs");
3497        assert_eq!(honest, format!("{:?}", Path::new("/ws/quote\"d\\ name.rs")));
3498    }
3499
3500    /// #749 FR-013: a cut never leaves half an escape sequence before the marker.
3501    #[test]
3502    fn test_bounded_debug_path_never_splits_an_escape() {
3503        for prefix in 0..8 {
3504            let text = format!(
3505                "{}{}",
3506                "a".repeat(prefix),
3507                "\u{1b}".repeat(MAX_ECHOED_PATH_BYTES)
3508            );
3509            let cut = debug_of(&text);
3510            let body = cut.strip_suffix(TRUNCATION_MARKER).expect("cut");
3511            let tail = body.rsplit('\\').next().unwrap_or_default();
3512            assert!(
3513                tail.is_empty() || (tail.starts_with("u{1b}") && tail == "u{1b}"),
3514                "prefix {prefix}: ends with {tail:?}"
3515            );
3516            assert!(body.len() <= MAX_ECHOED_PATH_BYTES, "{}", body.len());
3517        }
3518    }
3519
3520    /// #749 FR-014: every error that echoes client text renders a bounded
3521    /// message for a 1 MiB value, and keeps the whole stored value.
3522    #[test]
3523    fn test_client_text_echoes_stay_bounded() {
3524        let huge = "Z".repeat(1 << 20);
3525        let path = PathBuf::from(format!("/{huge}"));
3526        let io = || std::io::Error::other("cause");
3527        let errors = [
3528            Error::PathOutsideWorkspace(path.clone()),
3529            Error::NoWorkspaceRoots(path.clone()),
3530            Error::NotARegularFile(path.clone()),
3531            Error::DocumentNotFound(path.clone()),
3532            Error::PathToUri(path.clone()),
3533            Error::FileIo {
3534                path: path.clone(),
3535                source: io(),
3536            },
3537            Error::MalformedPath {
3538                path: path.clone(),
3539                source: io(),
3540            },
3541            Error::from(ResourceUriError::InvalidPath(huge.clone())),
3542            Error::from(ResourceUriError::InvalidScheme(huge.clone())),
3543            Error::from(ResourceUriError::NotAFilePath(huge)),
3544        ];
3545        for error in &errors {
3546            let text = ErrorChain(error).to_string();
3547            let len = text.len();
3548            assert!(len <= 2 * MAX_ECHOED_PATH_BYTES, "{len} bytes: {text:.80}");
3549            assert!(text.contains(TRUNCATION_MARKER), "{text:.80}");
3550        }
3551        assert_matches!(&errors[0], Error::PathOutsideWorkspace(stored) if *stored == path);
3552    }
3553
3554    /// #749 FR-013: a path at the bound is echoed whole, one byte over is cut.
3555    #[test]
3556    fn test_path_echo_variants_cut_one_byte_over_the_bound() {
3557        let at_bound = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES));
3558        let text = Error::PathOutsideWorkspace(at_bound).to_string();
3559        assert!(!text.contains(TRUNCATION_MARKER), "{text:.80}");
3560
3561        let over = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES + 1));
3562        let text = Error::NotARegularFile(over).to_string();
3563        assert!(text.ends_with(TRUNCATION_MARKER), "{text:.80}");
3564
3565        let controls = PathBuf::from("\u{1b}".repeat(MAX_ECHOED_PATH_BYTES));
3566        let text = Error::NoWorkspaceRoots(controls).to_string();
3567        assert!(text.len() <= 2 * MAX_ECHOED_PATH_BYTES, "{}", text.len());
3568    }
3569
3570    #[test]
3571    fn test_document_not_found_and_path_to_uri_escape_control_characters() {
3572        let path = || PathBuf::from("/a\nERROR forged\u{1b}[31m");
3573
3574        for error in [Error::DocumentNotFound(path()), Error::PathToUri(path())] {
3575            let text = error.to_string();
3576            assert!(!text.contains('\n') && !text.contains('\u{1b}'), "{text}");
3577        }
3578    }
3579
3580    #[test]
3581    fn test_error_chain_joins_causes_once() {
3582        let not_found = || std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
3583        let file_io = Error::FileIo {
3584            path: PathBuf::from("a.rs"),
3585            source: not_found(),
3586        };
3587        let spawn = Error::ServerSpawnFailed {
3588            command: crate::config::ServerCommand::new("x").unwrap(),
3589            source: not_found(),
3590        };
3591
3592        assert_eq!(
3593            ErrorChain(&file_io).to_string(),
3594            "file I/O error for \"a.rs\": gone"
3595        );
3596        assert_eq!(
3597            ErrorChain(&spawn).to_string(),
3598            "failed to spawn LSP server 'x': gone"
3599        );
3600    }
3601
3602    #[test]
3603    fn test_toml_parse_error_reports_line_and_column_without_the_line() {
3604        let text = "a = 1\nsecret = \"sk-SECRET\" bad\n";
3605        let error = toml::from_str::<toml::Value>(text).unwrap_err();
3606
3607        let rendered = TomlParseError::new(error, text).to_string();
3608
3609        assert!(rendered.contains("(line 2, column "), "{rendered}");
3610        assert!(!rendered.contains("sk-SECRET"), "{rendered}");
3611    }
3612
3613    /// #751: the parser quotes the source line of a failed entry; an `env`
3614    /// secret on it must not reach the error text or its chain.
3615    #[test]
3616    fn test_toml_error_never_quotes_an_env_secret() {
3617        let text = "lsp_servers = [{ language_id = \"rust\", command = \"ra\", \
3618                    env = { API_TOKEN = \"sk-SECRET\\u0000\" } }]";
3619        let parse_error = toml::from_str::<crate::config::ServerConfig>(text).unwrap_err();
3620        let err = Error::TomlDe(TomlParseError::new(parse_error, text));
3621
3622        let rendered = ErrorChain(&err).to_string();
3623        assert!(!rendered.contains("sk-SECRET"), "{rendered}");
3624        assert!(
3625            rendered.contains("environment variable 'API_TOKEN'"),
3626            "{rendered}"
3627        );
3628        assert!(rendered.contains("cannot contain NUL"), "{rendered}");
3629    }
3630
3631    #[test]
3632    fn test_result_type_alias() {
3633        fn _returns_error() -> Result<i32> {
3634            Err(Error::Config(ConfigError::NoParentDirectory {
3635                path: PathBuf::new(),
3636            }))
3637        }
3638
3639        let result: Result<i32> = Ok(42);
3640        assert!(result.is_ok());
3641        if let Ok(value) = result {
3642            assert_eq!(value, 42);
3643        }
3644    }
3645
3646    fn not_found(command: &str) -> Error {
3647        Error::ServerNotFound {
3648            command: crate::config::ServerCommand::new(command).unwrap(),
3649            io: std::io::Error::from(std::io::ErrorKind::NotFound),
3650        }
3651    }
3652
3653    #[test]
3654    fn test_server_not_found_bare_builtin_has_path_and_install_hint() {
3655        let msg = not_found("rust-analyzer").to_string();
3656        assert!(msg.contains("not on the PATH"), "{msg}");
3657        assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3658    }
3659
3660    #[test]
3661    fn test_server_not_found_bare_unknown_has_no_install_hint() {
3662        let msg = not_found("my-custom-lsp").to_string();
3663        assert!(msg.contains("not on the PATH"), "{msg}");
3664        assert!(!msg.contains("install it"), "{msg}");
3665        assert!(!msg.contains(".cmd"), "{msg}");
3666    }
3667
3668    #[test]
3669    fn test_server_not_found_path_command_has_no_path_text() {
3670        let msg = not_found("/nonexistent/rust-analyzer").to_string();
3671        assert!(msg.contains("configured path exists"), "{msg}");
3672        assert!(!msg.contains("PATH"), "{msg}");
3673        assert!(!msg.contains("install it"), "{msg}");
3674    }
3675
3676    #[test]
3677    fn test_lifecycle_errors_map_to_internal() {
3678        for err in [Error::ShutdownTimeout, not_found("x")] {
3679            assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3680        }
3681    }
3682
3683    #[test]
3684    fn test_not_found_guidance_cmd_note_only_for_npm_builtins_on_windows() {
3685        let cases = [
3686            ("pyright-langserver", Platform::Windows, true),
3687            ("typescript-language-server", Platform::Windows, true),
3688            ("pyright-langserver", Platform::Other, false),
3689            ("rust-analyzer", Platform::Windows, false),
3690            ("gopls", Platform::Windows, false),
3691            ("my-custom-lsp", Platform::Windows, false),
3692        ];
3693        for (command, platform, expects_cmd_note) in cases {
3694            let msg = NotFoundGuidance(command, platform).to_string();
3695            assert_eq!(
3696                msg.contains(".cmd"),
3697                expects_cmd_note,
3698                "{command} {platform:?}: {msg}"
3699            );
3700        }
3701    }
3702
3703    #[test]
3704    fn test_error_source_chain() {
3705        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
3706        let err = Error::ServerSpawnFailed {
3707            command: crate::config::ServerCommand::from_static("rust-analyzer"),
3708            source: io_err,
3709        };
3710
3711        let source = std::error::Error::source(&err);
3712        assert!(source.is_some());
3713    }
3714
3715    fn spawn_failure(id: &str, command: &str, error: Error) -> ServerSpawnFailure {
3716        ServerSpawnFailure {
3717            server_id: ServerId::new(id).unwrap(),
3718            language_id: LanguageId::new(id).unwrap(),
3719            command: ServerCommand::new(command).unwrap(),
3720            reason: StartupFailure::Spawn(Arc::new(error)),
3721        }
3722    }
3723
3724    #[test]
3725    fn test_server_spawn_failure_display() {
3726        let failure = spawn_failure(
3727            "rust",
3728            "rust-analyzer",
3729            Error::LspInitFailed {
3730                phase: InitPhase::Initialize,
3731                cause: Box::new(Error::Io(std::io::Error::other("boom"))),
3732                hint: None,
3733                stderr: None,
3734            },
3735        );
3736        assert_eq!(
3737            failure.to_string(),
3738            "rust [rust] (rust-analyzer): LSP server initialization failed: Initialize request failed: boom"
3739        );
3740    }
3741
3742    #[test]
3743    fn test_server_spawn_failure_clone_shares_reason() {
3744        let failure = spawn_failure("python", "pyright", not_found("pyright"));
3745        let cloned = failure.clone();
3746        assert_eq!(failure.language_id, cloned.language_id);
3747        assert_eq!(failure.to_string(), cloned.to_string());
3748    }
3749
3750    #[test]
3751    fn test_server_failed_to_start_display_carries_not_found_guidance() {
3752        let err = Error::ServerFailedToStart(Box::new(spawn_failure(
3753            "rust",
3754            "rust-analyzer",
3755            not_found("rust-analyzer"),
3756        )));
3757        let msg = err.to_string();
3758        assert!(
3759            msg.contains("'rust' for language 'rust' failed to start"),
3760            "{msg}"
3761        );
3762        assert!(msg.contains("not on the PATH"), "{msg}");
3763        assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3764        assert!(msg.contains("restart mcpls"), "{msg}");
3765    }
3766
3767    #[test]
3768    fn test_server_failed_to_start_init_task_panicked_display() {
3769        let err = Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
3770            server_id: ServerId::from_static("rust"),
3771            language_id: LanguageId::from_static("rust"),
3772            command: ServerCommand::from_static("rust-analyzer"),
3773            reason: StartupFailure::InitTaskPanicked,
3774        }));
3775        assert!(err.to_string().contains("initialization task panicked"));
3776    }
3777
3778    #[test]
3779    fn test_startup_errors_map_to_internal() {
3780        let failure = spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer"));
3781        for err in [
3782            Error::ServerFailedToStart(Box::new(failure.clone())),
3783            Error::AllServersFailedToInit {
3784                failures: vec![failure],
3785            },
3786            Error::ServerExitedDuringInit {
3787                command: crate::config::ServerCommand::from_static("x"),
3788                exit_code: Some(1),
3789                hint: None,
3790                stderr: None,
3791            },
3792            Error::ServerUnavailable {
3793                server_id: ServerId::from_static("rust"),
3794                retry_in: Duration::from_secs(1),
3795            },
3796        ] {
3797            assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3798        }
3799    }
3800
3801    #[test]
3802    fn test_init_failure_hint_renders_between_detail_and_stderr() {
3803        let stderr = StderrExcerpt::complete(b"boom", &Redactions::default());
3804        let hint = Some(InitFailureHint::NativeTypescriptOnly);
3805        let failed = Error::LspInitFailed {
3806            phase: InitPhase::Initialize,
3807            cause: Box::new(Error::Io(std::io::Error::other("x"))),
3808            hint,
3809            stderr: stderr.clone(),
3810        }
3811        .to_string();
3812        let exited = Error::ServerExitedDuringInit {
3813            command: crate::config::ServerCommand::from_static("typescript-language-server"),
3814            exit_code: Some(1),
3815            hint,
3816            stderr,
3817        }
3818        .to_string();
3819        for text in [failed, exited] {
3820            let hint_at = text.find("TypeScript 7").unwrap();
3821            assert!(hint_at < text.find("; stderr: boom").unwrap(), "{text}");
3822            assert!(
3823                text.contains(BuiltinServer::TypescriptLanguageServer.install_hint()),
3824                "{text}"
3825            );
3826        }
3827    }
3828
3829    #[test]
3830    fn test_server_exited_during_init_display_hints_only_for_rust_analyzer() {
3831        let hinted = Error::ServerExitedDuringInit {
3832            command: crate::config::ServerCommand::from_static("rust-analyzer"),
3833            exit_code: Some(1),
3834            hint: None,
3835            stderr: None,
3836        }
3837        .to_string();
3838        assert!(hinted.contains("exit code 1"), "{hinted}");
3839        assert!(
3840            hinted.contains("rustup component add rust-analyzer"),
3841            "{hinted}"
3842        );
3843
3844        let plain = Error::ServerExitedDuringInit {
3845            command: crate::config::ServerCommand::from_static("gopls"),
3846            exit_code: None,
3847            hint: None,
3848            stderr: None,
3849        }
3850        .to_string();
3851        assert!(plain.contains("terminated by a signal"), "{plain}");
3852        assert!(!plain.contains("rustup"), "{plain}");
3853    }
3854
3855    #[test]
3856    fn test_server_unavailable_display_names_retry_delay() {
3857        let err = Error::ServerUnavailable {
3858            server_id: ServerId::from_static("rust"),
3859            retry_in: Duration::from_secs(2),
3860        };
3861        assert_eq!(
3862            err.to_string(),
3863            "LSP server 'rust' is unavailable: crash-looping, retry in 2.0s"
3864        );
3865    }
3866
3867    #[test]
3868    fn test_error_display_all_servers_failed_lists_each_failure() {
3869        let err = Error::AllServersFailedToInit {
3870            failures: vec![
3871                spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer")),
3872                spawn_failure(
3873                    "python",
3874                    "pyright",
3875                    Error::LspInitFailed {
3876                        phase: InitPhase::Initialize,
3877                        cause: Box::new(Error::Io(std::io::Error::other("denied"))),
3878                        hint: None,
3879                        stderr: None,
3880                    },
3881                ),
3882            ],
3883        };
3884        let msg = err.to_string();
3885        assert!(
3886            msg.starts_with("all LSP servers failed to initialize: "),
3887            "{msg}"
3888        );
3889        assert!(msg.contains("rust [rust] (rust-analyzer)"), "{msg}");
3890        assert!(msg.contains("python [python] (pyright)"), "{msg}");
3891    }
3892
3893    #[test]
3894    fn test_untrusted_refusal_paths_are_escaped_and_bounded() {
3895        let hostile = PathBuf::from(format!("/ws/evil{}", "\n\u{202e}".repeat(3000)));
3896        for refusal in [
3897            UntrustedRefusal::WorkspaceExecutable {
3898                executable: hostile.clone(),
3899            },
3900            UntrustedRefusal::WorkspaceTsserver {
3901                tsserver: hostile.clone(),
3902            },
3903            UntrustedRefusal::WorkspaceHome {
3904                variable: HomeVariable::Home,
3905                home: hostile.clone(),
3906            },
3907            UntrustedRefusal::NonUtf8Path {
3908                what: ResolvedItem::Executable,
3909                path: hostile,
3910            },
3911        ] {
3912            let text = refusal.to_string();
3913            assert!(!text.contains('\n'), "{text}");
3914            assert!(!text.contains('\u{202e}'), "{text}");
3915            assert!(text.len() < MAX_ECHOED_PATH_BYTES + 512, "{}", text.len());
3916        }
3917        let name = EchoedArgument::name(&"\u{202e}".repeat(5000));
3918        assert!(name.as_str().len() < MAX_ECHOED_NAME_BYTES + 64);
3919    }
3920
3921    #[test]
3922    fn test_untrusted_refusal_new_variants_name_their_cause() {
3923        let launcher = UntrustedRefusal::ProjectLauncher {
3924            command: ServerCommand::from_static("npx"),
3925            cause: LauncherRefusal::SelectsWorkspaceCode {
3926                program: EchoedPath::program("npx"),
3927                trigger: LaunchTrigger::Always,
3928            },
3929        };
3930        assert!(launcher.to_string().contains("'npx'"), "{launcher}");
3931        let unpinned = UntrustedRefusal::UnpinnedTypescriptLauncher {
3932            command: ServerCommand::from_static("pnpm"),
3933        };
3934        assert!(unpinned.to_string().contains("'pnpm'"), "{unpinned}");
3935        let non_utf8 = UntrustedRefusal::NonUtf8Path {
3936            what: ResolvedItem::SearchPath,
3937            path: PathBuf::from("/odd"),
3938        };
3939        assert_eq!(
3940            non_utf8.to_string(),
3941            "its PATH, /odd, is not valid UTF-8, so untrusted mode cannot pass it on"
3942        );
3943        assert!(
3944            UntrustedRefusal::NoSafeWorkingDirectory
3945                .to_string()
3946                .contains("outside the workspace")
3947        );
3948    }
3949
3950    fn no_server_for_language(language: &'static str) -> Error {
3951        Error::NoServerForLanguage {
3952            language: LanguageId::from_static(language),
3953            file: FileKey::Unmappable,
3954            patterns: Arc::default(),
3955        }
3956    }
3957
3958    #[test]
3959    fn test_no_server_for_plaintext_names_extension_and_patterns() {
3960        let err = Error::NoServerForLanguage {
3961            language: LanguageId::PLAINTEXT,
3962            file: FileKey::Extension(FileExtension::from_static("cpp")),
3963            patterns: Arc::from([
3964                FilePattern::from_static("**/*.rs"),
3965                FilePattern::from_static("**/*.h"),
3966            ]),
3967        };
3968        let message = err.to_string();
3969        assert!(message.starts_with("no LSP server configured for language: plaintext ("));
3970        assert!(
3971            message.contains("file extension 'cpp' is not mapped"),
3972            "{message}"
3973        );
3974        assert!(
3975            message.contains("configured file_patterns: **/*.rs, **/*.h"),
3976            "{message}"
3977        );
3978    }
3979
3980    #[test]
3981    fn test_no_server_for_plaintext_without_patterns_or_extension() {
3982        let err = Error::NoServerForLanguage {
3983            language: LanguageId::PLAINTEXT,
3984            file: FileKey::Unmappable,
3985            patterns: Arc::default(),
3986        };
3987        let message = err.to_string();
3988        assert!(
3989            message.contains("the file has no usable extension or name"),
3990            "{message}"
3991        );
3992        assert!(
3993            message.contains("no file_patterns are configured"),
3994            "{message}"
3995        );
3996    }
3997
3998    #[test]
3999    fn test_no_server_for_other_language_text_is_unchanged() {
4000        let err = Error::NoServerForLanguage {
4001            language: LanguageId::from_static("nushell"),
4002            file: FileKey::Extension(FileExtension::from_static("nu")),
4003            patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
4004        };
4005        assert_eq!(
4006            err.to_string(),
4007            "no LSP server configured for language: nushell"
4008        );
4009    }
4010
4011    #[test]
4012    fn test_no_server_for_plaintext_name_suggests_a_name_pattern() {
4013        let err = Error::NoServerForLanguage {
4014            language: LanguageId::PLAINTEXT,
4015            file: FileKey::Name(FileName::from_static("Makefile")),
4016            patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
4017        };
4018        let message = err.to_string();
4019        assert!(
4020            message.contains("file name 'Makefile' is not mapped"),
4021            "{message}"
4022        );
4023        assert!(
4024            message.contains("a `**/Makefile` file_patterns entry"),
4025            "{message}"
4026        );
4027        assert!(!message.contains("*.EXT"), "{message}");
4028    }
4029
4030    #[test]
4031    fn test_error_display_capability_not_supported() {
4032        let err = Error::CapabilityNotSupported {
4033            server_id: ServerId::from_static("rust"),
4034            capability: Capability::Rename,
4035        };
4036        assert_eq!(
4037            err.to_string(),
4038            "server 'rust' does not support capability 'renameProvider'"
4039        );
4040    }
4041
4042    #[test]
4043    fn test_error_display_workspace_indexing() {
4044        let err = Error::WorkspaceIndexing {
4045            server_id: ServerId::from_static("rust"),
4046            elapsed_secs: 30,
4047        };
4048        assert_eq!(
4049            err.to_string(),
4050            "LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
4051        );
4052    }
4053
4054    /// #479: caller-fault variants must classify as `InvalidParams`, not fall
4055    /// through to the generic `Internal` bucket.
4056    #[test]
4057    fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
4058        let caller_fault_errors = vec![
4059            Error::InvalidToolParams("bad params".to_string()),
4060            Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
4061            Error::NotARegularFile(PathBuf::from("/dev/null")),
4062            Error::NoResolvableListenUris,
4063            Error::ResourceUri(ResourceUriError::InvalidScheme("x".to_string())),
4064            Error::DocumentNotFound(PathBuf::from("/missing.rs")),
4065            Error::FileSizeLimitExceeded(SizeExceeded {
4066                size: 100,
4067                max: std::num::NonZeroU64::new(10).unwrap(),
4068            }),
4069            Error::ListenFilterTooLarge { max: 1000 },
4070            Error::InvalidClientPath(InvalidClientPath::ContainsNul),
4071        ];
4072
4073        for err in caller_fault_errors {
4074            assert_eq!(
4075                err.mcp_error_kind(),
4076                McpErrorKind::InvalidParams,
4077                "expected {err:?} to classify as InvalidParams"
4078            );
4079        }
4080    }
4081
4082    #[tokio::test]
4083    async fn test_mcp_error_kind_internal_failures_are_internal() {
4084        let join_error = tokio::spawn(async { panic!("boom") }).await.unwrap_err();
4085        let internal_errors = vec![
4086            Error::TaskFailed {
4087                task: BackgroundTask::McpService,
4088                source: join_error,
4089            },
4090            Error::StdioCapture(StdioStream::Stdin),
4091            Error::PathToUri(PathBuf::from("/x")),
4092            Error::McpServerStart("handshake".into()),
4093        ];
4094
4095        for err in internal_errors {
4096            assert_eq!(
4097                err.mcp_error_kind(),
4098                McpErrorKind::Internal,
4099                "expected {err:?} to classify as Internal"
4100            );
4101        }
4102    }
4103
4104    #[tokio::test]
4105    async fn test_task_failed_keeps_the_panic_payload_as_source() {
4106        let join_error = tokio::spawn(async { panic!("secret boom") })
4107            .await
4108            .unwrap_err();
4109        let err = Error::TaskFailed {
4110            task: BackgroundTask::LspReceiver,
4111            source: join_error,
4112        };
4113        let source = std::error::Error::source(&err).unwrap();
4114        assert!(source.to_string().contains("secret boom"), "{source}");
4115        assert!(err.to_string().starts_with("LSP receiver task failed"));
4116    }
4117
4118    #[test]
4119    fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
4120        let err = Error::WorkspaceIndexing {
4121            server_id: ServerId::from_static("rust"),
4122            elapsed_secs: 30,
4123        };
4124        let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4125            panic!("expected WorkspaceIndexing to classify as Retryable");
4126        };
4127        assert_eq!(data.code(), WORKSPACE_INDEXING_ERROR_CODE);
4128        assert_eq!(
4129            serde_json::to_value(&data).unwrap(),
4130            serde_json::json!({"server_id": "rust", "elapsed_secs": 30})
4131        );
4132    }
4133
4134    #[test]
4135    fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
4136        let err = Error::ServerInitializing {
4137            server_id: ServerId::from_static("python"),
4138        };
4139        let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4140            panic!("expected ServerInitializing to classify as Retryable");
4141        };
4142        assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
4143        assert_eq!(
4144            serde_json::to_value(&data).unwrap(),
4145            serde_json::json!({"server_id": "python"})
4146        );
4147        assert_ne!(
4148            data.code(),
4149            WORKSPACE_INDEXING_ERROR_CODE,
4150            "ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
4151        );
4152    }
4153
4154    /// `WorkspaceServersInitializing` is `ServerInitializing`'s counterpart
4155    /// for a resolution that never narrowed down to a single server (see the
4156    /// variant's doc comment), so it must be retryable too -- a client that
4157    /// auto-retries on the bespoke retryable code must not treat this as a
4158    /// hard failure just because no `server_id` was available.
4159    #[test]
4160    fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
4161        let err = Error::WorkspaceServersInitializing;
4162        let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4163            panic!("expected WorkspaceServersInitializing to classify as Retryable");
4164        };
4165        assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
4166        assert_eq!(
4167            serde_json::to_value(&data).unwrap(),
4168            serde_json::json!({}),
4169            "the empty struct variant must serialize as `{{}}`, not `null`"
4170        );
4171    }
4172
4173    #[test]
4174    fn test_retryable_error_data_codes_and_snake_case_keys() {
4175        let cases = [
4176            (
4177                RetryableErrorData::WorkspaceIndexing {
4178                    server_id: ServerId::from_static("rust"),
4179                    elapsed_secs: 7,
4180                },
4181                WORKSPACE_INDEXING_ERROR_CODE,
4182                serde_json::json!({"server_id": "rust", "elapsed_secs": 7}),
4183            ),
4184            (
4185                RetryableErrorData::ServerInitializing {
4186                    server_id: ServerId::from_static("python"),
4187                },
4188                SERVER_INITIALIZING_ERROR_CODE,
4189                serde_json::json!({"server_id": "python"}),
4190            ),
4191            (
4192                RetryableErrorData::WorkspaceServersInitializing {},
4193                SERVER_INITIALIZING_ERROR_CODE,
4194                serde_json::json!({}),
4195            ),
4196            (
4197                RetryableErrorData::ListenStreamsExhausted {
4198                    max_listen_streams: 100,
4199                },
4200                LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
4201                serde_json::json!({"max_listen_streams": 100}),
4202            ),
4203        ];
4204        for (data, code, wire) in cases {
4205            assert_eq!(data.code(), code);
4206            assert_eq!(serde_json::to_value(&data).unwrap(), wire);
4207        }
4208    }
4209
4210    #[test]
4211    fn test_mcp_error_kind_listen_streams_exhausted_is_retryable() {
4212        let err = Error::ListenStreamsExhausted { max: 100 };
4213        let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4214            panic!("expected ListenStreamsExhausted to classify as Retryable");
4215        };
4216        assert_eq!(data.code(), LISTEN_STREAMS_EXHAUSTED_ERROR_CODE);
4217        assert_eq!(
4218            data,
4219            RetryableErrorData::ListenStreamsExhausted {
4220                max_listen_streams: 100
4221            }
4222        );
4223    }
4224
4225    #[test]
4226    fn test_mcp_error_kind_unretained_variants_stay_internal() {
4227        let internal_errors = vec![
4228            no_server_for_language("python"),
4229            Error::NoServerForTool {
4230                language_id: LanguageId::from_static("rust"),
4231                tool: crate::config::ToolKind::Hover,
4232            },
4233            Error::CapabilityNotSupported {
4234                server_id: ServerId::from_static("rust"),
4235                capability: Capability::Rename,
4236            },
4237            Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
4238            Error::DocumentLimitExceeded {
4239                current: 150,
4240                max: std::num::NonZeroUsize::new(100).unwrap(),
4241            },
4242            Error::SubscriptionLimitReached { max: 1000 },
4243        ];
4244
4245        for err in internal_errors {
4246            assert_eq!(
4247                err.mcp_error_kind(),
4248                McpErrorKind::Internal,
4249                "expected {err:?} to classify as Internal"
4250            );
4251        }
4252    }
4253
4254    /// #479 regression: a client-supplied path that doesn't exist (the
4255    /// common case behind `WorkspaceRoots::validate`'s `canonicalize()`
4256    /// failure) must classify as caller-fault, matching `DocumentNotFound`.
4257    #[test]
4258    fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
4259        let err = Error::FileIo {
4260            path: PathBuf::from("/no/such/file.rs"),
4261            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
4262        };
4263        assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
4264    }
4265
4266    #[test]
4267    fn test_position_beyond_document_is_invalid_params_naming_the_line() {
4268        let err = Error::PositionBeyondDocument {
4269            line: std::num::NonZeroU32::new(7).unwrap(),
4270            last_line: std::num::NonZeroU32::new(3).unwrap(),
4271        };
4272        assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
4273        assert_eq!(
4274            err.to_string(),
4275            "line 7 is beyond the end of the document (the document ends at line 3)"
4276        );
4277    }
4278
4279    /// #575: a malformed client path is caller-fault, whatever IO kind the
4280    /// validation hit.
4281    #[test]
4282    fn test_mcp_error_kind_malformed_path_is_invalid_params() {
4283        use std::io::ErrorKind;
4284
4285        for kind in [
4286            ErrorKind::NotADirectory,
4287            ErrorKind::InvalidFilename,
4288            ErrorKind::InvalidInput,
4289        ] {
4290            let err = Error::MalformedPath {
4291                path: PathBuf::from("/ws/main.rs/x"),
4292                source: std::io::Error::new(kind, "bad path"),
4293            };
4294            assert_eq!(
4295                err.mcp_error_kind(),
4296                McpErrorKind::InvalidParams,
4297                "{kind:?}"
4298            );
4299        }
4300    }
4301
4302    /// The same IO kinds from a post-validation read or open are
4303    /// environmental, not caller-fault.
4304    #[test]
4305    fn test_mcp_error_kind_post_validation_file_io_invalid_input_stays_internal() {
4306        let err = Error::FileIo {
4307            path: PathBuf::from("/ws/main.rs"),
4308            source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad input"),
4309        };
4310        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4311    }
4312
4313    /// Counterpart: a non-not-found IO failure (permission denied, etc.) is
4314    /// a genuine server-side problem, not something the caller can fix by
4315    /// changing their request.
4316    #[test]
4317    fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
4318        let err = Error::FileIo {
4319            path: PathBuf::from("/root/secret.rs"),
4320            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
4321        };
4322        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4323    }
4324
4325    /// #496: an `LspServerError` carrying the recognized "position out of
4326    /// range" shape (same substring `sanitize_lsp_server_message` rewrites
4327    /// for display) is caller-fault, not a generic internal failure.
4328    #[test]
4329    fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
4330        let err = Error::LspServerError(JsonRpcError {
4331            code: -32603,
4332            message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
4333                .to_string(),
4334            data: None,
4335        });
4336        assert_eq!(
4337            err.mcp_error_kind(),
4338            McpErrorKind::InvalidPosition(RewrittenServerError::new(
4339                -32603,
4340                "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
4341            ))
4342        );
4343    }
4344
4345    /// #465: server-supplied control characters never reach an error `Display`
4346    /// raw, so a logged or echoed error cannot forge log lines.
4347    #[test]
4348    fn test_display_escapes_control_characters_from_the_server() {
4349        let server_error = Error::LspServerError(JsonRpcError {
4350            code: -32603,
4351            message: "boom\nERROR forged\x1b[31m".to_string(),
4352            data: None,
4353        });
4354        assert_eq!(
4355            server_error.to_string(),
4356            "LSP server error: -32603 - boom\\nERROR forged\\u{1b}[31m"
4357        );
4358
4359        let protocol_error = Error::LspProtocolError(RedactedText::fixed("bad\nline"));
4360        assert_eq!(protocol_error.to_string(), "LSP protocol error: bad\\nline");
4361    }
4362
4363    /// The raw message carried as `data` is bounded at construction.
4364    #[test]
4365    fn test_rewritten_server_error_bounds_raw_message() {
4366        let raw =
4367            RewrittenServerError::new(-32603, &"x".repeat(MAX_ERROR_MESSAGE_CALLER_BYTES * 2));
4368        assert!(raw.raw_message().len() < MAX_ERROR_MESSAGE_CALLER_BYTES * 2);
4369        assert!(raw.raw_message().ends_with("(truncated)"));
4370    }
4371
4372    /// Counterpart: an `LspServerError` whose message doesn't match the
4373    /// recognized position-out-of-range shape is a genuine server-side
4374    /// problem and must stay `Internal`.
4375    #[test]
4376    fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
4377        let err = Error::LspServerError(JsonRpcError {
4378            code: -32603,
4379            message: "internal error".to_string(),
4380            data: None,
4381        });
4382        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4383    }
4384
4385    /// #496: `SubscriptionLimitReached` fires on aggregate per-session
4386    /// tracker state, not this request's params, so it must classify the
4387    /// same way as `DocumentLimitExceeded` -- not `InvalidParams`.
4388    #[test]
4389    fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
4390        let err = Error::SubscriptionLimitReached { max: 1000 };
4391        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4392    }
4393
4394    fn refusal_failure(refusal: UntrustedRefusal) -> ServerSpawnFailure {
4395        ServerSpawnFailure {
4396            server_id: ServerId::from_static("rust"),
4397            language_id: LanguageId::new("rust").unwrap(),
4398            command: ServerCommand::from_static("rust-analyzer"),
4399            reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
4400        }
4401    }
4402
4403    #[test]
4404    fn test_not_allowed_refusal_names_the_server_class_and_the_remedy() {
4405        let err =
4406            Error::ServerFailedToStart(Box::new(refusal_failure(UntrustedRefusal::NotAllowed {
4407                builtin: Some(BuiltinServer::RustAnalyzer),
4408            })));
4409        assert_eq!(
4410            err.to_string(),
4411            "LSP server 'rust' for language 'rust' was not started: the workspace is untrusted \
4412             and it may run workspace code (Cargo build scripts and procedural macros); restart \
4413             mcpls with `--allow-server rust` to start it"
4414        );
4415        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4416    }
4417
4418    #[test]
4419    fn test_workspace_executable_refusal_offers_no_allow_remedy() {
4420        let err = Error::ServerFailedToStart(Box::new(refusal_failure(
4421            UntrustedRefusal::WorkspaceExecutable {
4422                executable: PathBuf::from("/ws/bin/rust-analyzer"),
4423            },
4424        )));
4425        let text = err.to_string();
4426        assert!(
4427            text.contains("its executable /ws/bin/rust-analyzer lies inside the workspace"),
4428            "{text}"
4429        );
4430        assert!(!text.contains("--allow-server"), "{text}");
4431        assert!(!text.contains("not retried"), "{text}");
4432    }
4433
4434    #[test]
4435    fn test_custom_server_refusal_says_its_workspace_code_is_unknown() {
4436        let text = UntrustedRefusal::NotAllowed { builtin: None }.to_string();
4437        assert!(text.contains("not a built-in server"), "{text}");
4438    }
4439
4440    #[test]
4441    fn test_config_inside_workspace_is_a_startup_error_of_kind_internal() {
4442        let err = Error::ConfigInsideWorkspace {
4443            path: PathBuf::from("/ws/mcpls.toml"),
4444        };
4445        assert!(err.to_string().contains("/ws/mcpls.toml"));
4446        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4447    }
4448
4449    #[test]
4450    fn echoed_path_keeps_spaces_and_equals() {
4451        let path = EchoedPath::new(Path::new("/tmp/out/no such dir/a=b/srv"));
4452        assert_eq!(path.as_str(), "/tmp/out/no such dir/a=b/srv");
4453    }
4454
4455    #[test]
4456    fn echoed_path_never_cuts_an_escape_in_half() {
4457        let escape = "\\u{202e}";
4458        let hostile = "\u{202e}".repeat(MAX_ECHOED_PATH_BYTES);
4459        let echoed = EchoedPath::new(Path::new(&hostile));
4460        let kept = echoed
4461            .as_str()
4462            .strip_suffix(TRUNCATION_MARKER)
4463            .expect("an over-long path is marked as truncated");
4464        assert!(kept.len() <= MAX_ECHOED_PATH_BYTES);
4465        assert!(
4466            !kept.is_empty() && kept.len().is_multiple_of(escape.len()),
4467            "{kept}"
4468        );
4469        assert_eq!(kept, escape.repeat(kept.len() / escape.len()));
4470    }
4471
4472    #[test]
4473    fn echoed_program_cuts_an_assignment_but_keeps_a_path() {
4474        assert_eq!(EchoedPath::program("API_TOKEN=abc").as_str(), "API_TOKEN");
4475        assert_eq!(EchoedPath::program("X=/tmp/secret").as_str(), "X");
4476        assert_eq!(
4477            EchoedPath::program("/tmp/out/a=b/srv").as_str(),
4478            "/tmp/out/a=b/srv"
4479        );
4480        assert_eq!(EchoedPath::program("no such dir").as_str(), "no such dir");
4481    }
4482
4483    #[test]
4484    fn shell_flags_display_the_prefix_they_are_spelled_with() {
4485        assert_eq!(ShellFlag::PlusC.short_spelling(), Some(('+', 'c')));
4486        assert_eq!(ShellFlag::PlusC.to_string(), "+c");
4487        assert_eq!(ShellFlag::DashC.to_string(), "-c");
4488        assert_eq!(ShellFlag::DashCapitalC.short_spelling(), Some(('-', 'C')));
4489        let long = ShellFlag::LongCommand(LongCommandName::Command);
4490        assert_eq!(long.short_spelling(), None);
4491        assert_eq!(long.to_string(), "--command");
4492    }
4493
4494    #[test]
4495    fn echoed_path_within_the_bound_is_not_marked() {
4496        let path = "a".repeat(MAX_ECHOED_PATH_BYTES);
4497        assert_eq!(EchoedPath::new(Path::new(&path)).as_str(), path);
4498    }
4499
4500    #[test]
4501    fn wrapped_program_refusals_name_the_program_its_launcher_starts() {
4502        let program = EchoedPath::new(Path::new("/tmp/out/a=b"));
4503        let text = UntrustedRefusal::WrappedProgramPathContainsEquals { program }.to_string();
4504        assert!(text.contains("'/tmp/out/a=b'"), "{text}");
4505        assert!(text.contains("the program '"), "{text}");
4506        assert!(!text.contains("its launcher '/tmp"), "{text}");
4507    }
4508}