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