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().to_path_buf());
876 }
877
878 #[test]
879 fn max_depth_limit() {
880 let tmp = TempDir::new().unwrap();
881
882 let deep = tmp
884 .path()
885 .join("a")
886 .join("b")
887 .join("c")
888 .join("d")
889 .join("e")
890 .join("f");
891 fs::create_dir_all(&deep).unwrap();
892 fs::write(deep.join("Cargo.toml"), "[package]\nname = \"deep\"\n").unwrap();
893
894 let engine = DetectionEngine::new();
895 let config = WorkspaceConfig {
896 max_depth: 3,
897 ..Default::default()
898 };
899 let projects = discover_projects(tmp.path(), &engine, &config);
900 assert!(projects.is_empty());
902 }
903
904 #[test]
905 fn filter_by_language() {
906 let tmp = TempDir::new().unwrap();
907
908 fs::write(
910 tmp.path().join("Cargo.toml"),
911 "[package]\nname = \"root\"\n",
912 )
913 .unwrap();
914
915 let py_dir = tmp.path().join("py");
917 fs::create_dir_all(&py_dir).unwrap();
918 fs::write(py_dir.join("pyproject.toml"), "[tool.pytest]\n").unwrap();
919
920 let engine = DetectionEngine::new();
921 let config = WorkspaceConfig {
922 filter_languages: vec!["rust".to_string()],
923 ..Default::default()
924 };
925 let projects = discover_projects(tmp.path(), &engine, &config);
926 assert_eq!(projects.len(), 1);
927 assert_eq!(projects[0].language, "Rust");
928 }
929
930 #[test]
931 fn workspace_report_summary() {
932 let report = WorkspaceReport {
933 results: vec![],
934 total_duration: Duration::from_secs(5),
935 projects_found: 3,
936 projects_run: 3,
937 projects_passed: 2,
938 projects_failed: 1,
939 projects_skipped: 0,
940 total_tests: 50,
941 total_passed: 48,
942 total_failed: 2,
943 };
944
945 let output = format_workspace_report(&report);
946 assert!(output.contains("3 projects found"));
947 assert!(output.contains("50 tests"));
948 }
949
950 #[test]
951 fn workspace_report_json_format() {
952 let report = WorkspaceReport {
953 results: vec![],
954 total_duration: Duration::from_secs(5),
955 projects_found: 2,
956 projects_run: 2,
957 projects_passed: 1,
958 projects_failed: 1,
959 projects_skipped: 0,
960 total_tests: 30,
961 total_passed: 28,
962 total_failed: 2,
963 };
964
965 let json = workspace_report_json(&report);
966 assert_eq!(json["projects_found"], 2);
967 assert_eq!(json["total_tests"], 30);
968 assert_eq!(json["total_failed"], 2);
969 }
970
971 #[test]
974 fn effective_jobs_auto() {
975 let config = WorkspaceConfig::default();
976 assert_eq!(config.max_jobs, 0);
977 let jobs = config.effective_jobs();
978 assert!(jobs >= 1, "auto-detected jobs should be >= 1, got {jobs}");
979 }
980
981 #[test]
982 fn effective_jobs_explicit() {
983 let config = WorkspaceConfig {
984 max_jobs: 8,
985 ..Default::default()
986 };
987 assert_eq!(config.effective_jobs(), 8);
988 }
989
990 #[test]
993 fn custom_skip_dirs() {
994 let tmp = TempDir::new().unwrap();
995
996 let exp_dir = tmp.path().join("experiments");
998 fs::create_dir_all(&exp_dir).unwrap();
999 fs::write(exp_dir.join("Cargo.toml"), "[package]\nname = \"exp\"\n").unwrap();
1000
1001 fs::write(
1003 tmp.path().join("Cargo.toml"),
1004 "[package]\nname = \"root\"\n",
1005 )
1006 .unwrap();
1007
1008 let engine = DetectionEngine::new();
1009 let config = WorkspaceConfig {
1010 skip_dirs: vec!["experiments".to_string()],
1011 ..Default::default()
1012 };
1013 let projects = discover_projects(tmp.path(), &engine, &config);
1014 assert_eq!(projects.len(), 1);
1015 assert_eq!(projects[0].language, "Rust");
1016 }
1017
1018 #[test]
1021 fn include_dirs_overrides_default_skip() {
1022 let tmp = TempDir::new().unwrap();
1023
1024 let pkg_dir = tmp.path().join("packages").join("shared-fixtures");
1026 fs::create_dir_all(&pkg_dir).unwrap();
1027 fs::write(
1028 pkg_dir.join("package.json"),
1029 r#"{"name": "shared-fixtures", "scripts": {"test": "jest"}}"#,
1030 )
1031 .unwrap();
1032
1033 fs::write(
1035 tmp.path().join("Cargo.toml"),
1036 "[package]\nname = \"root\"\n",
1037 )
1038 .unwrap();
1039
1040 let engine = DetectionEngine::new();
1041
1042 let config_default = WorkspaceConfig::default();
1044 let projects = discover_projects(tmp.path(), &engine, &config_default);
1045 assert_eq!(projects.len(), 1, "packages/ should be skipped by default");
1046 assert_eq!(projects[0].language, "Rust");
1047
1048 let config_include = WorkspaceConfig {
1050 include_dirs: vec!["packages".to_string()],
1051 ..Default::default()
1052 };
1053 let projects = discover_projects(tmp.path(), &engine, &config_include);
1054 assert_eq!(
1055 projects.len(),
1056 2,
1057 "packages/ should be scanned when included"
1058 );
1059 let languages: Vec<&str> = projects.iter().map(|p| p.language.as_str()).collect();
1060 assert!(languages.contains(&"JavaScript"));
1061 assert!(languages.contains(&"Rust"));
1062 }
1063
1064 #[test]
1065 fn include_dirs_does_not_affect_custom_skip() {
1066 let tmp = TempDir::new().unwrap();
1067
1068 let exp_dir = tmp.path().join("experiments");
1070 fs::create_dir_all(&exp_dir).unwrap();
1071 fs::write(exp_dir.join("Cargo.toml"), "[package]\nname = \"exp\"\n").unwrap();
1072
1073 let engine = DetectionEngine::new();
1074
1075 let config = WorkspaceConfig {
1077 skip_dirs: vec!["experiments".to_string()],
1078 include_dirs: vec!["packages".to_string()],
1079 ..Default::default()
1080 };
1081 let projects = discover_projects(tmp.path(), &engine, &config);
1082 assert_eq!(projects.len(), 0, "custom skip_dirs should still apply");
1083 }
1084
1085 #[cfg(unix)]
1088 #[test]
1089 fn symlink_loop_does_not_hang() {
1090 let tmp = TempDir::new().unwrap();
1091 let sub = tmp.path().join("sub");
1092 fs::create_dir_all(&sub).unwrap();
1093 std::os::unix::fs::symlink(tmp.path(), sub.join("loop")).unwrap();
1095
1096 fs::write(
1097 tmp.path().join("Cargo.toml"),
1098 "[package]\nname = \"root\"\n",
1099 )
1100 .unwrap();
1101
1102 let engine = DetectionEngine::new();
1103 let config = WorkspaceConfig::default();
1104 let projects = discover_projects(tmp.path(), &engine, &config);
1106 assert_eq!(projects.len(), 1);
1107 }
1108
1109 #[test]
1112 fn build_report_empty() {
1113 let report = build_report(vec![], 0, Duration::from_secs(0));
1114 assert_eq!(report.projects_found, 0);
1115 assert_eq!(report.projects_run, 0);
1116 assert_eq!(report.projects_passed, 0);
1117 assert_eq!(report.projects_failed, 0);
1118 assert_eq!(report.total_tests, 0);
1119 }
1120
1121 #[test]
1122 fn build_report_with_results() {
1123 use crate::adapters::{TestCase, TestStatus, TestSuite};
1124
1125 let project = WorkspaceProject {
1126 path: PathBuf::from("/tmp/test"),
1127 language: "Rust".to_string(),
1128 framework: "cargo".to_string(),
1129 confidence: 1.0,
1130 adapter_index: 0,
1131 };
1132
1133 let results = vec![
1134 WorkspaceRunResult {
1135 project: project.clone(),
1136 result: Some(TestRunResult {
1137 suites: vec![TestSuite {
1138 name: "suite1".to_string(),
1139 tests: vec![
1140 TestCase {
1141 name: "test_a".to_string(),
1142 status: TestStatus::Passed,
1143 duration: Duration::from_millis(10),
1144 error: None,
1145 },
1146 TestCase {
1147 name: "test_b".to_string(),
1148 status: TestStatus::Passed,
1149 duration: Duration::from_millis(20),
1150 error: None,
1151 },
1152 ],
1153 }],
1154 raw_exit_code: 0,
1155 duration: Duration::from_millis(30),
1156 }),
1157 duration: Duration::from_millis(50),
1158 error: None,
1159 skipped: false,
1160 },
1161 WorkspaceRunResult {
1162 project: project.clone(),
1163 result: None,
1164 duration: Duration::ZERO,
1165 error: None,
1166 skipped: true,
1167 },
1168 ];
1169
1170 let report = build_report(results, 3, Duration::from_secs(1));
1171 assert_eq!(report.projects_found, 3);
1172 assert_eq!(report.projects_run, 1);
1173 assert_eq!(report.projects_passed, 1);
1174 assert_eq!(report.projects_failed, 0);
1175 assert_eq!(report.projects_skipped, 1);
1176 assert_eq!(report.total_tests, 2);
1177 assert_eq!(report.total_passed, 2);
1178 assert_eq!(report.total_failed, 0);
1179 }
1180
1181 #[test]
1182 fn build_report_with_failures() {
1183 use crate::adapters::{TestCase, TestError, TestStatus, TestSuite};
1184
1185 let project = WorkspaceProject {
1186 path: PathBuf::from("/tmp/test"),
1187 language: "Go".to_string(),
1188 framework: "go test".to_string(),
1189 confidence: 1.0,
1190 adapter_index: 0,
1191 };
1192
1193 let results = vec![WorkspaceRunResult {
1194 project: project.clone(),
1195 result: Some(TestRunResult {
1196 suites: vec![TestSuite {
1197 name: "suite".to_string(),
1198 tests: vec![
1199 TestCase {
1200 name: "pass".to_string(),
1201 status: TestStatus::Passed,
1202 duration: Duration::from_millis(5),
1203 error: None,
1204 },
1205 TestCase {
1206 name: "fail".to_string(),
1207 status: TestStatus::Failed,
1208 duration: Duration::from_millis(5),
1209 error: Some(TestError {
1210 message: "expected true".to_string(),
1211 location: None,
1212 }),
1213 },
1214 ],
1215 }],
1216 raw_exit_code: 1,
1217 duration: Duration::from_millis(10),
1218 }),
1219 duration: Duration::from_millis(20),
1220 error: None,
1221 skipped: false,
1222 }];
1223
1224 let report = build_report(results, 1, Duration::from_secs(1));
1225 assert_eq!(report.projects_failed, 1);
1226 assert_eq!(report.projects_passed, 0);
1227 assert_eq!(report.total_tests, 2);
1228 assert_eq!(report.total_passed, 1);
1229 assert_eq!(report.total_failed, 1);
1230 }
1231
1232 #[test]
1233 fn build_report_error_counts_as_failed() {
1234 let project = WorkspaceProject {
1235 path: PathBuf::from("/tmp/test"),
1236 language: "Rust".to_string(),
1237 framework: "cargo".to_string(),
1238 confidence: 1.0,
1239 adapter_index: 0,
1240 };
1241
1242 let results = vec![WorkspaceRunResult {
1243 project,
1244 result: None,
1245 duration: Duration::ZERO,
1246 error: Some("runner not found".to_string()),
1247 skipped: false,
1248 }];
1249
1250 let report = build_report(results, 1, Duration::from_secs(0));
1251 assert_eq!(report.projects_failed, 1);
1252 assert_eq!(report.projects_passed, 0);
1253 assert_eq!(report.projects_run, 0); }
1255
1256 #[test]
1259 fn format_report_skipped_project() {
1260 let project = WorkspaceProject {
1261 path: PathBuf::from("/tmp/myproj"),
1262 language: "Rust".to_string(),
1263 framework: "cargo".to_string(),
1264 confidence: 1.0,
1265 adapter_index: 0,
1266 };
1267
1268 let report = WorkspaceReport {
1269 results: vec![WorkspaceRunResult {
1270 project,
1271 result: None,
1272 duration: Duration::ZERO,
1273 error: None,
1274 skipped: true,
1275 }],
1276 total_duration: Duration::from_secs(0),
1277 projects_found: 1,
1278 projects_run: 0,
1279 projects_passed: 0,
1280 projects_failed: 0,
1281 projects_skipped: 1,
1282 total_tests: 0,
1283 total_passed: 0,
1284 total_failed: 0,
1285 };
1286
1287 let output = format_workspace_report(&report);
1288 assert!(
1289 output.contains("skipped"),
1290 "should mention skipped: {output}"
1291 );
1292 }
1293
1294 #[test]
1295 fn format_report_error_project() {
1296 let project = WorkspaceProject {
1297 path: PathBuf::from("/tmp/badproj"),
1298 language: "Go".to_string(),
1299 framework: "go test".to_string(),
1300 confidence: 1.0,
1301 adapter_index: 0,
1302 };
1303
1304 let report = WorkspaceReport {
1305 results: vec![WorkspaceRunResult {
1306 project,
1307 result: None,
1308 duration: Duration::ZERO,
1309 error: Some("go not found".to_string()),
1310 skipped: false,
1311 }],
1312 total_duration: Duration::from_secs(0),
1313 projects_found: 1,
1314 projects_run: 0,
1315 projects_passed: 0,
1316 projects_failed: 1,
1317 projects_skipped: 0,
1318 total_tests: 0,
1319 total_passed: 0,
1320 total_failed: 0,
1321 };
1322
1323 let output = format_workspace_report(&report);
1324 assert!(output.contains("error"), "should mention error: {output}");
1325 assert!(output.contains("go not found"));
1326 }
1327
1328 #[test]
1331 fn json_report_with_project_results() {
1332 use crate::adapters::{TestCase, TestStatus, TestSuite};
1333
1334 let project = WorkspaceProject {
1335 path: PathBuf::from("/tmp/proj"),
1336 language: "Python".to_string(),
1337 framework: "pytest".to_string(),
1338 confidence: 0.9,
1339 adapter_index: 0,
1340 };
1341
1342 let report = WorkspaceReport {
1343 results: vec![WorkspaceRunResult {
1344 project,
1345 result: Some(TestRunResult {
1346 suites: vec![TestSuite {
1347 name: "suite".to_string(),
1348 tests: vec![TestCase {
1349 name: "test_x".to_string(),
1350 status: TestStatus::Passed,
1351 duration: Duration::from_millis(5),
1352 error: None,
1353 }],
1354 }],
1355 raw_exit_code: 0,
1356 duration: Duration::from_millis(5),
1357 }),
1358 duration: Duration::from_millis(100),
1359 error: None,
1360 skipped: false,
1361 }],
1362 total_duration: Duration::from_secs(1),
1363 projects_found: 1,
1364 projects_run: 1,
1365 projects_passed: 1,
1366 projects_failed: 0,
1367 projects_skipped: 0,
1368 total_tests: 1,
1369 total_passed: 1,
1370 total_failed: 0,
1371 };
1372
1373 let json = workspace_report_json(&report);
1374 assert_eq!(json["projects"][0]["language"], "Python");
1375 assert_eq!(json["projects"][0]["framework"], "pytest");
1376 assert_eq!(json["projects"][0]["passed"], true);
1377 assert_eq!(json["projects"][0]["total_tests"], 1);
1378 assert_eq!(json["projects"][0]["skipped"], false);
1379 }
1380
1381 #[test]
1382 fn json_report_with_error_project() {
1383 let project = WorkspaceProject {
1384 path: PathBuf::from("/tmp/err"),
1385 language: "Rust".to_string(),
1386 framework: "cargo".to_string(),
1387 confidence: 1.0,
1388 adapter_index: 0,
1389 };
1390
1391 let report = WorkspaceReport {
1392 results: vec![WorkspaceRunResult {
1393 project,
1394 result: None,
1395 duration: Duration::ZERO,
1396 error: Some("compilation failed".to_string()),
1397 skipped: false,
1398 }],
1399 total_duration: Duration::from_secs(0),
1400 projects_found: 1,
1401 projects_run: 0,
1402 projects_passed: 0,
1403 projects_failed: 1,
1404 projects_skipped: 0,
1405 total_tests: 0,
1406 total_passed: 0,
1407 total_failed: 0,
1408 };
1409
1410 let json = workspace_report_json(&report);
1411 assert_eq!(json["projects"][0]["error"], "compilation failed");
1412 }
1413
1414 #[test]
1417 fn discover_respects_depth_zero_unlimited() {
1418 let tmp = TempDir::new().unwrap();
1419 let deep = tmp
1420 .path()
1421 .join("a")
1422 .join("b")
1423 .join("c")
1424 .join("d")
1425 .join("e")
1426 .join("f")
1427 .join("g");
1428 fs::create_dir_all(&deep).unwrap();
1429 fs::write(deep.join("Cargo.toml"), "[package]\nname = \"deep\"\n").unwrap();
1430
1431 let engine = DetectionEngine::new();
1432 let config = WorkspaceConfig {
1433 max_depth: 0, ..Default::default()
1435 };
1436 let projects = discover_projects(tmp.path(), &engine, &config);
1437 assert_eq!(projects.len(), 1, "depth=0 should be unlimited");
1438 }
1439
1440 #[test]
1441 fn discover_multiple_languages_sorted_by_path() {
1442 let tmp = TempDir::new().unwrap();
1443
1444 let z_dir = tmp.path().join("z-project");
1446 fs::create_dir_all(&z_dir).unwrap();
1447 fs::write(z_dir.join("Cargo.toml"), "[package]\nname = \"z\"\n").unwrap();
1448
1449 let a_dir = tmp.path().join("a-project");
1450 fs::create_dir_all(&a_dir).unwrap();
1451 fs::write(a_dir.join("Cargo.toml"), "[package]\nname = \"a\"\n").unwrap();
1452
1453 let engine = DetectionEngine::new();
1454 let config = WorkspaceConfig::default();
1455 let projects = discover_projects(tmp.path(), &engine, &config);
1456 assert!(projects.len() >= 2);
1457 for w in projects.windows(2) {
1459 assert!(w[0].path <= w[1].path, "projects should be sorted by path");
1460 }
1461 }
1462
1463 #[test]
1464 fn filter_languages_case_insensitive() {
1465 let tmp = TempDir::new().unwrap();
1466 fs::write(
1467 tmp.path().join("Cargo.toml"),
1468 "[package]\nname = \"test\"\n",
1469 )
1470 .unwrap();
1471
1472 let engine = DetectionEngine::new();
1473 let config = WorkspaceConfig {
1474 filter_languages: vec!["RUST".to_string()],
1475 ..Default::default()
1476 };
1477 let projects = discover_projects(tmp.path(), &engine, &config);
1478 assert_eq!(projects.len(), 1, "filter should be case-insensitive");
1479 }
1480
1481 #[test]
1482 fn filter_no_match() {
1483 let tmp = TempDir::new().unwrap();
1484 fs::write(
1485 tmp.path().join("Cargo.toml"),
1486 "[package]\nname = \"test\"\n",
1487 )
1488 .unwrap();
1489
1490 let engine = DetectionEngine::new();
1491 let config = WorkspaceConfig {
1492 filter_languages: vec!["java".to_string()],
1493 ..Default::default()
1494 };
1495 let projects = discover_projects(tmp.path(), &engine, &config);
1496 assert!(
1497 projects.is_empty(),
1498 "should find no Rust projects when filtering for Java"
1499 );
1500 }
1501
1502 #[test]
1505 fn workspace_config_defaults() {
1506 let config = WorkspaceConfig::default();
1507 assert_eq!(config.max_depth, 5);
1508 assert!(config.parallel);
1509 assert_eq!(config.max_jobs, 0);
1510 assert!(!config.fail_fast);
1511 assert!(config.filter_languages.is_empty());
1512 assert!(config.skip_dirs.is_empty());
1513 }
1514
1515 #[test]
1518 fn deep_recursion_100_levels_respects_depth_limit() {
1519 let tmp = TempDir::new().unwrap();
1520
1521 let mut current = tmp.path().to_path_buf();
1523 for i in 0..100 {
1524 current = current.join(format!("level_{}", i));
1525 }
1526 fs::create_dir_all(¤t).unwrap();
1527 fs::write(
1528 current.join("Cargo.toml"),
1529 "[package]\nname = \"deep100\"\n",
1530 )
1531 .unwrap();
1532
1533 let engine = DetectionEngine::new();
1534 let config = WorkspaceConfig {
1535 max_depth: 5,
1536 ..Default::default()
1537 };
1538 let projects = discover_projects(tmp.path(), &engine, &config);
1540 assert!(
1541 projects.is_empty(),
1542 "should not discover project at depth 100 with max_depth=5"
1543 );
1544 }
1545
1546 #[test]
1547 fn deep_recursion_unlimited_depth_handles_deep_trees() {
1548 let tmp = TempDir::new().unwrap();
1549
1550 let mut current = tmp.path().to_path_buf();
1552 for i in 0..50 {
1553 current = current.join(format!("d{}", i));
1554 }
1555 fs::create_dir_all(¤t).unwrap();
1556 fs::write(current.join("Cargo.toml"), "[package]\nname = \"deep50\"\n").unwrap();
1557
1558 let engine = DetectionEngine::new();
1559 let config = WorkspaceConfig {
1560 max_depth: 0, ..Default::default()
1562 };
1563 let projects = discover_projects(tmp.path(), &engine, &config);
1565 assert_eq!(
1566 projects.len(),
1567 1,
1568 "should find deep project with unlimited depth"
1569 );
1570 }
1571
1572 #[cfg(unix)]
1573 #[test]
1574 fn symlink_chain_does_not_hang() {
1575 let tmp = TempDir::new().unwrap();
1576 let dir_a = tmp.path().join("a");
1578 let dir_b = tmp.path().join("b");
1579 let dir_c = tmp.path().join("c");
1580 fs::create_dir_all(&dir_a).unwrap();
1581 fs::create_dir_all(&dir_b).unwrap();
1582 fs::create_dir_all(&dir_c).unwrap();
1583 std::os::unix::fs::symlink(&dir_b, dir_a.join("link_to_b")).unwrap();
1584 std::os::unix::fs::symlink(&dir_c, dir_b.join("link_to_c")).unwrap();
1585 std::os::unix::fs::symlink(&dir_a, dir_c.join("link_to_a")).unwrap();
1586
1587 fs::write(
1588 tmp.path().join("Cargo.toml"),
1589 "[package]\nname = \"root\"\n",
1590 )
1591 .unwrap();
1592
1593 let engine = DetectionEngine::new();
1594 let config = WorkspaceConfig::default();
1595 let projects = discover_projects(tmp.path(), &engine, &config);
1597 assert!(
1598 !projects.is_empty(),
1599 "should find at least the root project"
1600 );
1601 }
1602
1603 #[cfg(unix)]
1604 #[test]
1605 fn self_referencing_symlink_safe() {
1606 let tmp = TempDir::new().unwrap();
1607 let sub = tmp.path().join("sub");
1608 fs::create_dir_all(&sub).unwrap();
1609 std::os::unix::fs::symlink(&sub, sub.join("self")).unwrap();
1611
1612 let engine = DetectionEngine::new();
1613 let config = WorkspaceConfig::default();
1614 let projects = discover_projects(tmp.path(), &engine, &config);
1615 assert!(projects.is_empty());
1617 }
1618
1619 #[test]
1622 fn broad_directory_tree_no_excessive_memory() {
1623 let tmp = TempDir::new().unwrap();
1624
1625 for i in 0..500 {
1627 let dir = tmp.path().join(format!("project_{}", i));
1628 fs::create_dir_all(&dir).unwrap();
1629 }
1631
1632 let engine = DetectionEngine::new();
1633 let config = WorkspaceConfig::default();
1634 let projects = discover_projects(tmp.path(), &engine, &config);
1635 assert!(projects.is_empty(), "empty dirs should produce no projects");
1637 }
1638
1639 #[test]
1640 fn many_projects_discovered_without_crash() {
1641 let tmp = TempDir::new().unwrap();
1642
1643 for i in 0..50 {
1645 let dir = tmp.path().join(format!("proj_{}", i));
1646 fs::create_dir_all(&dir).unwrap();
1647 fs::write(
1648 dir.join("Cargo.toml"),
1649 format!("[package]\nname = \"proj_{}\"\n", i),
1650 )
1651 .unwrap();
1652 }
1653
1654 let engine = DetectionEngine::new();
1655 let config = WorkspaceConfig {
1656 max_depth: 2,
1657 ..Default::default()
1658 };
1659 let projects = discover_projects(tmp.path(), &engine, &config);
1660 assert_eq!(projects.len(), 50, "should discover all 50 projects");
1661 }
1662
1663 #[test]
1664 fn visited_set_prevents_re_scanning() {
1665 let tmp = TempDir::new().unwrap();
1667
1668 let real_dir = tmp.path().join("real");
1670 fs::create_dir_all(&real_dir).unwrap();
1671 fs::write(real_dir.join("Cargo.toml"), "[package]\nname = \"real\"\n").unwrap();
1672
1673 let engine = DetectionEngine::new();
1674 let config = WorkspaceConfig::default();
1675 let mut projects = Vec::new();
1676 let mut visited = HashSet::new();
1677 let skip_set: HashSet<&str> = SKIP_DIRS.iter().copied().collect();
1678 let custom_skip: HashSet<String> = HashSet::new();
1679
1680 scan_dir(
1682 &real_dir,
1683 &engine,
1684 &config,
1685 &skip_set,
1686 &custom_skip,
1687 0,
1688 &mut projects,
1689 &mut visited,
1690 );
1691 scan_dir(
1692 &real_dir,
1693 &engine,
1694 &config,
1695 &skip_set,
1696 &custom_skip,
1697 0,
1698 &mut projects,
1699 &mut visited,
1700 );
1701
1702 assert_eq!(
1704 projects.len(),
1705 1,
1706 "visited set should prevent duplicate scanning"
1707 );
1708 }
1709}