Skip to main content

mcpls_core/config/
routing.rs

1//! Explicit per-tool routing (#174).
2//!
3//! `language_id` alone is not a unique server identity: two servers can
4//! share one language (e.g. pyright and pylsp both for `python`), each
5//! handling a different subset of MCP tools. This module defines the typed
6//! vocabulary for that routing — [`ServerId`], [`ToolKind`] — and
7//! [`ToolRouter`], which resolves `(language, tool)` to the server that
8//! should handle it.
9//!
10//! `ToolKind` lives here, in `config`, rather than in `mcp` (which is where
11//! its variants are semantically drawn from) to keep `config` a leaf module:
12//! `mcp` and `bridge` both depend on `config`, so putting `ToolKind` in `mcp`
13//! would create a `config -> mcp -> bridge -> config` cycle. When a new
14//! routable MCP tool is added, extend [`ToolKind::ALL`] here.
15
16use std::collections::{HashMap, HashSet};
17
18use serde::{Deserialize, Serialize};
19
20use super::server::LspServerConfig;
21use crate::error::{Error, Result};
22
23/// Unique identity of a configured LSP server within a workspace.
24///
25/// Derived from [`LspServerConfig::id`]: a server's explicit `name` if set,
26/// otherwise its `language_id`. This is the key used throughout the bridge
27/// layer (`Translator::lsp_clients`, `lsp_servers`, notification receivers)
28/// instead of a raw language string, so two servers sharing a language no
29/// longer silently overwrite each other.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub struct ServerId(String);
32
33impl ServerId {
34    /// Borrow the identity as a plain string, e.g. for log messages or map
35    /// lookups against external APIs that expect `&str`.
36    #[must_use]
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }
40}
41
42impl std::fmt::Display for ServerId {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl From<String> for ServerId {
49    fn from(id: String) -> Self {
50        Self(id)
51    }
52}
53
54impl From<&str> for ServerId {
55    fn from(id: &str) -> Self {
56        Self(id.to_string())
57    }
58}
59
60/// A routable MCP tool: every MCP tool that dispatches a request to a
61/// specific LSP server via [`ToolRouter`].
62///
63/// Cache-only tools (`get_cached_diagnostics`, `get_server_logs`,
64/// `get_server_messages`) are deliberately excluded — they never reach a
65/// client directly, so they have nothing to route.
66///
67/// `CallHierarchy` covers `prepare`, `incoming_calls`, and `outgoing_calls`
68/// as a single route: the opaque item returned by `prepare` is only
69/// meaningful to the server that produced it, and the incoming/outgoing
70/// handlers never call `ensure_open` themselves — they rely on `prepare`
71/// having already synced the document to the *same* server.
72///
73/// # Examples
74///
75/// ```
76/// use mcpls_core::config::ToolKind;
77///
78/// assert_eq!(ToolKind::Hover.as_str(), "hover");
79/// assert_eq!(ToolKind::ALL.len(), 15);
80/// ```
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum ToolKind {
85    /// `textDocument/hover`.
86    Hover,
87    /// `textDocument/definition`.
88    Definition,
89    /// `textDocument/typeDefinition`.
90    TypeDefinition,
91    /// `textDocument/implementation`.
92    Implementation,
93    /// `textDocument/references`.
94    References,
95    /// `textDocument/diagnostic` (pull) and the `publishDiagnostics` cache filter.
96    Diagnostics,
97    /// `textDocument/rename`.
98    Rename,
99    /// `textDocument/completion`.
100    Completions,
101    /// `textDocument/signatureHelp`.
102    SignatureHelp,
103    /// `textDocument/documentSymbol`.
104    DocumentSymbols,
105    /// `workspace/symbol`.
106    WorkspaceSymbols,
107    /// `textDocument/formatting`.
108    FormatDocument,
109    /// `textDocument/codeAction`.
110    CodeActions,
111    /// `textDocument/prepareCallHierarchy`, `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`.
112    CallHierarchy,
113    /// `textDocument/inlayHint`.
114    InlayHints,
115}
116
117impl ToolKind {
118    /// Every routable tool, in a fixed order. Used to compute the §5
119    /// coverage warning and to build error messages that enumerate tools.
120    pub const ALL: &[Self] = &[
121        Self::Hover,
122        Self::Definition,
123        Self::TypeDefinition,
124        Self::Implementation,
125        Self::References,
126        Self::Diagnostics,
127        Self::Rename,
128        Self::Completions,
129        Self::SignatureHelp,
130        Self::DocumentSymbols,
131        Self::WorkspaceSymbols,
132        Self::FormatDocument,
133        Self::CodeActions,
134        Self::CallHierarchy,
135        Self::InlayHints,
136    ];
137
138    /// The `snake_case` name used in config `handles` lists and error messages.
139    #[must_use]
140    pub const fn as_str(&self) -> &'static str {
141        match self {
142            Self::Hover => "hover",
143            Self::Definition => "definition",
144            Self::TypeDefinition => "type_definition",
145            Self::Implementation => "implementation",
146            Self::References => "references",
147            Self::Diagnostics => "diagnostics",
148            Self::Rename => "rename",
149            Self::Completions => "completions",
150            Self::SignatureHelp => "signature_help",
151            Self::DocumentSymbols => "document_symbols",
152            Self::WorkspaceSymbols => "workspace_symbols",
153            Self::FormatDocument => "format_document",
154            Self::CodeActions => "code_actions",
155            Self::CallHierarchy => "call_hierarchy",
156            Self::InlayHints => "inlay_hints",
157        }
158    }
159}
160
161impl std::fmt::Display for ToolKind {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.write_str(self.as_str())
164    }
165}
166
167/// Describe a `[[lsp_servers]]` entry for use in error messages that must let
168/// a user tell apart two entries sharing the same [`ServerId`] — the id
169/// alone is useless there, since it's exactly what collided.
170///
171/// Deliberately does not include a positional index: [`ToolRouter::from_configs`]
172/// only ever sees the post-heuristics *applicable* subset for a given
173/// workspace, not the raw `[[lsp_servers]]` array, so a printed index would
174/// usually name the wrong TOML entry (misleading, worse than omitting it).
175/// `command`/`args` distinguish the entries instead; when two entries are
176/// truly identical in every visible field, the description is the same for
177/// both halves, which is an honest reflection of the ambiguity.
178fn describe_entry(cfg: &LspServerConfig) -> String {
179    if cfg.args.is_empty() {
180        format!("language '{}', command '{}'", cfg.language_id, cfg.command)
181    } else {
182        format!(
183            "language '{}', command '{}', args {:?}",
184            cfg.language_id, cfg.command, cfg.args
185        )
186    }
187}
188
189/// Per-language routing table: which server handles which tool.
190#[derive(Debug, Default)]
191struct LanguageRoutes {
192    /// Tools explicitly claimed via a server's `handles` list.
193    explicit: HashMap<ToolKind, ServerId>,
194    /// The single server (if any) that omitted `handles` — serves every
195    /// tool not explicitly claimed by another server for this language.
196    default: Option<ServerId>,
197}
198
199/// Why [`ToolRouter::resolve_any`] could not find a server for a
200/// workspace-wide tool.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[non_exhaustive]
203pub enum NoServerReason {
204    /// No server is registered in this workspace at all. Reflects what has
205    /// *registered* (i.e. finished spawning), not what is configured in
206    /// `mcpls.toml` — a server that is still initializing, or one that was
207    /// configured but failed to spawn, is indistinguishable from "nothing
208    /// configured" at this layer. Callers with access to the set of servers
209    /// still expected to register (e.g. `Translator::expected_servers`) can
210    /// tell these apart.
211    NothingRegistered,
212    /// At least one server is registered, but none explicitly claims the
213    /// requested tool and none is a catch-all.
214    NoClaimant,
215}
216
217/// Resolves `(language, tool)` to the [`ServerId`] that should handle it.
218///
219/// Built once at startup by [`Self::from_configs`] over the *applicable*
220/// (post-heuristics) server configs, then rebound once at registration time
221/// by [`Self::rebind_to_registered`] so that no route ever points at a
222/// server that failed to spawn.
223#[derive(Debug, Default)]
224pub struct ToolRouter {
225    by_language: HashMap<String, LanguageRoutes>,
226    /// Config declaration order, used by `resolve_any` for a deterministic
227    /// choice among candidates. Pruned to registered servers by
228    /// `rebind_to_registered`.
229    order: Vec<ServerId>,
230}
231
232impl ToolRouter {
233    /// Build a router from the configs applicable in this workspace,
234    /// enforcing the workspace-scoped validation rules:
235    ///
236    /// 1. No two applicable servers (in any language) may share a
237    ///    [`ServerId`] — it is the key of every map keyed by server identity.
238    /// 2. No two applicable servers for one language may both omit `handles`
239    ///    (two catch-alls).
240    /// 3. No tool may be claimed via `handles` by two applicable servers of
241    ///    the same language.
242    ///
243    /// Also emits a `tracing::warn!` for any language whose union of
244    /// `handles` claims is partial and has no catch-all server, naming the
245    /// tools nobody will serve.
246    ///
247    /// # Errors
248    ///
249    /// Returns `Error::InvalidConfig` naming the conflicting entries if any
250    /// of the three rules above is violated.
251    pub fn from_configs<'a, I>(cfgs: I) -> Result<Self>
252    where
253        I: IntoIterator<Item = &'a LspServerConfig>,
254    {
255        let mut by_language: HashMap<String, LanguageRoutes> = HashMap::new();
256        let mut order: Vec<ServerId> = Vec::new();
257        let mut seen_ids: HashMap<ServerId, String> = HashMap::new();
258
259        for cfg in cfgs {
260            let id = cfg.id();
261
262            if let Some(prev_description) = seen_ids.get(&id) {
263                return Err(Error::InvalidConfig(format!(
264                    "duplicate server id '{id}' in this workspace (used by both an entry with \
265                     {prev_description} and one with {}); add a unique `name` to each \
266                     `[[lsp_servers]]` entry",
267                    describe_entry(cfg)
268                )));
269            }
270            seen_ids.insert(id.clone(), describe_entry(cfg));
271            order.push(id.clone());
272
273            let routes = by_language.entry(cfg.language_id.clone()).or_default();
274
275            match &cfg.handles {
276                None => {
277                    if let Some(existing) = &routes.default {
278                        return Err(Error::InvalidConfig(format!(
279                            "language '{}' has two catch-all servers ('{existing}' and '{id}'); \
280                             at most one server per language may omit `handles`",
281                            cfg.language_id
282                        )));
283                    }
284                    routes.default = Some(id);
285                }
286                Some(tools) => {
287                    for tool in tools {
288                        if let Some(existing) = routes.explicit.get(tool) {
289                            return Err(Error::InvalidConfig(format!(
290                                "tool '{tool}' for language '{}' is claimed by both \
291                                 '{existing}' and '{id}'",
292                                cfg.language_id
293                            )));
294                        }
295                        routes.explicit.insert(*tool, id.clone());
296                    }
297                }
298            }
299        }
300
301        // Deliberately untested (M4): asserting on `tracing` output would
302        // need a subscriber/capture dev-dependency this crate doesn't
303        // otherwise pull in. Verified by inspection instead; the `uncovered`
304        // computation itself is exercised indirectly by every `resolve`
305        // test above that checks an unclaimed tool returns `None`.
306        for (language, routes) in &by_language {
307            if routes.default.is_none() {
308                let uncovered: Vec<&str> = ToolKind::ALL
309                    .iter()
310                    .filter(|t| !routes.explicit.contains_key(t))
311                    .map(ToolKind::as_str)
312                    .collect();
313                if !uncovered.is_empty() {
314                    tracing::warn!(
315                        "language '{language}' has no catch-all server and does not claim: {}",
316                        uncovered.join(", ")
317                    );
318                }
319            }
320        }
321
322        Ok(Self { by_language, order })
323    }
324
325    /// Build a router where every entry is a catch-all for its language.
326    ///
327    /// Test helper: takes `(id, language)` pairs rather than a single entry
328    /// because some tests (e.g. the `typescript`/`typescriptreact` exact-match
329    /// preference) need two catch-alls registered at once.
330    #[must_use]
331    pub fn catch_all<I>(entries: I) -> Self
332    where
333        I: IntoIterator<Item = (ServerId, String)>,
334    {
335        let mut by_language: HashMap<String, LanguageRoutes> = HashMap::new();
336        let mut order = Vec::new();
337        for (id, language) in entries {
338            order.push(id.clone());
339            by_language.entry(language).or_default().default = Some(id);
340        }
341        Self { by_language, order }
342    }
343
344    /// Rebind every route pointing at a server that did not register — i.e.
345    /// failed to spawn — to that language's live catch-all, or drop the
346    /// route entirely if no catch-all is live.
347    ///
348    /// A dead route is never rebound to a *narrowly-scoped* live server: a
349    /// server that declared `handles = [...]` has explicitly declined every
350    /// other tool, and conscripting it would override that declaration (and,
351    /// via the diagnostics cache filter, start caching diagnostics the user
352    /// deliberately routed away).
353    ///
354    /// # Preconditions
355    ///
356    /// Call this exactly once, after all spawn attempts for a `serve_with`
357    /// invocation have completed and before any request can observe the
358    /// router. This is sound only because `LspServer::spawn_batch` is a
359    /// sequential loop that produces one `ServerInitResult` registered under
360    /// a single lock — registration is one atomic all-or-nothing event, so
361    /// no request can observe a half-rebound router. If server registration
362    /// is ever made incremental (servers registering as they finish spawning,
363    /// rather than all together), an early rebind here would permanently
364    /// steal a slow server's routes with no way back; this function would
365    /// need to be replaced with a design that derives the active table on
366    /// each lookup instead of mutating it once.
367    pub fn rebind_to_registered(&mut self, registered: &HashSet<ServerId>) {
368        for (language, routes) in &mut self.by_language {
369            let live_catch_all = routes.default.clone().filter(|id| registered.contains(id));
370
371            let mut dead: HashMap<ServerId, Vec<ToolKind>> = HashMap::new();
372            for (tool, id) in &routes.explicit {
373                if !registered.contains(id) {
374                    dead.entry(id.clone()).or_default().push(*tool);
375                }
376            }
377
378            for (dead_id, tools) in dead {
379                let tool_names: Vec<&str> = tools.iter().map(ToolKind::as_str).collect();
380                if let Some(catch_all_id) = &live_catch_all {
381                    for tool in &tools {
382                        routes.explicit.insert(*tool, catch_all_id.clone());
383                    }
384                    tracing::warn!(
385                        "language '{language}': server '{dead_id}' failed to spawn; \
386                         rebinding [{}] to catch-all '{catch_all_id}'",
387                        tool_names.join(", ")
388                    );
389                } else {
390                    for tool in &tools {
391                        routes.explicit.remove(tool);
392                    }
393                    tracing::warn!(
394                        "language '{language}': server '{dead_id}' failed to spawn and no \
395                         live catch-all is available; [{}] will report no server available",
396                        tool_names.join(", ")
397                    );
398                }
399            }
400
401            if let Some(dead_catch_all) = routes
402                .default
403                .as_ref()
404                .filter(|id| !registered.contains(*id))
405                .cloned()
406            {
407                routes.default = None;
408                tracing::warn!(
409                    "language '{language}': catch-all server '{dead_catch_all}' failed to \
410                     spawn; every tool it wasn't already explicitly rebound above will report \
411                     no server available"
412                );
413            }
414        }
415
416        self.order.retain(|id| registered.contains(id));
417    }
418
419    /// Resolve the server that should handle `tool` for `language_id`.
420    ///
421    /// Explicit claims win over the language's catch-all; if neither exists,
422    /// returns `None`.
423    #[must_use]
424    pub fn resolve(&self, language_id: &str, tool: ToolKind) -> Option<&ServerId> {
425        let routes = self.by_language.get(language_id)?;
426        routes.explicit.get(&tool).or(routes.default.as_ref())
427    }
428
429    /// Resolve a server for `tool` without a specific language — used for
430    /// workspace-wide tools like `workspace_symbol_search` that have no
431    /// document to detect a language from.
432    ///
433    /// Resolves in two tiers, in config declaration order:
434    /// 1. the first server that explicitly claims `tool`;
435    /// 2. else the first catch-all server.
436    ///
437    /// Deliberately does *not* fall back to "the first server at all" when
438    /// neither tier matches: a server with a `handles` list has explicitly
439    /// declined every tool not on it, so forwarding an unclaimed workspace-wide
440    /// tool to it anyway would silently violate that declaration. Callers get
441    /// [`NoServerReason`] instead, distinguishing "nothing configured" from
442    /// "something is configured but nothing claims this tool" so they can
443    /// report a precise error rather than defaulting to an arbitrary server.
444    ///
445    /// # Errors
446    ///
447    /// Returns [`NoServerReason::NothingRegistered`] if no server is
448    /// registered at all, or [`NoServerReason::NoClaimant`] if servers are
449    /// registered but none explicitly claims `tool` and none is a catch-all.
450    pub fn resolve_any(&self, tool: ToolKind) -> std::result::Result<&ServerId, NoServerReason> {
451        let claims_explicitly = |id: &ServerId| {
452            self.by_language
453                .values()
454                .any(|r| r.explicit.get(&tool) == Some(id))
455        };
456        let is_catch_all = |id: &ServerId| {
457            self.by_language
458                .values()
459                .any(|r| r.default.as_ref() == Some(id))
460        };
461
462        self.order
463            .iter()
464            .find(|id| claims_explicitly(id))
465            .or_else(|| self.order.iter().find(|id| is_catch_all(id)))
466            .ok_or(if self.order.is_empty() {
467                NoServerReason::NothingRegistered
468            } else {
469                NoServerReason::NoClaimant
470            })
471    }
472
473    /// Whether `language_id` currently has at least one live-or-configured
474    /// route (a catch-all or an explicit claim), used to distinguish
475    /// `NoServerForTool` (some server handles this language, just not this
476    /// tool) from `NoServerForLanguage` (nothing does).
477    ///
478    /// Deliberately checks route *contents*, not just map-key presence: after
479    /// `rebind_to_registered` drops every route for a language whose sole
480    /// server failed to spawn, this must go back to `false` so that language
481    /// reports `NoServerForLanguage` exactly as it did before per-tool
482    /// routing existed, not `NoServerForTool`.
483    #[must_use]
484    pub fn has_language(&self, language_id: &str) -> bool {
485        self.by_language
486            .get(language_id)
487            .is_some_and(|r| r.default.is_some() || !r.explicit.is_empty())
488    }
489}
490
491#[cfg(test)]
492#[allow(clippy::unwrap_used)]
493mod tests {
494    use super::*;
495
496    fn cfg(
497        language_id: &str,
498        name: Option<&str>,
499        handles: Option<Vec<ToolKind>>,
500    ) -> LspServerConfig {
501        LspServerConfig {
502            language_id: language_id.to_string(),
503            command: "cmd".to_string(),
504            args: vec![],
505            env: HashMap::new(),
506            file_patterns: vec![],
507            initialization_options: None,
508            timeout_seconds: 30,
509            request_timeout_seconds: 30,
510            heuristics: None,
511            name: name.map(str::to_string),
512            handles,
513            indexing: crate::bridge::IndexingPolicy::Auto,
514        }
515    }
516
517    #[test]
518    fn test_resolve_explicit_wins_over_catch_all() {
519        let configs = vec![
520            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
521            cfg("python", Some("pylsp"), None),
522        ];
523        let router = ToolRouter::from_configs(&configs).unwrap();
524        assert_eq!(
525            router.resolve("python", ToolKind::Hover),
526            Some(&ServerId::from("pyright"))
527        );
528        assert_eq!(
529            router.resolve("python", ToolKind::Diagnostics),
530            Some(&ServerId::from("pylsp"))
531        );
532    }
533
534    #[test]
535    fn test_resolve_no_catch_all_unclaimed_is_none() {
536        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
537        let router = ToolRouter::from_configs(&configs).unwrap();
538        assert_eq!(router.resolve("python", ToolKind::Diagnostics), None);
539    }
540
541    #[test]
542    fn test_resolve_any_explicit_claimer_beats_catch_all_declared_first() {
543        let configs = vec![
544            cfg("python", Some("python-narrow"), Some(vec![ToolKind::Hover])),
545            cfg("rust", Some("rust-catch-all"), None),
546        ];
547        let router = ToolRouter::from_configs(&configs).unwrap();
548        // Neither server explicitly claims WorkspaceSymbols, so the rust
549        // catch-all must win over the narrowly-scoped python server, even
550        // though python was declared first.
551        assert_eq!(
552            router.resolve_any(ToolKind::WorkspaceSymbols),
553            Ok(&ServerId::from("rust-catch-all"))
554        );
555    }
556
557    #[test]
558    fn test_resolve_any_prefers_explicit_claimer_over_catch_all() {
559        let configs = vec![
560            cfg("rust", Some("rust-catch-all"), None),
561            cfg(
562                "python",
563                Some("python-explicit"),
564                Some(vec![ToolKind::WorkspaceSymbols]),
565            ),
566        ];
567        let router = ToolRouter::from_configs(&configs).unwrap();
568        assert_eq!(
569            router.resolve_any(ToolKind::WorkspaceSymbols),
570            Ok(&ServerId::from("python-explicit"))
571        );
572    }
573
574    #[test]
575    fn test_from_configs_rejects_duplicate_server_id_across_languages() {
576        let configs = vec![
577            cfg("python", None, None),
578            cfg("typescript", Some("python"), None),
579        ];
580        let err = ToolRouter::from_configs(&configs).unwrap_err();
581        assert!(matches!(err, Error::InvalidConfig(_)));
582    }
583
584    #[test]
585    fn test_from_configs_duplicate_server_id_error_distinguishes_entries() {
586        // Two `[[lsp_servers]]` entries sharing `language_id = "rust"` with
587        // neither setting `name`: both resolve to the same ServerId, which
588        // used to make the error message name both conflicting halves
589        // identically ("used by both the 'rust' and 'rust' language
590        // entries"). The message must let a user tell the two entries apart.
591        let configs = vec![
592            LspServerConfig {
593                language_id: "rust".to_string(),
594                command: "rust-analyzer".to_string(),
595                args: vec![],
596                env: HashMap::new(),
597                file_patterns: vec![],
598                initialization_options: None,
599                timeout_seconds: 30,
600                request_timeout_seconds: 30,
601                heuristics: None,
602                name: None,
603                handles: None,
604                indexing: crate::bridge::IndexingPolicy::Auto,
605            },
606            LspServerConfig {
607                language_id: "rust".to_string(),
608                command: "rust-analyzer".to_string(),
609                args: vec!["--dummy-second-instance".to_string()],
610                env: HashMap::new(),
611                file_patterns: vec![],
612                initialization_options: None,
613                timeout_seconds: 30,
614                request_timeout_seconds: 30,
615                heuristics: None,
616                name: None,
617                handles: None,
618                indexing: crate::bridge::IndexingPolicy::Auto,
619            },
620        ];
621        let err = ToolRouter::from_configs(&configs).unwrap_err();
622        let Error::InvalidConfig(msg) = err else {
623            panic!("expected InvalidConfig, got {err:?}");
624        };
625        // Must not print a positional index: `from_configs` only ever sees
626        // the post-heuristics applicable subset, so any "entry #N" would
627        // usually name the wrong `[[lsp_servers]]` array position.
628        assert!(!msg.contains("entry #"), "message was: {msg}");
629        assert!(msg.contains("rust-analyzer"), "message was: {msg}");
630        assert!(
631            msg.contains("--dummy-second-instance"),
632            "message was: {msg}"
633        );
634    }
635
636    #[test]
637    fn test_from_configs_duplicate_server_id_error_identical_entries_still_reports() {
638        // When two colliding entries are identical in every visible field,
639        // there's nothing left to distinguish them by; the message should
640        // still name the collision (both halves read the same) rather than
641        // fabricate a misleading index.
642        let configs = vec![cfg("rust", None, None), cfg("rust", None, None)];
643        let err = ToolRouter::from_configs(&configs).unwrap_err();
644        let Error::InvalidConfig(msg) = err else {
645            panic!("expected InvalidConfig, got {err:?}");
646        };
647        assert!(!msg.contains("entry #"), "message was: {msg}");
648        assert!(
649            msg.contains("duplicate server id 'rust'"),
650            "message was: {msg}"
651        );
652    }
653
654    #[test]
655    fn test_from_configs_rejects_two_catch_alls() {
656        let configs = vec![
657            cfg("python", Some("a"), None),
658            cfg("python", Some("b"), None),
659        ];
660        let err = ToolRouter::from_configs(&configs).unwrap_err();
661        assert!(matches!(err, Error::InvalidConfig(_)));
662    }
663
664    #[test]
665    fn test_from_configs_rejects_duplicate_tool_claim() {
666        let configs = vec![
667            cfg("python", Some("a"), Some(vec![ToolKind::Hover])),
668            cfg("python", Some("b"), Some(vec![ToolKind::Hover])),
669        ];
670        let err = ToolRouter::from_configs(&configs).unwrap_err();
671        assert!(matches!(err, Error::InvalidConfig(_)));
672    }
673
674    #[test]
675    fn test_rebind_to_registered_dead_server_with_live_catch_all() {
676        let configs = vec![
677            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
678            cfg("python", Some("pylsp"), None),
679        ];
680        let mut router = ToolRouter::from_configs(&configs).unwrap();
681        let registered: HashSet<ServerId> = HashSet::from([ServerId::from("pylsp")]);
682        router.rebind_to_registered(&registered);
683
684        assert_eq!(
685            router.resolve("python", ToolKind::Hover),
686            Some(&ServerId::from("pylsp"))
687        );
688    }
689
690    #[test]
691    fn test_rebind_to_registered_dead_server_no_catch_all_drops_route() {
692        let configs = vec![
693            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
694            cfg("python", Some("pylsp"), Some(vec![ToolKind::Diagnostics])),
695        ];
696        let mut router = ToolRouter::from_configs(&configs).unwrap();
697        let registered: HashSet<ServerId> = HashSet::from([ServerId::from("pylsp")]);
698        router.rebind_to_registered(&registered);
699
700        // pyright died, no catch-all exists, and pylsp never claimed Hover:
701        // the route must drop rather than conscript pylsp.
702        assert_eq!(router.resolve("python", ToolKind::Hover), None);
703        assert_eq!(
704            router.resolve("python", ToolKind::Diagnostics),
705            Some(&ServerId::from("pylsp"))
706        );
707    }
708
709    #[test]
710    fn test_rebind_to_registered_all_failed_drops_everything() {
711        let configs = vec![cfg("rust", None, None)];
712        let mut router = ToolRouter::from_configs(&configs).unwrap();
713        router.rebind_to_registered(&HashSet::new());
714        assert_eq!(router.resolve("rust", ToolKind::Hover), None);
715        assert_eq!(
716            router.resolve_any(ToolKind::Hover),
717            Err(NoServerReason::NothingRegistered)
718        );
719        // A single-server-per-language config whose server fails to spawn
720        // must report NoServerForLanguage upstream, not NoServerForTool --
721        // has_language must go back to false once every route is dropped.
722        assert!(!router.has_language("rust"));
723    }
724
725    #[test]
726    fn test_rebind_prunes_order_for_resolve_any() {
727        let configs = vec![cfg("rust", Some("a"), None), cfg("python", Some("b"), None)];
728        let mut router = ToolRouter::from_configs(&configs).unwrap();
729        let registered: HashSet<ServerId> = HashSet::from([ServerId::from("b")]);
730        router.rebind_to_registered(&registered);
731        assert_eq!(
732            router.resolve_any(ToolKind::Hover),
733            Ok(&ServerId::from("b"))
734        );
735    }
736
737    #[test]
738    fn test_resolve_any_no_claimant_does_not_fall_back_to_arbitrary_server() {
739        // A single narrowly-scoped server that does not claim WorkspaceSymbols
740        // and has no catch-all anywhere must not be silently conscripted for
741        // it -- that would violate its explicit `handles` declaration.
742        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
743        let router = ToolRouter::from_configs(&configs).unwrap();
744        assert_eq!(
745            router.resolve_any(ToolKind::WorkspaceSymbols),
746            Err(NoServerReason::NoClaimant)
747        );
748    }
749
750    #[test]
751    fn test_has_language() {
752        let configs = vec![cfg("rust", None, None)];
753        let router = ToolRouter::from_configs(&configs).unwrap();
754        assert!(router.has_language("rust"));
755        assert!(!router.has_language("python"));
756    }
757
758    #[test]
759    fn test_catch_all_helper_registers_two_entries() {
760        let router = ToolRouter::catch_all([
761            (ServerId::from("ts"), "typescript".to_string()),
762            (ServerId::from("tsx"), "typescriptreact".to_string()),
763        ]);
764        assert_eq!(
765            router.resolve("typescript", ToolKind::Hover),
766            Some(&ServerId::from("ts"))
767        );
768        assert_eq!(
769            router.resolve("typescriptreact", ToolKind::Hover),
770            Some(&ServerId::from("tsx"))
771        );
772    }
773
774    #[test]
775    fn test_tool_kind_as_str_and_all_len() {
776        assert_eq!(ToolKind::Hover.as_str(), "hover");
777        assert_eq!(ToolKind::CallHierarchy.as_str(), "call_hierarchy");
778        assert_eq!(ToolKind::ALL.len(), 15);
779
780        // `ALL` is a slice now, so nothing pins its element count at compile
781        // time the way `[Self; 15]` used to -- guard against duplicate or
782        // missing entries at runtime instead.
783        let unique_names: std::collections::HashSet<&str> =
784            ToolKind::ALL.iter().map(ToolKind::as_str).collect();
785        assert_eq!(unique_names.len(), ToolKind::ALL.len());
786    }
787
788    #[test]
789    fn test_server_id_display_and_as_str() {
790        let id = ServerId::from("pyright");
791        assert_eq!(id.as_str(), "pyright");
792        assert_eq!(id.to_string(), "pyright");
793    }
794}