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