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    /// - **Node.js projects**: Presence of both `package.json` and `node_modules/` directory
273    /// - **Python projects**: Presence of configuration files and cache directories
274    /// - **Go projects**: Presence of both `go.mod` and `vendor/` directory
275    /// - **Java/Kotlin projects**: Presence of `pom.xml` or `build.gradle` with `target/` or `build/`
276    /// - **C/C++ projects**: Presence of `CMakeLists.txt` or `Makefile` with `build/`
277    /// - **Swift projects**: Presence of `Package.swift` with `.build/`
278    /// - **.NET/C# projects**: Presence of `.csproj` files with `bin/` or `obj/`
279    fn detect_project(
280        &self,
281        entry: &DirEntry,
282        errors: &Arc<Mutex<Vec<String>>>,
283    ) -> Option<Project> {
284        let path = entry.path();
285
286        if !entry.file_type().is_dir() {
287            return None;
288        }
289
290        // Detectors are tried in order; the first match wins.
291        // More specific ecosystems are checked before more generic ones
292        // (e.g. Java before C/C++, since both can use `build/`).
293        self.try_detect(ProjectFilter::Rust, || {
294            self.detect_rust_project(path, errors)
295        })
296        .or_else(|| {
297            self.try_detect(ProjectFilter::Node, || {
298                self.detect_node_project(path, errors)
299            })
300        })
301        .or_else(|| {
302            self.try_detect(ProjectFilter::Java, || {
303                self.detect_java_project(path, errors)
304            })
305        })
306        .or_else(|| {
307            self.try_detect(ProjectFilter::Swift, || {
308                self.detect_swift_project(path, errors)
309            })
310        })
311        .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
312        .or_else(|| {
313            self.try_detect(ProjectFilter::Python, || {
314                self.detect_python_project(path, errors)
315            })
316        })
317        .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
318        .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
319    }
320
321    /// Run a detector only if the current project filter allows it.
322    ///
323    /// Returns `None` immediately (without calling `detect`) when the
324    /// active filter doesn't include `filter`.
325    fn try_detect(
326        &self,
327        filter: ProjectFilter,
328        detect: impl FnOnce() -> Option<Project>,
329    ) -> Option<Project> {
330        if self.project_filter == ProjectFilter::All || self.project_filter == filter {
331            detect()
332        } else {
333            None
334        }
335    }
336
337    /// Detect a Rust project in the specified directory.
338    ///
339    /// This method checks for the presence of both `Cargo.toml` and `target/`
340    /// directory to identify a Rust project. If found, it attempts to extract
341    /// the project name from the `Cargo.toml` file.
342    ///
343    /// # Arguments
344    ///
345    /// * `path` - Directory path to check for a Rust project
346    /// * `errors` - Shared error collection for reporting parsing issues
347    ///
348    /// # Returns
349    ///
350    /// - `Some(Project)` if a valid Rust project is detected
351    /// - `None` if the directory doesn't contain a Rust project
352    ///
353    /// # Detection Criteria
354    ///
355    /// 1. `Cargo.toml` file exists in directory
356    /// 2. `target/` subdirectory exists in directory
357    /// 3. The project name is extracted from `Cargo.toml` if possible
358    fn detect_rust_project(
359        &self,
360        path: &Path,
361        errors: &Arc<Mutex<Vec<String>>>,
362    ) -> Option<Project> {
363        let cargo_toml = path.join("Cargo.toml");
364        let target_dir = path.join("target");
365
366        if cargo_toml.exists() && target_dir.exists() {
367            let name = self.extract_rust_project_name(&cargo_toml, errors);
368
369            let build_arts = BuildArtifacts {
370                path: path.join("target"),
371                size: 0, // Will be calculated later
372            };
373
374            return Some(Project::new(
375                ProjectType::Rust,
376                path.to_path_buf(),
377                build_arts,
378                name,
379            ));
380        }
381
382        None
383    }
384
385    /// Extract the project name from a Cargo.toml file.
386    ///
387    /// This method performs simple TOML parsing to extract the project name
388    /// from a Rust project's `Cargo.toml` file. It uses a line-by-line approach
389    /// rather than a full TOML parser for simplicity and performance.
390    ///
391    /// # Arguments
392    ///
393    /// * `cargo_toml` - Path to the Cargo.toml file
394    /// * `errors` - Shared error collection for reporting parsing issues
395    ///
396    /// # Returns
397    ///
398    /// - `Some(String)` containing the project name if successfully extracted
399    /// - `None` if the name cannot be found or parsed
400    ///
401    /// # Parsing Strategy
402    ///
403    /// The method looks for lines matching the pattern `name = "project_name"`
404    /// and extracts the quoted string value. This trivial approach handles
405    /// most common cases without requiring a full TOML parser.
406    fn extract_rust_project_name(
407        &self,
408        cargo_toml: &Path,
409        errors: &Arc<Mutex<Vec<String>>>,
410    ) -> Option<String> {
411        let content = self.read_file_content(cargo_toml, errors)?;
412        Self::parse_toml_name_field(&content)
413    }
414
415    /// Extract a quoted string value from a line.
416    fn extract_quoted_value(line: &str) -> Option<String> {
417        let start = line.find('"')?;
418        let end = line.rfind('"')?;
419
420        if start == end {
421            return None;
422        }
423
424        Some(line[start + 1..end].to_string())
425    }
426
427    /// Extract the name from a single TOML line if it contains a name field.
428    fn extract_name_from_line(line: &str) -> Option<String> {
429        if !Self::is_name_line(line) {
430            return None;
431        }
432
433        Self::extract_quoted_value(line)
434    }
435
436    /// Extract the project name from a package.json file.
437    ///
438    /// This method parses a Node.js project's `package.json` file to extract
439    /// the project name. It uses full JSON parsing to handle the file format
440    /// correctly and safely.
441    ///
442    /// # Arguments
443    ///
444    /// * `package_json` - Path to the package.json file
445    /// * `errors` - Shared error collection for reporting parsing issues
446    ///
447    /// # Returns
448    ///
449    /// - `Some(String)` containing the project name if successfully extracted
450    /// - `None` if the name cannot be found, parsed, or the file is invalid
451    ///
452    /// # Error Handling
453    ///
454    /// This method handles both file I/O errors and JSON parsing errors gracefully.
455    /// Errors are optionally reported to the shared error collection in verbose mode.
456    fn extract_node_project_name(
457        &self,
458        package_json: &Path,
459        errors: &Arc<Mutex<Vec<String>>>,
460    ) -> Option<String> {
461        match fs::read_to_string(package_json) {
462            Ok(content) => match from_str::<Value>(&content) {
463                Ok(json) => json
464                    .get("name")
465                    .and_then(|v| v.as_str())
466                    .map(std::string::ToString::to_string),
467                Err(e) => {
468                    if self.scan_options.verbose {
469                        errors
470                            .lock()
471                            .unwrap()
472                            .push(format!("Error parsing {}: {e}", package_json.display()));
473                    }
474                    None
475                }
476            },
477            Err(e) => {
478                if self.scan_options.verbose {
479                    errors
480                        .lock()
481                        .unwrap()
482                        .push(format!("Error reading {}: {e}", package_json.display()));
483                }
484                None
485            }
486        }
487    }
488
489    /// Check if a line contains a name field assignment.
490    fn is_name_line(line: &str) -> bool {
491        line.starts_with("name") && line.contains('=')
492    }
493
494    /// Log a file reading error if verbose mode is enabled.
495    fn log_file_error(
496        &self,
497        file_path: &Path,
498        error: &std::io::Error,
499        errors: &Arc<Mutex<Vec<String>>>,
500    ) {
501        if self.scan_options.verbose {
502            errors
503                .lock()
504                .unwrap()
505                .push(format!("Error reading {}: {error}", file_path.display()));
506        }
507    }
508
509    /// Parse the name field from TOML content.
510    fn parse_toml_name_field(content: &str) -> Option<String> {
511        for line in content.lines() {
512            if let Some(name) = Self::extract_name_from_line(line.trim()) {
513                return Some(name);
514            }
515        }
516        None
517    }
518
519    /// Read the content of a file and handle errors appropriately.
520    fn read_file_content(
521        &self,
522        file_path: &Path,
523        errors: &Arc<Mutex<Vec<String>>>,
524    ) -> Option<String> {
525        match fs::read_to_string(file_path) {
526            Ok(content) => Some(content),
527            Err(e) => {
528                self.log_file_error(file_path, &e, errors);
529                None
530            }
531        }
532    }
533
534    /// Determine if a directory entry should be scanned for projects.
535    ///
536    /// This method implements the filtering logic to decide whether a directory
537    /// should be traversed during the scanning process. It applies various
538    /// exclusion rules to improve performance and avoid scanning irrelevant
539    /// directories.
540    ///
541    /// # Arguments
542    ///
543    /// * `entry` - The directory entry to evaluate
544    ///
545    /// # Returns
546    ///
547    /// - `true` if the directory should be scanned
548    /// - `false` if the directory should be skipped
549    ///
550    /// # Exclusion Rules
551    ///
552    /// The following directories are excluded from scanning:
553    /// - Directories in the user-specified skip list
554    /// - Any directory inside a `node_modules/` directory (to avoid deep nesting)
555    /// - Hidden directories (starting with `.`) except `.cargo`
556    /// - Common build/temporary directories: `target`, `build`, `dist`, `out`, etc.
557    /// - Version control directories: `.git`, `.svn`, `.hg`
558    /// - Python cache and virtual environment directories
559    /// - Temporary directories: `temp`, `tmp`
560    /// - Go vendor directory
561    /// - Python pytest cache
562    /// - Python tox environments
563    /// - Python setuptools
564    /// - Python coverage files
565    /// - Node.js modules (already handled above but added for completeness)
566    /// - .NET `obj/` directory
567    fn should_scan_entry(&self, entry: &DirEntry) -> bool {
568        let path = entry.path();
569
570        // Early return if path is in skip list
571        if self.is_path_in_skip_list(path) {
572            return false;
573        }
574
575        // Skip any directory inside a node_modules directory
576        if path
577            .ancestors()
578            .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
579        {
580            return false;
581        }
582
583        // Skip hidden directories (except .cargo for Rust)
584        if Self::is_hidden_directory_to_skip(path) {
585            return false;
586        }
587
588        // Skip common non-project directories
589        !Self::is_excluded_directory(path)
590    }
591
592    /// Check if a path is in the skip list
593    fn is_path_in_skip_list(&self, path: &Path) -> bool {
594        self.scan_options.skip.iter().any(|skip| {
595            path.components().any(|component| {
596                component
597                    .as_os_str()
598                    .to_str()
599                    .is_some_and(|name| name == skip.to_string_lossy())
600            })
601        })
602    }
603
604    /// Check if directory is hidden and should be skipped
605    fn is_hidden_directory_to_skip(path: &Path) -> bool {
606        path.file_name()
607            .and_then(|n| n.to_str())
608            .is_some_and(|name| name.starts_with('.') && name != ".cargo")
609    }
610
611    /// Check if directory is in the excluded list
612    fn is_excluded_directory(path: &Path) -> bool {
613        let excluded_dirs = [
614            "target",
615            "build",
616            "dist",
617            "out",
618            ".git",
619            ".svn",
620            ".hg",
621            "__pycache__",
622            "venv",
623            ".venv",
624            "env",
625            ".env",
626            "temp",
627            "tmp",
628            "vendor",
629            ".pytest_cache",
630            ".tox",
631            ".eggs",
632            ".coverage",
633            "node_modules",
634            "obj",
635        ];
636
637        path.file_name()
638            .and_then(|n| n.to_str())
639            .is_some_and(|name| excluded_dirs.contains(&name))
640    }
641
642    /// Detect a Python project in the specified directory.
643    ///
644    /// This method checks for Python configuration files and associated cache directories.
645    /// It looks for multiple build artifacts that can be cleaned.
646    ///
647    /// # Arguments
648    ///
649    /// * `path` - Directory path to check for a Python project
650    /// * `errors` - Shared error collection for reporting parsing issues
651    ///
652    /// # Returns
653    ///
654    /// - `Some(Project)` if a valid Python project is detected
655    /// - `None` if the directory doesn't contain a Python project
656    ///
657    /// # Detection Criteria
658    ///
659    /// A Python project is identified by having:
660    /// 1. At least one of: requirements.txt, setup.py, pyproject.toml, setup.cfg, Pipfile
661    /// 2. At least one of the cache/build directories: `__pycache__`, `.pytest_cache`, venv, .venv, build, dist, .eggs
662    fn detect_python_project(
663        &self,
664        path: &Path,
665        errors: &Arc<Mutex<Vec<String>>>,
666    ) -> Option<Project> {
667        let config_files = [
668            "requirements.txt",
669            "setup.py",
670            "pyproject.toml",
671            "setup.cfg",
672            "Pipfile",
673            "pipenv.lock",
674            "poetry.lock",
675        ];
676
677        let build_dirs = [
678            "__pycache__",
679            ".pytest_cache",
680            "venv",
681            ".venv",
682            "build",
683            "dist",
684            ".eggs",
685            ".tox",
686            ".coverage",
687        ];
688
689        // Check if any config file exists
690        let has_config = config_files.iter().any(|&file| path.join(file).exists());
691
692        if !has_config {
693            return None;
694        }
695
696        // Find the largest cache/build directory that exists
697        let mut largest_build_dir = None;
698        let mut largest_size = 0;
699
700        for &dir_name in &build_dirs {
701            let dir_path = path.join(dir_name);
702
703            if dir_path.exists() && dir_path.is_dir() {
704                let size = crate::utils::calculate_dir_size(&dir_path);
705                if size > largest_size {
706                    largest_size = size;
707                    largest_build_dir = Some(dir_path);
708                }
709            }
710        }
711
712        if let Some(build_path) = largest_build_dir {
713            let name = self.extract_python_project_name(path, errors);
714
715            let build_arts = BuildArtifacts {
716                path: build_path,
717                size: largest_size,
718            };
719
720            return Some(Project::new(
721                ProjectType::Python,
722                path.to_path_buf(),
723                build_arts,
724                name,
725            ));
726        }
727
728        None
729    }
730
731    /// Detect a Go project in the specified directory.
732    ///
733    /// This method checks for the presence of both `go.mod` and `vendor/`
734    /// directory to identify a Go project. If found, it attempts to extract
735    /// the project name from the `go.mod` file.
736    ///
737    /// # Arguments
738    ///
739    /// * `path` - Directory path to check for a Go project
740    /// * `errors` - Shared error collection for reporting parsing issues
741    ///
742    /// # Returns
743    ///
744    /// - `Some(Project)` if a valid Go project is detected
745    /// - `None` if the directory doesn't contain a Go project
746    ///
747    /// # Detection Criteria
748    ///
749    /// 1. `go.mod` file exists in directory
750    /// 2. `vendor/` subdirectory exists in directory
751    /// 3. The project name is extracted from `go.mod` if possible
752    fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
753        let go_mod = path.join("go.mod");
754        let vendor_dir = path.join("vendor");
755
756        if go_mod.exists() && vendor_dir.exists() {
757            let name = self.extract_go_project_name(&go_mod, errors);
758
759            let build_arts = BuildArtifacts {
760                path: path.join("vendor"),
761                size: 0, // Will be calculated later
762            };
763
764            return Some(Project::new(
765                ProjectType::Go,
766                path.to_path_buf(),
767                build_arts,
768                name,
769            ));
770        }
771
772        None
773    }
774
775    /// Extract the project name from a Python project directory.
776    ///
777    /// This method attempts to extract the project name from various Python
778    /// configuration files in order of preference.
779    ///
780    /// # Arguments
781    ///
782    /// * `path` - Path to the Python project directory
783    /// * `errors` - Shared error collection for reporting parsing issues
784    ///
785    /// # Returns
786    ///
787    /// - `Some(String)` containing the project name if successfully extracted
788    /// - `None` if the name cannot be found or parsed
789    ///
790    /// # Extraction Order
791    ///
792    /// 1. pyproject.toml (from [project] name or [tool.poetry] name)
793    /// 2. setup.py (from name= parameter)
794    /// 3. setup.cfg (from [metadata] name)
795    /// 4. Use directory name as a fallback
796    fn extract_python_project_name(
797        &self,
798        path: &Path,
799        errors: &Arc<Mutex<Vec<String>>>,
800    ) -> Option<String> {
801        // Try files in order of preference
802        self.try_extract_from_pyproject_toml(path, errors)
803            .or_else(|| self.try_extract_from_setup_py(path, errors))
804            .or_else(|| self.try_extract_from_setup_cfg(path, errors))
805            .or_else(|| Self::fallback_to_directory_name(path))
806    }
807
808    /// Try to extract project name from pyproject.toml
809    fn try_extract_from_pyproject_toml(
810        &self,
811        path: &Path,
812        errors: &Arc<Mutex<Vec<String>>>,
813    ) -> Option<String> {
814        let pyproject_toml = path.join("pyproject.toml");
815        if !pyproject_toml.exists() {
816            return None;
817        }
818
819        let content = self.read_file_content(&pyproject_toml, errors)?;
820        Self::extract_name_from_toml_like_content(&content)
821    }
822
823    /// Try to extract project name from setup.py
824    fn try_extract_from_setup_py(
825        &self,
826        path: &Path,
827        errors: &Arc<Mutex<Vec<String>>>,
828    ) -> Option<String> {
829        let setup_py = path.join("setup.py");
830        if !setup_py.exists() {
831            return None;
832        }
833
834        let content = self.read_file_content(&setup_py, errors)?;
835        Self::extract_name_from_python_content(&content)
836    }
837
838    /// Try to extract project name from setup.cfg
839    fn try_extract_from_setup_cfg(
840        &self,
841        path: &Path,
842        errors: &Arc<Mutex<Vec<String>>>,
843    ) -> Option<String> {
844        let setup_cfg = path.join("setup.cfg");
845        if !setup_cfg.exists() {
846            return None;
847        }
848
849        let content = self.read_file_content(&setup_cfg, errors)?;
850        Self::extract_name_from_cfg_content(&content)
851    }
852
853    /// Extract name from TOML-like content (pyproject.toml)
854    fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
855        content
856            .lines()
857            .map(str::trim)
858            .find(|line| line.starts_with("name") && line.contains('='))
859            .and_then(Self::extract_quoted_value)
860    }
861
862    /// Extract name from Python content (setup.py)
863    fn extract_name_from_python_content(content: &str) -> Option<String> {
864        content
865            .lines()
866            .map(str::trim)
867            .find(|line| line.contains("name") && line.contains('='))
868            .and_then(Self::extract_quoted_value)
869    }
870
871    /// Extract name from INI-style configuration content (setup.cfg)
872    fn extract_name_from_cfg_content(content: &str) -> Option<String> {
873        let mut in_metadata_section = false;
874
875        for line in content.lines() {
876            let line = line.trim();
877
878            if line == "[metadata]" {
879                in_metadata_section = true;
880            } else if line.starts_with('[') && line.ends_with(']') {
881                in_metadata_section = false;
882            } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
883                return line.split('=').nth(1).map(|name| name.trim().to_string());
884            }
885        }
886
887        None
888    }
889
890    /// Fallback to directory name
891    fn fallback_to_directory_name(path: &Path) -> Option<String> {
892        path.file_name()
893            .and_then(|name| name.to_str())
894            .map(std::string::ToString::to_string)
895    }
896
897    /// Extract the project name from a `go.mod` file.
898    ///
899    /// This method parses a Go project's `go.mod` file to extract
900    /// the module name, which typically represents the project.
901    ///
902    /// # Arguments
903    ///
904    /// * `go_mod` - Path to the `go.mod` file
905    /// * `errors` - Shared error collection for reporting parsing issues
906    ///
907    /// # Returns
908    ///
909    /// - `Some(String)` containing the module name if successfully extracted
910    /// - `None` if the name cannot be found or parsed
911    ///
912    /// # Parsing Strategy
913    ///
914    /// The method looks for the first line starting with `module ` and extracts
915    /// the module path. For better display, it takes the last component of the path.
916    fn extract_go_project_name(
917        &self,
918        go_mod: &Path,
919        errors: &Arc<Mutex<Vec<String>>>,
920    ) -> Option<String> {
921        let content = self.read_file_content(go_mod, errors)?;
922
923        for line in content.lines() {
924            let line = line.trim();
925            if line.starts_with("module ") {
926                let module_path = line.strip_prefix("module ")?.trim();
927
928                // Take the last component of the module path for a cleaner name
929                if let Some(name) = module_path.split('/').next_back() {
930                    return Some(name.to_string());
931                }
932
933                return Some(module_path.to_string());
934            }
935        }
936
937        None
938    }
939
940    /// Detect a Java/Kotlin project in the specified directory.
941    ///
942    /// This method checks for Maven (`pom.xml`) or Gradle (`build.gradle`,
943    /// `build.gradle.kts`) configuration files and their associated build output
944    /// directories (`target/` for Maven, `build/` for Gradle).
945    ///
946    /// # Detection Criteria
947    ///
948    /// 1. `pom.xml` + `target/` directory (Maven)
949    /// 2. `build.gradle` or `build.gradle.kts` + `build/` directory (Gradle)
950    fn detect_java_project(
951        &self,
952        path: &Path,
953        errors: &Arc<Mutex<Vec<String>>>,
954    ) -> Option<Project> {
955        let pom_xml = path.join("pom.xml");
956        let target_dir = path.join("target");
957
958        // Maven project: pom.xml + target/
959        if pom_xml.exists() && target_dir.exists() {
960            let name = self.extract_java_maven_project_name(&pom_xml, errors);
961
962            let build_arts = BuildArtifacts {
963                path: target_dir,
964                size: 0,
965            };
966
967            return Some(Project::new(
968                ProjectType::Java,
969                path.to_path_buf(),
970                build_arts,
971                name,
972            ));
973        }
974
975        // Gradle project: build.gradle(.kts) + build/
976        let has_gradle =
977            path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
978        let build_dir = path.join("build");
979
980        if has_gradle && build_dir.exists() {
981            let name = self.extract_java_gradle_project_name(path, errors);
982
983            let build_arts = BuildArtifacts {
984                path: build_dir,
985                size: 0,
986            };
987
988            return Some(Project::new(
989                ProjectType::Java,
990                path.to_path_buf(),
991                build_arts,
992                name,
993            ));
994        }
995
996        None
997    }
998
999    /// Extract the project name from a Maven `pom.xml` file.
1000    ///
1001    /// Looks for `<artifactId>` tags and extracts the text content.
1002    fn extract_java_maven_project_name(
1003        &self,
1004        pom_xml: &Path,
1005        errors: &Arc<Mutex<Vec<String>>>,
1006    ) -> Option<String> {
1007        let content = self.read_file_content(pom_xml, errors)?;
1008
1009        for line in content.lines() {
1010            let trimmed = line.trim();
1011            if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1012                let name = trimmed
1013                    .strip_prefix("<artifactId>")?
1014                    .strip_suffix("</artifactId>")?;
1015                return Some(name.to_string());
1016            }
1017        }
1018
1019        None
1020    }
1021
1022    /// Extract the project name from a Gradle project.
1023    ///
1024    /// Looks for `settings.gradle` or `settings.gradle.kts` and extracts
1025    /// the `rootProject.name` value. Falls back to directory name.
1026    fn extract_java_gradle_project_name(
1027        &self,
1028        path: &Path,
1029        errors: &Arc<Mutex<Vec<String>>>,
1030    ) -> Option<String> {
1031        for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1032            let settings_path = path.join(settings_file);
1033            if settings_path.exists()
1034                && let Some(content) = self.read_file_content(&settings_path, errors)
1035            {
1036                for line in content.lines() {
1037                    let trimmed = line.trim();
1038                    if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1039                        return Self::extract_quoted_value(trimmed).or_else(|| {
1040                            trimmed
1041                                .split('=')
1042                                .nth(1)
1043                                .map(|s| s.trim().trim_matches('\'').to_string())
1044                        });
1045                    }
1046                }
1047            }
1048        }
1049
1050        Self::fallback_to_directory_name(path)
1051    }
1052
1053    /// Detect a C/C++ project in the specified directory.
1054    ///
1055    /// This method checks for `CMakeLists.txt` or `Makefile` alongside a `build/`
1056    /// directory to identify C/C++ projects.
1057    ///
1058    /// # Detection Criteria
1059    ///
1060    /// 1. `CMakeLists.txt` + `build/` directory (`CMake`)
1061    /// 2. `Makefile` + `build/` directory (`Make`)
1062    fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1063        let build_dir = path.join("build");
1064
1065        if !build_dir.exists() {
1066            return None;
1067        }
1068
1069        let cmake_file = path.join("CMakeLists.txt");
1070        let makefile = path.join("Makefile");
1071
1072        if cmake_file.exists() || makefile.exists() {
1073            let name = if cmake_file.exists() {
1074                self.extract_cpp_cmake_project_name(&cmake_file, errors)
1075            } else {
1076                Self::fallback_to_directory_name(path)
1077            };
1078
1079            let build_arts = BuildArtifacts {
1080                path: build_dir,
1081                size: 0,
1082            };
1083
1084            return Some(Project::new(
1085                ProjectType::Cpp,
1086                path.to_path_buf(),
1087                build_arts,
1088                name,
1089            ));
1090        }
1091
1092        None
1093    }
1094
1095    /// Extract the project name from a `CMakeLists.txt` file.
1096    ///
1097    /// Looks for `project(name` patterns and extracts the project name.
1098    fn extract_cpp_cmake_project_name(
1099        &self,
1100        cmake_file: &Path,
1101        errors: &Arc<Mutex<Vec<String>>>,
1102    ) -> Option<String> {
1103        let content = self.read_file_content(cmake_file, errors)?;
1104
1105        for line in content.lines() {
1106            let trimmed = line.trim();
1107            if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1108                let inner = trimmed
1109                    .trim_start_matches("project(")
1110                    .trim_start_matches("PROJECT(")
1111                    .trim_end_matches(')')
1112                    .trim();
1113
1114                // The project name is the first word/token
1115                let name = inner.split_whitespace().next()?;
1116                // Remove possible surrounding quotes
1117                let name = name.trim_matches('"').trim_matches('\'');
1118                if !name.is_empty() {
1119                    return Some(name.to_string());
1120                }
1121            }
1122        }
1123
1124        Self::fallback_to_directory_name(cmake_file.parent()?)
1125    }
1126
1127    /// Detect a Swift project in the specified directory.
1128    ///
1129    /// This method checks for a `Package.swift` manifest and the `.build/`
1130    /// directory to identify Swift Package Manager projects.
1131    ///
1132    /// # Detection Criteria
1133    ///
1134    /// 1. `Package.swift` file exists
1135    /// 2. `.build/` directory exists
1136    fn detect_swift_project(
1137        &self,
1138        path: &Path,
1139        errors: &Arc<Mutex<Vec<String>>>,
1140    ) -> Option<Project> {
1141        let package_swift = path.join("Package.swift");
1142        let build_dir = path.join(".build");
1143
1144        if package_swift.exists() && build_dir.exists() {
1145            let name = self.extract_swift_project_name(&package_swift, errors);
1146
1147            let build_arts = BuildArtifacts {
1148                path: build_dir,
1149                size: 0,
1150            };
1151
1152            return Some(Project::new(
1153                ProjectType::Swift,
1154                path.to_path_buf(),
1155                build_arts,
1156                name,
1157            ));
1158        }
1159
1160        None
1161    }
1162
1163    /// Extract the project name from a `Package.swift` file.
1164    ///
1165    /// Looks for `name:` inside the `Package(` initializer.
1166    fn extract_swift_project_name(
1167        &self,
1168        package_swift: &Path,
1169        errors: &Arc<Mutex<Vec<String>>>,
1170    ) -> Option<String> {
1171        let content = self.read_file_content(package_swift, errors)?;
1172
1173        for line in content.lines() {
1174            let trimmed = line.trim();
1175            if trimmed.contains("name:") {
1176                return Self::extract_quoted_value(trimmed);
1177            }
1178        }
1179
1180        Self::fallback_to_directory_name(package_swift.parent()?)
1181    }
1182
1183    /// Detect a .NET/C# project in the specified directory.
1184    ///
1185    /// This method checks for `.csproj` files alongside `bin/` and/or `obj/`
1186    /// directories to identify .NET projects.
1187    ///
1188    /// # Detection Criteria
1189    ///
1190    /// 1. At least one `.csproj` file exists in the directory
1191    /// 2. At least one of `bin/` or `obj/` directories exists
1192    fn detect_dotnet_project(path: &Path) -> Option<Project> {
1193        let bin_dir = path.join("bin");
1194        let obj_dir = path.join("obj");
1195
1196        let has_build_dir = bin_dir.exists() || obj_dir.exists();
1197        if !has_build_dir {
1198            return None;
1199        }
1200
1201        let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1202
1203        // Pick the larger of bin/ and obj/ as the primary build artifact
1204        let (build_path, precomputed_size) = match (bin_dir.exists(), obj_dir.exists()) {
1205            (true, true) => {
1206                let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1207                let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1208                if obj_size >= bin_size {
1209                    (obj_dir, obj_size)
1210                } else {
1211                    (bin_dir, bin_size)
1212                }
1213            }
1214            (true, false) => (bin_dir, 0),
1215            (false, true) => (obj_dir, 0),
1216            (false, false) => return None,
1217        };
1218
1219        let name = csproj_file
1220            .file_stem()
1221            .and_then(|s| s.to_str())
1222            .map(std::string::ToString::to_string);
1223
1224        let build_arts = BuildArtifacts {
1225            path: build_path,
1226            size: precomputed_size,
1227        };
1228
1229        Some(Project::new(
1230            ProjectType::DotNet,
1231            path.to_path_buf(),
1232            build_arts,
1233            name,
1234        ))
1235    }
1236
1237    /// Find the first file with a given extension in a directory.
1238    fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1239        let entries = fs::read_dir(dir).ok()?;
1240        for entry in entries.flatten() {
1241            let path = entry.path();
1242            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1243                return Some(path);
1244            }
1245        }
1246        None
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use std::path::PathBuf;
1254    use tempfile::TempDir;
1255
1256    /// Create a scanner with default options and the given filter.
1257    fn default_scanner(filter: ProjectFilter) -> Scanner {
1258        Scanner::new(
1259            ScanOptions {
1260                verbose: false,
1261                threads: 1,
1262                skip: vec![],
1263            },
1264            filter,
1265        )
1266    }
1267
1268    /// Helper to create a file with content, ensuring parent dirs exist.
1269    fn create_file(path: &Path, content: &str) {
1270        if let Some(parent) = path.parent() {
1271            fs::create_dir_all(parent).unwrap();
1272        }
1273        fs::write(path, content).unwrap();
1274    }
1275
1276    // ── Static helper method tests ──────────────────────────────────────
1277
1278    #[test]
1279    fn test_is_hidden_directory_to_skip() {
1280        // Hidden directories should be skipped
1281        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1282            "/some/.hidden"
1283        )));
1284        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1285            "/some/.git"
1286        )));
1287        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1288            "/some/.svn"
1289        )));
1290        assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1291
1292        // .cargo is the special exception — should NOT be skipped
1293        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1294            "/home/user/.cargo"
1295        )));
1296        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1297
1298        // Non-hidden directories should not be skipped
1299        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1300            "/some/visible"
1301        )));
1302        assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1303    }
1304
1305    #[test]
1306    fn test_is_excluded_directory() {
1307        // Build/artifact directories should be excluded
1308        assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1309        assert!(Scanner::is_excluded_directory(Path::new(
1310            "/some/node_modules"
1311        )));
1312        assert!(Scanner::is_excluded_directory(Path::new(
1313            "/some/__pycache__"
1314        )));
1315        assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1316        assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1317        assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1318        assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1319
1320        // VCS directories should be excluded
1321        assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1322        assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1323        assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1324
1325        // Python-specific directories
1326        assert!(Scanner::is_excluded_directory(Path::new(
1327            "/some/.pytest_cache"
1328        )));
1329        assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1330        assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1331        assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1332
1333        // Virtual environments
1334        assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1335        assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1336        assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1337        assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1338
1339        // Temp directories
1340        assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1341        assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1342
1343        // Non-excluded directories
1344        assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1345        assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1346        assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1347        assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1348    }
1349
1350    #[test]
1351    fn test_extract_quoted_value() {
1352        assert_eq!(
1353            Scanner::extract_quoted_value(r#"name = "my-project""#),
1354            Some("my-project".to_string())
1355        );
1356        assert_eq!(
1357            Scanner::extract_quoted_value(r#"name = "with spaces""#),
1358            Some("with spaces".to_string())
1359        );
1360        assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1361        // Single quote mark is not a pair
1362        assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1363    }
1364
1365    #[test]
1366    fn test_is_name_line() {
1367        assert!(Scanner::is_name_line("name = \"test\""));
1368        assert!(Scanner::is_name_line("name=\"test\""));
1369        assert!(!Scanner::is_name_line("version = \"1.0\""));
1370        assert!(!Scanner::is_name_line("# name = \"commented\""));
1371        assert!(!Scanner::is_name_line("name: \"yaml style\""));
1372    }
1373
1374    #[test]
1375    fn test_parse_toml_name_field() {
1376        let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1377        assert_eq!(
1378            Scanner::parse_toml_name_field(content),
1379            Some("test-project".to_string())
1380        );
1381
1382        let no_name = "[package]\nversion = \"0.1.0\"\n";
1383        assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1384
1385        let empty = "";
1386        assert_eq!(Scanner::parse_toml_name_field(empty), None);
1387    }
1388
1389    #[test]
1390    fn test_extract_name_from_cfg_content() {
1391        let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1392        assert_eq!(
1393            Scanner::extract_name_from_cfg_content(content),
1394            Some("my-package".to_string())
1395        );
1396
1397        // Name in wrong section should not be found
1398        let wrong_section = "[options]\nname = not-this\n";
1399        assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1400
1401        // Multiple sections — name must be in [metadata]
1402        let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1403        assert_eq!(
1404            Scanner::extract_name_from_cfg_content(multi),
1405            Some("correct".to_string())
1406        );
1407    }
1408
1409    #[test]
1410    fn test_extract_name_from_python_content() {
1411        let content = "from setuptools import setup\nsetup(\n    name=\"my-pkg\",\n)\n";
1412        assert_eq!(
1413            Scanner::extract_name_from_python_content(content),
1414            Some("my-pkg".to_string())
1415        );
1416
1417        let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1418        assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1419    }
1420
1421    #[test]
1422    fn test_fallback_to_directory_name() {
1423        assert_eq!(
1424            Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1425            Some("project-name".to_string())
1426        );
1427        assert_eq!(
1428            Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1429            Some("my_app".to_string())
1430        );
1431    }
1432
1433    #[test]
1434    fn test_is_path_in_skip_list() {
1435        let scanner = Scanner::new(
1436            ScanOptions {
1437                verbose: false,
1438                threads: 1,
1439                skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1440            },
1441            ProjectFilter::All,
1442        );
1443
1444        assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1445        assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1446        assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1447        assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1448    }
1449
1450    #[test]
1451    fn test_is_path_in_empty_skip_list() {
1452        let scanner = default_scanner(ProjectFilter::All);
1453        assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1454    }
1455
1456    // ── Scanning with special path characters ───────────────────────────
1457
1458    #[test]
1459    fn test_scan_directory_with_spaces_in_path() {
1460        let tmp = TempDir::new().unwrap();
1461        let base = tmp.path().join("path with spaces");
1462        fs::create_dir_all(&base).unwrap();
1463
1464        let project = base.join("my project");
1465        create_file(
1466            &project.join("Cargo.toml"),
1467            "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1468        );
1469        create_file(&project.join("target/dummy"), "content");
1470
1471        let scanner = default_scanner(ProjectFilter::Rust);
1472        let projects = scanner.scan_directory(&base);
1473        assert_eq!(projects.len(), 1);
1474        assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1475    }
1476
1477    #[test]
1478    fn test_scan_directory_with_unicode_names() {
1479        let tmp = TempDir::new().unwrap();
1480        let base = tmp.path();
1481
1482        let project = base.join("プロジェクト");
1483        create_file(
1484            &project.join("package.json"),
1485            r#"{"name": "unicode-project"}"#,
1486        );
1487        create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1488
1489        let scanner = default_scanner(ProjectFilter::Node);
1490        let projects = scanner.scan_directory(base);
1491        assert_eq!(projects.len(), 1);
1492        assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1493    }
1494
1495    #[test]
1496    fn test_scan_directory_with_special_characters_in_name() {
1497        let tmp = TempDir::new().unwrap();
1498        let base = tmp.path();
1499
1500        let project = base.join("project-with-dashes_and_underscores.v2");
1501        create_file(
1502            &project.join("Cargo.toml"),
1503            "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1504        );
1505        create_file(&project.join("target/dummy"), "content");
1506
1507        let scanner = default_scanner(ProjectFilter::Rust);
1508        let projects = scanner.scan_directory(base);
1509        assert_eq!(projects.len(), 1);
1510        assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1511    }
1512
1513    // ── Unix-specific scanning tests ────────────────────────────────────
1514
1515    #[test]
1516    #[cfg(unix)]
1517    fn test_hidden_directory_itself_not_detected_as_project_unix() {
1518        let tmp = TempDir::new().unwrap();
1519        let base = tmp.path();
1520
1521        // A hidden directory with Cargo.toml + target/ directly inside it
1522        // should NOT be detected because the .hidden entry is filtered by
1523        // is_hidden_directory_to_skip. However, non-hidden children inside
1524        // hidden dirs CAN still be found because WalkDir descends into them.
1525        let hidden = base.join(".hidden-project");
1526        create_file(
1527            &hidden.join("Cargo.toml"),
1528            "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1529        );
1530        create_file(&hidden.join("target/dummy"), "content");
1531
1532        // A visible project should be found
1533        let visible = base.join("visible-project");
1534        create_file(
1535            &visible.join("Cargo.toml"),
1536            "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1537        );
1538        create_file(&visible.join("target/dummy"), "content");
1539
1540        let scanner = default_scanner(ProjectFilter::Rust);
1541        let projects = scanner.scan_directory(base);
1542
1543        // Only the visible project should be found; the hidden one is excluded
1544        // because its directory name starts with '.'
1545        assert_eq!(projects.len(), 1);
1546        assert_eq!(projects[0].name.as_deref(), Some("visible"));
1547    }
1548
1549    #[test]
1550    #[cfg(unix)]
1551    fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1552        let tmp = TempDir::new().unwrap();
1553        let base = tmp.path();
1554
1555        // A non-hidden project nested inside a hidden directory.
1556        // WalkDir still descends into .hidden, so the child project IS found.
1557        let nested = base.join(".hidden-parent/visible-child");
1558        create_file(
1559            &nested.join("Cargo.toml"),
1560            "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1561        );
1562        create_file(&nested.join("target/dummy"), "content");
1563
1564        let scanner = default_scanner(ProjectFilter::Rust);
1565        let projects = scanner.scan_directory(base);
1566
1567        // The child project has a non-hidden name, so it IS detected
1568        assert_eq!(projects.len(), 1);
1569        assert_eq!(projects[0].name.as_deref(), Some("nested"));
1570    }
1571
1572    #[test]
1573    #[cfg(unix)]
1574    fn test_dotcargo_directory_not_skipped_unix() {
1575        // .cargo is the exception — hidden but should NOT be skipped.
1576        // Verify via the static method.
1577        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1578            "/home/user/.cargo"
1579        )));
1580
1581        // Other dot-dirs ARE skipped
1582        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1583            "/home/user/.local"
1584        )));
1585        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1586            "/home/user/.npm"
1587        )));
1588    }
1589
1590    // ── Python project detection tests ──────────────────────────────────
1591
1592    #[test]
1593    fn test_detect_python_with_pyproject_toml() {
1594        let tmp = TempDir::new().unwrap();
1595        let base = tmp.path();
1596
1597        let project = base.join("py-project");
1598        create_file(
1599            &project.join("pyproject.toml"),
1600            "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1601        );
1602        let pycache = project.join("__pycache__");
1603        fs::create_dir_all(&pycache).unwrap();
1604        create_file(&pycache.join("module.pyc"), "bytecode");
1605
1606        let scanner = default_scanner(ProjectFilter::Python);
1607        let projects = scanner.scan_directory(base);
1608        assert_eq!(projects.len(), 1);
1609        assert_eq!(projects[0].kind, ProjectType::Python);
1610    }
1611
1612    #[test]
1613    fn test_detect_python_with_setup_py() {
1614        let tmp = TempDir::new().unwrap();
1615        let base = tmp.path();
1616
1617        let project = base.join("setup-project");
1618        create_file(
1619            &project.join("setup.py"),
1620            "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1621        );
1622        let pycache = project.join("__pycache__");
1623        fs::create_dir_all(&pycache).unwrap();
1624        create_file(&pycache.join("module.pyc"), "bytecode");
1625
1626        let scanner = default_scanner(ProjectFilter::Python);
1627        let projects = scanner.scan_directory(base);
1628        assert_eq!(projects.len(), 1);
1629    }
1630
1631    #[test]
1632    fn test_detect_python_with_pipfile() {
1633        let tmp = TempDir::new().unwrap();
1634        let base = tmp.path();
1635
1636        let project = base.join("pipenv-project");
1637        create_file(
1638            &project.join("Pipfile"),
1639            "[[source]]\nurl = \"https://pypi.org/simple\"",
1640        );
1641        let pycache = project.join("__pycache__");
1642        fs::create_dir_all(&pycache).unwrap();
1643        create_file(&pycache.join("module.pyc"), "bytecode");
1644
1645        let scanner = default_scanner(ProjectFilter::Python);
1646        let projects = scanner.scan_directory(base);
1647        assert_eq!(projects.len(), 1);
1648    }
1649
1650    // ── Go project detection tests ──────────────────────────────────────
1651
1652    #[test]
1653    fn test_detect_go_extracts_module_name() {
1654        let tmp = TempDir::new().unwrap();
1655        let base = tmp.path();
1656
1657        let project = base.join("go-service");
1658        create_file(
1659            &project.join("go.mod"),
1660            "module github.com/user/my-service\n\ngo 1.21\n",
1661        );
1662        let vendor = project.join("vendor");
1663        fs::create_dir_all(&vendor).unwrap();
1664        create_file(&vendor.join("modules.txt"), "vendor manifest");
1665
1666        let scanner = default_scanner(ProjectFilter::Go);
1667        let projects = scanner.scan_directory(base);
1668        assert_eq!(projects.len(), 1);
1669        // Should extract last path component as name
1670        assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1671    }
1672
1673    // ── Java/Kotlin project detection tests ────────────────────────────
1674
1675    #[test]
1676    fn test_detect_java_maven_project() {
1677        let tmp = TempDir::new().unwrap();
1678        let base = tmp.path();
1679
1680        let project = base.join("java-maven");
1681        create_file(
1682            &project.join("pom.xml"),
1683            "<project>\n  <artifactId>my-java-app</artifactId>\n</project>",
1684        );
1685        create_file(&project.join("target/classes/Main.class"), "bytecode");
1686
1687        let scanner = default_scanner(ProjectFilter::Java);
1688        let projects = scanner.scan_directory(base);
1689        assert_eq!(projects.len(), 1);
1690        assert_eq!(projects[0].kind, ProjectType::Java);
1691        assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
1692    }
1693
1694    #[test]
1695    fn test_detect_java_gradle_project() {
1696        let tmp = TempDir::new().unwrap();
1697        let base = tmp.path();
1698
1699        let project = base.join("java-gradle");
1700        create_file(&project.join("build.gradle"), "apply plugin: 'java'");
1701        create_file(
1702            &project.join("settings.gradle"),
1703            "rootProject.name = \"my-gradle-app\"",
1704        );
1705        create_file(&project.join("build/classes/main/Main.class"), "bytecode");
1706
1707        let scanner = default_scanner(ProjectFilter::Java);
1708        let projects = scanner.scan_directory(base);
1709        assert_eq!(projects.len(), 1);
1710        assert_eq!(projects[0].kind, ProjectType::Java);
1711        assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
1712    }
1713
1714    #[test]
1715    fn test_detect_java_gradle_kts_project() {
1716        let tmp = TempDir::new().unwrap();
1717        let base = tmp.path();
1718
1719        let project = base.join("kotlin-gradle");
1720        create_file(
1721            &project.join("build.gradle.kts"),
1722            "plugins { kotlin(\"jvm\") }",
1723        );
1724        create_file(
1725            &project.join("settings.gradle.kts"),
1726            "rootProject.name = \"my-kotlin-app\"",
1727        );
1728        create_file(
1729            &project.join("build/classes/kotlin/main/MainKt.class"),
1730            "bytecode",
1731        );
1732
1733        let scanner = default_scanner(ProjectFilter::Java);
1734        let projects = scanner.scan_directory(base);
1735        assert_eq!(projects.len(), 1);
1736        assert_eq!(projects[0].kind, ProjectType::Java);
1737        assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
1738    }
1739
1740    // ── C/C++ project detection tests ────────────────────────────────────
1741
1742    #[test]
1743    fn test_detect_cpp_cmake_project() {
1744        let tmp = TempDir::new().unwrap();
1745        let base = tmp.path();
1746
1747        let project = base.join("cpp-cmake");
1748        create_file(
1749            &project.join("CMakeLists.txt"),
1750            "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
1751        );
1752        create_file(&project.join("build/CMakeCache.txt"), "cache");
1753
1754        let scanner = default_scanner(ProjectFilter::Cpp);
1755        let projects = scanner.scan_directory(base);
1756        assert_eq!(projects.len(), 1);
1757        assert_eq!(projects[0].kind, ProjectType::Cpp);
1758        assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
1759    }
1760
1761    #[test]
1762    fn test_detect_cpp_makefile_project() {
1763        let tmp = TempDir::new().unwrap();
1764        let base = tmp.path();
1765
1766        let project = base.join("cpp-make");
1767        create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
1768        create_file(&project.join("build/main.o"), "object");
1769
1770        let scanner = default_scanner(ProjectFilter::Cpp);
1771        let projects = scanner.scan_directory(base);
1772        assert_eq!(projects.len(), 1);
1773        assert_eq!(projects[0].kind, ProjectType::Cpp);
1774    }
1775
1776    // ── Swift project detection tests ────────────────────────────────────
1777
1778    #[test]
1779    fn test_detect_swift_project() {
1780        let tmp = TempDir::new().unwrap();
1781        let base = tmp.path();
1782
1783        let project = base.join("swift-pkg");
1784        create_file(
1785            &project.join("Package.swift"),
1786            "let package = Package(\n    name: \"my-swift-lib\",\n    targets: []\n)",
1787        );
1788        create_file(&project.join(".build/debug/my-swift-lib"), "binary");
1789
1790        let scanner = default_scanner(ProjectFilter::Swift);
1791        let projects = scanner.scan_directory(base);
1792        assert_eq!(projects.len(), 1);
1793        assert_eq!(projects[0].kind, ProjectType::Swift);
1794        assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
1795    }
1796
1797    // ── .NET/C# project detection tests ──────────────────────────────────
1798
1799    #[test]
1800    fn test_detect_dotnet_project() {
1801        let tmp = TempDir::new().unwrap();
1802        let base = tmp.path();
1803
1804        let project = base.join("dotnet-app");
1805        create_file(
1806            &project.join("MyApp.csproj"),
1807            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
1808        );
1809        create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
1810        create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
1811
1812        let scanner = default_scanner(ProjectFilter::DotNet);
1813        let projects = scanner.scan_directory(base);
1814        assert_eq!(projects.len(), 1);
1815        assert_eq!(projects[0].kind, ProjectType::DotNet);
1816        assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
1817    }
1818
1819    #[test]
1820    fn test_detect_dotnet_project_obj_only() {
1821        let tmp = TempDir::new().unwrap();
1822        let base = tmp.path();
1823
1824        let project = base.join("dotnet-obj-only");
1825        create_file(
1826            &project.join("Lib.csproj"),
1827            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
1828        );
1829        create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
1830
1831        let scanner = default_scanner(ProjectFilter::DotNet);
1832        let projects = scanner.scan_directory(base);
1833        assert_eq!(projects.len(), 1);
1834        assert_eq!(projects[0].kind, ProjectType::DotNet);
1835        assert_eq!(projects[0].name.as_deref(), Some("Lib"));
1836    }
1837
1838    // ── Excluded directory tests ─────────────────────────────────────────
1839
1840    #[test]
1841    fn test_obj_directory_is_excluded() {
1842        assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
1843    }
1844
1845    // ── Cross-platform calculate_build_dir_size ─────────────────────────
1846
1847    #[test]
1848    fn test_calculate_build_dir_size_empty() {
1849        let tmp = TempDir::new().unwrap();
1850        let empty_dir = tmp.path().join("empty");
1851        fs::create_dir_all(&empty_dir).unwrap();
1852
1853        assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
1854    }
1855
1856    #[test]
1857    fn test_calculate_build_dir_size_nonexistent() {
1858        assert_eq!(
1859            Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
1860            0
1861        );
1862    }
1863
1864    #[test]
1865    fn test_calculate_build_dir_size_with_nested_files() {
1866        let tmp = TempDir::new().unwrap();
1867        let dir = tmp.path().join("nested");
1868
1869        create_file(&dir.join("file1.txt"), "hello"); // 5 bytes
1870        create_file(&dir.join("sub/file2.txt"), "world!"); // 6 bytes
1871        create_file(&dir.join("sub/deep/file3.txt"), "!"); // 1 byte
1872
1873        let size = Scanner::calculate_build_dir_size(&dir);
1874        assert_eq!(size, 12);
1875    }
1876
1877    // ── Quiet mode ──────────────────────────────────────────────────────
1878
1879    #[test]
1880    fn test_scanner_quiet_mode() {
1881        let tmp = TempDir::new().unwrap();
1882        let base = tmp.path();
1883
1884        let project = base.join("quiet-project");
1885        create_file(
1886            &project.join("Cargo.toml"),
1887            "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
1888        );
1889        create_file(&project.join("target/dummy"), "content");
1890
1891        let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
1892        let projects = scanner.scan_directory(base);
1893        assert_eq!(projects.len(), 1);
1894    }
1895}