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