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