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