Skip to main content

clean_dev_dirs/
scanner.rs

1//! Directory scanning and project detection functionality.
2//!
3//! This module provides the core scanning logic that traverses directory trees
4//! to find development projects and their build artifacts. It supports parallel
5//! processing for improved performance and handles various error conditions
6//! gracefully.
7
8use std::{
9    fs,
10    path::{Path, PathBuf},
11    sync::{
12        Arc, Mutex,
13        atomic::{AtomicUsize, Ordering},
14    },
15};
16
17use colored::Colorize;
18use indicatif::{ProgressBar, ProgressStyle};
19use rayon::prelude::*;
20use serde_json::{Value, from_str};
21use walkdir::{DirEntry, WalkDir};
22
23use crate::{
24    config::{ProjectFilter, ScanOptions},
25    project::{BuildArtifacts, Project, ProjectType},
26};
27
28/// Directory scanner for detecting development projects.
29///
30/// The `Scanner` struct encapsulates the logic for traversing directory trees
31/// and identifying development projects (Rust and Node.js) along with their
32/// build artifacts. It supports configurable filtering and parallel processing
33/// for efficient scanning of large directory structures.
34pub struct Scanner {
35    /// Configuration options for scanning behavior
36    scan_options: ScanOptions,
37
38    /// Filter to restrict scanning to specific project types
39    project_filter: ProjectFilter,
40
41    /// When `true`, suppresses progress spinner output (used by `--json` mode).
42    quiet: bool,
43}
44
45impl Scanner {
46    /// Create a new scanner with the specified options.
47    ///
48    /// # Arguments
49    ///
50    /// * `scan_options` - Configuration for scanning behavior (threads, verbosity, etc.)
51    /// * `project_filter` - Filter to restrict scanning to specific project types
52    ///
53    /// # Returns
54    ///
55    /// A new `Scanner` instance configured with the provided options.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// # use crate::{Scanner, ScanOptions, ProjectFilter};
61    /// let scan_options = ScanOptions {
62    ///     verbose: true,
63    ///     threads: 4,
64    ///     skip: vec![],
65    /// };
66    ///
67    /// let scanner = Scanner::new(scan_options, ProjectFilter::All);
68    /// ```
69    #[must_use]
70    pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
71        Self {
72            scan_options,
73            project_filter,
74            quiet: false,
75        }
76    }
77
78    /// Enable or disable quiet mode (suppresses progress spinner).
79    ///
80    /// When quiet mode is active the scanning spinner is hidden, which is
81    /// required for `--json` output so that only the final JSON is printed.
82    #[must_use]
83    pub const fn with_quiet(mut self, quiet: bool) -> Self {
84        self.quiet = quiet;
85        self
86    }
87
88    /// Scan a directory tree for development projects.
89    ///
90    /// This method performs a recursive scan of the specified directory to find
91    /// development projects. It operates in two phases:
92    /// 1. Directory traversal to identify potential projects
93    /// 2. Parallel size calculation for build directories
94    ///
95    /// # Arguments
96    ///
97    /// * `root` - The root directory to start scanning from
98    ///
99    /// # Returns
100    ///
101    /// A vector of `Project` instances representing all detected projects with
102    /// non-zero build directory sizes.
103    ///
104    /// # Panics
105    ///
106    /// This method may panic if the progress bar template string is invalid,
107    /// though this should not occur under normal circumstances as the template
108    /// is hardcoded and valid.
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// # use std::path::Path;
114    /// # use crate::Scanner;
115    /// let projects = scanner.scan_directory(Path::new("/path/to/projects"));
116    /// println!("Found {} projects", projects.len());
117    /// ```
118    ///
119    /// # Performance
120    ///
121    /// This method uses parallel processing for both directory traversal and
122    /// size calculation to maximize performance on systems with multiple cores
123    /// and fast storage.
124    pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
125        let errors = Arc::new(Mutex::new(Vec::<String>::new()));
126
127        let progress = if self.quiet {
128            ProgressBar::hidden()
129        } else {
130            let pb = ProgressBar::new_spinner();
131            pb.set_style(
132                ProgressStyle::default_spinner()
133                    .template("{spinner:.green} {msg}")
134                    .unwrap(),
135            );
136            pb.set_message("Scanning...");
137            pb.enable_steady_tick(std::time::Duration::from_millis(100));
138            pb
139        };
140
141        let found_count = Arc::new(AtomicUsize::new(0));
142        let progress_clone = progress.clone();
143        let count_clone = Arc::clone(&found_count);
144
145        // Find all potential project directories
146        let walker = self.scan_options.max_depth.map_or_else(
147            || WalkDir::new(root),
148            |depth| WalkDir::new(root).max_depth(depth),
149        );
150
151        let potential_projects: Vec<_> = walker
152            .into_iter()
153            .filter_map(Result::ok)
154            .filter(|entry| self.should_scan_entry(entry))
155            .collect::<Vec<_>>()
156            .into_par_iter()
157            .filter_map(|entry| {
158                let result = self.detect_project(&entry, &errors);
159                if result.is_some() {
160                    let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
161                    progress_clone.set_message(format!("Scanning... {n} found"));
162                }
163                result
164            })
165            .collect();
166
167        progress.finish_with_message("✅ Directory scan complete");
168
169        // Process projects in parallel to calculate sizes
170        let projects_with_sizes: Vec<_> = potential_projects
171            .into_par_iter()
172            .filter_map(|mut project| {
173                for artifact in &mut project.build_arts {
174                    if artifact.size == 0 {
175                        artifact.size = Self::calculate_build_dir_size(&artifact.path);
176                    }
177                }
178
179                if project.total_size() > 0 {
180                    Some(project)
181                } else {
182                    None
183                }
184            })
185            .collect();
186
187        // Print errors if verbose
188        if self.scan_options.verbose {
189            let errors = errors.lock().unwrap();
190            for error in errors.iter() {
191                eprintln!("{}", error.red());
192            }
193        }
194
195        projects_with_sizes
196    }
197
198    /// Scan multiple root directories and return a deduplicated list of projects.
199    ///
200    /// Calls [`scan_directory`](Scanner::scan_directory) for each root and merges
201    /// the results, skipping any project whose `root_path` was already seen.
202    ///
203    /// # Arguments
204    ///
205    /// * `roots` - Slice of root directories to scan
206    ///
207    /// # Returns
208    ///
209    /// A `Vec<Project>` containing all unique projects found across all roots.
210    #[must_use]
211    pub fn scan_directories(&self, roots: &[PathBuf]) -> Vec<Project> {
212        use std::collections::HashSet;
213        let mut seen: HashSet<PathBuf> = HashSet::new();
214        let mut result = Vec::new();
215        for root in roots {
216            for project in self.scan_directory(root) {
217                if seen.insert(project.root_path.clone()) {
218                    result.push(project);
219                }
220            }
221        }
222        result
223    }
224
225    /// Calculate the total size of a build directory.
226    ///
227    /// This method recursively traverses the specified directory and sums up
228    /// the sizes of all files contained within it. It handles errors gracefully
229    /// and optionally reports them in verbose mode.
230    ///
231    /// # Arguments
232    ///
233    /// * `path` - Path to the build directory to measure
234    ///
235    /// # Returns
236    ///
237    /// The total size of all files in the directory, in bytes. Returns 0 if
238    /// the directory doesn't exist or cannot be accessed.
239    ///
240    /// # Performance
241    ///
242    /// This method can be CPU and I/O intensive for large directories with
243    /// many files. It's designed to be called in parallel for multiple
244    /// directories to maximize throughput.
245    fn calculate_build_dir_size(path: &Path) -> u64 {
246        if !path.exists() {
247            return 0;
248        }
249
250        crate::utils::calculate_dir_size(path)
251    }
252
253    /// Detect a Node.js project in the specified directory.
254    ///
255    /// This method checks for the presence of both `package.json` and `node_modules/`
256    /// directory to identify a Node.js project. If found, it attempts to extract
257    /// the project name from the `package.json` file.
258    ///
259    /// # Arguments
260    ///
261    /// * `path` - Directory path to check for Node.js project
262    /// * `errors` - Shared error collection for reporting parsing issues
263    ///
264    /// # Returns
265    ///
266    /// - `Some(Project)` if a valid Node.js project is detected
267    /// - `None` if the directory doesn't contain a Node.js project
268    ///
269    /// # Detection Criteria
270    ///
271    /// 1. `package.json` file exists in directory
272    /// 2. `node_modules/` subdirectory exists in directory
273    /// 3. The project name is extracted from `package.json` if possible
274    fn detect_node_project(
275        &self,
276        path: &Path,
277        errors: &Arc<Mutex<Vec<String>>>,
278    ) -> Option<Project> {
279        let package_json = path.join("package.json");
280        let node_modules = path.join("node_modules");
281
282        if package_json.exists() && node_modules.exists() {
283            let name = self.extract_node_project_name(&package_json, errors);
284
285            let build_arts = vec![BuildArtifacts {
286                path: path.join("node_modules"),
287                size: 0, // Will be calculated later
288            }];
289
290            return Some(Project::new(
291                ProjectType::Node,
292                path.to_path_buf(),
293                build_arts,
294                name,
295            ));
296        }
297
298        None
299    }
300
301    /// Detect if a directory entry represents a development project.
302    ///
303    /// This method examines a directory entry and determines if it contains
304    /// a development project based on the presence of characteristic files
305    /// and directories. It respects the project filter settings.
306    ///
307    /// # Arguments
308    ///
309    /// * `entry` - The directory entry to examine
310    /// * `errors` - Shared error collection for reporting issues
311    ///
312    /// # Returns
313    ///
314    /// - `Some(Project)` if a valid project is detected
315    /// - `None` if no project is found or the entry doesn't match filters
316    ///
317    /// # Project Detection Logic
318    ///
319    /// - **Rust projects**: Presence of both `Cargo.toml` and `target/` directory
320    /// - **Deno projects**: Presence of `deno.json`/`deno.jsonc` with `vendor/` or `node_modules/`
321    /// - **Node.js projects**: Presence of both `package.json` and `node_modules/` directory
322    /// - **Scala projects**: Presence of `build.sbt` with `target/`
323    /// - **Java/Kotlin projects**: Presence of `pom.xml` or `build.gradle` with `target/` or `build/`
324    /// - **Python projects**: Presence of configuration files and cache directories
325    /// - **Go projects**: Presence of both `go.mod` and `vendor/` directory
326    /// - **C/C++ projects**: Presence of `CMakeLists.txt` or `Makefile` with `build/`
327    /// - **Swift projects**: Presence of `Package.swift` with `.build/`
328    /// - **.NET/C# projects**: Presence of `.csproj` files with `bin/` or `obj/`
329    /// - **Ruby projects**: Presence of `Gemfile` with `.bundle/` or `vendor/bundle/`
330    /// - **Elixir projects**: Presence of `mix.exs` with `_build/`
331    /// - **PHP projects**: Presence of `composer.json` with `vendor/`
332    /// - **Haskell projects**: Presence of `stack.yaml` with `.stack-work/`, or `*.cabal` with `dist-newstyle/`
333    /// - **Dart/Flutter projects**: Presence of `pubspec.yaml` with `.dart_tool/` or `build/`
334    /// - **Zig projects**: Presence of `build.zig` with `zig-cache/` or `zig-out/`
335    fn detect_project(
336        &self,
337        entry: &DirEntry,
338        errors: &Arc<Mutex<Vec<String>>>,
339    ) -> Option<Project> {
340        let path = entry.path();
341
342        if !entry.file_type().is_dir() {
343            return None;
344        }
345
346        // Detectors are tried in order; the first match wins.
347        // More specific ecosystems are checked before more generic ones
348        // (e.g. Scala before Java, since both use target/; Deno before
349        // Node since Deno 2 projects may also have a node_modules/).
350        self.try_detect(ProjectFilter::Rust, || {
351            self.detect_rust_project(path, errors)
352        })
353        .or_else(|| {
354            self.try_detect(ProjectFilter::Deno, || {
355                self.detect_deno_project(path, errors)
356            })
357        })
358        .or_else(|| {
359            self.try_detect(ProjectFilter::Node, || {
360                self.detect_node_project(path, errors)
361            })
362        })
363        .or_else(|| {
364            self.try_detect(ProjectFilter::Scala, || {
365                self.detect_scala_project(path, errors)
366            })
367        })
368        .or_else(|| {
369            self.try_detect(ProjectFilter::Java, || {
370                self.detect_java_project(path, errors)
371            })
372        })
373        .or_else(|| {
374            self.try_detect(ProjectFilter::Swift, || {
375                self.detect_swift_project(path, errors)
376            })
377        })
378        .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
379        .or_else(|| {
380            self.try_detect(ProjectFilter::Python, || {
381                self.detect_python_project(path, errors)
382            })
383        })
384        .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
385        .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
386        .or_else(|| {
387            self.try_detect(ProjectFilter::Ruby, || {
388                self.detect_ruby_project(path, errors)
389            })
390        })
391        .or_else(|| {
392            self.try_detect(ProjectFilter::Elixir, || {
393                self.detect_elixir_project(path, errors)
394            })
395        })
396        .or_else(|| self.try_detect(ProjectFilter::Php, || self.detect_php_project(path, errors)))
397        .or_else(|| {
398            self.try_detect(ProjectFilter::Haskell, || {
399                self.detect_haskell_project(path, errors)
400            })
401        })
402        .or_else(|| {
403            self.try_detect(ProjectFilter::Dart, || {
404                self.detect_dart_project(path, errors)
405            })
406        })
407        .or_else(|| self.try_detect(ProjectFilter::Zig, || Self::detect_zig_project(path)))
408    }
409
410    /// Run a detector only if the current project filter allows it.
411    ///
412    /// Returns `None` immediately (without calling `detect`) when the
413    /// active filter doesn't include `filter`.
414    fn try_detect(
415        &self,
416        filter: ProjectFilter,
417        detect: impl FnOnce() -> Option<Project>,
418    ) -> Option<Project> {
419        if self.project_filter == ProjectFilter::All || self.project_filter == filter {
420            detect()
421        } else {
422            None
423        }
424    }
425
426    /// Detect a Rust project in the specified directory.
427    ///
428    /// This method checks for the presence of both `Cargo.toml` and `target/`
429    /// directory to identify a Rust project. If found, it attempts to extract
430    /// the project name from the `Cargo.toml` file.
431    ///
432    /// # Arguments
433    ///
434    /// * `path` - Directory path to check for a Rust project
435    /// * `errors` - Shared error collection for reporting parsing issues
436    ///
437    /// # Returns
438    ///
439    /// - `Some(Project)` if a valid Rust project is detected
440    /// - `None` if the directory doesn't contain a Rust project
441    ///
442    /// # Detection Criteria
443    ///
444    /// 1. `Cargo.toml` file exists in directory
445    /// 2. `target/` subdirectory exists in directory
446    /// 3. The project name is extracted from `Cargo.toml` if possible
447    fn detect_rust_project(
448        &self,
449        path: &Path,
450        errors: &Arc<Mutex<Vec<String>>>,
451    ) -> Option<Project> {
452        let cargo_toml = path.join("Cargo.toml");
453        let target_dir = path.join("target");
454
455        if cargo_toml.exists() && target_dir.exists() {
456            // Skip workspace members — their artifacts are managed by the workspace root.
457            if Self::is_inside_cargo_workspace(path) {
458                return None;
459            }
460
461            let name = self.extract_rust_project_name(&cargo_toml, errors);
462
463            let build_arts = vec![BuildArtifacts {
464                path: path.join("target"),
465                size: 0, // Will be calculated later
466            }];
467
468            return Some(Project::new(
469                ProjectType::Rust,
470                path.to_path_buf(),
471                build_arts,
472                name,
473            ));
474        }
475
476        None
477    }
478
479    /// Return true if the given `Cargo.toml` declares a `[workspace]` section.
480    fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
481        fs::read_to_string(cargo_toml)
482            .map(|content| content.lines().any(|line| line.trim() == "[workspace]"))
483            .unwrap_or(false)
484    }
485
486    /// Return true if `path` is inside a Rust workspace (an ancestor directory
487    /// contains a `Cargo.toml` that declares `[workspace]`).
488    fn is_inside_cargo_workspace(path: &Path) -> bool {
489        path.ancestors()
490            .skip(1) // skip `path` itself
491            .any(|ancestor| {
492                let cargo_toml = ancestor.join("Cargo.toml");
493                cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
494            })
495    }
496
497    /// Extract the project name from a Cargo.toml file.
498    ///
499    /// This method performs simple TOML parsing to extract the project name
500    /// from a Rust project's `Cargo.toml` file. It uses a line-by-line approach
501    /// rather than a full TOML parser for simplicity and performance.
502    ///
503    /// # Arguments
504    ///
505    /// * `cargo_toml` - Path to the Cargo.toml file
506    /// * `errors` - Shared error collection for reporting parsing issues
507    ///
508    /// # Returns
509    ///
510    /// - `Some(String)` containing the project name if successfully extracted
511    /// - `None` if the name cannot be found or parsed
512    ///
513    /// # Parsing Strategy
514    ///
515    /// The method looks for lines matching the pattern `name = "project_name"`
516    /// and extracts the quoted string value. This trivial approach handles
517    /// most common cases without requiring a full TOML parser.
518    fn extract_rust_project_name(
519        &self,
520        cargo_toml: &Path,
521        errors: &Arc<Mutex<Vec<String>>>,
522    ) -> Option<String> {
523        let content = self.read_file_content(cargo_toml, errors)?;
524        Self::parse_toml_name_field(&content)
525    }
526
527    /// Extract a quoted string value from a line.
528    fn extract_quoted_value(line: &str) -> Option<String> {
529        let start = line.find('"')?;
530        let end = line.rfind('"')?;
531
532        if start == end {
533            return None;
534        }
535
536        Some(line[start + 1..end].to_string())
537    }
538
539    /// Extract the name from a single TOML line if it contains a name field.
540    fn extract_name_from_line(line: &str) -> Option<String> {
541        if !Self::is_name_line(line) {
542            return None;
543        }
544
545        Self::extract_quoted_value(line)
546    }
547
548    /// Extract the project name from a package.json file.
549    ///
550    /// This method parses a Node.js project's `package.json` file to extract
551    /// the project name. It uses full JSON parsing to handle the file format
552    /// correctly and safely.
553    ///
554    /// # Arguments
555    ///
556    /// * `package_json` - Path to the package.json file
557    /// * `errors` - Shared error collection for reporting parsing issues
558    ///
559    /// # Returns
560    ///
561    /// - `Some(String)` containing the project name if successfully extracted
562    /// - `None` if the name cannot be found, parsed, or the file is invalid
563    ///
564    /// # Error Handling
565    ///
566    /// This method handles both file I/O errors and JSON parsing errors gracefully.
567    /// Errors are optionally reported to the shared error collection in verbose mode.
568    fn extract_node_project_name(
569        &self,
570        package_json: &Path,
571        errors: &Arc<Mutex<Vec<String>>>,
572    ) -> Option<String> {
573        match fs::read_to_string(package_json) {
574            Ok(content) => match from_str::<Value>(&content) {
575                Ok(json) => json
576                    .get("name")
577                    .and_then(|v| v.as_str())
578                    .map(std::string::ToString::to_string),
579                Err(e) => {
580                    if self.scan_options.verbose {
581                        errors
582                            .lock()
583                            .unwrap()
584                            .push(format!("Error parsing {}: {e}", package_json.display()));
585                    }
586                    None
587                }
588            },
589            Err(e) => {
590                if self.scan_options.verbose {
591                    errors
592                        .lock()
593                        .unwrap()
594                        .push(format!("Error reading {}: {e}", package_json.display()));
595                }
596                None
597            }
598        }
599    }
600
601    /// Check if a line contains a name field assignment.
602    fn is_name_line(line: &str) -> bool {
603        line.starts_with("name") && line.contains('=')
604    }
605
606    /// Log a file reading error if verbose mode is enabled.
607    fn log_file_error(
608        &self,
609        file_path: &Path,
610        error: &std::io::Error,
611        errors: &Arc<Mutex<Vec<String>>>,
612    ) {
613        if self.scan_options.verbose {
614            errors
615                .lock()
616                .unwrap()
617                .push(format!("Error reading {}: {error}", file_path.display()));
618        }
619    }
620
621    /// Parse the name field from TOML content.
622    fn parse_toml_name_field(content: &str) -> Option<String> {
623        for line in content.lines() {
624            if let Some(name) = Self::extract_name_from_line(line.trim()) {
625                return Some(name);
626            }
627        }
628        None
629    }
630
631    /// Read the content of a file and handle errors appropriately.
632    fn read_file_content(
633        &self,
634        file_path: &Path,
635        errors: &Arc<Mutex<Vec<String>>>,
636    ) -> Option<String> {
637        match fs::read_to_string(file_path) {
638            Ok(content) => Some(content),
639            Err(e) => {
640                self.log_file_error(file_path, &e, errors);
641                None
642            }
643        }
644    }
645
646    /// Determine if a directory entry should be scanned for projects.
647    ///
648    /// This method implements the filtering logic to decide whether a directory
649    /// should be traversed during the scanning process. It applies various
650    /// exclusion rules to improve performance and avoid scanning irrelevant
651    /// directories.
652    ///
653    /// # Arguments
654    ///
655    /// * `entry` - The directory entry to evaluate
656    ///
657    /// # Returns
658    ///
659    /// - `true` if the directory should be scanned
660    /// - `false` if the directory should be skipped
661    ///
662    /// # Exclusion Rules
663    ///
664    /// The following directories are excluded from scanning:
665    /// - Directories in the user-specified skip list
666    /// - Any directory inside a `node_modules/` directory (to avoid deep nesting)
667    /// - Hidden directories (starting with `.`) except `.cargo`
668    /// - Common build/temporary directories: `target`, `build`, `dist`, `out`, etc.
669    /// - Version control directories: `.git`, `.svn`, `.hg`
670    /// - Python cache and virtual environment directories
671    /// - Temporary directories: `temp`, `tmp`
672    /// - Go vendor directory
673    /// - Python pytest cache
674    /// - Python tox environments
675    /// - Python setuptools
676    /// - Python coverage files
677    /// - Node.js modules (already handled above but added for completeness)
678    /// - .NET `obj/` directory
679    fn should_scan_entry(&self, entry: &DirEntry) -> bool {
680        let path = entry.path();
681
682        // Early return if path is in skip list
683        if self.is_path_in_skip_list(path) {
684            return false;
685        }
686
687        // Skip any directory inside a node_modules directory
688        if path
689            .ancestors()
690            .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
691        {
692            return false;
693        }
694
695        // Skip hidden directories (except .cargo for Rust)
696        if Self::is_hidden_directory_to_skip(path) {
697            return false;
698        }
699
700        // Skip common non-project directories
701        !Self::is_excluded_directory(path)
702    }
703
704    /// Check if a path is in the skip list
705    fn is_path_in_skip_list(&self, path: &Path) -> bool {
706        self.scan_options.skip.iter().any(|skip| {
707            path.components().any(|component| {
708                component
709                    .as_os_str()
710                    .to_str()
711                    .is_some_and(|name| name == skip.to_string_lossy())
712            })
713        })
714    }
715
716    /// Check if directory is hidden and should be skipped
717    fn is_hidden_directory_to_skip(path: &Path) -> bool {
718        path.file_name()
719            .and_then(|n| n.to_str())
720            .is_some_and(|name| name.starts_with('.') && name != ".cargo")
721    }
722
723    /// Check if directory is in the excluded list
724    fn is_excluded_directory(path: &Path) -> bool {
725        let excluded_dirs = [
726            "target",
727            "build",
728            "dist",
729            "out",
730            ".git",
731            ".svn",
732            ".hg",
733            "__pycache__",
734            "venv",
735            ".venv",
736            "env",
737            ".env",
738            "temp",
739            "tmp",
740            "vendor",
741            ".pytest_cache",
742            ".tox",
743            ".eggs",
744            ".coverage",
745            "node_modules",
746            "obj",
747            "_build",
748            "zig-cache",
749            "zig-out",
750            "dist-newstyle",
751        ];
752
753        path.file_name()
754            .and_then(|n| n.to_str())
755            .is_some_and(|name| excluded_dirs.contains(&name))
756    }
757
758    /// Detect a Python project in the specified directory.
759    ///
760    /// This method checks for Python configuration files and associated cache directories.
761    /// It looks for multiple build artifacts that can be cleaned.
762    ///
763    /// # Arguments
764    ///
765    /// * `path` - Directory path to check for a Python project
766    /// * `errors` - Shared error collection for reporting parsing issues
767    ///
768    /// # Returns
769    ///
770    /// - `Some(Project)` if a valid Python project is detected
771    /// - `None` if the directory doesn't contain a Python project
772    ///
773    /// # Detection Criteria
774    ///
775    /// A Python project is identified by having:
776    /// 1. At least one of: requirements.txt, setup.py, pyproject.toml, setup.cfg, Pipfile
777    /// 2. At least one of the cache/build directories: `__pycache__`, `.pytest_cache`, venv, .venv, build, dist, .eggs
778    fn detect_python_project(
779        &self,
780        path: &Path,
781        errors: &Arc<Mutex<Vec<String>>>,
782    ) -> Option<Project> {
783        let config_files = [
784            "requirements.txt",
785            "setup.py",
786            "pyproject.toml",
787            "setup.cfg",
788            "Pipfile",
789            "pipenv.lock",
790            "poetry.lock",
791        ];
792
793        let build_dirs = [
794            "__pycache__",
795            ".pytest_cache",
796            "venv",
797            ".venv",
798            "build",
799            "dist",
800            ".eggs",
801            ".tox",
802            ".coverage",
803        ];
804
805        // Check if any config file exists
806        let has_config = config_files.iter().any(|&file| path.join(file).exists());
807
808        if !has_config {
809            return None;
810        }
811
812        // Collect all existing cache/build directories.
813        let mut build_arts: Vec<BuildArtifacts> = build_dirs
814            .iter()
815            .filter_map(|&dir_name| {
816                let dir_path = path.join(dir_name);
817                if dir_path.exists() && dir_path.is_dir() {
818                    let size = crate::utils::calculate_dir_size(&dir_path);
819                    Some(BuildArtifacts {
820                        path: dir_path,
821                        size,
822                    })
823                } else {
824                    None
825                }
826            })
827            .collect();
828
829        // Also collect any *.egg-info directories present in the project root.
830        if let Ok(entries) = std::fs::read_dir(path) {
831            for entry in entries.flatten() {
832                let entry_path = entry.path();
833                if entry_path.is_dir()
834                    && entry_path
835                        .file_name()
836                        .and_then(|n| n.to_str())
837                        .is_some_and(|n| n.ends_with(".egg-info"))
838                {
839                    let size = crate::utils::calculate_dir_size(&entry_path);
840                    build_arts.push(BuildArtifacts {
841                        path: entry_path,
842                        size,
843                    });
844                }
845            }
846        }
847
848        if build_arts.is_empty() {
849            return None;
850        }
851
852        let name = self.extract_python_project_name(path, errors);
853
854        Some(Project::new(
855            ProjectType::Python,
856            path.to_path_buf(),
857            build_arts,
858            name,
859        ))
860    }
861
862    /// Detect a Go project in the specified directory.
863    ///
864    /// This method checks for the presence of both `go.mod` and `vendor/`
865    /// directory to identify a Go project. If found, it attempts to extract
866    /// the project name from the `go.mod` file.
867    ///
868    /// # Arguments
869    ///
870    /// * `path` - Directory path to check for a Go project
871    /// * `errors` - Shared error collection for reporting parsing issues
872    ///
873    /// # Returns
874    ///
875    /// - `Some(Project)` if a valid Go project is detected
876    /// - `None` if the directory doesn't contain a Go project
877    ///
878    /// # Detection Criteria
879    ///
880    /// 1. `go.mod` file exists in directory
881    /// 2. `vendor/` subdirectory exists in directory
882    /// 3. The project name is extracted from `go.mod` if possible
883    fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
884        let go_mod = path.join("go.mod");
885        let vendor_dir = path.join("vendor");
886
887        if go_mod.exists() && vendor_dir.exists() {
888            let name = self.extract_go_project_name(&go_mod, errors);
889
890            let build_arts = vec![BuildArtifacts {
891                path: path.join("vendor"),
892                size: 0, // Will be calculated later
893            }];
894
895            return Some(Project::new(
896                ProjectType::Go,
897                path.to_path_buf(),
898                build_arts,
899                name,
900            ));
901        }
902
903        None
904    }
905
906    /// Extract the project name from a Python project directory.
907    ///
908    /// This method attempts to extract the project name from various Python
909    /// configuration files in order of preference.
910    ///
911    /// # Arguments
912    ///
913    /// * `path` - Path to the Python project directory
914    /// * `errors` - Shared error collection for reporting parsing issues
915    ///
916    /// # Returns
917    ///
918    /// - `Some(String)` containing the project name if successfully extracted
919    /// - `None` if the name cannot be found or parsed
920    ///
921    /// # Extraction Order
922    ///
923    /// 1. pyproject.toml (from [project] name or [tool.poetry] name)
924    /// 2. setup.py (from name= parameter)
925    /// 3. setup.cfg (from [metadata] name)
926    /// 4. Use directory name as a fallback
927    fn extract_python_project_name(
928        &self,
929        path: &Path,
930        errors: &Arc<Mutex<Vec<String>>>,
931    ) -> Option<String> {
932        // Try files in order of preference
933        self.try_extract_from_pyproject_toml(path, errors)
934            .or_else(|| self.try_extract_from_setup_py(path, errors))
935            .or_else(|| self.try_extract_from_setup_cfg(path, errors))
936            .or_else(|| Self::fallback_to_directory_name(path))
937    }
938
939    /// Try to extract project name from pyproject.toml
940    fn try_extract_from_pyproject_toml(
941        &self,
942        path: &Path,
943        errors: &Arc<Mutex<Vec<String>>>,
944    ) -> Option<String> {
945        let pyproject_toml = path.join("pyproject.toml");
946        if !pyproject_toml.exists() {
947            return None;
948        }
949
950        let content = self.read_file_content(&pyproject_toml, errors)?;
951        Self::extract_name_from_toml_like_content(&content)
952    }
953
954    /// Try to extract project name from setup.py
955    fn try_extract_from_setup_py(
956        &self,
957        path: &Path,
958        errors: &Arc<Mutex<Vec<String>>>,
959    ) -> Option<String> {
960        let setup_py = path.join("setup.py");
961        if !setup_py.exists() {
962            return None;
963        }
964
965        let content = self.read_file_content(&setup_py, errors)?;
966        Self::extract_name_from_python_content(&content)
967    }
968
969    /// Try to extract project name from setup.cfg
970    fn try_extract_from_setup_cfg(
971        &self,
972        path: &Path,
973        errors: &Arc<Mutex<Vec<String>>>,
974    ) -> Option<String> {
975        let setup_cfg = path.join("setup.cfg");
976        if !setup_cfg.exists() {
977            return None;
978        }
979
980        let content = self.read_file_content(&setup_cfg, errors)?;
981        Self::extract_name_from_cfg_content(&content)
982    }
983
984    /// Extract name from TOML-like content (pyproject.toml)
985    fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
986        content
987            .lines()
988            .map(str::trim)
989            .find(|line| line.starts_with("name") && line.contains('='))
990            .and_then(Self::extract_quoted_value)
991    }
992
993    /// Extract name from Python content (setup.py)
994    fn extract_name_from_python_content(content: &str) -> Option<String> {
995        content
996            .lines()
997            .map(str::trim)
998            .find(|line| line.contains("name") && line.contains('='))
999            .and_then(Self::extract_quoted_value)
1000    }
1001
1002    /// Extract name from INI-style configuration content (setup.cfg)
1003    fn extract_name_from_cfg_content(content: &str) -> Option<String> {
1004        let mut in_metadata_section = false;
1005
1006        for line in content.lines() {
1007            let line = line.trim();
1008
1009            if line == "[metadata]" {
1010                in_metadata_section = true;
1011            } else if line.starts_with('[') && line.ends_with(']') {
1012                in_metadata_section = false;
1013            } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
1014                return line.split('=').nth(1).map(|name| name.trim().to_string());
1015            }
1016        }
1017
1018        None
1019    }
1020
1021    /// Fallback to directory name
1022    fn fallback_to_directory_name(path: &Path) -> Option<String> {
1023        path.file_name()
1024            .and_then(|name| name.to_str())
1025            .map(std::string::ToString::to_string)
1026    }
1027
1028    /// Extract the project name from a `go.mod` file.
1029    ///
1030    /// This method parses a Go project's `go.mod` file to extract
1031    /// the module name, which typically represents the project.
1032    ///
1033    /// # Arguments
1034    ///
1035    /// * `go_mod` - Path to the `go.mod` file
1036    /// * `errors` - Shared error collection for reporting parsing issues
1037    ///
1038    /// # Returns
1039    ///
1040    /// - `Some(String)` containing the module name if successfully extracted
1041    /// - `None` if the name cannot be found or parsed
1042    ///
1043    /// # Parsing Strategy
1044    ///
1045    /// The method looks for the first line starting with `module ` and extracts
1046    /// the module path. For better display, it takes the last component of the path.
1047    fn extract_go_project_name(
1048        &self,
1049        go_mod: &Path,
1050        errors: &Arc<Mutex<Vec<String>>>,
1051    ) -> Option<String> {
1052        let content = self.read_file_content(go_mod, errors)?;
1053
1054        for line in content.lines() {
1055            let line = line.trim();
1056            if line.starts_with("module ") {
1057                let module_path = line.strip_prefix("module ")?.trim();
1058
1059                // Take the last component of the module path for a cleaner name
1060                if let Some(name) = module_path.split('/').next_back() {
1061                    return Some(name.to_string());
1062                }
1063
1064                return Some(module_path.to_string());
1065            }
1066        }
1067
1068        None
1069    }
1070
1071    /// Detect a Java/Kotlin project in the specified directory.
1072    ///
1073    /// This method checks for Maven (`pom.xml`) or Gradle (`build.gradle`,
1074    /// `build.gradle.kts`) configuration files and their associated build output
1075    /// directories (`target/` for Maven, `build/` for Gradle).
1076    ///
1077    /// # Detection Criteria
1078    ///
1079    /// 1. `pom.xml` + `target/` directory (Maven)
1080    /// 2. `build.gradle` or `build.gradle.kts` + `build/` directory (Gradle)
1081    fn detect_java_project(
1082        &self,
1083        path: &Path,
1084        errors: &Arc<Mutex<Vec<String>>>,
1085    ) -> Option<Project> {
1086        let pom_xml = path.join("pom.xml");
1087        let target_dir = path.join("target");
1088
1089        // Maven project: pom.xml + target/
1090        if pom_xml.exists() && target_dir.exists() {
1091            let name = self.extract_java_maven_project_name(&pom_xml, errors);
1092
1093            let build_arts = vec![BuildArtifacts {
1094                path: target_dir,
1095                size: 0,
1096            }];
1097
1098            return Some(Project::new(
1099                ProjectType::Java,
1100                path.to_path_buf(),
1101                build_arts,
1102                name,
1103            ));
1104        }
1105
1106        // Gradle project: build.gradle(.kts) + build/
1107        let has_gradle =
1108            path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1109        let build_dir = path.join("build");
1110
1111        if has_gradle && build_dir.exists() {
1112            let name = self.extract_java_gradle_project_name(path, errors);
1113
1114            let build_arts = vec![BuildArtifacts {
1115                path: build_dir,
1116                size: 0,
1117            }];
1118
1119            return Some(Project::new(
1120                ProjectType::Java,
1121                path.to_path_buf(),
1122                build_arts,
1123                name,
1124            ));
1125        }
1126
1127        None
1128    }
1129
1130    /// Extract the project name from a Maven `pom.xml` file.
1131    ///
1132    /// Looks for `<artifactId>` tags and extracts the text content.
1133    fn extract_java_maven_project_name(
1134        &self,
1135        pom_xml: &Path,
1136        errors: &Arc<Mutex<Vec<String>>>,
1137    ) -> Option<String> {
1138        let content = self.read_file_content(pom_xml, errors)?;
1139
1140        for line in content.lines() {
1141            let trimmed = line.trim();
1142            if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1143                let name = trimmed
1144                    .strip_prefix("<artifactId>")?
1145                    .strip_suffix("</artifactId>")?;
1146                return Some(name.to_string());
1147            }
1148        }
1149
1150        None
1151    }
1152
1153    /// Extract the project name from a Gradle project.
1154    ///
1155    /// Looks for `settings.gradle` or `settings.gradle.kts` and extracts
1156    /// the `rootProject.name` value. Falls back to directory name.
1157    fn extract_java_gradle_project_name(
1158        &self,
1159        path: &Path,
1160        errors: &Arc<Mutex<Vec<String>>>,
1161    ) -> Option<String> {
1162        for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1163            let settings_path = path.join(settings_file);
1164            if settings_path.exists()
1165                && let Some(content) = self.read_file_content(&settings_path, errors)
1166            {
1167                for line in content.lines() {
1168                    let trimmed = line.trim();
1169                    if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1170                        return Self::extract_quoted_value(trimmed).or_else(|| {
1171                            trimmed
1172                                .split('=')
1173                                .nth(1)
1174                                .map(|s| s.trim().trim_matches('\'').to_string())
1175                        });
1176                    }
1177                }
1178            }
1179        }
1180
1181        Self::fallback_to_directory_name(path)
1182    }
1183
1184    /// Detect a C/C++ project in the specified directory.
1185    ///
1186    /// This method checks for `CMakeLists.txt` or `Makefile` alongside a `build/`
1187    /// directory to identify C/C++ projects.
1188    ///
1189    /// # Detection Criteria
1190    ///
1191    /// 1. `CMakeLists.txt` + `build/` directory (`CMake`)
1192    /// 2. `Makefile` + `build/` directory (`Make`)
1193    fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1194        let build_dir = path.join("build");
1195
1196        if !build_dir.exists() {
1197            return None;
1198        }
1199
1200        let cmake_file = path.join("CMakeLists.txt");
1201        let makefile = path.join("Makefile");
1202
1203        if cmake_file.exists() || makefile.exists() {
1204            let name = if cmake_file.exists() {
1205                self.extract_cpp_cmake_project_name(&cmake_file, errors)
1206            } else {
1207                Self::fallback_to_directory_name(path)
1208            };
1209
1210            let build_arts = vec![BuildArtifacts {
1211                path: build_dir,
1212                size: 0,
1213            }];
1214
1215            return Some(Project::new(
1216                ProjectType::Cpp,
1217                path.to_path_buf(),
1218                build_arts,
1219                name,
1220            ));
1221        }
1222
1223        None
1224    }
1225
1226    /// Extract the project name from a `CMakeLists.txt` file.
1227    ///
1228    /// Looks for `project(name` patterns and extracts the project name.
1229    fn extract_cpp_cmake_project_name(
1230        &self,
1231        cmake_file: &Path,
1232        errors: &Arc<Mutex<Vec<String>>>,
1233    ) -> Option<String> {
1234        let content = self.read_file_content(cmake_file, errors)?;
1235
1236        for line in content.lines() {
1237            let trimmed = line.trim();
1238            if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1239                let inner = trimmed
1240                    .trim_start_matches("project(")
1241                    .trim_start_matches("PROJECT(")
1242                    .trim_end_matches(')')
1243                    .trim();
1244
1245                // The project name is the first word/token
1246                let name = inner.split_whitespace().next()?;
1247                // Remove possible surrounding quotes
1248                let name = name.trim_matches('"').trim_matches('\'');
1249                if !name.is_empty() {
1250                    return Some(name.to_string());
1251                }
1252            }
1253        }
1254
1255        Self::fallback_to_directory_name(cmake_file.parent()?)
1256    }
1257
1258    /// Detect a Swift project in the specified directory.
1259    ///
1260    /// This method checks for a `Package.swift` manifest and the `.build/`
1261    /// directory to identify Swift Package Manager projects.
1262    ///
1263    /// # Detection Criteria
1264    ///
1265    /// 1. `Package.swift` file exists
1266    /// 2. `.build/` directory exists
1267    fn detect_swift_project(
1268        &self,
1269        path: &Path,
1270        errors: &Arc<Mutex<Vec<String>>>,
1271    ) -> Option<Project> {
1272        let package_swift = path.join("Package.swift");
1273        let build_dir = path.join(".build");
1274
1275        if package_swift.exists() && build_dir.exists() {
1276            let name = self.extract_swift_project_name(&package_swift, errors);
1277
1278            let build_arts = vec![BuildArtifacts {
1279                path: build_dir,
1280                size: 0,
1281            }];
1282
1283            return Some(Project::new(
1284                ProjectType::Swift,
1285                path.to_path_buf(),
1286                build_arts,
1287                name,
1288            ));
1289        }
1290
1291        None
1292    }
1293
1294    /// Extract the project name from a `Package.swift` file.
1295    ///
1296    /// Looks for `name:` inside the `Package(` initializer.
1297    fn extract_swift_project_name(
1298        &self,
1299        package_swift: &Path,
1300        errors: &Arc<Mutex<Vec<String>>>,
1301    ) -> Option<String> {
1302        let content = self.read_file_content(package_swift, errors)?;
1303
1304        for line in content.lines() {
1305            let trimmed = line.trim();
1306            if trimmed.contains("name:") {
1307                return Self::extract_quoted_value(trimmed);
1308            }
1309        }
1310
1311        Self::fallback_to_directory_name(package_swift.parent()?)
1312    }
1313
1314    /// Detect a .NET/C# project in the specified directory.
1315    ///
1316    /// This method checks for `.csproj` files alongside `bin/` and/or `obj/`
1317    /// directories to identify .NET projects.
1318    ///
1319    /// # Detection Criteria
1320    ///
1321    /// 1. At least one `.csproj` file exists in the directory
1322    /// 2. At least one of `bin/` or `obj/` directories exists
1323    fn detect_dotnet_project(path: &Path) -> Option<Project> {
1324        let bin_dir = path.join("bin");
1325        let obj_dir = path.join("obj");
1326
1327        let has_build_dir = bin_dir.exists() || obj_dir.exists();
1328        if !has_build_dir {
1329            return None;
1330        }
1331
1332        let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1333
1334        // Collect bin/ and obj/ as separate build artifacts (both when present).
1335        let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1336            (true, true) => {
1337                let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1338                let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1339                vec![
1340                    BuildArtifacts {
1341                        path: bin_dir,
1342                        size: bin_size,
1343                    },
1344                    BuildArtifacts {
1345                        path: obj_dir,
1346                        size: obj_size,
1347                    },
1348                ]
1349            }
1350            (true, false) => vec![BuildArtifacts {
1351                path: bin_dir,
1352                size: 0,
1353            }],
1354            (false, true) => vec![BuildArtifacts {
1355                path: obj_dir,
1356                size: 0,
1357            }],
1358            (false, false) => return None,
1359        };
1360
1361        let name = csproj_file
1362            .file_stem()
1363            .and_then(|s| s.to_str())
1364            .map(std::string::ToString::to_string);
1365
1366        Some(Project::new(
1367            ProjectType::DotNet,
1368            path.to_path_buf(),
1369            build_arts,
1370            name,
1371        ))
1372    }
1373
1374    /// Find the first file with a given extension in a directory.
1375    fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1376        let entries = fs::read_dir(dir).ok()?;
1377        for entry in entries.flatten() {
1378            let path = entry.path();
1379            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1380                return Some(path);
1381            }
1382        }
1383        None
1384    }
1385
1386    /// Detect a Deno project in the specified directory.
1387    ///
1388    /// This method checks for a `deno.json` or `deno.jsonc` manifest alongside a
1389    /// `vendor/` directory (from `deno vendor`) or a `node_modules/` directory
1390    /// (Deno 2 npm support without a `package.json` to avoid overlap with Node.js).
1391    ///
1392    /// Deno detection runs before Node.js so that a project with `deno.json` and
1393    /// `node_modules/` (but no `package.json`) is classified as Deno.
1394    fn detect_deno_project(
1395        &self,
1396        path: &Path,
1397        errors: &Arc<Mutex<Vec<String>>>,
1398    ) -> Option<Project> {
1399        let deno_json = path.join("deno.json");
1400        let deno_jsonc = path.join("deno.jsonc");
1401
1402        if !deno_json.exists() && !deno_jsonc.exists() {
1403            return None;
1404        }
1405
1406        let config_path = if deno_json.exists() {
1407            deno_json
1408        } else {
1409            deno_jsonc
1410        };
1411
1412        // vendor/ directory (created by `deno vendor`)
1413        let vendor_dir = path.join("vendor");
1414        if vendor_dir.exists() {
1415            let name = self.extract_deno_project_name(&config_path, errors);
1416            return Some(Project::new(
1417                ProjectType::Deno,
1418                path.to_path_buf(),
1419                vec![BuildArtifacts {
1420                    path: vendor_dir,
1421                    size: 0,
1422                }],
1423                name,
1424            ));
1425        }
1426
1427        // node_modules/ (Deno 2 npm support) — only when no package.json exists
1428        let node_modules = path.join("node_modules");
1429        if node_modules.exists() && !path.join("package.json").exists() {
1430            let name = self.extract_deno_project_name(&config_path, errors);
1431            return Some(Project::new(
1432                ProjectType::Deno,
1433                path.to_path_buf(),
1434                vec![BuildArtifacts {
1435                    path: node_modules,
1436                    size: 0,
1437                }],
1438                name,
1439            ));
1440        }
1441
1442        None
1443    }
1444
1445    /// Extract the project name from a `deno.json` or `deno.jsonc` file.
1446    ///
1447    /// Parses the JSON file and reads the top-level `"name"` field.
1448    /// Falls back to the directory name if the field is absent or the file cannot be parsed.
1449    fn extract_deno_project_name(
1450        &self,
1451        config_path: &Path,
1452        errors: &Arc<Mutex<Vec<String>>>,
1453    ) -> Option<String> {
1454        match fs::read_to_string(config_path) {
1455            Ok(content) => {
1456                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1457                    && let Some(name) = json.get("name").and_then(|v| v.as_str())
1458                {
1459                    return Some(name.to_string());
1460                }
1461                Self::fallback_to_directory_name(config_path.parent()?)
1462            }
1463            Err(e) => {
1464                self.log_file_error(config_path, &e, errors);
1465                Self::fallback_to_directory_name(config_path.parent()?)
1466            }
1467        }
1468    }
1469
1470    /// Detect a Ruby project in the specified directory.
1471    ///
1472    /// This method checks for a `Gemfile` alongside a `.bundle/` or `vendor/bundle/`
1473    /// directory. When both exist the larger one is selected as the primary artifact.
1474    ///
1475    /// # Detection Criteria
1476    ///
1477    /// 1. `Gemfile` file exists in directory
1478    /// 2. At least one of `.bundle/` or `vendor/bundle/` directories exists
1479    fn detect_ruby_project(
1480        &self,
1481        path: &Path,
1482        errors: &Arc<Mutex<Vec<String>>>,
1483    ) -> Option<Project> {
1484        let gemfile = path.join("Gemfile");
1485        if !gemfile.exists() {
1486            return None;
1487        }
1488
1489        let bundle_dir = path.join(".bundle");
1490        let vendor_bundle_dir = path.join("vendor").join("bundle");
1491
1492        let build_arts: Vec<BuildArtifacts> =
1493            match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1494                (true, true) => {
1495                    let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1496                    let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1497                    vec![
1498                        BuildArtifacts {
1499                            path: bundle_dir,
1500                            size: bundle_size,
1501                        },
1502                        BuildArtifacts {
1503                            path: vendor_bundle_dir,
1504                            size: vendor_size,
1505                        },
1506                    ]
1507                }
1508                (true, false) => vec![BuildArtifacts {
1509                    path: bundle_dir,
1510                    size: 0,
1511                }],
1512                (false, true) => vec![BuildArtifacts {
1513                    path: vendor_bundle_dir,
1514                    size: 0,
1515                }],
1516                (false, false) => return None,
1517            };
1518
1519        let name = self.extract_ruby_project_name(path, errors);
1520
1521        Some(Project::new(
1522            ProjectType::Ruby,
1523            path.to_path_buf(),
1524            build_arts,
1525            name,
1526        ))
1527    }
1528
1529    /// Extract the project name from a Ruby project directory.
1530    ///
1531    /// Looks for a `.gemspec` file and parses the `spec.name` or `s.name` assignment.
1532    /// Falls back to the directory name.
1533    fn extract_ruby_project_name(
1534        &self,
1535        path: &Path,
1536        errors: &Arc<Mutex<Vec<String>>>,
1537    ) -> Option<String> {
1538        let entries = fs::read_dir(path).ok()?;
1539        for entry in entries.flatten() {
1540            let entry_path = entry.path();
1541            if entry_path.is_file()
1542                && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1543                && let Some(content) = self.read_file_content(&entry_path, errors)
1544            {
1545                for line in content.lines() {
1546                    let trimmed = line.trim();
1547                    if trimmed.contains(".name")
1548                        && trimmed.contains('=')
1549                        && let Some(name) = Self::extract_quoted_value(trimmed)
1550                    {
1551                        return Some(name);
1552                    }
1553                }
1554            }
1555        }
1556
1557        Self::fallback_to_directory_name(path)
1558    }
1559
1560    /// Detect an Elixir project in the specified directory.
1561    ///
1562    /// This method checks for the presence of both `mix.exs` and `_build/`
1563    /// to identify an Elixir/Mix project.
1564    ///
1565    /// # Detection Criteria
1566    ///
1567    /// 1. `mix.exs` file exists in directory
1568    /// 2. `_build/` subdirectory exists in directory
1569    fn detect_elixir_project(
1570        &self,
1571        path: &Path,
1572        errors: &Arc<Mutex<Vec<String>>>,
1573    ) -> Option<Project> {
1574        let mix_exs = path.join("mix.exs");
1575        let build_dir = path.join("_build");
1576
1577        if mix_exs.exists() && build_dir.exists() {
1578            let name = self.extract_elixir_project_name(&mix_exs, errors);
1579
1580            return Some(Project::new(
1581                ProjectType::Elixir,
1582                path.to_path_buf(),
1583                vec![BuildArtifacts {
1584                    path: build_dir,
1585                    size: 0,
1586                }],
1587                name,
1588            ));
1589        }
1590
1591        None
1592    }
1593
1594    /// Extract the project name from a `mix.exs` file.
1595    ///
1596    /// Looks for the `app: :atom_name` pattern inside the Mix project definition.
1597    /// Falls back to the directory name.
1598    fn extract_elixir_project_name(
1599        &self,
1600        mix_exs: &Path,
1601        errors: &Arc<Mutex<Vec<String>>>,
1602    ) -> Option<String> {
1603        let content = self.read_file_content(mix_exs, errors)?;
1604
1605        for line in content.lines() {
1606            let trimmed = line.trim();
1607            if trimmed.contains("app:")
1608                && let Some(pos) = trimmed.find("app:")
1609            {
1610                let after = trimmed[pos + 4..].trim_start();
1611                if let Some(atom) = after.strip_prefix(':') {
1612                    // Elixir atom names consist of alphanumeric chars and underscores
1613                    let name: String = atom
1614                        .chars()
1615                        .take_while(|c| c.is_alphanumeric() || *c == '_')
1616                        .collect();
1617                    if !name.is_empty() {
1618                        return Some(name);
1619                    }
1620                }
1621            }
1622        }
1623
1624        Self::fallback_to_directory_name(mix_exs.parent()?)
1625    }
1626
1627    /// Detect a PHP project in the specified directory.
1628    ///
1629    /// This method checks for the presence of both `composer.json` and `vendor/`
1630    /// to identify a PHP/Composer project.
1631    ///
1632    /// # Detection Criteria
1633    ///
1634    /// 1. `composer.json` file exists in directory
1635    /// 2. `vendor/` subdirectory exists in directory
1636    fn detect_php_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1637        let composer_json = path.join("composer.json");
1638        let vendor_dir = path.join("vendor");
1639
1640        if composer_json.exists() && vendor_dir.exists() {
1641            let name = self.extract_php_project_name(&composer_json, errors);
1642
1643            return Some(Project::new(
1644                ProjectType::Php,
1645                path.to_path_buf(),
1646                vec![BuildArtifacts {
1647                    path: vendor_dir,
1648                    size: 0,
1649                }],
1650                name,
1651            ));
1652        }
1653
1654        None
1655    }
1656
1657    /// Extract the project name from a `composer.json` file.
1658    ///
1659    /// Parses the JSON and reads the top-level `"name"` field.
1660    /// The name is typically `vendor/package`; only the package component is returned.
1661    /// Falls back to the directory name if the field is absent or the file cannot be parsed.
1662    fn extract_php_project_name(
1663        &self,
1664        composer_json: &Path,
1665        errors: &Arc<Mutex<Vec<String>>>,
1666    ) -> Option<String> {
1667        match fs::read_to_string(composer_json) {
1668            Ok(content) => {
1669                if let Ok(json) = from_str::<Value>(&content)
1670                    && let Some(name) = json.get("name").and_then(|v| v.as_str())
1671                {
1672                    // composer.json name is "vendor/package"; return just the package part.
1673                    let package = name.split('/').next_back().unwrap_or(name);
1674                    return Some(package.to_string());
1675                }
1676                Self::fallback_to_directory_name(composer_json.parent()?)
1677            }
1678            Err(e) => {
1679                self.log_file_error(composer_json, &e, errors);
1680                Self::fallback_to_directory_name(composer_json.parent()?)
1681            }
1682        }
1683    }
1684
1685    /// Detect a Haskell project in the specified directory.
1686    ///
1687    /// Supports both Stack and Cabal build systems:
1688    /// - Stack: `stack.yaml` + `.stack-work/`
1689    /// - Cabal: (`cabal.project` or `*.cabal` file) + `dist-newstyle/`
1690    fn detect_haskell_project(
1691        &self,
1692        path: &Path,
1693        errors: &Arc<Mutex<Vec<String>>>,
1694    ) -> Option<Project> {
1695        // Stack project: stack.yaml + .stack-work/
1696        let stack_yaml = path.join("stack.yaml");
1697        let stack_work = path.join(".stack-work");
1698
1699        if stack_yaml.exists() && stack_work.exists() {
1700            let name = self.extract_haskell_project_name(path, errors);
1701            return Some(Project::new(
1702                ProjectType::Haskell,
1703                path.to_path_buf(),
1704                vec![BuildArtifacts {
1705                    path: stack_work,
1706                    size: 0,
1707                }],
1708                name,
1709            ));
1710        }
1711
1712        // Cabal project: (cabal.project OR *.cabal file) + dist-newstyle/
1713        let dist_newstyle = path.join("dist-newstyle");
1714        if dist_newstyle.exists() {
1715            let has_cabal_project = path.join("cabal.project").exists();
1716            let has_cabal_file = Self::find_file_with_extension(path, "cabal").is_some();
1717
1718            if has_cabal_project || has_cabal_file {
1719                let name = self.extract_haskell_project_name(path, errors);
1720                return Some(Project::new(
1721                    ProjectType::Haskell,
1722                    path.to_path_buf(),
1723                    vec![BuildArtifacts {
1724                        path: dist_newstyle,
1725                        size: 0,
1726                    }],
1727                    name,
1728                ));
1729            }
1730        }
1731
1732        None
1733    }
1734
1735    /// Extract the project name from a Haskell project directory.
1736    ///
1737    /// Looks for a `*.cabal` file and reads the `name:` field from it.
1738    /// Also checks `package.yaml` (hpack). Falls back to the directory name.
1739    fn extract_haskell_project_name(
1740        &self,
1741        path: &Path,
1742        errors: &Arc<Mutex<Vec<String>>>,
1743    ) -> Option<String> {
1744        // Try *.cabal file first
1745        if let Some(cabal_file) = Self::find_file_with_extension(path, "cabal")
1746            && let Some(content) = self.read_file_content(&cabal_file, errors)
1747        {
1748            for line in content.lines() {
1749                let trimmed = line.trim();
1750                if let Some(rest) = trimmed.strip_prefix("name:") {
1751                    let name = rest.trim().to_string();
1752                    if !name.is_empty() {
1753                        return Some(name);
1754                    }
1755                }
1756            }
1757        }
1758
1759        // Try package.yaml (hpack)
1760        let package_yaml = path.join("package.yaml");
1761        if package_yaml.exists()
1762            && let Some(content) = self.read_file_content(&package_yaml, errors)
1763        {
1764            for line in content.lines() {
1765                let trimmed = line.trim();
1766                if let Some(rest) = trimmed.strip_prefix("name:") {
1767                    let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1768                    if !name.is_empty() {
1769                        return Some(name);
1770                    }
1771                }
1772            }
1773        }
1774
1775        Self::fallback_to_directory_name(path)
1776    }
1777
1778    /// Detect a Dart/Flutter project in the specified directory.
1779    ///
1780    /// This method checks for a `pubspec.yaml` manifest alongside `.dart_tool/`
1781    /// and/or `build/` directories.
1782    ///
1783    /// # Detection Criteria
1784    ///
1785    /// 1. `pubspec.yaml` file exists in directory
1786    /// 2. At least one of `.dart_tool/` or `build/` exists
1787    fn detect_dart_project(
1788        &self,
1789        path: &Path,
1790        errors: &Arc<Mutex<Vec<String>>>,
1791    ) -> Option<Project> {
1792        let pubspec_yaml = path.join("pubspec.yaml");
1793        if !pubspec_yaml.exists() {
1794            return None;
1795        }
1796
1797        let dart_tool = path.join(".dart_tool");
1798        let build_dir = path.join("build");
1799
1800        let build_arts: Vec<BuildArtifacts> = match (dart_tool.exists(), build_dir.exists()) {
1801            (true, true) => {
1802                let dart_size = crate::utils::calculate_dir_size(&dart_tool);
1803                let build_size = crate::utils::calculate_dir_size(&build_dir);
1804                vec![
1805                    BuildArtifacts {
1806                        path: dart_tool,
1807                        size: dart_size,
1808                    },
1809                    BuildArtifacts {
1810                        path: build_dir,
1811                        size: build_size,
1812                    },
1813                ]
1814            }
1815            (true, false) => vec![BuildArtifacts {
1816                path: dart_tool,
1817                size: 0,
1818            }],
1819            (false, true) => vec![BuildArtifacts {
1820                path: build_dir,
1821                size: 0,
1822            }],
1823            (false, false) => return None,
1824        };
1825
1826        let name = self.extract_dart_project_name(&pubspec_yaml, errors);
1827
1828        Some(Project::new(
1829            ProjectType::Dart,
1830            path.to_path_buf(),
1831            build_arts,
1832            name,
1833        ))
1834    }
1835
1836    /// Extract the project name from a `pubspec.yaml` file.
1837    ///
1838    /// Reads the `name:` field from the YAML file using simple line parsing.
1839    /// Falls back to the directory name.
1840    fn extract_dart_project_name(
1841        &self,
1842        pubspec_yaml: &Path,
1843        errors: &Arc<Mutex<Vec<String>>>,
1844    ) -> Option<String> {
1845        let content = self.read_file_content(pubspec_yaml, errors)?;
1846
1847        for line in content.lines() {
1848            let trimmed = line.trim();
1849            if let Some(rest) = trimmed.strip_prefix("name:") {
1850                let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1851                if !name.is_empty() {
1852                    return Some(name);
1853                }
1854            }
1855        }
1856
1857        Self::fallback_to_directory_name(pubspec_yaml.parent()?)
1858    }
1859
1860    /// Detect a Zig project in the specified directory.
1861    ///
1862    /// This method checks for a `build.zig` file alongside `zig-cache/` and/or
1863    /// `zig-out/` directories.
1864    ///
1865    /// # Detection Criteria
1866    ///
1867    /// 1. `build.zig` file exists in directory
1868    /// 2. At least one of `zig-cache/` or `zig-out/` exists
1869    fn detect_zig_project(path: &Path) -> Option<Project> {
1870        let build_zig = path.join("build.zig");
1871        if !build_zig.exists() {
1872            return None;
1873        }
1874
1875        let zig_cache = path.join("zig-cache");
1876        let zig_out = path.join("zig-out");
1877
1878        let build_arts: Vec<BuildArtifacts> = match (zig_cache.exists(), zig_out.exists()) {
1879            (true, true) => {
1880                let cache_size = crate::utils::calculate_dir_size(&zig_cache);
1881                let out_size = crate::utils::calculate_dir_size(&zig_out);
1882                vec![
1883                    BuildArtifacts {
1884                        path: zig_cache,
1885                        size: cache_size,
1886                    },
1887                    BuildArtifacts {
1888                        path: zig_out,
1889                        size: out_size,
1890                    },
1891                ]
1892            }
1893            (true, false) => vec![BuildArtifacts {
1894                path: zig_cache,
1895                size: 0,
1896            }],
1897            (false, true) => vec![BuildArtifacts {
1898                path: zig_out,
1899                size: 0,
1900            }],
1901            (false, false) => return None,
1902        };
1903
1904        let name = Self::fallback_to_directory_name(path);
1905
1906        Some(Project::new(
1907            ProjectType::Zig,
1908            path.to_path_buf(),
1909            build_arts,
1910            name,
1911        ))
1912    }
1913
1914    /// Detect a Scala project in the specified directory.
1915    ///
1916    /// This method checks for the presence of both `build.sbt` and `target/`
1917    /// to identify an sbt-based Scala project.
1918    ///
1919    /// # Detection Criteria
1920    ///
1921    /// 1. `build.sbt` file exists in directory
1922    /// 2. `target/` subdirectory exists in directory
1923    fn detect_scala_project(
1924        &self,
1925        path: &Path,
1926        errors: &Arc<Mutex<Vec<String>>>,
1927    ) -> Option<Project> {
1928        let build_sbt = path.join("build.sbt");
1929        let target_dir = path.join("target");
1930
1931        if build_sbt.exists() && target_dir.exists() {
1932            let name = self.extract_scala_project_name(&build_sbt, errors);
1933
1934            return Some(Project::new(
1935                ProjectType::Scala,
1936                path.to_path_buf(),
1937                vec![BuildArtifacts {
1938                    path: target_dir,
1939                    size: 0,
1940                }],
1941                name,
1942            ));
1943        }
1944
1945        None
1946    }
1947
1948    /// Extract the project name from a `build.sbt` file.
1949    ///
1950    /// Looks for a `name := "..."` assignment in the file.
1951    /// Falls back to the directory name.
1952    fn extract_scala_project_name(
1953        &self,
1954        build_sbt: &Path,
1955        errors: &Arc<Mutex<Vec<String>>>,
1956    ) -> Option<String> {
1957        let content = self.read_file_content(build_sbt, errors)?;
1958
1959        for line in content.lines() {
1960            let trimmed = line.trim();
1961            if trimmed.starts_with("name")
1962                && trimmed.contains(":=")
1963                && let Some(name) = Self::extract_quoted_value(trimmed)
1964            {
1965                return Some(name);
1966            }
1967        }
1968
1969        Self::fallback_to_directory_name(build_sbt.parent()?)
1970    }
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975    use super::*;
1976    use std::path::PathBuf;
1977    use tempfile::TempDir;
1978
1979    /// Create a scanner with default options and the given filter.
1980    fn default_scanner(filter: ProjectFilter) -> Scanner {
1981        Scanner::new(
1982            ScanOptions {
1983                verbose: false,
1984                threads: 1,
1985                skip: vec![],
1986                max_depth: None,
1987            },
1988            filter,
1989        )
1990    }
1991
1992    /// Helper to create a file with content, ensuring parent dirs exist.
1993    fn create_file(path: &Path, content: &str) {
1994        if let Some(parent) = path.parent() {
1995            fs::create_dir_all(parent).unwrap();
1996        }
1997        fs::write(path, content).unwrap();
1998    }
1999
2000    // ── Static helper method tests ──────────────────────────────────────
2001
2002    #[test]
2003    fn test_is_hidden_directory_to_skip() {
2004        // Hidden directories should be skipped
2005        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2006            "/some/.hidden"
2007        )));
2008        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2009            "/some/.git"
2010        )));
2011        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2012            "/some/.svn"
2013        )));
2014        assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
2015
2016        // .cargo is the special exception — should NOT be skipped
2017        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2018            "/home/user/.cargo"
2019        )));
2020        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
2021
2022        // Non-hidden directories should not be skipped
2023        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2024            "/some/visible"
2025        )));
2026        assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
2027    }
2028
2029    #[test]
2030    fn test_is_excluded_directory() {
2031        // Build/artifact directories should be excluded
2032        assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
2033        assert!(Scanner::is_excluded_directory(Path::new(
2034            "/some/node_modules"
2035        )));
2036        assert!(Scanner::is_excluded_directory(Path::new(
2037            "/some/__pycache__"
2038        )));
2039        assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
2040        assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
2041        assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
2042        assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
2043
2044        // VCS directories should be excluded
2045        assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
2046        assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
2047        assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
2048
2049        // Python-specific directories
2050        assert!(Scanner::is_excluded_directory(Path::new(
2051            "/some/.pytest_cache"
2052        )));
2053        assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
2054        assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
2055        assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
2056
2057        // Virtual environments
2058        assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
2059        assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
2060        assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
2061        assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
2062
2063        // Temp directories
2064        assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
2065        assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
2066
2067        // Non-excluded directories
2068        assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
2069        assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
2070        assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
2071        assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
2072    }
2073
2074    #[test]
2075    fn test_extract_quoted_value() {
2076        assert_eq!(
2077            Scanner::extract_quoted_value(r#"name = "my-project""#),
2078            Some("my-project".to_string())
2079        );
2080        assert_eq!(
2081            Scanner::extract_quoted_value(r#"name = "with spaces""#),
2082            Some("with spaces".to_string())
2083        );
2084        assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
2085        // Single quote mark is not a pair
2086        assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
2087    }
2088
2089    #[test]
2090    fn test_is_name_line() {
2091        assert!(Scanner::is_name_line("name = \"test\""));
2092        assert!(Scanner::is_name_line("name=\"test\""));
2093        assert!(!Scanner::is_name_line("version = \"1.0\""));
2094        assert!(!Scanner::is_name_line("# name = \"commented\""));
2095        assert!(!Scanner::is_name_line("name: \"yaml style\""));
2096    }
2097
2098    #[test]
2099    fn test_parse_toml_name_field() {
2100        let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
2101        assert_eq!(
2102            Scanner::parse_toml_name_field(content),
2103            Some("test-project".to_string())
2104        );
2105
2106        let no_name = "[package]\nversion = \"0.1.0\"\n";
2107        assert_eq!(Scanner::parse_toml_name_field(no_name), None);
2108
2109        let empty = "";
2110        assert_eq!(Scanner::parse_toml_name_field(empty), None);
2111    }
2112
2113    #[test]
2114    fn test_extract_name_from_cfg_content() {
2115        let content = "[metadata]\nname = my-package\nversion = 1.0\n";
2116        assert_eq!(
2117            Scanner::extract_name_from_cfg_content(content),
2118            Some("my-package".to_string())
2119        );
2120
2121        // Name in wrong section should not be found
2122        let wrong_section = "[options]\nname = not-this\n";
2123        assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
2124
2125        // Multiple sections — name must be in [metadata]
2126        let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
2127        assert_eq!(
2128            Scanner::extract_name_from_cfg_content(multi),
2129            Some("correct".to_string())
2130        );
2131    }
2132
2133    #[test]
2134    fn test_extract_name_from_python_content() {
2135        let content = "from setuptools import setup\nsetup(\n    name=\"my-pkg\",\n)\n";
2136        assert_eq!(
2137            Scanner::extract_name_from_python_content(content),
2138            Some("my-pkg".to_string())
2139        );
2140
2141        let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
2142        assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
2143    }
2144
2145    #[test]
2146    fn test_fallback_to_directory_name() {
2147        assert_eq!(
2148            Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
2149            Some("project-name".to_string())
2150        );
2151        assert_eq!(
2152            Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
2153            Some("my_app".to_string())
2154        );
2155    }
2156
2157    #[test]
2158    fn test_is_path_in_skip_list() {
2159        let scanner = Scanner::new(
2160            ScanOptions {
2161                verbose: false,
2162                threads: 1,
2163                skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
2164                max_depth: None,
2165            },
2166            ProjectFilter::All,
2167        );
2168
2169        assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
2170        assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
2171        assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
2172        assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
2173    }
2174
2175    #[test]
2176    fn test_is_path_in_empty_skip_list() {
2177        let scanner = default_scanner(ProjectFilter::All);
2178        assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
2179    }
2180
2181    // ── Scanning with special path characters ───────────────────────────
2182
2183    #[test]
2184    fn test_scan_directory_with_spaces_in_path() {
2185        let tmp = TempDir::new().unwrap();
2186        let base = tmp.path().join("path with spaces");
2187        fs::create_dir_all(&base).unwrap();
2188
2189        let project = base.join("my project");
2190        create_file(
2191            &project.join("Cargo.toml"),
2192            "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
2193        );
2194        create_file(&project.join("target/dummy"), "content");
2195
2196        let scanner = default_scanner(ProjectFilter::Rust);
2197        let projects = scanner.scan_directory(&base);
2198        assert_eq!(projects.len(), 1);
2199        assert_eq!(projects[0].name.as_deref(), Some("spaced"));
2200    }
2201
2202    #[test]
2203    fn test_scan_directory_with_unicode_names() {
2204        let tmp = TempDir::new().unwrap();
2205        let base = tmp.path();
2206
2207        let project = base.join("プロジェクト");
2208        create_file(
2209            &project.join("package.json"),
2210            r#"{"name": "unicode-project"}"#,
2211        );
2212        create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
2213
2214        let scanner = default_scanner(ProjectFilter::Node);
2215        let projects = scanner.scan_directory(base);
2216        assert_eq!(projects.len(), 1);
2217        assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
2218    }
2219
2220    #[test]
2221    fn test_scan_directory_with_special_characters_in_name() {
2222        let tmp = TempDir::new().unwrap();
2223        let base = tmp.path();
2224
2225        let project = base.join("project-with-dashes_and_underscores.v2");
2226        create_file(
2227            &project.join("Cargo.toml"),
2228            "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
2229        );
2230        create_file(&project.join("target/dummy"), "content");
2231
2232        let scanner = default_scanner(ProjectFilter::Rust);
2233        let projects = scanner.scan_directory(base);
2234        assert_eq!(projects.len(), 1);
2235        assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
2236    }
2237
2238    // ── Unix-specific scanning tests ────────────────────────────────────
2239
2240    #[test]
2241    #[cfg(unix)]
2242    fn test_hidden_directory_itself_not_detected_as_project_unix() {
2243        let tmp = TempDir::new().unwrap();
2244        let base = tmp.path();
2245
2246        // A hidden directory with Cargo.toml + target/ directly inside it
2247        // should NOT be detected because the .hidden entry is filtered by
2248        // is_hidden_directory_to_skip. However, non-hidden children inside
2249        // hidden dirs CAN still be found because WalkDir descends into them.
2250        let hidden = base.join(".hidden-project");
2251        create_file(
2252            &hidden.join("Cargo.toml"),
2253            "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
2254        );
2255        create_file(&hidden.join("target/dummy"), "content");
2256
2257        // A visible project should be found
2258        let visible = base.join("visible-project");
2259        create_file(
2260            &visible.join("Cargo.toml"),
2261            "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
2262        );
2263        create_file(&visible.join("target/dummy"), "content");
2264
2265        let scanner = default_scanner(ProjectFilter::Rust);
2266        let projects = scanner.scan_directory(base);
2267
2268        // Only the visible project should be found; the hidden one is excluded
2269        // because its directory name starts with '.'
2270        assert_eq!(projects.len(), 1);
2271        assert_eq!(projects[0].name.as_deref(), Some("visible"));
2272    }
2273
2274    #[test]
2275    #[cfg(unix)]
2276    fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
2277        let tmp = TempDir::new().unwrap();
2278        let base = tmp.path();
2279
2280        // A non-hidden project nested inside a hidden directory.
2281        // WalkDir still descends into .hidden, so the child project IS found.
2282        let nested = base.join(".hidden-parent/visible-child");
2283        create_file(
2284            &nested.join("Cargo.toml"),
2285            "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
2286        );
2287        create_file(&nested.join("target/dummy"), "content");
2288
2289        let scanner = default_scanner(ProjectFilter::Rust);
2290        let projects = scanner.scan_directory(base);
2291
2292        // The child project has a non-hidden name, so it IS detected
2293        assert_eq!(projects.len(), 1);
2294        assert_eq!(projects[0].name.as_deref(), Some("nested"));
2295    }
2296
2297    #[test]
2298    #[cfg(unix)]
2299    fn test_dotcargo_directory_not_skipped_unix() {
2300        // .cargo is the exception — hidden but should NOT be skipped.
2301        // Verify via the static method.
2302        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2303            "/home/user/.cargo"
2304        )));
2305
2306        // Other dot-dirs ARE skipped
2307        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2308            "/home/user/.local"
2309        )));
2310        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2311            "/home/user/.npm"
2312        )));
2313    }
2314
2315    // ── Python project detection tests ──────────────────────────────────
2316
2317    #[test]
2318    fn test_detect_python_with_pyproject_toml() {
2319        let tmp = TempDir::new().unwrap();
2320        let base = tmp.path();
2321
2322        let project = base.join("py-project");
2323        create_file(
2324            &project.join("pyproject.toml"),
2325            "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
2326        );
2327        let pycache = project.join("__pycache__");
2328        fs::create_dir_all(&pycache).unwrap();
2329        create_file(&pycache.join("module.pyc"), "bytecode");
2330
2331        let scanner = default_scanner(ProjectFilter::Python);
2332        let projects = scanner.scan_directory(base);
2333        assert_eq!(projects.len(), 1);
2334        assert_eq!(projects[0].kind, ProjectType::Python);
2335    }
2336
2337    #[test]
2338    fn test_detect_python_with_setup_py() {
2339        let tmp = TempDir::new().unwrap();
2340        let base = tmp.path();
2341
2342        let project = base.join("setup-project");
2343        create_file(
2344            &project.join("setup.py"),
2345            "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
2346        );
2347        let pycache = project.join("__pycache__");
2348        fs::create_dir_all(&pycache).unwrap();
2349        create_file(&pycache.join("module.pyc"), "bytecode");
2350
2351        let scanner = default_scanner(ProjectFilter::Python);
2352        let projects = scanner.scan_directory(base);
2353        assert_eq!(projects.len(), 1);
2354    }
2355
2356    #[test]
2357    fn test_detect_python_with_pipfile() {
2358        let tmp = TempDir::new().unwrap();
2359        let base = tmp.path();
2360
2361        let project = base.join("pipenv-project");
2362        create_file(
2363            &project.join("Pipfile"),
2364            "[[source]]\nurl = \"https://pypi.org/simple\"",
2365        );
2366        let pycache = project.join("__pycache__");
2367        fs::create_dir_all(&pycache).unwrap();
2368        create_file(&pycache.join("module.pyc"), "bytecode");
2369
2370        let scanner = default_scanner(ProjectFilter::Python);
2371        let projects = scanner.scan_directory(base);
2372        assert_eq!(projects.len(), 1);
2373    }
2374
2375    // ── Go project detection tests ──────────────────────────────────────
2376
2377    #[test]
2378    fn test_detect_go_extracts_module_name() {
2379        let tmp = TempDir::new().unwrap();
2380        let base = tmp.path();
2381
2382        let project = base.join("go-service");
2383        create_file(
2384            &project.join("go.mod"),
2385            "module github.com/user/my-service\n\ngo 1.21\n",
2386        );
2387        let vendor = project.join("vendor");
2388        fs::create_dir_all(&vendor).unwrap();
2389        create_file(&vendor.join("modules.txt"), "vendor manifest");
2390
2391        let scanner = default_scanner(ProjectFilter::Go);
2392        let projects = scanner.scan_directory(base);
2393        assert_eq!(projects.len(), 1);
2394        // Should extract last path component as name
2395        assert_eq!(projects[0].name.as_deref(), Some("my-service"));
2396    }
2397
2398    // ── Java/Kotlin project detection tests ────────────────────────────
2399
2400    #[test]
2401    fn test_detect_java_maven_project() {
2402        let tmp = TempDir::new().unwrap();
2403        let base = tmp.path();
2404
2405        let project = base.join("java-maven");
2406        create_file(
2407            &project.join("pom.xml"),
2408            "<project>\n  <artifactId>my-java-app</artifactId>\n</project>",
2409        );
2410        create_file(&project.join("target/classes/Main.class"), "bytecode");
2411
2412        let scanner = default_scanner(ProjectFilter::Java);
2413        let projects = scanner.scan_directory(base);
2414        assert_eq!(projects.len(), 1);
2415        assert_eq!(projects[0].kind, ProjectType::Java);
2416        assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
2417    }
2418
2419    #[test]
2420    fn test_detect_java_gradle_project() {
2421        let tmp = TempDir::new().unwrap();
2422        let base = tmp.path();
2423
2424        let project = base.join("java-gradle");
2425        create_file(&project.join("build.gradle"), "apply plugin: 'java'");
2426        create_file(
2427            &project.join("settings.gradle"),
2428            "rootProject.name = \"my-gradle-app\"",
2429        );
2430        create_file(&project.join("build/classes/main/Main.class"), "bytecode");
2431
2432        let scanner = default_scanner(ProjectFilter::Java);
2433        let projects = scanner.scan_directory(base);
2434        assert_eq!(projects.len(), 1);
2435        assert_eq!(projects[0].kind, ProjectType::Java);
2436        assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2437    }
2438
2439    #[test]
2440    fn test_detect_java_gradle_kts_project() {
2441        let tmp = TempDir::new().unwrap();
2442        let base = tmp.path();
2443
2444        let project = base.join("kotlin-gradle");
2445        create_file(
2446            &project.join("build.gradle.kts"),
2447            "plugins { kotlin(\"jvm\") }",
2448        );
2449        create_file(
2450            &project.join("settings.gradle.kts"),
2451            "rootProject.name = \"my-kotlin-app\"",
2452        );
2453        create_file(
2454            &project.join("build/classes/kotlin/main/MainKt.class"),
2455            "bytecode",
2456        );
2457
2458        let scanner = default_scanner(ProjectFilter::Java);
2459        let projects = scanner.scan_directory(base);
2460        assert_eq!(projects.len(), 1);
2461        assert_eq!(projects[0].kind, ProjectType::Java);
2462        assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2463    }
2464
2465    // ── C/C++ project detection tests ────────────────────────────────────
2466
2467    #[test]
2468    fn test_detect_cpp_cmake_project() {
2469        let tmp = TempDir::new().unwrap();
2470        let base = tmp.path();
2471
2472        let project = base.join("cpp-cmake");
2473        create_file(
2474            &project.join("CMakeLists.txt"),
2475            "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2476        );
2477        create_file(&project.join("build/CMakeCache.txt"), "cache");
2478
2479        let scanner = default_scanner(ProjectFilter::Cpp);
2480        let projects = scanner.scan_directory(base);
2481        assert_eq!(projects.len(), 1);
2482        assert_eq!(projects[0].kind, ProjectType::Cpp);
2483        assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2484    }
2485
2486    #[test]
2487    fn test_detect_cpp_makefile_project() {
2488        let tmp = TempDir::new().unwrap();
2489        let base = tmp.path();
2490
2491        let project = base.join("cpp-make");
2492        create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2493        create_file(&project.join("build/main.o"), "object");
2494
2495        let scanner = default_scanner(ProjectFilter::Cpp);
2496        let projects = scanner.scan_directory(base);
2497        assert_eq!(projects.len(), 1);
2498        assert_eq!(projects[0].kind, ProjectType::Cpp);
2499    }
2500
2501    // ── Swift project detection tests ────────────────────────────────────
2502
2503    #[test]
2504    fn test_detect_swift_project() {
2505        let tmp = TempDir::new().unwrap();
2506        let base = tmp.path();
2507
2508        let project = base.join("swift-pkg");
2509        create_file(
2510            &project.join("Package.swift"),
2511            "let package = Package(\n    name: \"my-swift-lib\",\n    targets: []\n)",
2512        );
2513        create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2514
2515        let scanner = default_scanner(ProjectFilter::Swift);
2516        let projects = scanner.scan_directory(base);
2517        assert_eq!(projects.len(), 1);
2518        assert_eq!(projects[0].kind, ProjectType::Swift);
2519        assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2520    }
2521
2522    // ── .NET/C# project detection tests ──────────────────────────────────
2523
2524    #[test]
2525    fn test_detect_dotnet_project() {
2526        let tmp = TempDir::new().unwrap();
2527        let base = tmp.path();
2528
2529        let project = base.join("dotnet-app");
2530        create_file(
2531            &project.join("MyApp.csproj"),
2532            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2533        );
2534        create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2535        create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2536
2537        let scanner = default_scanner(ProjectFilter::DotNet);
2538        let projects = scanner.scan_directory(base);
2539        assert_eq!(projects.len(), 1);
2540        assert_eq!(projects[0].kind, ProjectType::DotNet);
2541        assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2542    }
2543
2544    #[test]
2545    fn test_detect_dotnet_project_obj_only() {
2546        let tmp = TempDir::new().unwrap();
2547        let base = tmp.path();
2548
2549        let project = base.join("dotnet-obj-only");
2550        create_file(
2551            &project.join("Lib.csproj"),
2552            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2553        );
2554        create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2555
2556        let scanner = default_scanner(ProjectFilter::DotNet);
2557        let projects = scanner.scan_directory(base);
2558        assert_eq!(projects.len(), 1);
2559        assert_eq!(projects[0].kind, ProjectType::DotNet);
2560        assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2561    }
2562
2563    // ── Excluded directory tests ─────────────────────────────────────────
2564
2565    #[test]
2566    fn test_obj_directory_is_excluded() {
2567        assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2568    }
2569
2570    // ── Cross-platform calculate_build_dir_size ─────────────────────────
2571
2572    #[test]
2573    fn test_calculate_build_dir_size_empty() {
2574        let tmp = TempDir::new().unwrap();
2575        let empty_dir = tmp.path().join("empty");
2576        fs::create_dir_all(&empty_dir).unwrap();
2577
2578        assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2579    }
2580
2581    #[test]
2582    fn test_calculate_build_dir_size_nonexistent() {
2583        assert_eq!(
2584            Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2585            0
2586        );
2587    }
2588
2589    #[test]
2590    fn test_calculate_build_dir_size_with_nested_files() {
2591        let tmp = TempDir::new().unwrap();
2592        let dir = tmp.path().join("nested");
2593
2594        create_file(&dir.join("file1.txt"), "hello"); // 5 bytes
2595        create_file(&dir.join("sub/file2.txt"), "world!"); // 6 bytes
2596        create_file(&dir.join("sub/deep/file3.txt"), "!"); // 1 byte
2597
2598        let size = Scanner::calculate_build_dir_size(&dir);
2599        assert_eq!(size, 12);
2600    }
2601
2602    // ── Quiet mode ──────────────────────────────────────────────────────
2603
2604    #[test]
2605    fn test_scanner_quiet_mode() {
2606        let tmp = TempDir::new().unwrap();
2607        let base = tmp.path();
2608
2609        let project = base.join("quiet-project");
2610        create_file(
2611            &project.join("Cargo.toml"),
2612            "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2613        );
2614        create_file(&project.join("target/dummy"), "content");
2615
2616        let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2617        let projects = scanner.scan_directory(base);
2618        assert_eq!(projects.len(), 1);
2619    }
2620
2621    // ── Ruby project detection tests ─────────────────────────────────────
2622
2623    #[test]
2624    fn test_detect_ruby_with_vendor_bundle() {
2625        let tmp = TempDir::new().unwrap();
2626        let base = tmp.path();
2627
2628        let project = base.join("ruby-project");
2629        create_file(
2630            &project.join("Gemfile"),
2631            "source 'https://rubygems.org'\ngem 'rails'",
2632        );
2633        create_file(
2634            &project.join("my-app.gemspec"),
2635            "Gem::Specification.new do |spec|\n  spec.name = \"my-ruby-gem\"\nend",
2636        );
2637        create_file(
2638            &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2639            "# rails",
2640        );
2641
2642        let scanner = default_scanner(ProjectFilter::Ruby);
2643        let projects = scanner.scan_directory(base);
2644        assert_eq!(projects.len(), 1);
2645        assert_eq!(projects[0].kind, ProjectType::Ruby);
2646        assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2647    }
2648
2649    #[test]
2650    fn test_detect_ruby_with_dot_bundle() {
2651        let tmp = TempDir::new().unwrap();
2652        let base = tmp.path();
2653
2654        let project = base.join("ruby-dot-bundle");
2655        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2656        create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2657
2658        let scanner = default_scanner(ProjectFilter::Ruby);
2659        let projects = scanner.scan_directory(base);
2660        assert_eq!(projects.len(), 1);
2661        assert_eq!(projects[0].kind, ProjectType::Ruby);
2662    }
2663
2664    #[test]
2665    fn test_detect_ruby_no_artifact_not_detected() {
2666        let tmp = TempDir::new().unwrap();
2667        let base = tmp.path();
2668
2669        // Gemfile exists but no .bundle/ or vendor/bundle/
2670        let project = base.join("gemfile-only");
2671        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2672
2673        let scanner = default_scanner(ProjectFilter::Ruby);
2674        let projects = scanner.scan_directory(base);
2675        assert_eq!(projects.len(), 0);
2676    }
2677
2678    #[test]
2679    fn test_detect_ruby_fallback_to_dir_name() {
2680        let tmp = TempDir::new().unwrap();
2681        let base = tmp.path();
2682
2683        let project = base.join("my-ruby-app");
2684        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2685        create_file(
2686            &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2687            "# sinatra",
2688        );
2689
2690        let scanner = default_scanner(ProjectFilter::Ruby);
2691        let projects = scanner.scan_directory(base);
2692        assert_eq!(projects.len(), 1);
2693        assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2694    }
2695
2696    // ── Elixir project detection tests ───────────────────────────────────
2697
2698    #[test]
2699    fn test_detect_elixir_project() {
2700        let tmp = TempDir::new().unwrap();
2701        let base = tmp.path();
2702
2703        let project = base.join("elixir-project");
2704        create_file(
2705            &project.join("mix.exs"),
2706            "defmodule MyApp.MixProject do\n  def project do\n    [app: :my_app,\n     version: \"0.1.0\"]\n  end\nend",
2707        );
2708        create_file(
2709            &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2710            "# build",
2711        );
2712
2713        let scanner = default_scanner(ProjectFilter::Elixir);
2714        let projects = scanner.scan_directory(base);
2715        assert_eq!(projects.len(), 1);
2716        assert_eq!(projects[0].kind, ProjectType::Elixir);
2717        assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2718    }
2719
2720    #[test]
2721    fn test_detect_elixir_no_build_not_detected() {
2722        let tmp = TempDir::new().unwrap();
2723        let base = tmp.path();
2724
2725        let project = base.join("mix-only");
2726        create_file(
2727            &project.join("mix.exs"),
2728            "defmodule MixOnly.MixProject do\n  def project do\n    [app: :mix_only]\n  end\nend",
2729        );
2730
2731        let scanner = default_scanner(ProjectFilter::Elixir);
2732        let projects = scanner.scan_directory(base);
2733        assert_eq!(projects.len(), 0);
2734    }
2735
2736    #[test]
2737    fn test_detect_elixir_fallback_to_dir_name() {
2738        let tmp = TempDir::new().unwrap();
2739        let base = tmp.path();
2740
2741        let project = base.join("my_elixir_project");
2742        create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2743        create_file(
2744            &project.join("_build/prod/lib/my_elixir_project.beam"),
2745            "bytecode",
2746        );
2747
2748        let scanner = default_scanner(ProjectFilter::Elixir);
2749        let projects = scanner.scan_directory(base);
2750        assert_eq!(projects.len(), 1);
2751        assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2752    }
2753
2754    // ── Deno project detection tests ─────────────────────────────────────
2755
2756    #[test]
2757    fn test_detect_deno_with_vendor() {
2758        let tmp = TempDir::new().unwrap();
2759        let base = tmp.path();
2760
2761        let project = base.join("deno-project");
2762        create_file(
2763            &project.join("deno.json"),
2764            r#"{"name": "my-deno-app", "imports": {}}"#,
2765        );
2766        create_file(&project.join("vendor/modules.json"), "{}");
2767
2768        let scanner = default_scanner(ProjectFilter::Deno);
2769        let projects = scanner.scan_directory(base);
2770        assert_eq!(projects.len(), 1);
2771        assert_eq!(projects[0].kind, ProjectType::Deno);
2772        assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2773    }
2774
2775    #[test]
2776    fn test_detect_deno_jsonc_config() {
2777        let tmp = TempDir::new().unwrap();
2778        let base = tmp.path();
2779
2780        let project = base.join("deno-jsonc-project");
2781        create_file(
2782            &project.join("deno.jsonc"),
2783            r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2784        );
2785        create_file(&project.join("vendor/modules.json"), "{}");
2786
2787        let scanner = default_scanner(ProjectFilter::Deno);
2788        let projects = scanner.scan_directory(base);
2789        assert_eq!(projects.len(), 1);
2790        assert_eq!(projects[0].kind, ProjectType::Deno);
2791        assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2792    }
2793
2794    #[test]
2795    fn test_detect_deno_node_modules_without_package_json() {
2796        let tmp = TempDir::new().unwrap();
2797        let base = tmp.path();
2798
2799        let project = base.join("deno-npm-project");
2800        create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2801        create_file(
2802            &project.join("node_modules/.deno/lodash/index.js"),
2803            "// lodash",
2804        );
2805
2806        let scanner = default_scanner(ProjectFilter::Deno);
2807        let projects = scanner.scan_directory(base);
2808        assert_eq!(projects.len(), 1);
2809        assert_eq!(projects[0].kind, ProjectType::Deno);
2810    }
2811
2812    #[test]
2813    fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2814        let tmp = TempDir::new().unwrap();
2815        let base = tmp.path();
2816
2817        // deno.json + package.json + node_modules → Node project (not Deno)
2818        let project = base.join("ambiguous-project");
2819        create_file(&project.join("deno.json"), r"{}");
2820        create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2821        create_file(&project.join("node_modules/dep/index.js"), "// dep");
2822
2823        let scanner = default_scanner(ProjectFilter::All);
2824        let projects = scanner.scan_directory(base);
2825        assert_eq!(projects.len(), 1);
2826        assert_eq!(projects[0].kind, ProjectType::Node);
2827    }
2828
2829    #[test]
2830    fn test_detect_deno_no_artifact_not_detected() {
2831        let tmp = TempDir::new().unwrap();
2832        let base = tmp.path();
2833
2834        let project = base.join("deno-no-artifact");
2835        create_file(&project.join("deno.json"), r"{}");
2836
2837        let scanner = default_scanner(ProjectFilter::Deno);
2838        let projects = scanner.scan_directory(base);
2839        assert_eq!(projects.len(), 0);
2840    }
2841
2842    #[test]
2843    fn test_build_directory_is_excluded() {
2844        assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2845    }
2846
2847    // ── Rust workspace awareness tests ─────────────────────────────────
2848
2849    #[test]
2850    fn test_is_cargo_workspace_root() {
2851        let tmp = TempDir::new().unwrap();
2852        let cargo_toml = tmp.path().join("Cargo.toml");
2853
2854        // A workspace root must contain a bare `[workspace]` section header.
2855        create_file(
2856            &cargo_toml,
2857            "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2858        );
2859        assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2860
2861        // A regular package Cargo.toml is not a workspace root.
2862        create_file(
2863            &cargo_toml,
2864            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2865        );
2866        assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2867
2868        // A non-existent file returns false.
2869        assert!(!Scanner::is_cargo_workspace_root(Path::new(
2870            "/nonexistent/Cargo.toml"
2871        )));
2872    }
2873
2874    #[test]
2875    fn test_workspace_root_detected() {
2876        let tmp = TempDir::new().unwrap();
2877        let base = tmp.path();
2878
2879        // Workspace root: has [workspace] in Cargo.toml and a target/ dir with content.
2880        let workspace = base.join("my-workspace");
2881        create_file(
2882            &workspace.join("Cargo.toml"),
2883            "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2884        );
2885        create_file(&workspace.join("target/dummy"), "content");
2886
2887        let scanner = default_scanner(ProjectFilter::Rust);
2888        let projects = scanner.scan_directory(base);
2889
2890        assert_eq!(projects.len(), 1);
2891        assert_eq!(projects[0].root_path, workspace);
2892    }
2893
2894    #[test]
2895    fn test_workspace_member_with_own_target_skipped() {
2896        let tmp = TempDir::new().unwrap();
2897        let base = tmp.path();
2898
2899        // Workspace root with content in target/.
2900        let workspace = base.join("my-workspace");
2901        create_file(
2902            &workspace.join("Cargo.toml"),
2903            "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2904        );
2905        create_file(&workspace.join("target/dummy"), "content");
2906
2907        // Workspace member that also happens to have its own target/ directory.
2908        let member = workspace.join("crate-a");
2909        create_file(
2910            &member.join("Cargo.toml"),
2911            "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2912        );
2913        create_file(&member.join("target/dummy"), "content");
2914
2915        let scanner = default_scanner(ProjectFilter::Rust);
2916        let projects = scanner.scan_directory(base);
2917
2918        // Only the workspace root should be reported; the member must be skipped.
2919        assert_eq!(projects.len(), 1);
2920        assert_eq!(projects[0].root_path, workspace);
2921    }
2922
2923    // ── PHP project detection tests ───────────────────────────────────────
2924
2925    #[test]
2926    fn test_detect_php_project() {
2927        let tmp = TempDir::new().unwrap();
2928        let base = tmp.path();
2929
2930        let project = base.join("php-project");
2931        create_file(
2932            &project.join("composer.json"),
2933            r#"{"name": "acme/my-php-app", "require": {}}"#,
2934        );
2935        create_file(&project.join("vendor/autoload.php"), "<?php // autoloader");
2936
2937        let scanner = default_scanner(ProjectFilter::Php);
2938        let projects = scanner.scan_directory(base);
2939        assert_eq!(projects.len(), 1);
2940        assert_eq!(projects[0].kind, ProjectType::Php);
2941        // Only the package component (after /) should be returned.
2942        assert_eq!(projects[0].name.as_deref(), Some("my-php-app"));
2943    }
2944
2945    #[test]
2946    fn test_detect_php_no_vendor_not_detected() {
2947        let tmp = TempDir::new().unwrap();
2948        let base = tmp.path();
2949
2950        let project = base.join("php-no-vendor");
2951        create_file(&project.join("composer.json"), r#"{"name": "acme/my-app"}"#);
2952
2953        let scanner = default_scanner(ProjectFilter::Php);
2954        let projects = scanner.scan_directory(base);
2955        assert_eq!(projects.len(), 0);
2956    }
2957
2958    #[test]
2959    fn test_detect_php_fallback_to_dir_name() {
2960        let tmp = TempDir::new().unwrap();
2961        let base = tmp.path();
2962
2963        let project = base.join("my-php-project");
2964        // composer.json without a "name" field
2965        create_file(&project.join("composer.json"), r#"{"require": {}}"#);
2966        create_file(&project.join("vendor/autoload.php"), "<?php");
2967
2968        let scanner = default_scanner(ProjectFilter::Php);
2969        let projects = scanner.scan_directory(base);
2970        assert_eq!(projects.len(), 1);
2971        assert_eq!(projects[0].name.as_deref(), Some("my-php-project"));
2972    }
2973
2974    // ── Haskell project detection tests ──────────────────────────────────
2975
2976    #[test]
2977    fn test_detect_haskell_stack_project() {
2978        let tmp = TempDir::new().unwrap();
2979        let base = tmp.path();
2980
2981        let project = base.join("haskell-stack");
2982        create_file(
2983            &project.join("stack.yaml"),
2984            "resolver: lts-21.0\npackages:\n  - .",
2985        );
2986        create_file(
2987            &project.join("my-haskell-lib.cabal"),
2988            "name:          my-haskell-lib\nversion:       0.1.0.0\n",
2989        );
2990        create_file(
2991            &project.join(".stack-work/dist/x86_64-linux/ghc-9.4.7/build/Main.o"),
2992            "object",
2993        );
2994
2995        let scanner = default_scanner(ProjectFilter::Haskell);
2996        let projects = scanner.scan_directory(base);
2997        assert_eq!(projects.len(), 1);
2998        assert_eq!(projects[0].kind, ProjectType::Haskell);
2999        assert_eq!(projects[0].name.as_deref(), Some("my-haskell-lib"));
3000    }
3001
3002    #[test]
3003    fn test_detect_haskell_cabal_project() {
3004        let tmp = TempDir::new().unwrap();
3005        let base = tmp.path();
3006
3007        let project = base.join("haskell-cabal");
3008        create_file(&project.join("cabal.project"), "packages: .\n");
3009        create_file(
3010            &project.join("my-cabal-lib.cabal"),
3011            "name:          my-cabal-lib\nversion:       0.1.0.0\n",
3012        );
3013        create_file(
3014            &project.join(
3015                "dist-newstyle/build/x86_64-linux/ghc-9.4.7/my-cabal-lib-0.1.0.0/build/Main.o",
3016            ),
3017            "object",
3018        );
3019
3020        let scanner = default_scanner(ProjectFilter::Haskell);
3021        let projects = scanner.scan_directory(base);
3022        assert_eq!(projects.len(), 1);
3023        assert_eq!(projects[0].kind, ProjectType::Haskell);
3024        assert_eq!(projects[0].name.as_deref(), Some("my-cabal-lib"));
3025    }
3026
3027    #[test]
3028    fn test_detect_haskell_no_artifact_not_detected() {
3029        let tmp = TempDir::new().unwrap();
3030        let base = tmp.path();
3031
3032        let project = base.join("haskell-no-artifact");
3033        create_file(&project.join("stack.yaml"), "resolver: lts-21.0");
3034
3035        let scanner = default_scanner(ProjectFilter::Haskell);
3036        let projects = scanner.scan_directory(base);
3037        assert_eq!(projects.len(), 0);
3038    }
3039
3040    // ── Dart/Flutter project detection tests ─────────────────────────────
3041
3042    #[test]
3043    fn test_detect_dart_project_with_dart_tool() {
3044        let tmp = TempDir::new().unwrap();
3045        let base = tmp.path();
3046
3047        let project = base.join("dart-project");
3048        create_file(
3049            &project.join("pubspec.yaml"),
3050            "name: my_dart_app\nversion: 1.0.0\n",
3051        );
3052        create_file(
3053            &project.join(".dart_tool/package_config.json"),
3054            r#"{"configVersion": 2}"#,
3055        );
3056
3057        let scanner = default_scanner(ProjectFilter::Dart);
3058        let projects = scanner.scan_directory(base);
3059        assert_eq!(projects.len(), 1);
3060        assert_eq!(projects[0].kind, ProjectType::Dart);
3061        assert_eq!(projects[0].name.as_deref(), Some("my_dart_app"));
3062    }
3063
3064    #[test]
3065    fn test_detect_dart_project_with_build_dir() {
3066        let tmp = TempDir::new().unwrap();
3067        let base = tmp.path();
3068
3069        let project = base.join("flutter-project");
3070        create_file(
3071            &project.join("pubspec.yaml"),
3072            "name: my_flutter_app\nversion: 1.0.0\n",
3073        );
3074        create_file(
3075            &project.join("build/flutter_assets/AssetManifest.json"),
3076            "{}",
3077        );
3078
3079        let scanner = default_scanner(ProjectFilter::Dart);
3080        let projects = scanner.scan_directory(base);
3081        assert_eq!(projects.len(), 1);
3082        assert_eq!(projects[0].kind, ProjectType::Dart);
3083        assert_eq!(projects[0].name.as_deref(), Some("my_flutter_app"));
3084    }
3085
3086    #[test]
3087    fn test_detect_dart_no_artifact_not_detected() {
3088        let tmp = TempDir::new().unwrap();
3089        let base = tmp.path();
3090
3091        let project = base.join("pubspec-only");
3092        create_file(&project.join("pubspec.yaml"), "name: empty_project\n");
3093
3094        let scanner = default_scanner(ProjectFilter::Dart);
3095        let projects = scanner.scan_directory(base);
3096        assert_eq!(projects.len(), 0);
3097    }
3098
3099    // ── Zig project detection tests ───────────────────────────────────────
3100
3101    #[test]
3102    fn test_detect_zig_project_with_cache() {
3103        let tmp = TempDir::new().unwrap();
3104        let base = tmp.path();
3105
3106        let project = base.join("zig-project");
3107        create_file(
3108            &project.join("build.zig"),
3109            "const std = @import(\"std\");\npub fn build(b: *std.Build) void {}\n",
3110        );
3111        create_file(&project.join("zig-cache/h/abc123.h"), "// generated");
3112
3113        let scanner = default_scanner(ProjectFilter::Zig);
3114        let projects = scanner.scan_directory(base);
3115        assert_eq!(projects.len(), 1);
3116        assert_eq!(projects[0].kind, ProjectType::Zig);
3117        // Zig falls back to directory name.
3118        assert_eq!(projects[0].name.as_deref(), Some("zig-project"));
3119    }
3120
3121    #[test]
3122    fn test_detect_zig_project_with_out_dir() {
3123        let tmp = TempDir::new().unwrap();
3124        let base = tmp.path();
3125
3126        let project = base.join("zig-out-project");
3127        create_file(&project.join("build.zig"), "// zig build script");
3128        create_file(&project.join("zig-out/bin/my-app"), "binary");
3129
3130        let scanner = default_scanner(ProjectFilter::Zig);
3131        let projects = scanner.scan_directory(base);
3132        assert_eq!(projects.len(), 1);
3133        assert_eq!(projects[0].kind, ProjectType::Zig);
3134    }
3135
3136    #[test]
3137    fn test_detect_zig_no_artifact_not_detected() {
3138        let tmp = TempDir::new().unwrap();
3139        let base = tmp.path();
3140
3141        let project = base.join("zig-no-artifact");
3142        create_file(&project.join("build.zig"), "// zig build script");
3143
3144        let scanner = default_scanner(ProjectFilter::Zig);
3145        let projects = scanner.scan_directory(base);
3146        assert_eq!(projects.len(), 0);
3147    }
3148
3149    #[test]
3150    fn test_zig_cache_directory_is_excluded() {
3151        assert!(Scanner::is_excluded_directory(Path::new("/some/zig-cache")));
3152        assert!(Scanner::is_excluded_directory(Path::new("/some/zig-out")));
3153        assert!(Scanner::is_excluded_directory(Path::new(
3154            "/some/dist-newstyle"
3155        )));
3156    }
3157
3158    // ── Scala project detection tests ─────────────────────────────────────
3159
3160    #[test]
3161    fn test_detect_scala_project() {
3162        let tmp = TempDir::new().unwrap();
3163        let base = tmp.path();
3164
3165        let project = base.join("scala-project");
3166        create_file(
3167            &project.join("build.sbt"),
3168            "name := \"my-scala-app\"\nscalaVersion := \"3.3.0\"\n",
3169        );
3170        create_file(
3171            &project.join("target/scala-3.3.0/classes/Main.class"),
3172            "bytecode",
3173        );
3174
3175        let scanner = default_scanner(ProjectFilter::Scala);
3176        let projects = scanner.scan_directory(base);
3177        assert_eq!(projects.len(), 1);
3178        assert_eq!(projects[0].kind, ProjectType::Scala);
3179        assert_eq!(projects[0].name.as_deref(), Some("my-scala-app"));
3180    }
3181
3182    #[test]
3183    fn test_detect_scala_no_target_not_detected() {
3184        let tmp = TempDir::new().unwrap();
3185        let base = tmp.path();
3186
3187        let project = base.join("sbt-only");
3188        create_file(&project.join("build.sbt"), "name := \"unbuilt-project\"\n");
3189
3190        let scanner = default_scanner(ProjectFilter::Scala);
3191        let projects = scanner.scan_directory(base);
3192        assert_eq!(projects.len(), 0);
3193    }
3194
3195    #[test]
3196    fn test_detect_scala_fallback_to_dir_name() {
3197        let tmp = TempDir::new().unwrap();
3198        let base = tmp.path();
3199
3200        let project = base.join("my-scala-project");
3201        // build.sbt without a name := field
3202        create_file(&project.join("build.sbt"), "scalaVersion := \"3.3.0\"\n");
3203        create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode");
3204
3205        let scanner = default_scanner(ProjectFilter::Scala);
3206        let projects = scanner.scan_directory(base);
3207        assert_eq!(projects.len(), 1);
3208        assert_eq!(projects[0].name.as_deref(), Some("my-scala-project"));
3209    }
3210
3211    #[test]
3212    fn test_scala_detected_before_java_for_build_sbt_projects() {
3213        let tmp = TempDir::new().unwrap();
3214        let base = tmp.path();
3215
3216        // A project with both build.sbt and pom.xml should be detected as Scala.
3217        let project = base.join("scala-maven-project");
3218        create_file(&project.join("build.sbt"), "name := \"scala-maven\"\n");
3219        create_file(
3220            &project.join("pom.xml"),
3221            "<project><artifactId>scala-maven</artifactId></project>",
3222        );
3223        create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode");
3224
3225        let scanner = default_scanner(ProjectFilter::All);
3226        let projects = scanner.scan_directory(base);
3227        assert_eq!(projects.len(), 1);
3228        assert_eq!(projects[0].kind, ProjectType::Scala);
3229    }
3230}