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