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    /// `-32802` (content modified) response, [`crate::lsp::LspClient::request`]
186    /// retries up to 4 attempts with backoff, so the worst-case latency for one
187    /// tool call is `4 * request_timeout_seconds + 3.5` seconds. Completion
188    /// requests are further capped at 10 seconds regardless of this value; see
189    /// [`crate::lsp::LspClient::completion_timeout`].
190    #[serde(default = "default_request_timeout")]
191    pub request_timeout_seconds: u64,
192
193    /// Heuristics for determining if this server should be spawned.
194    /// If not specified, the server will always attempt to spawn.
195    #[serde(default)]
196    pub heuristics: Option<ServerHeuristics>,
197
198    /// Human-readable server identity used as the routing key.
199    ///
200    /// Defaults to `language_id` when omitted (see [`Self::id`]). Must be
201    /// unique across all applicable servers in a workspace, regardless of
202    /// language: this is what lets two servers share one `language_id`
203    /// (e.g. pyright and pylsp both for `python`) without one silently
204    /// overwriting the other in the maps keyed by [`ServerId`].
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub name: Option<String>,
207
208    /// Tools this server handles.
209    ///
210    /// `None` means this server is a catch-all: it serves every tool not
211    /// explicitly claimed by another server for the same language.
212    /// `Some(list)` restricts the server to exactly those tools.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub handles: Option<Vec<ToolKind>>,
215}
216
217const fn default_timeout() -> u64 {
218    30
219}
220
221const fn default_request_timeout() -> u64 {
222    30
223}
224
225/// Maximum allowed value, in seconds, for both [`LspServerConfig::timeout_seconds`]
226/// and [`LspServerConfig::request_timeout_seconds`].
227///
228/// Both fields are passed straight into `Duration::from_secs` — `timeout_seconds`
229/// in the `initialize` handshake (`lsp::lifecycle::LspServer::initialize`),
230/// `request_timeout_seconds` in [`crate::lsp::LspClient::request_timeout`].
231/// tokio's `timeout`/`sleep` fall back to `Instant::far_future()` for
232/// astronomically large durations instead of panicking, so an unbounded value
233/// on either field (misconfiguration or typo) would silently disable the
234/// timeout rather than fail with a diagnosable error. One shared constant
235/// bounds both, since the underlying defect and fix are identical for each.
236///
237/// Set to 900 (15 minutes), not a rounder 3600 (1 hour): [`LspClient::request`]
238/// retries a request up to 4 times total on a `-32802` (`ServerCancelled`)
239/// response, so the worst-case latency for a single call bounded by this
240/// value is `4 * 900 + 3.5s` ≈ 1 hour, not 4 hours — this constant bounds one
241/// attempt, so it is chosen such that the actually-experienced worst case
242/// (the retried total) stays within about an hour.
243///
244/// [`LspClient::request`]: crate::lsp::LspClient::request
245pub const MAX_TIMEOUT_SECONDS: u64 = 900;
246
247impl LspServerConfig {
248    /// Check if this server should be spawned for the given workspace.
249    ///
250    /// Uses recursive marker search to detect nested projects.
251    ///
252    /// # Arguments
253    ///
254    /// * `workspace_root` - Root directory of the workspace
255    /// * `max_depth` - Maximum depth for recursive search (default: 10)
256    #[must_use]
257    pub fn should_spawn(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
258        self.heuristics
259            .as_ref()
260            .is_none_or(|h| h.is_applicable_recursive(workspace_root, max_depth))
261    }
262
263    /// The routing identity of this server: `name` if set, otherwise `language_id`.
264    ///
265    /// This is the key used across `Translator`'s client/server maps, so two
266    /// servers for the same language must set distinct `name`s or they
267    /// collide (see `ToolRouter::from_configs` for the enforcement).
268    #[must_use]
269    pub fn id(&self) -> ServerId {
270        self.name
271            .clone()
272            .map_or_else(|| ServerId::from(self.language_id.clone()), ServerId::from)
273    }
274
275    /// Build a built-in server config, filling in every field not passed as
276    /// a parameter.
277    fn builtin(
278        language_id: &str,
279        command: &str,
280        args: &[&str],
281        file_patterns: &[&str],
282        markers: impl IntoIterator<Item = &'static str>,
283    ) -> Self {
284        Self {
285            language_id: language_id.to_string(),
286            command: command.to_string(),
287            args: args.iter().map(ToString::to_string).collect(),
288            env: HashMap::new(),
289            file_patterns: file_patterns.iter().map(ToString::to_string).collect(),
290            initialization_options: None,
291            timeout_seconds: default_timeout(),
292            request_timeout_seconds: default_request_timeout(),
293            heuristics: Some(ServerHeuristics::with_markers(markers)),
294            name: None,
295            handles: None,
296        }
297    }
298
299    /// Create a default configuration for rust-analyzer.
300    #[must_use]
301    pub fn rust_analyzer() -> Self {
302        Self::builtin(
303            "rust",
304            "rust-analyzer",
305            &[],
306            &["**/*.rs"],
307            ["Cargo.toml", "rust-toolchain.toml"],
308        )
309    }
310
311    /// Create a default configuration for pyright.
312    #[must_use]
313    pub fn pyright() -> Self {
314        Self::builtin(
315            "python",
316            "pyright-langserver",
317            &["--stdio"],
318            &["**/*.py"],
319            [
320                "pyproject.toml",
321                "setup.py",
322                "requirements.txt",
323                "pyrightconfig.json",
324            ],
325        )
326    }
327
328    /// Create a default configuration for TypeScript language server.
329    #[must_use]
330    pub fn typescript() -> Self {
331        Self::builtin(
332            "typescript",
333            "typescript-language-server",
334            &["--stdio"],
335            &["**/*.ts", "**/*.tsx"],
336            ["package.json", "tsconfig.json", "jsconfig.json"],
337        )
338    }
339
340    /// Create a default configuration for gopls.
341    #[must_use]
342    pub fn gopls() -> Self {
343        Self::builtin(
344            "go",
345            "gopls",
346            &["serve"],
347            &["**/*.go"],
348            ["go.mod", "go.sum"],
349        )
350    }
351
352    /// Create a default configuration for clangd.
353    #[must_use]
354    pub fn clangd() -> Self {
355        Self::builtin(
356            "cpp",
357            "clangd",
358            &[],
359            &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
360            [
361                "CMakeLists.txt",
362                "compile_commands.json",
363                "Makefile",
364                ".clangd",
365            ],
366        )
367    }
368
369    /// Create a default configuration for zls.
370    #[must_use]
371    pub fn zls() -> Self {
372        Self::builtin(
373            "zig",
374            "zls",
375            &[],
376            &["**/*.zig"],
377            ["build.zig", "build.zig.zon"],
378        )
379    }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used)]
384mod tests {
385    use tempfile::TempDir;
386
387    use super::*;
388
389    #[test]
390    fn test_rust_analyzer_defaults() {
391        let config = LspServerConfig::rust_analyzer();
392
393        assert_eq!(config.language_id, "rust");
394        assert_eq!(config.command, "rust-analyzer");
395        assert!(config.args.is_empty());
396        assert!(config.env.is_empty());
397        assert_eq!(config.file_patterns, vec!["**/*.rs"]);
398        assert!(config.initialization_options.is_none());
399        assert_eq!(config.timeout_seconds, 30);
400    }
401
402    #[test]
403    fn test_pyright_defaults() {
404        let config = LspServerConfig::pyright();
405
406        assert_eq!(config.language_id, "python");
407        assert_eq!(config.command, "pyright-langserver");
408        assert_eq!(config.args, vec!["--stdio"]);
409        assert!(config.env.is_empty());
410        assert_eq!(config.file_patterns, vec!["**/*.py"]);
411        assert!(config.initialization_options.is_none());
412        assert_eq!(config.timeout_seconds, 30);
413    }
414
415    #[test]
416    fn test_typescript_defaults() {
417        let config = LspServerConfig::typescript();
418
419        assert_eq!(config.language_id, "typescript");
420        assert_eq!(config.command, "typescript-language-server");
421        assert_eq!(config.args, vec!["--stdio"]);
422        assert!(config.env.is_empty());
423        assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
424        assert!(config.initialization_options.is_none());
425        assert_eq!(config.timeout_seconds, 30);
426    }
427
428    #[test]
429    fn test_default_timeout() {
430        assert_eq!(default_timeout(), 30);
431    }
432
433    #[test]
434    fn test_custom_config() {
435        let mut env = HashMap::new();
436        env.insert("RUST_LOG".to_string(), "debug".to_string());
437
438        let config = LspServerConfig {
439            language_id: "custom".to_string(),
440            command: "custom-lsp".to_string(),
441            args: vec!["--flag".to_string()],
442            env: env.clone(),
443            file_patterns: vec!["**/*.custom".to_string()],
444            initialization_options: Some(serde_json::json!({"key": "value"})),
445            timeout_seconds: 60,
446            request_timeout_seconds: 45,
447            heuristics: None,
448            name: None,
449            handles: None,
450        };
451
452        assert_eq!(config.language_id, "custom");
453        assert_eq!(config.command, "custom-lsp");
454        assert_eq!(config.args, vec!["--flag"]);
455        assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
456        assert_eq!(config.file_patterns, vec!["**/*.custom"]);
457        assert!(config.initialization_options.is_some());
458        assert_eq!(config.timeout_seconds, 60);
459    }
460
461    #[test]
462    fn test_serde_roundtrip() {
463        let original = LspServerConfig::rust_analyzer();
464
465        let serialized = serde_json::to_string(&original).unwrap();
466        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
467
468        assert_eq!(deserialized.language_id, original.language_id);
469        assert_eq!(deserialized.command, original.command);
470        assert_eq!(deserialized.args, original.args);
471        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
472        assert_eq!(
473            deserialized.request_timeout_seconds,
474            original.request_timeout_seconds
475        );
476    }
477
478    #[test]
479    fn test_default_request_timeout() {
480        assert_eq!(default_request_timeout(), 30);
481    }
482
483    #[test]
484    fn test_clone() {
485        let config = LspServerConfig::rust_analyzer();
486        let cloned = config.clone();
487
488        assert_eq!(cloned.language_id, config.language_id);
489        assert_eq!(cloned.command, config.command);
490        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
491    }
492
493    #[test]
494    fn test_empty_env() {
495        let config = LspServerConfig::rust_analyzer();
496        assert!(config.env.is_empty());
497    }
498
499    #[test]
500    fn test_multiple_file_patterns() {
501        let config = LspServerConfig::typescript();
502        assert_eq!(config.file_patterns.len(), 2);
503        assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
504        assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
505    }
506
507    #[test]
508    fn test_initialization_options_none_by_default() {
509        let configs = vec![
510            LspServerConfig::rust_analyzer(),
511            LspServerConfig::pyright(),
512            LspServerConfig::typescript(),
513        ];
514
515        for config in configs {
516            assert!(config.initialization_options.is_none());
517        }
518    }
519
520    // Heuristics tests
521    #[test]
522    fn test_heuristics_empty_always_applicable() {
523        let heuristics = ServerHeuristics::default();
524        let tmp = TempDir::new().unwrap();
525        assert!(heuristics.is_applicable(tmp.path()));
526    }
527
528    #[test]
529    fn test_heuristics_marker_present() {
530        let tmp = TempDir::new().unwrap();
531        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
532
533        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
534        assert!(heuristics.is_applicable(tmp.path()));
535    }
536
537    #[test]
538    fn test_heuristics_marker_absent() {
539        let tmp = TempDir::new().unwrap();
540        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
541        assert!(!heuristics.is_applicable(tmp.path()));
542    }
543
544    #[test]
545    fn test_heuristics_any_marker_matches() {
546        let tmp = TempDir::new().unwrap();
547        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
548
549        let heuristics =
550            ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
551        assert!(heuristics.is_applicable(tmp.path()));
552    }
553
554    #[test]
555    fn test_should_spawn_without_heuristics() {
556        let config = LspServerConfig {
557            language_id: "test".to_string(),
558            command: "test-lsp".to_string(),
559            args: vec![],
560            env: HashMap::new(),
561            file_patterns: vec![],
562            initialization_options: None,
563            timeout_seconds: 30,
564            request_timeout_seconds: 30,
565            heuristics: None,
566            name: None,
567            handles: None,
568        };
569
570        let tmp = TempDir::new().unwrap();
571        assert!(config.should_spawn(tmp.path(), None));
572    }
573
574    #[test]
575    fn test_should_spawn_with_heuristics() {
576        let tmp = TempDir::new().unwrap();
577        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
578
579        let config = LspServerConfig::rust_analyzer();
580        assert!(config.should_spawn(tmp.path(), None));
581    }
582
583    #[test]
584    fn test_should_not_spawn_without_markers() {
585        let tmp = TempDir::new().unwrap();
586        let config = LspServerConfig::rust_analyzer();
587        assert!(!config.should_spawn(tmp.path(), None));
588    }
589
590    #[test]
591    fn test_heuristics_serde_roundtrip() {
592        let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
593        let json = serde_json::to_string(&heuristics).unwrap();
594        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
595        assert_eq!(deserialized.project_markers, heuristics.project_markers);
596    }
597
598    #[test]
599    fn test_default_rust_analyzer_heuristics() {
600        let config = LspServerConfig::rust_analyzer();
601        assert!(config.heuristics.is_some());
602        let markers = &config.heuristics.unwrap().project_markers;
603        assert!(markers.contains(&"Cargo.toml".to_string()));
604    }
605
606    #[test]
607    fn test_gopls_defaults() {
608        let config = LspServerConfig::gopls();
609
610        assert_eq!(config.language_id, "go");
611        assert_eq!(config.command, "gopls");
612        assert_eq!(config.args, vec!["serve"]);
613        assert!(config.heuristics.is_some());
614        let markers = &config.heuristics.unwrap().project_markers;
615        assert!(markers.contains(&"go.mod".to_string()));
616        assert!(markers.contains(&"go.sum".to_string()));
617    }
618
619    #[test]
620    fn test_clangd_defaults() {
621        let config = LspServerConfig::clangd();
622
623        assert_eq!(config.language_id, "cpp");
624        assert_eq!(config.command, "clangd");
625        assert!(config.args.is_empty());
626        assert!(config.heuristics.is_some());
627        let markers = &config.heuristics.unwrap().project_markers;
628        assert!(markers.contains(&"CMakeLists.txt".to_string()));
629        assert!(markers.contains(&"compile_commands.json".to_string()));
630    }
631
632    #[test]
633    fn test_zls_defaults() {
634        let config = LspServerConfig::zls();
635
636        assert_eq!(config.language_id, "zig");
637        assert_eq!(config.command, "zls");
638        assert!(config.args.is_empty());
639        assert!(config.heuristics.is_some());
640        let markers = &config.heuristics.unwrap().project_markers;
641        assert!(markers.contains(&"build.zig".to_string()));
642        assert!(markers.contains(&"build.zig.zon".to_string()));
643    }
644
645    // Recursive scanning tests
646    #[test]
647    fn test_recursive_empty_markers_always_applicable() {
648        let heuristics = ServerHeuristics::default();
649        let tmp = TempDir::new().unwrap();
650        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
651    }
652
653    #[test]
654    fn test_recursive_marker_at_root() {
655        let tmp = TempDir::new().unwrap();
656        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
657
658        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
659        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
660    }
661
662    #[test]
663    fn test_recursive_nested_python_project() {
664        let tmp = TempDir::new().unwrap();
665        // Create Rust project at root
666        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
667        // Create nested Python project
668        let python_dir = tmp.path().join("python");
669        std::fs::create_dir(&python_dir).unwrap();
670        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
671
672        let heuristics = ServerHeuristics::with_markers(["pyproject.toml", "setup.py"]);
673        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
674    }
675
676    #[test]
677    fn test_recursive_deeply_nested_marker() {
678        let tmp = TempDir::new().unwrap();
679        // Create a deeply nested structure
680        let deep_path = tmp.path().join("level1").join("level2").join("level3");
681        std::fs::create_dir_all(&deep_path).unwrap();
682        std::fs::write(deep_path.join("go.mod"), "").unwrap();
683
684        let heuristics = ServerHeuristics::with_markers(["go.mod"]);
685        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
686    }
687
688    #[test]
689    fn test_recursive_no_marker_found() {
690        let tmp = TempDir::new().unwrap();
691        std::fs::create_dir(tmp.path().join("src")).unwrap();
692        std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
693
694        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
695        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
696    }
697
698    #[test]
699    fn test_recursive_max_depth_respected() {
700        let tmp = TempDir::new().unwrap();
701        // Create marker at depth 5
702        let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
703        std::fs::create_dir_all(&deep_path).unwrap();
704        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
705
706        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
707        // With max_depth=3, should not find marker at depth 5
708        assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
709        // With max_depth=10 (default), should find it
710        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
711    }
712
713    #[test]
714    fn test_recursive_excludes_node_modules() {
715        let tmp = TempDir::new().unwrap();
716        // Create package.json inside node_modules (should be ignored)
717        let node_modules = tmp.path().join("node_modules").join("some-package");
718        std::fs::create_dir_all(&node_modules).unwrap();
719        std::fs::write(node_modules.join("package.json"), "").unwrap();
720
721        let heuristics = ServerHeuristics::with_markers(["package.json"]);
722        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
723    }
724
725    #[test]
726    fn test_recursive_excludes_target_directory() {
727        let tmp = TempDir::new().unwrap();
728        // Create Cargo.toml inside target (should be ignored)
729        let target = tmp.path().join("target").join("debug");
730        std::fs::create_dir_all(&target).unwrap();
731        std::fs::write(target.join("Cargo.toml"), "").unwrap();
732
733        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
734        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
735    }
736
737    #[test]
738    fn test_recursive_excludes_git_directory() {
739        let tmp = TempDir::new().unwrap();
740        let git_dir = tmp.path().join(".git").join("hooks");
741        std::fs::create_dir_all(&git_dir).unwrap();
742        std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
743
744        let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
745        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
746    }
747
748    #[test]
749    fn test_recursive_excludes_pycache() {
750        let tmp = TempDir::new().unwrap();
751        let pycache = tmp.path().join("__pycache__");
752        std::fs::create_dir_all(&pycache).unwrap();
753        std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
754
755        let heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
756        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
757    }
758
759    #[test]
760    fn test_recursive_excludes_venv() {
761        let tmp = TempDir::new().unwrap();
762        let venv = tmp.path().join(".venv").join("lib");
763        std::fs::create_dir_all(&venv).unwrap();
764        std::fs::write(venv.join("setup.py"), "").unwrap();
765
766        let heuristics = ServerHeuristics::with_markers(["setup.py"]);
767        assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
768    }
769
770    #[test]
771    fn test_recursive_finds_marker_outside_excluded() {
772        let tmp = TempDir::new().unwrap();
773        // Create excluded dir with marker
774        let node_modules = tmp.path().join("node_modules");
775        std::fs::create_dir_all(&node_modules).unwrap();
776        std::fs::write(node_modules.join("package.json"), "").unwrap();
777        // Create valid marker in src
778        let src = tmp.path().join("src");
779        std::fs::create_dir_all(&src).unwrap();
780        std::fs::write(src.join("package.json"), "").unwrap();
781
782        let heuristics = ServerHeuristics::with_markers(["package.json"]);
783        assert!(heuristics.is_applicable_recursive(tmp.path(), None));
784    }
785
786    #[test]
787    fn test_recursive_monorepo_structure() {
788        let tmp = TempDir::new().unwrap();
789        // Create monorepo with multiple language projects
790        let rust_pkg = tmp.path().join("packages").join("rust-lib");
791        let python_pkg = tmp.path().join("packages").join("python-bindings");
792        let ts_pkg = tmp.path().join("packages").join("typescript-client");
793
794        std::fs::create_dir_all(&rust_pkg).unwrap();
795        std::fs::create_dir_all(&python_pkg).unwrap();
796        std::fs::create_dir_all(&ts_pkg).unwrap();
797
798        std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
799        std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
800        std::fs::write(ts_pkg.join("package.json"), "").unwrap();
801
802        // All should be detected
803        let rust_heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
804        let python_heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
805        let ts_heuristics = ServerHeuristics::with_markers(["package.json"]);
806
807        assert!(rust_heuristics.is_applicable_recursive(tmp.path(), None));
808        assert!(python_heuristics.is_applicable_recursive(tmp.path(), None));
809        assert!(ts_heuristics.is_applicable_recursive(tmp.path(), None));
810    }
811
812    #[test]
813    fn test_should_spawn_recursive() {
814        let tmp = TempDir::new().unwrap();
815        // Create nested Python project in Rust workspace
816        let python_dir = tmp.path().join("bindings").join("python");
817        std::fs::create_dir_all(&python_dir).unwrap();
818        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
819
820        let config = LspServerConfig::pyright();
821        assert!(config.should_spawn(tmp.path(), None));
822    }
823
824    #[test]
825    fn test_should_spawn_with_custom_max_depth() {
826        let tmp = TempDir::new().unwrap();
827        let deep_path = tmp.path().join("a").join("b").join("c").join("d");
828        std::fs::create_dir_all(&deep_path).unwrap();
829        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
830
831        let config = LspServerConfig::rust_analyzer();
832        // Shallow depth should not find it
833        assert!(!config.should_spawn(tmp.path(), Some(2)));
834        // Default depth should find it
835        assert!(config.should_spawn(tmp.path(), None));
836    }
837
838    #[test]
839    fn test_default_heuristics_max_depth() {
840        assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
841    }
842
843    #[test]
844    fn test_excluded_directories_constant() {
845        assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
846        assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
847        assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
848        assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
849        assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
850    }
851}