Skip to main content

testx/
workspace.rs

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
9/// Directories to skip during recursive workspace scanning.
10const 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/// A project discovered within a workspace.
50#[derive(Debug, Clone)]
51pub struct WorkspaceProject {
52    /// Path to the project root directory.
53    pub path: PathBuf,
54    /// Detection result — language, framework, confidence.
55    pub language: String,
56    /// Framework name.
57    pub framework: String,
58    /// Confidence score.
59    pub confidence: f64,
60    /// Index of the adapter in the detection engine.
61    pub adapter_index: usize,
62}
63
64/// Result of running tests in a single workspace project.
65#[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/// Aggregated results across all workspace projects.
75#[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/// Configuration for workspace scanning and execution.
90#[derive(Debug, Clone)]
91pub struct WorkspaceConfig {
92    /// Maximum directory depth to scan (0 = unlimited).
93    pub max_depth: usize,
94    /// Run projects in parallel.
95    pub parallel: bool,
96    /// Maximum parallel jobs (0 = auto-detect CPU count).
97    pub max_jobs: usize,
98    /// Fail fast — stop on first project failure.
99    pub fail_fast: bool,
100    /// Filter to specific languages.
101    pub filter_languages: Vec<String>,
102    /// Custom directories to skip.
103    pub skip_dirs: Vec<String>,
104    /// Directories to include even if they're in the default skip list.
105    /// Overrides SKIP_DIRS for specific directory names (e.g., "packages").
106    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
135/// Recursively scan a directory tree to discover all testable projects.
136///
137/// Returns projects sorted by path. Each project is the "deepest" match —
138/// if `/repo/services/api` has a Cargo.toml and `/repo` also has one,
139/// both are returned as separate projects.
140pub 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    // Allow include_dirs to override default skip list
149    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    // Sort by path for deterministic ordering
168    projects.sort_by(|a, b| a.path.cmp(&b.path));
169
170    // Apply language filter if specified
171    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    // Depth limit
195    if config.max_depth > 0 && depth > config.max_depth {
196        return;
197    }
198
199    // Canonicalize to avoid symlink loops
200    let canonical = match dir.canonicalize() {
201        Ok(p) => p,
202        Err(_) => return,
203    };
204    if !visited.insert(canonical.clone()) {
205        return;
206    }
207
208    // Try to detect a project in this directory
209    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    // Recurse into subdirectories
220    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        // Skip hidden directories (starting with .)
237        if dir_name.starts_with('.') {
238            continue;
239        }
240
241        // Skip known non-project directories
242        if skip_set.contains(dir_name.as_str()) {
243            continue;
244        }
245
246        // Skip custom directories
247        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
264/// Run tests in all discovered workspace projects.
265pub 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            // Mark remaining projects as skipped
304            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    // Build all commands before spawning threads (engine/adapters are not Send/Sync)
335    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    // Thread result: either raw output to parse, or an error/skip
354    #[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    // Partition work into chunks across threads
379    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    // Parse output on the main thread where we have access to the engine
462    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    // Convert ThreadResults into WorkspaceRunResults (parsing happens here)
472    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    // Check if runner is available
541    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
649/// Format a workspace report for terminal display.
650pub 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
720/// Format workspace report as JSON.
721pub 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        // Root Rust project
800        fs::write(
801            tmp.path().join("Cargo.toml"),
802            "[package]\nname = \"root\"\n",
803        )
804        .unwrap();
805
806        // Nested Go project
807        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        // Nested Python project
813        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        // Project in node_modules should be skipped
838        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        // Root project
853        fs::write(
854            tmp.path().join("Cargo.toml"),
855            "[package]\nname = \"root\"\n",
856        )
857        .unwrap();
858
859        // target/ should be skipped
860        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        // Compare using the original (non-canonicalized) path since discover_projects
873        // stores dir.to_path_buf(). Canonicalize differs across platforms:
874        // macOS: /var -> /private/var, Windows: short paths vs UNC paths.
875        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        // Create deeply nested project
883        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        // Should not find the deeply nested project
901        assert!(projects.is_empty());
902    }
903
904    #[test]
905    fn filter_by_language() {
906        let tmp = TempDir::new().unwrap();
907
908        // Rust project
909        fs::write(
910            tmp.path().join("Cargo.toml"),
911            "[package]\nname = \"root\"\n",
912        )
913        .unwrap();
914
915        // Python project
916        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    // ─── Effective jobs ───
972
973    #[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    // ─── Custom skip dirs ───
991
992    #[test]
993    fn custom_skip_dirs() {
994        let tmp = TempDir::new().unwrap();
995
996        // Project in "experiments" dir
997        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        // Project in root
1002        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    // ─── Include dirs override ───
1019
1020    #[test]
1021    fn include_dirs_overrides_default_skip() {
1022        let tmp = TempDir::new().unwrap();
1023
1024        // Project inside "packages" dir (normally skipped by SKIP_DIRS)
1025        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        // Project in root
1034        fs::write(
1035            tmp.path().join("Cargo.toml"),
1036            "[package]\nname = \"root\"\n",
1037        )
1038        .unwrap();
1039
1040        let engine = DetectionEngine::new();
1041
1042        // Without include_dirs: packages/ is skipped
1043        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        // With include_dirs: packages/ is scanned
1049        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        // Project in "experiments" dir
1069        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        // include_dirs only overrides SKIP_DIRS, not custom skip_dirs
1076        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    // ─── Symlink loop protection ───
1086
1087    #[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        // Create a symlink loop: sub/loop -> parent
1094        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        // This should not hang or crash
1105        let projects = discover_projects(tmp.path(), &engine, &config);
1106        assert_eq!(projects.len(), 1);
1107    }
1108
1109    // ─── build_report ───
1110
1111    #[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); // error is not counted as "run"
1254    }
1255
1256    // ─── Format report variants ───
1257
1258    #[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    // ─── JSON report with project details ───
1329
1330    #[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    // ─── Discovery edge cases ───
1415
1416    #[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, // unlimited
1434            ..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        // Create projects in reverse alphabetical order
1445        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        // Should be sorted by path
1458        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    // ─── Config defaults ───
1503
1504    #[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    // ─── Recursion depth safety ───
1516
1517    #[test]
1518    fn deep_recursion_100_levels_respects_depth_limit() {
1519        let tmp = TempDir::new().unwrap();
1520
1521        // Create a 100-level deep directory tree
1522        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(&current).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        // Should NOT find the deeply nested project and should NOT stack overflow
1539        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        // Create a 50-level deep directory tree with a project at the bottom
1551        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(&current).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, // unlimited
1561            ..Default::default()
1562        };
1563        // Should find it without stack overflow
1564        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        // A -> B -> C -> A (multi-hop symlink loop)
1577        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        // Must complete without hanging
1596        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        // Symlink pointing to itself
1610        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        // Should complete without infinite loop
1616        assert!(projects.is_empty());
1617    }
1618
1619    // ─── Memory growth safety ───
1620
1621    #[test]
1622    fn broad_directory_tree_no_excessive_memory() {
1623        let tmp = TempDir::new().unwrap();
1624
1625        // Create 500 sibling directories (wide tree)
1626        for i in 0..500 {
1627            let dir = tmp.path().join(format!("project_{}", i));
1628            fs::create_dir_all(&dir).unwrap();
1629            // Each directory is just empty — no project marker
1630        }
1631
1632        let engine = DetectionEngine::new();
1633        let config = WorkspaceConfig::default();
1634        let projects = discover_projects(tmp.path(), &engine, &config);
1635        // Should handle 500 directories without issues
1636        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        // Create 50 Rust projects
1644        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        // This ensures visited HashSet actually prevents revisiting
1666        let tmp = TempDir::new().unwrap();
1667
1668        // Create two paths to the same directory
1669        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 same directory twice
1681        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        // Should only appear once due to visited set
1703        assert_eq!(
1704            projects.len(),
1705            1,
1706            "visited set should prevent duplicate scanning"
1707        );
1708    }
1709}