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