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