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