1use std::collections::HashMap;
4use std::path::Path;
5
6use ignore::WalkBuilder;
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_HEURISTICS_MAX_DEPTH: usize = 10;
11
12const EXCLUDED_DIRECTORIES: &[&str] = &[
15 "node_modules",
16 "target",
17 ".git",
18 "__pycache__",
19 ".venv",
20 "venv",
21 ".tox",
22 ".mypy_cache",
23 ".pytest_cache",
24 "build",
25 "dist",
26 ".cargo",
27 ".rustup",
28 "vendor",
29 "coverage",
30 ".next",
31 ".nuxt",
32];
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct ServerHeuristics {
41 #[serde(default)]
49 pub project_markers: Vec<String>,
50}
51
52impl ServerHeuristics {
53 #[must_use]
55 pub fn with_markers<I, S>(markers: I) -> Self
56 where
57 I: IntoIterator<Item = S>,
58 S: Into<String>,
59 {
60 Self {
61 project_markers: markers.into_iter().map(Into::into).collect(),
62 }
63 }
64
65 #[must_use]
71 pub fn is_applicable(&self, workspace_root: &Path) -> bool {
72 if self.project_markers.is_empty() {
73 return true;
74 }
75 self.project_markers
76 .iter()
77 .any(|marker| workspace_root.join(marker).exists())
78 }
79
80 #[must_use]
94 pub fn is_applicable_recursive(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
95 if self.project_markers.is_empty() {
96 return true;
97 }
98
99 if self.is_applicable(workspace_root) {
101 return true;
102 }
103
104 let depth = max_depth.unwrap_or(DEFAULT_HEURISTICS_MAX_DEPTH);
105 self.find_any_marker_recursive(workspace_root, depth)
106 }
107
108 fn find_any_marker_recursive(&self, workspace_root: &Path, max_depth: usize) -> bool {
110 let mut builder = WalkBuilder::new(workspace_root);
111 builder
112 .max_depth(Some(max_depth))
113 .hidden(false)
114 .git_ignore(true)
115 .git_global(false)
116 .git_exclude(false)
117 .follow_links(false)
118 .standard_filters(false)
119 .filter_entry(|entry| {
120 if entry.file_type().is_some_and(|ft| ft.is_dir())
122 && let Some(name) = entry.file_name().to_str()
123 && EXCLUDED_DIRECTORIES.contains(&name)
124 {
125 return false;
126 }
127 true
128 });
129
130 for entry in builder.build().flatten() {
131 let path = entry.path();
132
133 if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
135 && self.project_markers.iter().any(|m| m == file_name)
136 {
137 return true;
138 }
139 }
140
141 false
142 }
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct LspServerConfig {
149 pub language_id: String,
151
152 pub command: String,
154
155 #[serde(default)]
157 pub args: Vec<String>,
158
159 #[serde(default)]
161 pub env: HashMap<String, String>,
162
163 #[serde(default)]
165 pub file_patterns: Vec<String>,
166
167 #[serde(default)]
169 pub initialization_options: Option<serde_json::Value>,
170
171 #[serde(default = "default_timeout")]
173 pub timeout_seconds: u64,
174
175 #[serde(default)]
178 pub heuristics: Option<ServerHeuristics>,
179}
180
181const fn default_timeout() -> u64 {
182 30
183}
184
185impl LspServerConfig {
186 #[must_use]
195 pub fn should_spawn(&self, workspace_root: &Path, max_depth: Option<usize>) -> bool {
196 self.heuristics
197 .as_ref()
198 .is_none_or(|h| h.is_applicable_recursive(workspace_root, max_depth))
199 }
200
201 #[must_use]
203 pub fn rust_analyzer() -> Self {
204 Self {
205 language_id: "rust".to_string(),
206 command: "rust-analyzer".to_string(),
207 args: vec![],
208 env: HashMap::new(),
209 file_patterns: vec!["**/*.rs".to_string()],
210 initialization_options: None,
211 timeout_seconds: default_timeout(),
212 heuristics: Some(ServerHeuristics::with_markers([
213 "Cargo.toml",
214 "rust-toolchain.toml",
215 ])),
216 }
217 }
218
219 #[must_use]
221 pub fn pyright() -> Self {
222 Self {
223 language_id: "python".to_string(),
224 command: "pyright-langserver".to_string(),
225 args: vec!["--stdio".to_string()],
226 env: HashMap::new(),
227 file_patterns: vec!["**/*.py".to_string()],
228 initialization_options: None,
229 timeout_seconds: default_timeout(),
230 heuristics: Some(ServerHeuristics::with_markers([
231 "pyproject.toml",
232 "setup.py",
233 "requirements.txt",
234 "pyrightconfig.json",
235 ])),
236 }
237 }
238
239 #[must_use]
241 pub fn typescript() -> Self {
242 Self {
243 language_id: "typescript".to_string(),
244 command: "typescript-language-server".to_string(),
245 args: vec!["--stdio".to_string()],
246 env: HashMap::new(),
247 file_patterns: vec!["**/*.ts".to_string(), "**/*.tsx".to_string()],
248 initialization_options: None,
249 timeout_seconds: default_timeout(),
250 heuristics: Some(ServerHeuristics::with_markers([
251 "package.json",
252 "tsconfig.json",
253 "jsconfig.json",
254 ])),
255 }
256 }
257
258 #[must_use]
260 pub fn gopls() -> Self {
261 Self {
262 language_id: "go".to_string(),
263 command: "gopls".to_string(),
264 args: vec!["serve".to_string()],
265 env: HashMap::new(),
266 file_patterns: vec!["**/*.go".to_string()],
267 initialization_options: None,
268 timeout_seconds: default_timeout(),
269 heuristics: Some(ServerHeuristics::with_markers(["go.mod", "go.sum"])),
270 }
271 }
272
273 #[must_use]
275 pub fn clangd() -> Self {
276 Self {
277 language_id: "cpp".to_string(),
278 command: "clangd".to_string(),
279 args: vec![],
280 env: HashMap::new(),
281 file_patterns: vec![
282 "**/*.c".to_string(),
283 "**/*.cpp".to_string(),
284 "**/*.h".to_string(),
285 "**/*.hpp".to_string(),
286 ],
287 initialization_options: None,
288 timeout_seconds: default_timeout(),
289 heuristics: Some(ServerHeuristics::with_markers([
290 "CMakeLists.txt",
291 "compile_commands.json",
292 "Makefile",
293 ".clangd",
294 ])),
295 }
296 }
297
298 #[must_use]
300 pub fn zls() -> Self {
301 Self {
302 language_id: "zig".to_string(),
303 command: "zls".to_string(),
304 args: vec![],
305 env: HashMap::new(),
306 file_patterns: vec!["**/*.zig".to_string()],
307 initialization_options: None,
308 timeout_seconds: default_timeout(),
309 heuristics: Some(ServerHeuristics::with_markers([
310 "build.zig",
311 "build.zig.zon",
312 ])),
313 }
314 }
315}
316
317#[cfg(test)]
318#[allow(clippy::unwrap_used)]
319mod tests {
320 use tempfile::TempDir;
321
322 use super::*;
323
324 #[test]
325 fn test_rust_analyzer_defaults() {
326 let config = LspServerConfig::rust_analyzer();
327
328 assert_eq!(config.language_id, "rust");
329 assert_eq!(config.command, "rust-analyzer");
330 assert!(config.args.is_empty());
331 assert!(config.env.is_empty());
332 assert_eq!(config.file_patterns, vec!["**/*.rs"]);
333 assert!(config.initialization_options.is_none());
334 assert_eq!(config.timeout_seconds, 30);
335 }
336
337 #[test]
338 fn test_pyright_defaults() {
339 let config = LspServerConfig::pyright();
340
341 assert_eq!(config.language_id, "python");
342 assert_eq!(config.command, "pyright-langserver");
343 assert_eq!(config.args, vec!["--stdio"]);
344 assert!(config.env.is_empty());
345 assert_eq!(config.file_patterns, vec!["**/*.py"]);
346 assert!(config.initialization_options.is_none());
347 assert_eq!(config.timeout_seconds, 30);
348 }
349
350 #[test]
351 fn test_typescript_defaults() {
352 let config = LspServerConfig::typescript();
353
354 assert_eq!(config.language_id, "typescript");
355 assert_eq!(config.command, "typescript-language-server");
356 assert_eq!(config.args, vec!["--stdio"]);
357 assert!(config.env.is_empty());
358 assert_eq!(config.file_patterns, vec!["**/*.ts", "**/*.tsx"]);
359 assert!(config.initialization_options.is_none());
360 assert_eq!(config.timeout_seconds, 30);
361 }
362
363 #[test]
364 fn test_default_timeout() {
365 assert_eq!(default_timeout(), 30);
366 }
367
368 #[test]
369 fn test_custom_config() {
370 let mut env = HashMap::new();
371 env.insert("RUST_LOG".to_string(), "debug".to_string());
372
373 let config = LspServerConfig {
374 language_id: "custom".to_string(),
375 command: "custom-lsp".to_string(),
376 args: vec!["--flag".to_string()],
377 env: env.clone(),
378 file_patterns: vec!["**/*.custom".to_string()],
379 initialization_options: Some(serde_json::json!({"key": "value"})),
380 timeout_seconds: 60,
381 heuristics: None,
382 };
383
384 assert_eq!(config.language_id, "custom");
385 assert_eq!(config.command, "custom-lsp");
386 assert_eq!(config.args, vec!["--flag"]);
387 assert_eq!(config.env.get("RUST_LOG"), Some(&"debug".to_string()));
388 assert_eq!(config.file_patterns, vec!["**/*.custom"]);
389 assert!(config.initialization_options.is_some());
390 assert_eq!(config.timeout_seconds, 60);
391 }
392
393 #[test]
394 fn test_serde_roundtrip() {
395 let original = LspServerConfig::rust_analyzer();
396
397 let serialized = serde_json::to_string(&original).unwrap();
398 let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
399
400 assert_eq!(deserialized.language_id, original.language_id);
401 assert_eq!(deserialized.command, original.command);
402 assert_eq!(deserialized.args, original.args);
403 assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
404 }
405
406 #[test]
407 fn test_clone() {
408 let config = LspServerConfig::rust_analyzer();
409 let cloned = config.clone();
410
411 assert_eq!(cloned.language_id, config.language_id);
412 assert_eq!(cloned.command, config.command);
413 assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
414 }
415
416 #[test]
417 fn test_empty_env() {
418 let config = LspServerConfig::rust_analyzer();
419 assert!(config.env.is_empty());
420 }
421
422 #[test]
423 fn test_multiple_file_patterns() {
424 let config = LspServerConfig::typescript();
425 assert_eq!(config.file_patterns.len(), 2);
426 assert!(config.file_patterns.contains(&"**/*.ts".to_string()));
427 assert!(config.file_patterns.contains(&"**/*.tsx".to_string()));
428 }
429
430 #[test]
431 fn test_initialization_options_none_by_default() {
432 let configs = vec![
433 LspServerConfig::rust_analyzer(),
434 LspServerConfig::pyright(),
435 LspServerConfig::typescript(),
436 ];
437
438 for config in configs {
439 assert!(config.initialization_options.is_none());
440 }
441 }
442
443 #[test]
445 fn test_heuristics_empty_always_applicable() {
446 let heuristics = ServerHeuristics::default();
447 let tmp = TempDir::new().unwrap();
448 assert!(heuristics.is_applicable(tmp.path()));
449 }
450
451 #[test]
452 fn test_heuristics_marker_present() {
453 let tmp = TempDir::new().unwrap();
454 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
455
456 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
457 assert!(heuristics.is_applicable(tmp.path()));
458 }
459
460 #[test]
461 fn test_heuristics_marker_absent() {
462 let tmp = TempDir::new().unwrap();
463 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
464 assert!(!heuristics.is_applicable(tmp.path()));
465 }
466
467 #[test]
468 fn test_heuristics_any_marker_matches() {
469 let tmp = TempDir::new().unwrap();
470 std::fs::write(tmp.path().join("setup.py"), "").unwrap();
471
472 let heuristics =
473 ServerHeuristics::with_markers(["pyproject.toml", "setup.py", "requirements.txt"]);
474 assert!(heuristics.is_applicable(tmp.path()));
475 }
476
477 #[test]
478 fn test_should_spawn_without_heuristics() {
479 let config = LspServerConfig {
480 language_id: "test".to_string(),
481 command: "test-lsp".to_string(),
482 args: vec![],
483 env: HashMap::new(),
484 file_patterns: vec![],
485 initialization_options: None,
486 timeout_seconds: 30,
487 heuristics: None,
488 };
489
490 let tmp = TempDir::new().unwrap();
491 assert!(config.should_spawn(tmp.path(), None));
492 }
493
494 #[test]
495 fn test_should_spawn_with_heuristics() {
496 let tmp = TempDir::new().unwrap();
497 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
498
499 let config = LspServerConfig::rust_analyzer();
500 assert!(config.should_spawn(tmp.path(), None));
501 }
502
503 #[test]
504 fn test_should_not_spawn_without_markers() {
505 let tmp = TempDir::new().unwrap();
506 let config = LspServerConfig::rust_analyzer();
507 assert!(!config.should_spawn(tmp.path(), None));
508 }
509
510 #[test]
511 fn test_heuristics_serde_roundtrip() {
512 let heuristics = ServerHeuristics::with_markers(["Cargo.toml", "rust-toolchain.toml"]);
513 let json = serde_json::to_string(&heuristics).unwrap();
514 let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
515 assert_eq!(deserialized.project_markers, heuristics.project_markers);
516 }
517
518 #[test]
519 fn test_default_rust_analyzer_heuristics() {
520 let config = LspServerConfig::rust_analyzer();
521 assert!(config.heuristics.is_some());
522 let markers = &config.heuristics.unwrap().project_markers;
523 assert!(markers.contains(&"Cargo.toml".to_string()));
524 }
525
526 #[test]
527 fn test_gopls_defaults() {
528 let config = LspServerConfig::gopls();
529
530 assert_eq!(config.language_id, "go");
531 assert_eq!(config.command, "gopls");
532 assert_eq!(config.args, vec!["serve"]);
533 assert!(config.heuristics.is_some());
534 let markers = &config.heuristics.unwrap().project_markers;
535 assert!(markers.contains(&"go.mod".to_string()));
536 assert!(markers.contains(&"go.sum".to_string()));
537 }
538
539 #[test]
540 fn test_clangd_defaults() {
541 let config = LspServerConfig::clangd();
542
543 assert_eq!(config.language_id, "cpp");
544 assert_eq!(config.command, "clangd");
545 assert!(config.args.is_empty());
546 assert!(config.heuristics.is_some());
547 let markers = &config.heuristics.unwrap().project_markers;
548 assert!(markers.contains(&"CMakeLists.txt".to_string()));
549 assert!(markers.contains(&"compile_commands.json".to_string()));
550 }
551
552 #[test]
553 fn test_zls_defaults() {
554 let config = LspServerConfig::zls();
555
556 assert_eq!(config.language_id, "zig");
557 assert_eq!(config.command, "zls");
558 assert!(config.args.is_empty());
559 assert!(config.heuristics.is_some());
560 let markers = &config.heuristics.unwrap().project_markers;
561 assert!(markers.contains(&"build.zig".to_string()));
562 assert!(markers.contains(&"build.zig.zon".to_string()));
563 }
564
565 #[test]
567 fn test_recursive_empty_markers_always_applicable() {
568 let heuristics = ServerHeuristics::default();
569 let tmp = TempDir::new().unwrap();
570 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
571 }
572
573 #[test]
574 fn test_recursive_marker_at_root() {
575 let tmp = TempDir::new().unwrap();
576 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
577
578 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
579 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
580 }
581
582 #[test]
583 fn test_recursive_nested_python_project() {
584 let tmp = TempDir::new().unwrap();
585 std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
587 let python_dir = tmp.path().join("python");
589 std::fs::create_dir(&python_dir).unwrap();
590 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
591
592 let heuristics = ServerHeuristics::with_markers(["pyproject.toml", "setup.py"]);
593 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
594 }
595
596 #[test]
597 fn test_recursive_deeply_nested_marker() {
598 let tmp = TempDir::new().unwrap();
599 let deep_path = tmp.path().join("level1").join("level2").join("level3");
601 std::fs::create_dir_all(&deep_path).unwrap();
602 std::fs::write(deep_path.join("go.mod"), "").unwrap();
603
604 let heuristics = ServerHeuristics::with_markers(["go.mod"]);
605 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
606 }
607
608 #[test]
609 fn test_recursive_no_marker_found() {
610 let tmp = TempDir::new().unwrap();
611 std::fs::create_dir(tmp.path().join("src")).unwrap();
612 std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
613
614 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
615 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
616 }
617
618 #[test]
619 fn test_recursive_max_depth_respected() {
620 let tmp = TempDir::new().unwrap();
621 let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
623 std::fs::create_dir_all(&deep_path).unwrap();
624 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
625
626 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
627 assert!(!heuristics.is_applicable_recursive(tmp.path(), Some(3)));
629 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
631 }
632
633 #[test]
634 fn test_recursive_excludes_node_modules() {
635 let tmp = TempDir::new().unwrap();
636 let node_modules = tmp.path().join("node_modules").join("some-package");
638 std::fs::create_dir_all(&node_modules).unwrap();
639 std::fs::write(node_modules.join("package.json"), "").unwrap();
640
641 let heuristics = ServerHeuristics::with_markers(["package.json"]);
642 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
643 }
644
645 #[test]
646 fn test_recursive_excludes_target_directory() {
647 let tmp = TempDir::new().unwrap();
648 let target = tmp.path().join("target").join("debug");
650 std::fs::create_dir_all(&target).unwrap();
651 std::fs::write(target.join("Cargo.toml"), "").unwrap();
652
653 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
654 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
655 }
656
657 #[test]
658 fn test_recursive_excludes_git_directory() {
659 let tmp = TempDir::new().unwrap();
660 let git_dir = tmp.path().join(".git").join("hooks");
661 std::fs::create_dir_all(&git_dir).unwrap();
662 std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
663
664 let heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
665 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
666 }
667
668 #[test]
669 fn test_recursive_excludes_pycache() {
670 let tmp = TempDir::new().unwrap();
671 let pycache = tmp.path().join("__pycache__");
672 std::fs::create_dir_all(&pycache).unwrap();
673 std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
674
675 let heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
676 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
677 }
678
679 #[test]
680 fn test_recursive_excludes_venv() {
681 let tmp = TempDir::new().unwrap();
682 let venv = tmp.path().join(".venv").join("lib");
683 std::fs::create_dir_all(&venv).unwrap();
684 std::fs::write(venv.join("setup.py"), "").unwrap();
685
686 let heuristics = ServerHeuristics::with_markers(["setup.py"]);
687 assert!(!heuristics.is_applicable_recursive(tmp.path(), None));
688 }
689
690 #[test]
691 fn test_recursive_finds_marker_outside_excluded() {
692 let tmp = TempDir::new().unwrap();
693 let node_modules = tmp.path().join("node_modules");
695 std::fs::create_dir_all(&node_modules).unwrap();
696 std::fs::write(node_modules.join("package.json"), "").unwrap();
697 let src = tmp.path().join("src");
699 std::fs::create_dir_all(&src).unwrap();
700 std::fs::write(src.join("package.json"), "").unwrap();
701
702 let heuristics = ServerHeuristics::with_markers(["package.json"]);
703 assert!(heuristics.is_applicable_recursive(tmp.path(), None));
704 }
705
706 #[test]
707 fn test_recursive_monorepo_structure() {
708 let tmp = TempDir::new().unwrap();
709 let rust_pkg = tmp.path().join("packages").join("rust-lib");
711 let python_pkg = tmp.path().join("packages").join("python-bindings");
712 let ts_pkg = tmp.path().join("packages").join("typescript-client");
713
714 std::fs::create_dir_all(&rust_pkg).unwrap();
715 std::fs::create_dir_all(&python_pkg).unwrap();
716 std::fs::create_dir_all(&ts_pkg).unwrap();
717
718 std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
719 std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
720 std::fs::write(ts_pkg.join("package.json"), "").unwrap();
721
722 let rust_heuristics = ServerHeuristics::with_markers(["Cargo.toml"]);
724 let python_heuristics = ServerHeuristics::with_markers(["pyproject.toml"]);
725 let ts_heuristics = ServerHeuristics::with_markers(["package.json"]);
726
727 assert!(rust_heuristics.is_applicable_recursive(tmp.path(), None));
728 assert!(python_heuristics.is_applicable_recursive(tmp.path(), None));
729 assert!(ts_heuristics.is_applicable_recursive(tmp.path(), None));
730 }
731
732 #[test]
733 fn test_should_spawn_recursive() {
734 let tmp = TempDir::new().unwrap();
735 let python_dir = tmp.path().join("bindings").join("python");
737 std::fs::create_dir_all(&python_dir).unwrap();
738 std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
739
740 let config = LspServerConfig::pyright();
741 assert!(config.should_spawn(tmp.path(), None));
742 }
743
744 #[test]
745 fn test_should_spawn_with_custom_max_depth() {
746 let tmp = TempDir::new().unwrap();
747 let deep_path = tmp.path().join("a").join("b").join("c").join("d");
748 std::fs::create_dir_all(&deep_path).unwrap();
749 std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
750
751 let config = LspServerConfig::rust_analyzer();
752 assert!(!config.should_spawn(tmp.path(), Some(2)));
754 assert!(config.should_spawn(tmp.path(), None));
756 }
757
758 #[test]
759 fn test_default_heuristics_max_depth() {
760 assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
761 }
762
763 #[test]
764 fn test_excluded_directories_constant() {
765 assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
766 assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
767 assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
768 assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
769 assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
770 }
771}