Skip to main content

clean_dev_dirs/
scanner.rs

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