Skip to main content

mcpls_core/config/
server.rs

1//! LSP server configuration types.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use ignore::WalkBuilder;
7use serde::{Deserialize, Serialize};
8
9use super::routing::{ServerId, ToolKind};
10
11/// Default max depth for recursive marker search.
12pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
13
14/// Directories excluded from recursive marker search.
15/// These are well-known directories that should never contain project markers.
16const EXCLUDED_DIRECTORIES: &[&str] = &[
17    "node_modules",
18    "target",
19    ".git",
20    "__pycache__",
21    ".venv",
22    "venv",
23    ".tox",
24    ".mypy_cache",
25    ".pytest_cache",
26    "build",
27    "dist",
28    ".cargo",
29    ".rustup",
30    "vendor",
31    "coverage",
32    ".next",
33    ".nuxt",
34];
35
36/// Heuristics for determining if an LSP server should be spawned.
37///
38/// Used to prevent spawning servers in projects where they are not applicable
39/// (e.g., rust-analyzer in a Python-only project).
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(deny_unknown_fields)]
42pub struct ServerHeuristics {
43    /// Files or directories that indicate this server is applicable.
44    ///
45    /// The server will spawn if ANY of these markers exist anywhere in the workspace tree
46    /// (searched recursively up to `heuristics_max_depth`). Well-known directories like
47    /// `node_modules`, `target`, `.git` are excluded from the search.
48    ///
49    /// If empty, the server will always attempt to spawn.
50    #[serde(default)]
51    pub project_markers: Vec<String>,
52}
53
54impl ServerHeuristics {
55    /// Create heuristics with the given project markers.
56    #[must_use]
57    pub fn with_markers<I, S>(markers: I) -> Self
58    where
59        I: IntoIterator<Item = S>,
60        S: Into<String>,
61    {
62        Self {
63            project_markers: markers.into_iter().map(Into::into).collect(),
64        }
65    }
66
67    /// Check if any marker exists at the given workspace root.
68    ///
69    /// Returns `true` if:
70    /// - No markers are defined (empty = always applicable)
71    /// - At least one marker file/directory exists
72    #[must_use]
73    pub fn is_applicable(&self, workspace_root: &Path) -> bool {
74        if self.project_markers.is_empty() {
75            return true;
76        }
77        self.project_markers
78            .iter()
79            .any(|marker| workspace_root.join(marker).exists())
80    }
81
82    /// Check if any marker exists anywhere in the workspace tree.
83    ///
84    /// Recursively searches the workspace for project markers, excluding
85    /// well-known directories like `node_modules`, `target`, `.git`, etc.
86    ///
87    /// # Arguments
88    ///
89    /// * `workspace_root` - Root directory to search from
90    /// * `max_depth` - Maximum recursion depth (default: 10)
91    ///
92    /// # Returns
93    ///
94    /// `true` if any marker is found, `false` otherwise.
95    #[must_use]
96    pub fn is_applicable_recursive(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
97        if self.project_markers.is_empty() {
98            return true;
99        }
100
101        // First check the root level (fast path)
102        if self.is_applicable(workspace_root) {
103            return true;
104        }
105
106        let depth = max_depth.unwrap_or(DEFAULT_HEURISTICS_MAX_DEPTH);
107        self.find_any_marker_recursive(workspace_root, depth)
108    }
109
110    /// Search recursively for any marker file.
111    fn find_any_marker_recursive(&self, workspace_root: &Path, max_depth: usize) -> bool {
112        let mut builder = WalkBuilder::new(workspace_root);
113        builder
114            .max_depth(Some(max_depth))
115            .hidden(false)
116            .git_ignore(true)
117            .git_global(false)
118            .git_exclude(false)
119            .follow_links(false)
120            .standard_filters(false)
121            .filter_entry(|entry| {
122                // Skip excluded directories entirely (prevents descending into them)
123                if entry.file_type().is_some_and(|ft| ft.is_dir())
124                    && let Some(name) = entry.file_name().to_str()
125                    && EXCLUDED_DIRECTORIES.contains(&name)
126                {
127                    return false;
128                }
129                true
130            });
131
132        for entry in builder.build().flatten() {
133            let path = entry.path();
134
135            // Check if this entry matches any marker
136            if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
137                && self.project_markers.iter().any(|m| m == file_name)
138            {
139                return true;
140            }
141        }
142
143        false
144    }
145}
146
147/// Configuration for a single LSP server.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct LspServerConfig {
151    /// Language identifier (e.g., "rust", "python", "typescript").
152    pub language_id: String,
153
154    /// Command to start the LSP server.
155    pub command: String,
156
157    /// Arguments to pass to the LSP server command.
158    #[serde(default)]
159    pub args: Vec<String>,
160
161    /// Environment variables for the LSP server process.
162    #[serde(default)]
163    pub env: HashMap<String, String>,
164
165    /// File patterns this server handles (glob patterns).
166    #[serde(default)]
167    pub file_patterns: Vec<String>,
168
169    /// LSP initialization options (server-specific).
170    #[serde(default)]
171    pub initialization_options: Option<serde_json::Value>,
172
173    /// Handshake timeout in seconds: bounds the `initialize` request during
174    /// server startup. Does not affect individual tool-call requests sent
175    /// after initialization; see [`Self::request_timeout_seconds`] for that.
176    /// The LSP server's `shutdown` request during teardown uses a separate,
177    /// fixed 5-second timeout that is not configurable by this field.
178    #[serde(default = "default_timeout")]
179    pub timeout_seconds: u64,
180
181    /// Per-request timeout in seconds, applied to each LSP request issued
182    /// while translating an MCP tool call (hover, definition, references, etc.).
183    ///
184    /// This bounds a single request attempt, not a whole tool call: on a
185    /// `-32801` (`ContentModified`) or `-32802` (`ServerCancelled`) response,
186    /// [`crate::lsp::LspClient::request`] retries up to 4 attempts with
187    /// backoff (one shared budget across both codes), so the worst-case
188    /// latency for one tool call is `4 * request_timeout_seconds + 3.5`
189    /// seconds. Completion requests are further capped at 10 seconds
190    /// regardless of this value; see
191    /// [`crate::lsp::LspClient::completion_timeout`].
192    #[serde(default = "default_request_timeout")]
193    pub request_timeout_seconds: u64,
194
195    /// Heuristics for determining if this server should be spawned.
196    /// If not specified, the server will always attempt to spawn.
197    #[serde(default)]
198    pub heuristics: Option<ServerHeuristics>,
199
200    /// Human-readable server identity used as the routing key.
201    ///
202    /// Defaults to `language_id` when omitted (see [`Self::id`]). Must be
203    /// unique across all applicable servers in a workspace, regardless of
204    /// language: this is what lets two servers share one `language_id`
205    /// (e.g. pyright and pylsp both for `python`) without one silently
206    /// overwriting the other in the maps keyed by [`ServerId`].
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub name: Option<String>,
209
210    /// Tools this server handles.
211    ///
212    /// `None` means this server is a catch-all: it serves every tool not
213    /// explicitly claimed by another server for the same language.
214    /// `Some(list)` restricts the server to exactly those tools.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub handles: Option<Vec<ToolKind>>,
217}
218
219const fn default_timeout() -> u64 {
220    30
221}
222
223const fn default_request_timeout() -> u64 {
224    30
225}
226
227/// Maximum allowed value, in seconds, for both [`LspServerConfig::timeout_seconds`]
228/// and [`LspServerConfig::request_timeout_seconds`].
229///
230/// Both fields are passed straight into `Duration::from_secs` — `timeout_seconds`
231/// in the `initialize` handshake (`lsp::lifecycle::LspServer::initialize`),
232/// `request_timeout_seconds` in [`crate::lsp::LspClient::request_timeout`].
233/// tokio's `timeout`/`sleep` fall back to `Instant::far_future()` for
234/// astronomically large durations instead of panicking, so an unbounded value
235/// on either field (misconfiguration or typo) would silently disable the
236/// timeout rather than fail with a diagnosable error. One shared constant
237/// bounds both, since the underlying defect and fix are identical for each.
238///
239/// Set to 900 (15 minutes), not a rounder 3600 (1 hour): [`LspClient::request`]
240/// retries a request up to 4 times total on a `-32802` (`ServerCancelled`) or
241/// `-32801` (`ContentModified`) response (one shared budget across both
242/// codes), so the worst-case latency for a single call bounded by this value
243/// is `4 * 900 + 3.5s` ≈ 1 hour, not 4 hours — this constant bounds one
244/// attempt, so it is chosen such that the actually-experienced worst case
245/// (the retried total) stays within about an hour.
246///
247/// [`LspClient::request`]: crate::lsp::LspClient::request
248pub const MAX_TIMEOUT_SECONDS: u64 = 900;
249
250impl LspServerConfig {
251    /// Check if this server should be spawned for the given workspace.
252    ///
253    /// Uses recursive marker search to detect nested projects.
254    ///
255    /// # Arguments
256    ///
257    /// * `workspace_root` - Root directory of the workspace
258    /// * `max_depth` - Maximum depth for recursive search (default: 10)
259    #[must_use]
260    pub fn should_spawn(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
261        self.heuristics
262            .as_ref()
263            .is_none_or(|h| h.is_applicable_recursive(workspace_root, max_depth))
264    }
265
266    /// The routing identity of this server: `name` if set, otherwise `language_id`.
267    ///
268    /// This is the key used across `Translator`'s client/server maps, so two
269    /// servers for the same language must set distinct `name`s or they
270    /// collide (see `ToolRouter::from_configs` for the enforcement).
271    #[must_use]
272    pub fn id(&self) -> ServerId {
273        self.name
274            .clone()
275            .map_or_else(|| ServerId::from(self.language_id.clone()), ServerId::from)
276    }
277
278    /// Build a built-in server config, filling in every field not passed as
279    /// a parameter.
280    fn builtin(
281        language_id: &str,
282        command: &str,
283        args: &[&str],
284        file_patterns: &[&str],
285        markers: impl IntoIterator<Item = &'static str>,
286    ) -> Self {
287        Self {
288            language_id: language_id.to_string(),
289            command: command.to_string(),
290            args: args.iter().map(ToString::to_string).collect(),
291            env: HashMap::new(),
292            file_patterns: file_patterns.iter().map(ToString::to_string).collect(),
293            initialization_options: None,
294            timeout_seconds: default_timeout(),
295            request_timeout_seconds: default_request_timeout(),
296            heuristics: Some(ServerHeuristics::with_markers(markers)),
297            name: None,
298            handles: None,
299        }
300    }
301
302    /// Create a default configuration for rust-analyzer.
303    #[must_use]
304    pub fn rust_analyzer() -> Self {
305        Self::builtin(
306            "rust",
307            "rust-analyzer",
308            &[],
309            &["**/*.rs"],
310            ["Cargo.toml", "rust-toolchain.toml"],
311        )
312    }
313
314    /// Create a default configuration for pyright.
315    #[must_use]
316    pub fn pyright() -> Self {
317        Self::builtin(
318            "python",
319            "pyright-langserver",
320            &["--stdio"],
321            &["**/*.py"],
322            [
323                "pyproject.toml",
324                "setup.py",
325                "requirements.txt",
326                "pyrightconfig.json",
327            ],
328        )
329    }
330
331    /// Create a default configuration for TypeScript language server.
332    #[must_use]
333    pub fn typescript() -> Self {
334        Self::builtin(
335            "typescript",
336            "typescript-language-server",
337            &["--stdio"],
338            &["**/*.ts", "**/*.tsx"],
339            ["package.json", "tsconfig.json", "jsconfig.json"],
340        )
341    }
342
343    /// Create a default configuration for gopls.
344    #[must_use]
345    pub fn gopls() -> Self {
346        Self::builtin(
347            "go",
348            "gopls",
349            &["serve"],
350            &["**/*.go"],
351            ["go.mod", "go.sum"],
352        )
353    }
354
355    /// Create a default configuration for clangd.
356    #[must_use]
357    pub fn clangd() -> Self {
358        Self::builtin(
359            "cpp",
360            "clangd",
361            &[],
362            &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
363            [
364                "CMakeLists.txt",
365                "compile_commands.json",
366                "Makefile",
367                ".clangd",
368            ],
369        )
370    }
371
372    /// Create a default configuration for zls.
373    #[must_use]
374    pub fn zls() -> Self {
375        Self::builtin(
376            "zig",
377            "zls",
378            &[],
379            &["**/*.zig"],
380            ["build.zig", "build.zig.zon"],
381        )
382    }
383}
384
385#[cfg(test)]
386#[allow(clippy::unwrap_used)]
387mod tests {
388    use tempfile::TempDir;
389
390    use super::*;
391
392    #[test]
393    fn test_rust_analyzer_defaults() {
394        let config = LspServerConfig::rust_analyzer();
395
396        assert_eq!(config.language_id, "rust");
397        assert_eq!(config.command, "rust-analyzer");
398        assert!(config.args.is_empty());
399        assert!(config.env.is_empty());
400        assert_eq!(config.file_patterns, vec!["**/*.rs"]);
401        assert!(config.initialization_options.is_none());
402        assert_eq!(config.timeout_seconds, 30);
403    }
404
405    #[test]
406    fn test_pyright_defaults() {
407        let config = LspServerConfig::pyright();
408
409        assert_eq!(config.language_id, "python");
410        assert_eq!(config.command, "pyright-langserver");
411        assert_eq!(config.args, vec!["--stdio"]);
412        assert!(config.env.is_empty());
413        assert_eq!(config.file_patterns, vec!["**/*.py"]);
414        assert!(config.initialization_options.is_none());
415        assert_eq!(config.timeout_seconds, 30);
416    }
417
418    #[test]
419    fn test_typescript_defaults() {
420        let config = LspServerConfig::typescript();
421
422        assert_eq!(config.language_id, "typescript");
423        assert_eq!(config.command, "typescript-language-server");
424        assert_eq!(config.args, vec!["--stdio"]);
425        assert!(config.env.is_empty());
426        assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
427        assert!(config.initialization_options.is_none());
428        assert_eq!(config.timeout_seconds, 30);
429    }
430
431    #[test]
432    fn test_default_timeout() {
433        assert_eq!(default_timeout(), 30);
434    }
435
436    #[test]
437    fn test_custom_config() {
438        let mut env = HashMap::new();
439        env.insert("RUST_LOG".to_string(), "debug".to_string());
440
441        let config = LspServerConfig {
442            language_id: "custom".to_string(),
443            command: "custom-lsp".to_string(),
444            args: vec!["--flag".to_string()],
445            env: env.clone(),
446            file_patterns: vec!["**/*.custom".to_string()],
447            initialization_options: Some(serde_json::json!({"key": "value"})),
448            timeout_seconds: 60,
449            request_timeout_seconds: 45,
450            heuristics: None,
451            name: None,
452            handles: None,
453        };
454
455        assert_eq!(config.language_id, "custom");
456        assert_eq!(config.command, "custom-lsp");
457        assert_eq!(config.args, vec!["--flag"]);
458        assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
459        assert_eq!(config.file_patterns, vec!["**/*.custom"]);
460        assert!(config.initialization_options.is_some());
461        assert_eq!(config.timeout_seconds, 60);
462    }
463
464    #[test]
465    fn test_serde_roundtrip() {
466        let original = LspServerConfig::rust_analyzer();
467
468        let serialized = serde_json::to_string(&original).unwrap();
469        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
470
471        assert_eq!(deserialized.language_id, original.language_id);
472        assert_eq!(deserialized.command, original.command);
473        assert_eq!(deserialized.args, original.args);
474        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
475        assert_eq!(
476            deserialized.request_timeout_seconds,
477            original.request_timeout_seconds
478        );
479    }
480
481    #[test]
482    fn test_default_request_timeout() {
483        assert_eq!(default_request_timeout(), 30);
484    }
485
486    #[test]
487    fn test_clone() {
488        let config = LspServerConfig::rust_analyzer();
489        let cloned = config.clone();
490
491        assert_eq!(cloned.language_id, config.language_id);
492        assert_eq!(cloned.command, config.command);
493        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
494    }
495
496    #[test]
497    fn test_empty_env() {
498        let config = LspServerConfig::rust_analyzer();
499        assert!(config.env.is_empty());
500    }
501
502    #[test]
503    fn test_multiple_file_patterns() {
504        let config = LspServerConfig::typescript();
505        assert_eq!(config.file_patterns.len(), 2);
506        assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
507        assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
508    }
509
510    #[test]
511    fn test_initialization_options_none_by_default() {
512        let configs = vec![
513            LspServerConfig::rust_analyzer(),
514            LspServerConfig::pyright(),
515            LspServerConfig::typescript(),
516        ];
517
518        for config in configs {
519            assert!(config.initialization_options.is_none());
520        }
521    }
522
523    // Heuristics tests
524    #[test]
525    fn test_heuristics_empty_always_applicable() {
526        let heuristics = ServerHeuristics::default();
527        let tmp = TempDir::new().unwrap();
528        assert!(heuristics.is_applicable(tmp.path()));
529    }
530
531    #[test]
532    fn test_heuristics_marker_present() {
533        let tmp = TempDir::new().unwrap();
534        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
535
536        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
537        assert!(heuristics.is_applicable(tmp.path()));
538    }
539
540    #[test]
541    fn test_heuristics_marker_absent() {
542        let tmp = TempDir::new().unwrap();
543        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
544        assert!(!heuristics.is_applicable(tmp.path()));
545    }
546
547    #[test]
548    fn test_heuristics_any_marker_matches() {
549        let tmp = TempDir::new().unwrap();
550        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
551
552        let heuristics =
553            ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
554        assert!(heuristics.is_applicable(tmp.path()));
555    }
556
557    #[test]
558    fn test_should_spawn_without_heuristics() {
559        let config = LspServerConfig {
560            language_id: "test".to_string(),
561            command: "test-lsp".to_string(),
562            args: vec![],
563            env: HashMap::new(),
564            file_patterns: vec![],
565            initialization_options: None,
566            timeout_seconds: 30,
567            request_timeout_seconds: 30,
568            heuristics: None,
569            name: None,
570            handles: None,
571        };
572
573        let tmp = TempDir::new().unwrap();
574        assert!(config.should_spawn(tmp.path(), None));
575    }
576
577    #[test]
578    fn test_should_spawn_with_heuristics() {
579        let tmp = TempDir::new().unwrap();
580        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
581
582        let config = LspServerConfig::rust_analyzer();
583        assert!(config.should_spawn(tmp.path(), None));
584    }
585
586    #[test]
587    fn test_should_not_spawn_without_markers() {
588        let tmp = TempDir::new().unwrap();
589        let config = LspServerConfig::rust_analyzer();
590        assert!(!config.should_spawn(tmp.path(), None));
591    }
592
593    #[test]
594    fn test_heuristics_serde_roundtrip() {
595        let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
596        let json = serde_json::to_string(&heuristics).unwrap();
597        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
598        assert_eq!(deserialized.project_markers, heuristics.project_markers);
599    }
600
601    #[test]
602    fn test_default_rust_analyzer_heuristics() {
603        let config = LspServerConfig::rust_analyzer();
604        assert!(config.heuristics.is_some());
605        let markers = &config.heuristics.unwrap().project_markers;
606        assert!(markers.contains(&"Cargo.toml".to_string()));
607    }
608
609    #[test]
610    fn test_gopls_defaults() {
611        let config = LspServerConfig::gopls();
612
613        assert_eq!(config.language_id, "go");
614        assert_eq!(config.command, "gopls");
615        assert_eq!(config.args, vec!["serve"]);
616        assert!(config.heuristics.is_some());
617        let markers = &config.heuristics.unwrap().project_markers;
618        assert!(markers.contains(&"go.mod".to_string()));
619        assert!(markers.contains(&"go.sum".to_string()));
620    }
621
622    #[test]
623    fn test_clangd_defaults() {
624        let config = LspServerConfig::clangd();
625
626        assert_eq!(config.language_id, "cpp");
627        assert_eq!(config.command, "clangd");
628        assert!(config.args.is_empty());
629        assert!(config.heuristics.is_some());
630        let markers = &config.heuristics.unwrap().project_markers;
631        assert!(markers.contains(&"CMakeLists.txt".to_string()));
632        assert!(markers.contains(&"compile_commands.json".to_string()));
633    }
634
635    #[test]
636    fn test_zls_defaults() {
637        let config = LspServerConfig::zls();
638
639        assert_eq!(config.language_id, "zig");
640        assert_eq!(config.command, "zls");
641        assert!(config.args.is_empty());
642        assert!(config.heuristics.is_some());
643        let markers = &config.heuristics.unwrap().project_markers;
644        assert!(markers.contains(&"build.zig".to_string()));
645        assert!(markers.contains(&"build.zig.zon".to_string()));
646    }
647
648    // Recursive scanning tests
649    #[test]
650    fn test_recursive_empty_markers_always_applicable() {
651        let heuristics = ServerHeuristics::default();
652        let tmp = TempDir::new().unwrap();
653        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
654    }
655
656    #[test]
657    fn test_recursive_marker_at_root() {
658        let tmp = TempDir::new().unwrap();
659        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
660
661        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
662        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
663    }
664
665    #[test]
666    fn test_recursive_nested_python_project() {
667        let tmp = TempDir::new().unwrap();
668        // Create Rust project at root
669        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
670        // Create nested Python project
671        let python_dir = tmp.path().join("python");
672        std::fs::create_dir(&python_dir).unwrap();
673        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
674
675        let heuristics = ServerHeuristics::with_markers(["pyproject.toml", "setup.py"]);
676        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
677    }
678
679    #[test]
680    fn test_recursive_deeply_nested_marker() {
681        let tmp = TempDir::new().unwrap();
682        // Create a deeply nested structure
683        let deep_path = tmp.path().join("level1").join("level2").join("level3");
684        std::fs::create_dir_all(&deep_path).unwrap();
685        std::fs::write(deep_path.join("go.mod"), "").unwrap();
686
687        let heuristics = ServerHeuristics::with_markers(["go.mod"]);
688        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
689    }
690
691    #[test]
692    fn test_recursive_no_marker_found() {
693        let tmp = TempDir::new().unwrap();
694        std::fs::create_dir(tmp.path().join("src")).unwrap();
695        std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
696
697        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
698        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
699    }
700
701    #[test]
702    fn test_recursive_max_depth_respected() {
703        let tmp = TempDir::new().unwrap();
704        // Create marker at depth 5
705        let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
706        std::fs::create_dir_all(&deep_path).unwrap();
707        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
708
709        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
710        // With max_depth=3, should not find marker at depth 5
711        assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
712        // With max_depth=10 (default), should find it
713        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
714    }
715
716    #[test]
717    fn test_recursive_excludes_node_modules() {
718        let tmp = TempDir::new().unwrap();
719        // Create package.json inside node_modules (should be ignored)
720        let node_modules = tmp.path().join("node_modules").join("some-package");
721        std::fs::create_dir_all(&node_modules).unwrap();
722        std::fs::write(node_modules.join("package.json"), "").unwrap();
723
724        let heuristics = ServerHeuristics::with_markers(["package.json"]);
725        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
726    }
727
728    #[test]
729    fn test_recursive_excludes_target_directory() {
730        let tmp = TempDir::new().unwrap();
731        // Create Cargo.toml inside target (should be ignored)
732        let target = tmp.path().join("target").join("debug");
733        std::fs::create_dir_all(&target).unwrap();
734        std::fs::write(target.join("Cargo.toml"), "").unwrap();
735
736        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
737        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
738    }
739
740    #[test]
741    fn test_recursive_excludes_git_directory() {
742        let tmp = TempDir::new().unwrap();
743        let git_dir = tmp.path().join(".git").join("hooks");
744        std::fs::create_dir_all(&git_dir).unwrap();
745        std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
746
747        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
748        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
749    }
750
751    #[test]
752    fn test_recursive_excludes_pycache() {
753        let tmp = TempDir::new().unwrap();
754        let pycache = tmp.path().join("__pycache__");
755        std::fs::create_dir_all(&pycache).unwrap();
756        std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
757
758        let heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
759        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
760    }
761
762    #[test]
763    fn test_recursive_excludes_venv() {
764        let tmp = TempDir::new().unwrap();
765        let venv = tmp.path().join(".venv").join("lib");
766        std::fs::create_dir_all(&venv).unwrap();
767        std::fs::write(venv.join("setup.py"), "").unwrap();
768
769        let heuristics = ServerHeuristics::with_markers(["setup.py"]);
770        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
771    }
772
773    #[test]
774    fn test_recursive_finds_marker_outside_excluded() {
775        let tmp = TempDir::new().unwrap();
776        // Create excluded dir with marker
777        let node_modules = tmp.path().join("node_modules");
778        std::fs::create_dir_all(&node_modules).unwrap();
779        std::fs::write(node_modules.join("package.json"), "").unwrap();
780        // Create valid marker in src
781        let src = tmp.path().join("src");
782        std::fs::create_dir_all(&src).unwrap();
783        std::fs::write(src.join("package.json"), "").unwrap();
784
785        let heuristics = ServerHeuristics::with_markers(["package.json"]);
786        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
787    }
788
789    #[test]
790    fn test_recursive_monorepo_structure() {
791        let tmp = TempDir::new().unwrap();
792        // Create monorepo with multiple language projects
793        let rust_pkg = tmp.path().join("packages").join("rust-lib");
794        let python_pkg = tmp.path().join("packages").join("python-bindings");
795        let ts_pkg = tmp.path().join("packages").join("typescript-client");
796
797        std::fs::create_dir_all(&rust_pkg).unwrap();
798        std::fs::create_dir_all(&python_pkg).unwrap();
799        std::fs::create_dir_all(&ts_pkg).unwrap();
800
801        std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
802        std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
803        std::fs::write(ts_pkg.join("package.json"), "").unwrap();
804
805        // All should be detected
806        let rust_heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
807        let python_heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
808        let ts_heuristics = ServerHeuristics::with_markers(["package.json"]);
809
810        assert!(rust_heuristics.is_applicable_recursive(tmp.path(), None));
811        assert!(python_heuristics.is_applicable_recursive(tmp.path(), None));
812        assert!(ts_heuristics.is_applicable_recursive(tmp.path(), None));
813    }
814
815    #[test]
816    fn test_should_spawn_recursive() {
817        let tmp = TempDir::new().unwrap();
818        // Create nested Python project in Rust workspace
819        let python_dir = tmp.path().join("bindings").join("python");
820        std::fs::create_dir_all(&python_dir).unwrap();
821        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
822
823        let config = LspServerConfig::pyright();
824        assert!(config.should_spawn(tmp.path(), None));
825    }
826
827    #[test]
828    fn test_should_spawn_with_custom_max_depth() {
829        let tmp = TempDir::new().unwrap();
830        let deep_path = tmp.path().join("a").join("b").join("c").join("d");
831        std::fs::create_dir_all(&deep_path).unwrap();
832        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
833
834        let config = LspServerConfig::rust_analyzer();
835        // Shallow depth should not find it
836        assert!(!config.should_spawn(tmp.path(), Some(2)));
837        // Default depth should find it
838        assert!(config.should_spawn(tmp.path(), None));
839    }
840
841    #[test]
842    fn test_default_heuristics_max_depth() {
843        assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
844    }
845
846    #[test]
847    fn test_excluded_directories_constant() {
848        assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
849        assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
850        assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
851        assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
852        assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
853    }
854}