1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5
6use crate::adapters::TestRunResult;
7use crate::detection::DetectionEngine;
8
9const SKIP_DIRS: &[&str] = &[
11 ".git",
12 ".hg",
13 ".svn",
14 "node_modules",
15 "target",
16 "build",
17 "dist",
18 "out",
19 "vendor",
20 "venv",
21 ".venv",
22 "__pycache__",
23 ".tox",
24 ".nox",
25 ".mypy_cache",
26 ".pytest_cache",
27 ".eggs",
28 "coverage",
29 ".coverage",
30 "htmlcov",
31 ".gradle",
32 ".maven",
33 ".idea",
34 ".vscode",
35 "bin",
36 "obj",
37 "packages",
38 "zig-cache",
39 "zig-out",
40 "_build",
41 "deps",
42 ".elixir_ls",
43 ".bundle",
44 ".cache",
45 ".cargo",
46 ".rustup",
47];
48
49#[derive(Debug, Clone)]
51pub struct WorkspaceProject {
52 pub path: PathBuf,
54 pub language: String,
56 pub framework: String,
58 pub confidence: f64,
60 pub adapter_index: usize,
62}
63
64#[derive(Debug, Clone)]
66pub struct WorkspaceRunResult {
67 pub project: WorkspaceProject,
68 pub result: Option<TestRunResult>,
69 pub duration: Duration,
70 pub error: Option<String>,
71 pub skipped: bool,
72}
73
74#[derive(Debug, Clone)]
76pub struct WorkspaceReport {
77 pub results: Vec<WorkspaceRunResult>,
78 pub total_duration: Duration,
79 pub projects_found: usize,
80 pub projects_run: usize,
81 pub projects_passed: usize,
82 pub projects_failed: usize,
83 pub projects_skipped: usize,
84 pub total_tests: usize,
85 pub total_passed: usize,
86 pub total_failed: usize,
87}
88
89#[derive(Debug, Clone)]
91pub struct WorkspaceConfig {
92 pub max_depth: usize,
94 pub parallel: bool,
96 pub max_jobs: usize,
98 pub fail_fast: bool,
100 pub filter_languages: Vec<String>,
102 pub skip_dirs: Vec<String>,
104 pub include_dirs: Vec<String>,
107}
108
109impl Default for WorkspaceConfig {
110 fn default() -> Self {
111 Self {
112 max_depth: 5,
113 parallel: true,
114 max_jobs: 0,
115 fail_fast: false,
116 filter_languages: Vec::new(),
117 skip_dirs: Vec::new(),
118 include_dirs: Vec::new(),
119 }
120 }
121}
122
123impl WorkspaceConfig {
124 pub fn effective_jobs(&self) -> usize {
125 if self.max_jobs == 0 {
126 std::thread::available_parallelism()
127 .map(|n| n.get())
128 .unwrap_or(4)
129 } else {
130 self.max_jobs
131 }
132 }
133}
134
135pub fn discover_projects(
141 root: &Path,
142 engine: &DetectionEngine,
143 config: &WorkspaceConfig,
144) -> Vec<WorkspaceProject> {
145 let mut skip_set: HashSet<&str> = SKIP_DIRS.iter().copied().collect();
146 let custom_skip: HashSet<String> = config.skip_dirs.iter().cloned().collect();
147
148 for dir in &config.include_dirs {
150 skip_set.remove(dir.as_str());
151 }
152
153 let mut projects = Vec::new();
154 let mut visited = HashSet::new();
155
156 scan_dir(
157 root,
158 engine,
159 config,
160 &skip_set,
161 &custom_skip,
162 0,
163 &mut projects,
164 &mut visited,
165 );
166
167 projects.sort_by(|a, b| a.path.cmp(&b.path));
169
170 if !config.filter_languages.is_empty() {
172 projects.retain(|p| {
173 config
174 .filter_languages
175 .iter()
176 .any(|lang| p.language.to_lowercase().contains(&lang.to_lowercase()))
177 });
178 }
179
180 projects
181}
182
183#[allow(clippy::too_many_arguments)]
184fn scan_dir(
185 dir: &Path,
186 engine: &DetectionEngine,
187 config: &WorkspaceConfig,
188 skip_set: &HashSet<&str>,
189 custom_skip: &HashSet<String>,
190 depth: usize,
191 projects: &mut Vec<WorkspaceProject>,
192 visited: &mut HashSet<PathBuf>,
193) {
194 if config.max_depth > 0 && depth > config.max_depth {
196 return;
197 }
198
199 let canonical = match dir.canonicalize() {
201 Ok(p) => p,
202 Err(_) => return,
203 };
204 if !visited.insert(canonical.clone()) {
205 return;
206 }
207
208 if let Some(detected) = engine.detect(dir) {
210 projects.push(WorkspaceProject {
211 path: dir.to_path_buf(),
212 language: detected.detection.language.clone(),
213 framework: detected.detection.framework.clone(),
214 confidence: detected.detection.confidence as f64,
215 adapter_index: detected.adapter_index,
216 });
217 }
218
219 let entries = match std::fs::read_dir(dir) {
221 Ok(entries) => entries,
222 Err(_) => return,
223 };
224
225 for entry in entries.flatten() {
226 let entry_path = entry.path();
227 if !entry_path.is_dir() {
228 continue;
229 }
230
231 let dir_name = match entry_path.file_name().and_then(|n| n.to_str()) {
232 Some(name) => name.to_string(),
233 None => continue,
234 };
235
236 if dir_name.starts_with('.') {
238 continue;
239 }
240
241 if skip_set.contains(dir_name.as_str()) {
243 continue;
244 }
245
246 if custom_skip.contains(&dir_name) {
248 continue;
249 }
250
251 scan_dir(
252 &entry_path,
253 engine,
254 config,
255 skip_set,
256 custom_skip,
257 depth + 1,
258 projects,
259 visited,
260 );
261 }
262}
263
264pub fn run_workspace(
266 projects: &[WorkspaceProject],
267 engine: &DetectionEngine,
268 extra_args: &[String],
269 config: &WorkspaceConfig,
270 env_vars: &[(String, String)],
271 verbose: bool,
272) -> WorkspaceReport {
273 let start = Instant::now();
274
275 let results: Vec<WorkspaceRunResult> = if config.parallel && projects.len() > 1 {
276 run_parallel(projects, engine, extra_args, config, env_vars, verbose)
277 } else {
278 run_sequential(projects, engine, extra_args, config, env_vars, verbose)
279 };
280
281 build_report(results, projects.len(), start.elapsed())
282}
283
284fn run_sequential(
285 projects: &[WorkspaceProject],
286 engine: &DetectionEngine,
287 extra_args: &[String],
288 config: &WorkspaceConfig,
289 env_vars: &[(String, String)],
290 verbose: bool,
291) -> Vec<WorkspaceRunResult> {
292 let mut results = Vec::new();
293
294 for project in projects {
295 let result = run_single_project(project, engine, extra_args, env_vars, verbose);
296
297 let failed =
298 result.result.as_ref().is_some_and(|r| r.total_failed() > 0) || result.error.is_some();
299
300 results.push(result);
301
302 if config.fail_fast && failed {
303 for remaining in projects.iter().skip(results.len()) {
305 results.push(WorkspaceRunResult {
306 project: remaining.clone(),
307 result: None,
308 duration: Duration::ZERO,
309 error: None,
310 skipped: true,
311 });
312 }
313 break;
314 }
315 }
316
317 results
318}
319
320fn run_parallel(
321 projects: &[WorkspaceProject],
322 engine: &DetectionEngine,
323 extra_args: &[String],
324 config: &WorkspaceConfig,
325 env_vars: &[(String, String)],
326 _verbose: bool,
327) -> Vec<WorkspaceRunResult> {
328 use std::sync::atomic::{AtomicBool, Ordering};
329
330 let jobs = config.effective_jobs().min(projects.len());
331 let cancelled = Arc::new(AtomicBool::new(false));
332 let fail_fast = config.fail_fast;
333
334 let mut project_commands: Vec<(usize, WorkspaceProject, Option<std::process::Command>)> =
336 Vec::new();
337
338 for (i, project) in projects.iter().enumerate() {
339 let adapter = engine.adapter(project.adapter_index);
340 match adapter.build_command(&project.path, extra_args) {
341 Ok(mut cmd) => {
342 for (key, value) in env_vars {
343 cmd.env(key, value);
344 }
345 project_commands.push((i, project.clone(), Some(cmd)));
346 }
347 Err(_) => {
348 project_commands.push((i, project.clone(), None));
349 }
350 }
351 }
352
353 #[derive(Debug)]
355 enum ThreadResult {
356 RawOutput {
357 idx: usize,
358 project: WorkspaceProject,
359 stdout: String,
360 stderr: String,
361 exit_code: i32,
362 elapsed: Duration,
363 },
364 Error {
365 idx: usize,
366 project: WorkspaceProject,
367 error: String,
368 elapsed: Duration,
369 },
370 Skipped {
371 idx: usize,
372 project: WorkspaceProject,
373 },
374 }
375
376 let results: Arc<Mutex<Vec<ThreadResult>>> = Arc::new(Mutex::new(Vec::new()));
377
378 let mut chunks: Vec<Vec<(usize, WorkspaceProject, Option<std::process::Command>)>> =
380 (0..jobs).map(|_| Vec::new()).collect();
381 for (i, item) in project_commands.into_iter().enumerate() {
382 chunks[i % jobs].push(item);
383 }
384
385 std::thread::scope(|scope| {
386 for chunk in chunks {
387 let results_ref = Arc::clone(&results);
388 let cancelled_ref = Arc::clone(&cancelled);
389
390 scope.spawn(move || {
391 for (idx, project, cmd_opt) in chunk {
392 if cancelled_ref.load(Ordering::SeqCst) {
393 results_ref
394 .lock()
395 .unwrap_or_else(|e| e.into_inner())
396 .push(ThreadResult::Skipped { idx, project });
397 continue;
398 }
399
400 let mut cmd = match cmd_opt {
401 Some(c) => c,
402 None => {
403 if fail_fast {
404 cancelled_ref.store(true, Ordering::SeqCst);
405 }
406 results_ref.lock().unwrap_or_else(|e| e.into_inner()).push(
407 ThreadResult::Error {
408 idx,
409 project,
410 error: "Failed to build command".to_string(),
411 elapsed: Duration::ZERO,
412 },
413 );
414 continue;
415 }
416 };
417
418 let start = Instant::now();
419 match cmd.output() {
420 Ok(output) => {
421 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
422 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
423 let exit_code = output.status.code().unwrap_or(1);
424 let elapsed = start.elapsed();
425
426 if fail_fast && exit_code != 0 {
427 cancelled_ref.store(true, Ordering::SeqCst);
428 }
429
430 results_ref.lock().unwrap_or_else(|e| e.into_inner()).push(
431 ThreadResult::RawOutput {
432 idx,
433 project,
434 stdout,
435 stderr,
436 exit_code,
437 elapsed,
438 },
439 );
440 }
441 Err(e) => {
442 let elapsed = start.elapsed();
443 if fail_fast {
444 cancelled_ref.store(true, Ordering::SeqCst);
445 }
446 results_ref.lock().unwrap_or_else(|e| e.into_inner()).push(
447 ThreadResult::Error {
448 idx,
449 project,
450 error: e.to_string(),
451 elapsed,
452 },
453 );
454 }
455 }
456 }
457 });
458 }
459 });
460
461 let mut raw_results: Vec<ThreadResult> = match Arc::try_unwrap(results) {
463 Ok(mutex) => mutex.into_inner().unwrap_or_else(|e| e.into_inner()),
464 Err(arc) => arc
465 .lock()
466 .unwrap_or_else(|e| e.into_inner())
467 .drain(..)
468 .collect(),
469 };
470
471 let mut final_results: Vec<(usize, WorkspaceRunResult)> = raw_results
473 .drain(..)
474 .map(|tr| match tr {
475 ThreadResult::RawOutput {
476 idx,
477 project,
478 stdout,
479 stderr,
480 exit_code,
481 elapsed,
482 } => {
483 let adapter = engine.adapter(project.adapter_index);
484 let mut parsed = adapter.parse_output(&stdout, &stderr, exit_code);
485 if parsed.duration.as_millis() == 0 {
486 parsed.duration = elapsed;
487 }
488 (
489 idx,
490 WorkspaceRunResult {
491 project,
492 result: Some(parsed),
493 duration: elapsed,
494 error: None,
495 skipped: false,
496 },
497 )
498 }
499 ThreadResult::Error {
500 idx,
501 project,
502 error,
503 elapsed,
504 } => (
505 idx,
506 WorkspaceRunResult {
507 project,
508 result: None,
509 duration: elapsed,
510 error: Some(error),
511 skipped: false,
512 },
513 ),
514 ThreadResult::Skipped { idx, project } => (
515 idx,
516 WorkspaceRunResult {
517 project,
518 result: None,
519 duration: Duration::ZERO,
520 error: None,
521 skipped: true,
522 },
523 ),
524 })
525 .collect();
526
527 final_results.sort_by_key(|(idx, _)| *idx);
528 final_results.into_iter().map(|(_, r)| r).collect()
529}
530
531fn run_single_project(
532 project: &WorkspaceProject,
533 engine: &DetectionEngine,
534 extra_args: &[String],
535 env_vars: &[(String, String)],
536 _verbose: bool,
537) -> WorkspaceRunResult {
538 let adapter = engine.adapter(project.adapter_index);
539
540 if let Some(missing) = adapter.check_runner() {
542 return WorkspaceRunResult {
543 project: project.clone(),
544 result: None,
545 duration: Duration::ZERO,
546 error: Some(format!("Test runner '{}' not found", missing)),
547 skipped: false,
548 };
549 }
550
551 let start = Instant::now();
552
553 let mut cmd = match adapter.build_command(&project.path, extra_args) {
554 Ok(cmd) => cmd,
555 Err(e) => {
556 return WorkspaceRunResult {
557 project: project.clone(),
558 result: None,
559 duration: start.elapsed(),
560 error: Some(format!("Failed to build command: {}", e)),
561 skipped: false,
562 };
563 }
564 };
565
566 for (key, value) in env_vars {
567 cmd.env(key, value);
568 }
569
570 match cmd.output() {
571 Ok(output) => {
572 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
573 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
574 let exit_code = output.status.code().unwrap_or(1);
575
576 let mut result = adapter.parse_output(&stdout, &stderr, exit_code);
577 let elapsed = start.elapsed();
578 if result.duration.as_millis() == 0 {
579 result.duration = elapsed;
580 }
581
582 WorkspaceRunResult {
583 project: project.clone(),
584 result: Some(result),
585 duration: elapsed,
586 error: None,
587 skipped: false,
588 }
589 }
590 Err(e) => WorkspaceRunResult {
591 project: project.clone(),
592 result: None,
593 duration: start.elapsed(),
594 error: Some(e.to_string()),
595 skipped: false,
596 },
597 }
598}
599
600fn build_report(
601 results: Vec<WorkspaceRunResult>,
602 projects_found: usize,
603 total_duration: Duration,
604) -> WorkspaceReport {
605 let projects_run = results
606 .iter()
607 .filter(|r| !r.skipped && r.error.is_none())
608 .count();
609 let projects_passed = results
610 .iter()
611 .filter(|r| r.result.as_ref().is_some_and(|res| res.is_success()))
612 .count();
613 let projects_failed = results
614 .iter()
615 .filter(|r| r.result.as_ref().is_some_and(|res| !res.is_success()) || r.error.is_some())
616 .count();
617 let projects_skipped = results.iter().filter(|r| r.skipped).count();
618
619 let total_tests: usize = results
620 .iter()
621 .filter_map(|r| r.result.as_ref())
622 .map(|r| r.total_tests())
623 .sum();
624 let total_passed: usize = results
625 .iter()
626 .filter_map(|r| r.result.as_ref())
627 .map(|r| r.total_passed())
628 .sum();
629 let total_failed: usize = results
630 .iter()
631 .filter_map(|r| r.result.as_ref())
632 .map(|r| r.total_failed())
633 .sum();
634
635 WorkspaceReport {
636 results,
637 total_duration,
638 projects_found,
639 projects_run,
640 projects_passed,
641 projects_failed,
642 projects_skipped,
643 total_tests,
644 total_passed,
645 total_failed,
646 }
647}
648
649pub fn format_workspace_report(report: &WorkspaceReport) -> String {
651 let mut lines = Vec::new();
652
653 lines.push(format!(
654 " {} projects found, {} run, {} passed, {} failed{}",
655 report.projects_found,
656 report.projects_run,
657 report.projects_passed,
658 report.projects_failed,
659 if report.projects_skipped > 0 {
660 format!(", {} skipped", report.projects_skipped)
661 } else {
662 String::new()
663 }
664 ));
665 lines.push(String::new());
666
667 for run_result in &report.results {
668 let rel_path = run_result
669 .project
670 .path
671 .file_name()
672 .map(|n| n.to_string_lossy().to_string())
673 .unwrap_or_else(|| run_result.project.path.display().to_string());
674
675 if run_result.skipped {
676 lines.push(format!(
677 " {} {} ({}) — skipped",
678 "○", rel_path, run_result.project.language,
679 ));
680 continue;
681 }
682
683 if let Some(ref error) = run_result.error {
684 lines.push(format!(
685 " {} {} ({}) — error: {}",
686 "✗", rel_path, run_result.project.language, error,
687 ));
688 continue;
689 }
690
691 if let Some(ref result) = run_result.result {
692 let icon = if result.is_success() { "✓" } else { "✗" };
693 let status = if result.is_success() { "PASS" } else { "FAIL" };
694 lines.push(format!(
695 " {} {} ({}) — {} ({} passed, {} failed, {} total) in {:.1}s",
696 icon,
697 rel_path,
698 run_result.project.language,
699 status,
700 result.total_passed(),
701 result.total_failed(),
702 result.total_tests(),
703 run_result.duration.as_secs_f64(),
704 ));
705 }
706 }
707
708 lines.push(String::new());
709 lines.push(format!(
710 " Total: {} tests, {} passed, {} failed in {:.2}s",
711 report.total_tests,
712 report.total_passed,
713 report.total_failed,
714 report.total_duration.as_secs_f64(),
715 ));
716
717 lines.join("\n")
718}
719
720pub fn workspace_report_json(report: &WorkspaceReport) -> serde_json::Value {
722 use serde_json::json;
723
724 let projects: Vec<serde_json::Value> = report
725 .results
726 .iter()
727 .map(|r| {
728 let mut proj = json!({
729 "path": r.project.path.display().to_string(),
730 "language": r.project.language,
731 "framework": r.project.framework,
732 "duration_ms": r.duration.as_millis(),
733 "skipped": r.skipped,
734 });
735
736 if let Some(ref error) = r.error {
737 proj["error"] = json!(error);
738 }
739
740 if let Some(ref result) = r.result {
741 proj["passed"] = json!(result.is_success());
742 proj["total_tests"] = json!(result.total_tests());
743 proj["total_passed"] = json!(result.total_passed());
744 proj["total_failed"] = json!(result.total_failed());
745 }
746
747 proj
748 })
749 .collect();
750
751 json!({
752 "projects": projects,
753 "projects_found": report.projects_found,
754 "projects_run": report.projects_run,
755 "projects_passed": report.projects_passed,
756 "projects_failed": report.projects_failed,
757 "projects_skipped": report.projects_skipped,
758 "total_tests": report.total_tests,
759 "total_passed": report.total_passed,
760 "total_failed": report.total_failed,
761 "total_duration_ms": report.total_duration.as_millis(),
762 })
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use std::fs;
769 use tempfile::TempDir;
770
771 #[test]
772 fn discover_empty_dir() {
773 let tmp = TempDir::new().unwrap();
774 let engine = DetectionEngine::new();
775 let config = WorkspaceConfig::default();
776 let projects = discover_projects(tmp.path(), &engine, &config);
777 assert!(projects.is_empty());
778 }
779
780 #[test]
781 fn discover_single_rust_project() {
782 let tmp = TempDir::new().unwrap();
783 fs::write(
784 tmp.path().join("Cargo.toml"),
785 "[package]\nname = \"test\"\n",
786 )
787 .unwrap();
788 let engine = DetectionEngine::new();
789 let config = WorkspaceConfig::default();
790 let projects = discover_projects(tmp.path(), &engine, &config);
791 assert_eq!(projects.len(), 1);
792 assert_eq!(projects[0].language, "Rust");
793 }
794
795 #[test]
796 fn discover_multiple_projects() {
797 let tmp = TempDir::new().unwrap();
798
799 fs::write(
801 tmp.path().join("Cargo.toml"),
802 "[package]\nname = \"root\"\n",
803 )
804 .unwrap();
805
806 let go_dir = tmp.path().join("services").join("api");
808 fs::create_dir_all(&go_dir).unwrap();
809 fs::write(go_dir.join("go.mod"), "module example.com/api\n").unwrap();
810 fs::write(go_dir.join("main_test.go"), "package main\n").unwrap();
811
812 let py_dir = tmp.path().join("tools").join("scripts");
814 fs::create_dir_all(&py_dir).unwrap();
815 fs::write(py_dir.join("pyproject.toml"), "[tool.pytest]\n").unwrap();
816
817 let engine = DetectionEngine::new();
818 let config = WorkspaceConfig::default();
819 let projects = discover_projects(tmp.path(), &engine, &config);
820
821 assert!(
822 projects.len() >= 3,
823 "Expected at least 3 projects, found {}",
824 projects.len()
825 );
826
827 let languages: Vec<&str> = projects.iter().map(|p| p.language.as_str()).collect();
828 assert!(languages.contains(&"Rust"));
829 assert!(languages.contains(&"Go"));
830 assert!(languages.contains(&"Python"));
831 }
832
833 #[test]
834 fn skip_node_modules() {
835 let tmp = TempDir::new().unwrap();
836
837 let nm_dir = tmp.path().join("node_modules").join("some-package");
839 fs::create_dir_all(&nm_dir).unwrap();
840 fs::write(nm_dir.join("Cargo.toml"), "[package]\nname = \"inside\"\n").unwrap();
841
842 let engine = DetectionEngine::new();
843 let config = WorkspaceConfig::default();
844 let projects = discover_projects(tmp.path(), &engine, &config);
845 assert!(projects.is_empty());
846 }
847
848 #[test]
849 fn skip_target_directory() {
850 let tmp = TempDir::new().unwrap();
851
852 fs::write(
854 tmp.path().join("Cargo.toml"),
855 "[package]\nname = \"root\"\n",
856 )
857 .unwrap();
858
859 let target_dir = tmp.path().join("target").join("debug").join("sub");
861 fs::create_dir_all(&target_dir).unwrap();
862 fs::write(
863 target_dir.join("Cargo.toml"),
864 "[package]\nname = \"target-inner\"\n",
865 )
866 .unwrap();
867
868 let engine = DetectionEngine::new();
869 let config = WorkspaceConfig::default();
870 let projects = discover_projects(tmp.path(), &engine, &config);
871 assert_eq!(projects.len(), 1);
872 assert_eq!(projects[0].path, tmp.path().canonicalize().unwrap());
873 }
874
875 #[test]
876 fn max_depth_limit() {
877 let tmp = TempDir::new().unwrap();
878
879 let deep = tmp
881 .path()
882 .join("a")
883 .join("b")
884 .join("c")
885 .join("d")
886 .join("e")
887 .join("f");
888 fs::create_dir_all(&deep).unwrap();
889 fs::write(deep.join("Cargo.toml"), "[package]\nname = \"deep\"\n").unwrap();
890
891 let engine = DetectionEngine::new();
892 let config = WorkspaceConfig {
893 max_depth: 3,
894 ..Default::default()
895 };
896 let projects = discover_projects(tmp.path(), &engine, &config);
897 assert!(projects.is_empty());
899 }
900
901 #[test]
902 fn filter_by_language() {
903 let tmp = TempDir::new().unwrap();
904
905 fs::write(
907 tmp.path().join("Cargo.toml"),
908 "[package]\nname = \"root\"\n",
909 )
910 .unwrap();
911
912 let py_dir = tmp.path().join("py");
914 fs::create_dir_all(&py_dir).unwrap();
915 fs::write(py_dir.join("pyproject.toml"), "[tool.pytest]\n").unwrap();
916
917 let engine = DetectionEngine::new();
918 let config = WorkspaceConfig {
919 filter_languages: vec!["rust".to_string()],
920 ..Default::default()
921 };
922 let projects = discover_projects(tmp.path(), &engine, &config);
923 assert_eq!(projects.len(), 1);
924 assert_eq!(projects[0].language, "Rust");
925 }
926
927 #[test]
928 fn workspace_report_summary() {
929 let report = WorkspaceReport {
930 results: vec![],
931 total_duration: Duration::from_secs(5),
932 projects_found: 3,
933 projects_run: 3,
934 projects_passed: 2,
935 projects_failed: 1,
936 projects_skipped: 0,
937 total_tests: 50,
938 total_passed: 48,
939 total_failed: 2,
940 };
941
942 let output = format_workspace_report(&report);
943 assert!(output.contains("3 projects found"));
944 assert!(output.contains("50 tests"));
945 }
946
947 #[test]
948 fn workspace_report_json_format() {
949 let report = WorkspaceReport {
950 results: vec![],
951 total_duration: Duration::from_secs(5),
952 projects_found: 2,
953 projects_run: 2,
954 projects_passed: 1,
955 projects_failed: 1,
956 projects_skipped: 0,
957 total_tests: 30,
958 total_passed: 28,
959 total_failed: 2,
960 };
961
962 let json = workspace_report_json(&report);
963 assert_eq!(json["projects_found"], 2);
964 assert_eq!(json["total_tests"], 30);
965 assert_eq!(json["total_failed"], 2);
966 }
967
968 #[test]
971 fn effective_jobs_auto() {
972 let config = WorkspaceConfig::default();
973 assert_eq!(config.max_jobs, 0);
974 let jobs = config.effective_jobs();
975 assert!(jobs >= 1, "auto-detected jobs should be >= 1, got {jobs}");
976 }
977
978 #[test]
979 fn effective_jobs_explicit() {
980 let config = WorkspaceConfig {
981 max_jobs: 8,
982 ..Default::default()
983 };
984 assert_eq!(config.effective_jobs(), 8);
985 }
986
987 #[test]
990 fn custom_skip_dirs() {
991 let tmp = TempDir::new().unwrap();
992
993 let exp_dir = tmp.path().join("experiments");
995 fs::create_dir_all(&exp_dir).unwrap();
996 fs::write(exp_dir.join("Cargo.toml"), "[package]\nname = \"exp\"\n").unwrap();
997
998 fs::write(
1000 tmp.path().join("Cargo.toml"),
1001 "[package]\nname = \"root\"\n",
1002 )
1003 .unwrap();
1004
1005 let engine = DetectionEngine::new();
1006 let config = WorkspaceConfig {
1007 skip_dirs: vec!["experiments".to_string()],
1008 ..Default::default()
1009 };
1010 let projects = discover_projects(tmp.path(), &engine, &config);
1011 assert_eq!(projects.len(), 1);
1012 assert_eq!(projects[0].language, "Rust");
1013 }
1014
1015 #[test]
1018 fn include_dirs_overrides_default_skip() {
1019 let tmp = TempDir::new().unwrap();
1020
1021 let pkg_dir = tmp.path().join("packages").join("shared-fixtures");
1023 fs::create_dir_all(&pkg_dir).unwrap();
1024 fs::write(
1025 pkg_dir.join("package.json"),
1026 r#"{"name": "shared-fixtures", "scripts": {"test": "jest"}}"#,
1027 )
1028 .unwrap();
1029
1030 fs::write(
1032 tmp.path().join("Cargo.toml"),
1033 "[package]\nname = \"root\"\n",
1034 )
1035 .unwrap();
1036
1037 let engine = DetectionEngine::new();
1038
1039 let config_default = WorkspaceConfig::default();
1041 let projects = discover_projects(tmp.path(), &engine, &config_default);
1042 assert_eq!(projects.len(), 1, "packages/ should be skipped by default");
1043 assert_eq!(projects[0].language, "Rust");
1044
1045 let config_include = WorkspaceConfig {
1047 include_dirs: vec!["packages".to_string()],
1048 ..Default::default()
1049 };
1050 let projects = discover_projects(tmp.path(), &engine, &config_include);
1051 assert_eq!(
1052 projects.len(),
1053 2,
1054 "packages/ should be scanned when included"
1055 );
1056 let languages: Vec<&str> = projects.iter().map(|p| p.language.as_str()).collect();
1057 assert!(languages.contains(&"JavaScript"));
1058 assert!(languages.contains(&"Rust"));
1059 }
1060
1061 #[test]
1062 fn include_dirs_does_not_affect_custom_skip() {
1063 let tmp = TempDir::new().unwrap();
1064
1065 let exp_dir = tmp.path().join("experiments");
1067 fs::create_dir_all(&exp_dir).unwrap();
1068 fs::write(exp_dir.join("Cargo.toml"), "[package]\nname = \"exp\"\n").unwrap();
1069
1070 let engine = DetectionEngine::new();
1071
1072 let config = WorkspaceConfig {
1074 skip_dirs: vec!["experiments".to_string()],
1075 include_dirs: vec!["packages".to_string()],
1076 ..Default::default()
1077 };
1078 let projects = discover_projects(tmp.path(), &engine, &config);
1079 assert_eq!(projects.len(), 0, "custom skip_dirs should still apply");
1080 }
1081
1082 #[cfg(unix)]
1085 #[test]
1086 fn symlink_loop_does_not_hang() {
1087 let tmp = TempDir::new().unwrap();
1088 let sub = tmp.path().join("sub");
1089 fs::create_dir_all(&sub).unwrap();
1090 std::os::unix::fs::symlink(tmp.path(), sub.join("loop")).unwrap();
1092
1093 fs::write(
1094 tmp.path().join("Cargo.toml"),
1095 "[package]\nname = \"root\"\n",
1096 )
1097 .unwrap();
1098
1099 let engine = DetectionEngine::new();
1100 let config = WorkspaceConfig::default();
1101 let projects = discover_projects(tmp.path(), &engine, &config);
1103 assert_eq!(projects.len(), 1);
1104 }
1105
1106 #[test]
1109 fn build_report_empty() {
1110 let report = build_report(vec![], 0, Duration::from_secs(0));
1111 assert_eq!(report.projects_found, 0);
1112 assert_eq!(report.projects_run, 0);
1113 assert_eq!(report.projects_passed, 0);
1114 assert_eq!(report.projects_failed, 0);
1115 assert_eq!(report.total_tests, 0);
1116 }
1117
1118 #[test]
1119 fn build_report_with_results() {
1120 use crate::adapters::{TestCase, TestStatus, TestSuite};
1121
1122 let project = WorkspaceProject {
1123 path: PathBuf::from("/tmp/test"),
1124 language: "Rust".to_string(),
1125 framework: "cargo".to_string(),
1126 confidence: 1.0,
1127 adapter_index: 0,
1128 };
1129
1130 let results = vec![
1131 WorkspaceRunResult {
1132 project: project.clone(),
1133 result: Some(TestRunResult {
1134 suites: vec![TestSuite {
1135 name: "suite1".to_string(),
1136 tests: vec![
1137 TestCase {
1138 name: "test_a".to_string(),
1139 status: TestStatus::Passed,
1140 duration: Duration::from_millis(10),
1141 error: None,
1142 },
1143 TestCase {
1144 name: "test_b".to_string(),
1145 status: TestStatus::Passed,
1146 duration: Duration::from_millis(20),
1147 error: None,
1148 },
1149 ],
1150 }],
1151 raw_exit_code: 0,
1152 duration: Duration::from_millis(30),
1153 }),
1154 duration: Duration::from_millis(50),
1155 error: None,
1156 skipped: false,
1157 },
1158 WorkspaceRunResult {
1159 project: project.clone(),
1160 result: None,
1161 duration: Duration::ZERO,
1162 error: None,
1163 skipped: true,
1164 },
1165 ];
1166
1167 let report = build_report(results, 3, Duration::from_secs(1));
1168 assert_eq!(report.projects_found, 3);
1169 assert_eq!(report.projects_run, 1);
1170 assert_eq!(report.projects_passed, 1);
1171 assert_eq!(report.projects_failed, 0);
1172 assert_eq!(report.projects_skipped, 1);
1173 assert_eq!(report.total_tests, 2);
1174 assert_eq!(report.total_passed, 2);
1175 assert_eq!(report.total_failed, 0);
1176 }
1177
1178 #[test]
1179 fn build_report_with_failures() {
1180 use crate::adapters::{TestCase, TestError, TestStatus, TestSuite};
1181
1182 let project = WorkspaceProject {
1183 path: PathBuf::from("/tmp/test"),
1184 language: "Go".to_string(),
1185 framework: "go test".to_string(),
1186 confidence: 1.0,
1187 adapter_index: 0,
1188 };
1189
1190 let results = vec![WorkspaceRunResult {
1191 project: project.clone(),
1192 result: Some(TestRunResult {
1193 suites: vec![TestSuite {
1194 name: "suite".to_string(),
1195 tests: vec![
1196 TestCase {
1197 name: "pass".to_string(),
1198 status: TestStatus::Passed,
1199 duration: Duration::from_millis(5),
1200 error: None,
1201 },
1202 TestCase {
1203 name: "fail".to_string(),
1204 status: TestStatus::Failed,
1205 duration: Duration::from_millis(5),
1206 error: Some(TestError {
1207 message: "expected true".to_string(),
1208 location: None,
1209 }),
1210 },
1211 ],
1212 }],
1213 raw_exit_code: 1,
1214 duration: Duration::from_millis(10),
1215 }),
1216 duration: Duration::from_millis(20),
1217 error: None,
1218 skipped: false,
1219 }];
1220
1221 let report = build_report(results, 1, Duration::from_secs(1));
1222 assert_eq!(report.projects_failed, 1);
1223 assert_eq!(report.projects_passed, 0);
1224 assert_eq!(report.total_tests, 2);
1225 assert_eq!(report.total_passed, 1);
1226 assert_eq!(report.total_failed, 1);
1227 }
1228
1229 #[test]
1230 fn build_report_error_counts_as_failed() {
1231 let project = WorkspaceProject {
1232 path: PathBuf::from("/tmp/test"),
1233 language: "Rust".to_string(),
1234 framework: "cargo".to_string(),
1235 confidence: 1.0,
1236 adapter_index: 0,
1237 };
1238
1239 let results = vec![WorkspaceRunResult {
1240 project,
1241 result: None,
1242 duration: Duration::ZERO,
1243 error: Some("runner not found".to_string()),
1244 skipped: false,
1245 }];
1246
1247 let report = build_report(results, 1, Duration::from_secs(0));
1248 assert_eq!(report.projects_failed, 1);
1249 assert_eq!(report.projects_passed, 0);
1250 assert_eq!(report.projects_run, 0); }
1252
1253 #[test]
1256 fn format_report_skipped_project() {
1257 let project = WorkspaceProject {
1258 path: PathBuf::from("/tmp/myproj"),
1259 language: "Rust".to_string(),
1260 framework: "cargo".to_string(),
1261 confidence: 1.0,
1262 adapter_index: 0,
1263 };
1264
1265 let report = WorkspaceReport {
1266 results: vec![WorkspaceRunResult {
1267 project,
1268 result: None,
1269 duration: Duration::ZERO,
1270 error: None,
1271 skipped: true,
1272 }],
1273 total_duration: Duration::from_secs(0),
1274 projects_found: 1,
1275 projects_run: 0,
1276 projects_passed: 0,
1277 projects_failed: 0,
1278 projects_skipped: 1,
1279 total_tests: 0,
1280 total_passed: 0,
1281 total_failed: 0,
1282 };
1283
1284 let output = format_workspace_report(&report);
1285 assert!(
1286 output.contains("skipped"),
1287 "should mention skipped: {output}"
1288 );
1289 }
1290
1291 #[test]
1292 fn format_report_error_project() {
1293 let project = WorkspaceProject {
1294 path: PathBuf::from("/tmp/badproj"),
1295 language: "Go".to_string(),
1296 framework: "go test".to_string(),
1297 confidence: 1.0,
1298 adapter_index: 0,
1299 };
1300
1301 let report = WorkspaceReport {
1302 results: vec![WorkspaceRunResult {
1303 project,
1304 result: None,
1305 duration: Duration::ZERO,
1306 error: Some("go not found".to_string()),
1307 skipped: false,
1308 }],
1309 total_duration: Duration::from_secs(0),
1310 projects_found: 1,
1311 projects_run: 0,
1312 projects_passed: 0,
1313 projects_failed: 1,
1314 projects_skipped: 0,
1315 total_tests: 0,
1316 total_passed: 0,
1317 total_failed: 0,
1318 };
1319
1320 let output = format_workspace_report(&report);
1321 assert!(output.contains("error"), "should mention error: {output}");
1322 assert!(output.contains("go not found"));
1323 }
1324
1325 #[test]
1328 fn json_report_with_project_results() {
1329 use crate::adapters::{TestCase, TestStatus, TestSuite};
1330
1331 let project = WorkspaceProject {
1332 path: PathBuf::from("/tmp/proj"),
1333 language: "Python".to_string(),
1334 framework: "pytest".to_string(),
1335 confidence: 0.9,
1336 adapter_index: 0,
1337 };
1338
1339 let report = WorkspaceReport {
1340 results: vec![WorkspaceRunResult {
1341 project,
1342 result: Some(TestRunResult {
1343 suites: vec![TestSuite {
1344 name: "suite".to_string(),
1345 tests: vec![TestCase {
1346 name: "test_x".to_string(),
1347 status: TestStatus::Passed,
1348 duration: Duration::from_millis(5),
1349 error: None,
1350 }],
1351 }],
1352 raw_exit_code: 0,
1353 duration: Duration::from_millis(5),
1354 }),
1355 duration: Duration::from_millis(100),
1356 error: None,
1357 skipped: false,
1358 }],
1359 total_duration: Duration::from_secs(1),
1360 projects_found: 1,
1361 projects_run: 1,
1362 projects_passed: 1,
1363 projects_failed: 0,
1364 projects_skipped: 0,
1365 total_tests: 1,
1366 total_passed: 1,
1367 total_failed: 0,
1368 };
1369
1370 let json = workspace_report_json(&report);
1371 assert_eq!(json["projects"][0]["language"], "Python");
1372 assert_eq!(json["projects"][0]["framework"], "pytest");
1373 assert_eq!(json["projects"][0]["passed"], true);
1374 assert_eq!(json["projects"][0]["total_tests"], 1);
1375 assert_eq!(json["projects"][0]["skipped"], false);
1376 }
1377
1378 #[test]
1379 fn json_report_with_error_project() {
1380 let project = WorkspaceProject {
1381 path: PathBuf::from("/tmp/err"),
1382 language: "Rust".to_string(),
1383 framework: "cargo".to_string(),
1384 confidence: 1.0,
1385 adapter_index: 0,
1386 };
1387
1388 let report = WorkspaceReport {
1389 results: vec![WorkspaceRunResult {
1390 project,
1391 result: None,
1392 duration: Duration::ZERO,
1393 error: Some("compilation failed".to_string()),
1394 skipped: false,
1395 }],
1396 total_duration: Duration::from_secs(0),
1397 projects_found: 1,
1398 projects_run: 0,
1399 projects_passed: 0,
1400 projects_failed: 1,
1401 projects_skipped: 0,
1402 total_tests: 0,
1403 total_passed: 0,
1404 total_failed: 0,
1405 };
1406
1407 let json = workspace_report_json(&report);
1408 assert_eq!(json["projects"][0]["error"], "compilation failed");
1409 }
1410
1411 #[test]
1414 fn discover_respects_depth_zero_unlimited() {
1415 let tmp = TempDir::new().unwrap();
1416 let deep = tmp
1417 .path()
1418 .join("a")
1419 .join("b")
1420 .join("c")
1421 .join("d")
1422 .join("e")
1423 .join("f")
1424 .join("g");
1425 fs::create_dir_all(&deep).unwrap();
1426 fs::write(deep.join("Cargo.toml"), "[package]\nname = \"deep\"\n").unwrap();
1427
1428 let engine = DetectionEngine::new();
1429 let config = WorkspaceConfig {
1430 max_depth: 0, ..Default::default()
1432 };
1433 let projects = discover_projects(tmp.path(), &engine, &config);
1434 assert_eq!(projects.len(), 1, "depth=0 should be unlimited");
1435 }
1436
1437 #[test]
1438 fn discover_multiple_languages_sorted_by_path() {
1439 let tmp = TempDir::new().unwrap();
1440
1441 let z_dir = tmp.path().join("z-project");
1443 fs::create_dir_all(&z_dir).unwrap();
1444 fs::write(z_dir.join("Cargo.toml"), "[package]\nname = \"z\"\n").unwrap();
1445
1446 let a_dir = tmp.path().join("a-project");
1447 fs::create_dir_all(&a_dir).unwrap();
1448 fs::write(a_dir.join("Cargo.toml"), "[package]\nname = \"a\"\n").unwrap();
1449
1450 let engine = DetectionEngine::new();
1451 let config = WorkspaceConfig::default();
1452 let projects = discover_projects(tmp.path(), &engine, &config);
1453 assert!(projects.len() >= 2);
1454 for w in projects.windows(2) {
1456 assert!(w[0].path <= w[1].path, "projects should be sorted by path");
1457 }
1458 }
1459
1460 #[test]
1461 fn filter_languages_case_insensitive() {
1462 let tmp = TempDir::new().unwrap();
1463 fs::write(
1464 tmp.path().join("Cargo.toml"),
1465 "[package]\nname = \"test\"\n",
1466 )
1467 .unwrap();
1468
1469 let engine = DetectionEngine::new();
1470 let config = WorkspaceConfig {
1471 filter_languages: vec!["RUST".to_string()],
1472 ..Default::default()
1473 };
1474 let projects = discover_projects(tmp.path(), &engine, &config);
1475 assert_eq!(projects.len(), 1, "filter should be case-insensitive");
1476 }
1477
1478 #[test]
1479 fn filter_no_match() {
1480 let tmp = TempDir::new().unwrap();
1481 fs::write(
1482 tmp.path().join("Cargo.toml"),
1483 "[package]\nname = \"test\"\n",
1484 )
1485 .unwrap();
1486
1487 let engine = DetectionEngine::new();
1488 let config = WorkspaceConfig {
1489 filter_languages: vec!["java".to_string()],
1490 ..Default::default()
1491 };
1492 let projects = discover_projects(tmp.path(), &engine, &config);
1493 assert!(
1494 projects.is_empty(),
1495 "should find no Rust projects when filtering for Java"
1496 );
1497 }
1498
1499 #[test]
1502 fn workspace_config_defaults() {
1503 let config = WorkspaceConfig::default();
1504 assert_eq!(config.max_depth, 5);
1505 assert!(config.parallel);
1506 assert_eq!(config.max_jobs, 0);
1507 assert!(!config.fail_fast);
1508 assert!(config.filter_languages.is_empty());
1509 assert!(config.skip_dirs.is_empty());
1510 }
1511
1512 #[test]
1515 fn deep_recursion_100_levels_respects_depth_limit() {
1516 let tmp = TempDir::new().unwrap();
1517
1518 let mut current = tmp.path().to_path_buf();
1520 for i in 0..100 {
1521 current = current.join(format!("level_{}", i));
1522 }
1523 fs::create_dir_all(¤t).unwrap();
1524 fs::write(
1525 current.join("Cargo.toml"),
1526 "[package]\nname = \"deep100\"\n",
1527 )
1528 .unwrap();
1529
1530 let engine = DetectionEngine::new();
1531 let config = WorkspaceConfig {
1532 max_depth: 5,
1533 ..Default::default()
1534 };
1535 let projects = discover_projects(tmp.path(), &engine, &config);
1537 assert!(
1538 projects.is_empty(),
1539 "should not discover project at depth 100 with max_depth=5"
1540 );
1541 }
1542
1543 #[test]
1544 fn deep_recursion_unlimited_depth_handles_deep_trees() {
1545 let tmp = TempDir::new().unwrap();
1546
1547 let mut current = tmp.path().to_path_buf();
1549 for i in 0..50 {
1550 current = current.join(format!("d{}", i));
1551 }
1552 fs::create_dir_all(¤t).unwrap();
1553 fs::write(current.join("Cargo.toml"), "[package]\nname = \"deep50\"\n").unwrap();
1554
1555 let engine = DetectionEngine::new();
1556 let config = WorkspaceConfig {
1557 max_depth: 0, ..Default::default()
1559 };
1560 let projects = discover_projects(tmp.path(), &engine, &config);
1562 assert_eq!(
1563 projects.len(),
1564 1,
1565 "should find deep project with unlimited depth"
1566 );
1567 }
1568
1569 #[cfg(unix)]
1570 #[test]
1571 fn symlink_chain_does_not_hang() {
1572 let tmp = TempDir::new().unwrap();
1573 let dir_a = tmp.path().join("a");
1575 let dir_b = tmp.path().join("b");
1576 let dir_c = tmp.path().join("c");
1577 fs::create_dir_all(&dir_a).unwrap();
1578 fs::create_dir_all(&dir_b).unwrap();
1579 fs::create_dir_all(&dir_c).unwrap();
1580 std::os::unix::fs::symlink(&dir_b, dir_a.join("link_to_b")).unwrap();
1581 std::os::unix::fs::symlink(&dir_c, dir_b.join("link_to_c")).unwrap();
1582 std::os::unix::fs::symlink(&dir_a, dir_c.join("link_to_a")).unwrap();
1583
1584 fs::write(
1585 tmp.path().join("Cargo.toml"),
1586 "[package]\nname = \"root\"\n",
1587 )
1588 .unwrap();
1589
1590 let engine = DetectionEngine::new();
1591 let config = WorkspaceConfig::default();
1592 let projects = discover_projects(tmp.path(), &engine, &config);
1594 assert!(
1595 !projects.is_empty(),
1596 "should find at least the root project"
1597 );
1598 }
1599
1600 #[cfg(unix)]
1601 #[test]
1602 fn self_referencing_symlink_safe() {
1603 let tmp = TempDir::new().unwrap();
1604 let sub = tmp.path().join("sub");
1605 fs::create_dir_all(&sub).unwrap();
1606 std::os::unix::fs::symlink(&sub, sub.join("self")).unwrap();
1608
1609 let engine = DetectionEngine::new();
1610 let config = WorkspaceConfig::default();
1611 let projects = discover_projects(tmp.path(), &engine, &config);
1612 assert!(projects.is_empty());
1614 }
1615
1616 #[test]
1619 fn broad_directory_tree_no_excessive_memory() {
1620 let tmp = TempDir::new().unwrap();
1621
1622 for i in 0..500 {
1624 let dir = tmp.path().join(format!("project_{}", i));
1625 fs::create_dir_all(&dir).unwrap();
1626 }
1628
1629 let engine = DetectionEngine::new();
1630 let config = WorkspaceConfig::default();
1631 let projects = discover_projects(tmp.path(), &engine, &config);
1632 assert!(projects.is_empty(), "empty dirs should produce no projects");
1634 }
1635
1636 #[test]
1637 fn many_projects_discovered_without_crash() {
1638 let tmp = TempDir::new().unwrap();
1639
1640 for i in 0..50 {
1642 let dir = tmp.path().join(format!("proj_{}", i));
1643 fs::create_dir_all(&dir).unwrap();
1644 fs::write(
1645 dir.join("Cargo.toml"),
1646 format!("[package]\nname = \"proj_{}\"\n", i),
1647 )
1648 .unwrap();
1649 }
1650
1651 let engine = DetectionEngine::new();
1652 let config = WorkspaceConfig {
1653 max_depth: 2,
1654 ..Default::default()
1655 };
1656 let projects = discover_projects(tmp.path(), &engine, &config);
1657 assert_eq!(projects.len(), 50, "should discover all 50 projects");
1658 }
1659
1660 #[test]
1661 fn visited_set_prevents_re_scanning() {
1662 let tmp = TempDir::new().unwrap();
1664
1665 let real_dir = tmp.path().join("real");
1667 fs::create_dir_all(&real_dir).unwrap();
1668 fs::write(real_dir.join("Cargo.toml"), "[package]\nname = \"real\"\n").unwrap();
1669
1670 let engine = DetectionEngine::new();
1671 let config = WorkspaceConfig::default();
1672 let mut projects = Vec::new();
1673 let mut visited = HashSet::new();
1674 let skip_set: HashSet<&str> = SKIP_DIRS.iter().copied().collect();
1675 let custom_skip: HashSet<String> = HashSet::new();
1676
1677 scan_dir(
1679 &real_dir,
1680 &engine,
1681 &config,
1682 &skip_set,
1683 &custom_skip,
1684 0,
1685 &mut projects,
1686 &mut visited,
1687 );
1688 scan_dir(
1689 &real_dir,
1690 &engine,
1691 &config,
1692 &skip_set,
1693 &custom_skip,
1694 0,
1695 &mut projects,
1696 &mut visited,
1697 );
1698
1699 assert_eq!(
1701 projects.len(),
1702 1,
1703 "visited set should prevent duplicate scanning"
1704 );
1705 }
1706}