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, PathBuf},
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.
34#[derive(Debug)]
35pub struct Scanner {
36    /// Configuration options for scanning behavior
37    scan_options: ScanOptions,
38
39    /// Filter to restrict scanning to specific project types
40    project_filter: ProjectFilter,
41
42    /// When `true`, suppresses progress spinner output (used by `--json` mode).
43    quiet: bool,
44}
45
46impl Scanner {
47    /// Create a new scanner with the specified options.
48    ///
49    /// # Arguments
50    ///
51    /// * `scan_options` - Configuration for scanning behavior (threads, verbosity, etc.)
52    /// * `project_filter` - Filter to restrict scanning to specific project types
53    ///
54    /// # Returns
55    ///
56    /// A new `Scanner` instance configured with the provided options.
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// # use crate::{Scanner, ScanOptions, ProjectFilter};
62    /// let scan_options = ScanOptions {
63    ///     verbose: true,
64    ///     threads: 4,
65    ///     skip: vec![],
66    /// };
67    ///
68    /// let scanner = Scanner::new(scan_options, ProjectFilter::All);
69    /// ```
70    #[must_use]
71    pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
72        Self {
73            scan_options,
74            project_filter,
75            quiet: false,
76        }
77    }
78
79    /// Enable or disable quiet mode (suppresses progress spinner).
80    ///
81    /// When quiet mode is active the scanning spinner is hidden, which is
82    /// required for `--json` output so that only the final JSON is printed.
83    #[must_use]
84    pub const fn with_quiet(mut self, quiet: bool) -> Self {
85        self.quiet = quiet;
86        self
87    }
88
89    /// Scan a directory tree for development projects.
90    ///
91    /// This method performs a recursive scan of the specified directory to find
92    /// development projects. It operates in two phases:
93    /// 1. Directory traversal to identify potential projects
94    /// 2. Parallel size calculation for build directories
95    ///
96    /// # Arguments
97    ///
98    /// * `root` - The root directory to start scanning from
99    ///
100    /// # Returns
101    ///
102    /// A vector of `Project` instances representing all detected projects with
103    /// non-zero build directory sizes.
104    ///
105    /// # Panics
106    ///
107    /// This method may panic if the progress bar template string is invalid,
108    /// though this should not occur under normal circumstances as the template
109    /// is hardcoded and valid.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// # use std::path::Path;
115    /// # use crate::Scanner;
116    /// let projects = scanner.scan_directory(Path::new("/path/to/projects"));
117    /// println!("Found {} projects", projects.len());
118    /// ```
119    ///
120    /// # Performance
121    ///
122    /// This method uses parallel processing for both directory traversal and
123    /// size calculation to maximize performance on systems with multiple cores
124    /// and fast storage.
125    pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
126        let errors = Arc::new(Mutex::new(Vec::<String>::new()));
127
128        let progress = if self.quiet {
129            ProgressBar::hidden()
130        } else {
131            let pb = ProgressBar::new_spinner();
132            if let Ok(style) = ProgressStyle::default_spinner().template("{spinner:.green} {msg}") {
133                pb.set_style(style);
134            }
135            pb.set_message("Scanning...");
136            pb.enable_steady_tick(std::time::Duration::from_millis(100));
137            pb
138        };
139
140        let found_count = Arc::new(AtomicUsize::new(0));
141        let progress_clone = progress.clone();
142        let count_clone = Arc::clone(&found_count);
143
144        // Find all potential project directories
145        let walker = self.scan_options.max_depth.map_or_else(
146            || WalkDir::new(root),
147            |depth| WalkDir::new(root).max_depth(depth),
148        );
149
150        let potential_projects: Vec<_> = walker
151            .into_iter()
152            .filter_map(Result::ok)
153            .filter(|entry| self.should_scan_entry(entry))
154            .collect::<Vec<_>>()
155            .into_par_iter()
156            .filter_map(|entry| {
157                let result = self.detect_project(&entry, &errors);
158                if result.is_some() {
159                    let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
160                    progress_clone.set_message(format!("Scanning... {n} found"));
161                }
162                result
163            })
164            .collect();
165
166        progress.finish_with_message("[OK] Directory scan complete");
167
168        // Process projects in parallel to calculate sizes
169        let projects_with_sizes: Vec<_> = potential_projects
170            .into_par_iter()
171            .filter_map(|mut project| {
172                for artifact in &mut project.build_arts {
173                    if artifact.size == 0 {
174                        artifact.size = Self::calculate_build_dir_size(&artifact.path);
175                    }
176                }
177
178                if project.total_size() > 0 {
179                    Some(project)
180                } else {
181                    None
182                }
183            })
184            .collect();
185
186        // Print errors if verbose
187        if self.scan_options.verbose
188            && let Ok(errors) = errors.lock()
189        {
190            for error in errors.iter() {
191                eprintln!("{}", error.red());
192            }
193        }
194
195        projects_with_sizes
196    }
197
198    /// Scan multiple root directories and return a deduplicated list of projects.
199    ///
200    /// Calls [`scan_directory`](Scanner::scan_directory) for each root and merges
201    /// the results, skipping any project whose `root_path` was already seen.
202    ///
203    /// # Arguments
204    ///
205    /// * `roots` - Slice of root directories to scan
206    ///
207    /// # Returns
208    ///
209    /// A `Vec<Project>` containing all unique projects found across all roots.
210    #[must_use]
211    pub fn scan_directories(&self, roots: &[PathBuf]) -> Vec<Project> {
212        use std::collections::HashSet;
213        let mut seen: HashSet<PathBuf> = HashSet::new();
214        let mut result = Vec::new();
215        for root in roots {
216            for project in self.scan_directory(root) {
217                if seen.insert(project.root_path.clone()) {
218                    result.push(project);
219                }
220            }
221        }
222        result
223    }
224
225    /// Calculate the total size of a build directory.
226    ///
227    /// This method recursively traverses the specified directory and sums up
228    /// the sizes of all files contained within it. It handles errors gracefully
229    /// and optionally reports them in verbose mode.
230    ///
231    /// # Arguments
232    ///
233    /// * `path` - Path to the build directory to measure
234    ///
235    /// # Returns
236    ///
237    /// The total size of all files in the directory, in bytes. Returns 0 if
238    /// the directory doesn't exist or cannot be accessed.
239    ///
240    /// # Performance
241    ///
242    /// This method can be CPU and I/O intensive for large directories with
243    /// many files. It's designed to be called in parallel for multiple
244    /// directories to maximize throughput.
245    fn calculate_build_dir_size(path: &Path) -> u64 {
246        if !path.exists() {
247            return 0;
248        }
249
250        crate::utils::calculate_dir_size(path)
251    }
252
253    /// Detect a Node.js project in the specified directory.
254    ///
255    /// This method checks for the presence of both `package.json` and `node_modules/`
256    /// directory to identify a Node.js project. If found, it attempts to extract
257    /// the project name from the `package.json` file.
258    ///
259    /// # Arguments
260    ///
261    /// * `path` - Directory path to check for Node.js project
262    /// * `errors` - Shared error collection for reporting parsing issues
263    ///
264    /// # Returns
265    ///
266    /// - `Some(Project)` if a valid Node.js project is detected
267    /// - `None` if the directory doesn't contain a Node.js project
268    ///
269    /// # Detection Criteria
270    ///
271    /// 1. `package.json` file exists in directory
272    /// 2. `node_modules/` subdirectory exists in directory
273    /// 3. The project name is extracted from `package.json` if possible
274    fn detect_node_project(
275        &self,
276        path: &Path,
277        errors: &Arc<Mutex<Vec<String>>>,
278    ) -> Option<Project> {
279        let package_json = path.join("package.json");
280        let node_modules = path.join("node_modules");
281
282        if package_json.exists() && node_modules.exists() {
283            let name = self.extract_node_project_name(&package_json, errors);
284
285            let build_arts = vec![BuildArtifacts {
286                path: path.join("node_modules"),
287                size: 0, // Will be calculated later
288            }];
289
290            return Some(Project::new(
291                ProjectType::Node,
292                path.to_path_buf(),
293                build_arts,
294                name,
295            ));
296        }
297
298        None
299    }
300
301    /// Detect if a directory entry represents a development project.
302    ///
303    /// This method examines a directory entry and determines if it contains
304    /// a development project based on the presence of characteristic files
305    /// and directories. It respects the project filter settings.
306    ///
307    /// # Arguments
308    ///
309    /// * `entry` - The directory entry to examine
310    /// * `errors` - Shared error collection for reporting issues
311    ///
312    /// # Returns
313    ///
314    /// - `Some(Project)` if a valid project is detected
315    /// - `None` if no project is found or the entry doesn't match filters
316    ///
317    /// # Project Detection Logic
318    ///
319    /// - **Rust projects**: Presence of both `Cargo.toml` and `target/` directory
320    /// - **Deno projects**: Presence of `deno.json`/`deno.jsonc` with `vendor/` or `node_modules/`
321    /// - **Node.js projects**: Presence of both `package.json` and `node_modules/` directory
322    /// - **Scala projects**: Presence of `build.sbt` with `target/`
323    /// - **Java/Kotlin projects**: Presence of `pom.xml` or `build.gradle` with `target/` or `build/`
324    /// - **Python projects**: Presence of configuration files and cache directories
325    /// - **Go projects**: Presence of both `go.mod` and `vendor/` directory
326    /// - **C/C++ projects**: Presence of `CMakeLists.txt` or `Makefile` with `build/`
327    /// - **Swift projects**: Presence of `Package.swift` with `.build/`
328    /// - **.NET/C# projects**: Presence of `.csproj` files with `bin/` or `obj/`
329    /// - **Ruby projects**: Presence of `Gemfile` with `.bundle/` or `vendor/bundle/`
330    /// - **Elixir projects**: Presence of `mix.exs` with `_build/`
331    /// - **PHP projects**: Presence of `composer.json` with `vendor/`
332    /// - **Haskell projects**: Presence of `stack.yaml` with `.stack-work/`, or `*.cabal` with `dist-newstyle/`
333    /// - **Dart/Flutter projects**: Presence of `pubspec.yaml` with `.dart_tool/` or `build/`
334    /// - **Zig projects**: Presence of `build.zig` with `zig-cache/` or `zig-out/`
335    fn detect_project(
336        &self,
337        entry: &DirEntry,
338        errors: &Arc<Mutex<Vec<String>>>,
339    ) -> Option<Project> {
340        let path = entry.path();
341
342        if !entry.file_type().is_dir() {
343            return None;
344        }
345
346        // Detectors are tried in order; the first match wins.
347        // More specific ecosystems are checked before more generic ones
348        // (e.g. Scala before Java, since both use target/; Deno before
349        // Node since Deno 2 projects may also have a node_modules/).
350        self.try_detect(ProjectFilter::Rust, || {
351            self.detect_rust_project(path, errors)
352        })
353        .or_else(|| {
354            self.try_detect(ProjectFilter::Deno, || {
355                self.detect_deno_project(path, errors)
356            })
357        })
358        .or_else(|| {
359            self.try_detect(ProjectFilter::Node, || {
360                self.detect_node_project(path, errors)
361            })
362        })
363        .or_else(|| {
364            self.try_detect(ProjectFilter::Scala, || {
365                self.detect_scala_project(path, errors)
366            })
367        })
368        .or_else(|| {
369            self.try_detect(ProjectFilter::Java, || {
370                self.detect_java_project(path, errors)
371            })
372        })
373        .or_else(|| {
374            self.try_detect(ProjectFilter::Swift, || {
375                self.detect_swift_project(path, errors)
376            })
377        })
378        .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
379        .or_else(|| {
380            self.try_detect(ProjectFilter::Python, || {
381                self.detect_python_project(path, errors)
382            })
383        })
384        .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
385        .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
386        .or_else(|| {
387            self.try_detect(ProjectFilter::Ruby, || {
388                self.detect_ruby_project(path, errors)
389            })
390        })
391        .or_else(|| {
392            self.try_detect(ProjectFilter::Elixir, || {
393                self.detect_elixir_project(path, errors)
394            })
395        })
396        .or_else(|| self.try_detect(ProjectFilter::Php, || self.detect_php_project(path, errors)))
397        .or_else(|| {
398            self.try_detect(ProjectFilter::Haskell, || {
399                self.detect_haskell_project(path, errors)
400            })
401        })
402        .or_else(|| {
403            self.try_detect(ProjectFilter::Dart, || {
404                self.detect_dart_project(path, errors)
405            })
406        })
407        .or_else(|| self.try_detect(ProjectFilter::Zig, || Self::detect_zig_project(path)))
408    }
409
410    /// Run a detector only if the current project filter allows it.
411    ///
412    /// Returns `None` immediately (without calling `detect`) when the
413    /// active filter doesn't include `filter`.
414    fn try_detect(
415        &self,
416        filter: ProjectFilter,
417        detect: impl FnOnce() -> Option<Project>,
418    ) -> Option<Project> {
419        if self.project_filter == ProjectFilter::All || self.project_filter == filter {
420            detect()
421        } else {
422            None
423        }
424    }
425
426    /// Detect a Rust project in the specified directory.
427    ///
428    /// This method checks for the presence of both `Cargo.toml` and `target/`
429    /// directory to identify a Rust project. If found, it attempts to extract
430    /// the project name from the `Cargo.toml` file.
431    ///
432    /// # Arguments
433    ///
434    /// * `path` - Directory path to check for a Rust project
435    /// * `errors` - Shared error collection for reporting parsing issues
436    ///
437    /// # Returns
438    ///
439    /// - `Some(Project)` if a valid Rust project is detected
440    /// - `None` if the directory doesn't contain a Rust project
441    ///
442    /// # Detection Criteria
443    ///
444    /// 1. `Cargo.toml` file exists in directory
445    /// 2. `target/` subdirectory exists in directory
446    /// 3. The project name is extracted from `Cargo.toml` if possible
447    fn detect_rust_project(
448        &self,
449        path: &Path,
450        errors: &Arc<Mutex<Vec<String>>>,
451    ) -> Option<Project> {
452        let cargo_toml = path.join("Cargo.toml");
453        let target_dir = path.join("target");
454
455        if cargo_toml.exists() && target_dir.exists() {
456            // Skip workspace members — their artifacts are managed by the workspace root.
457            if Self::is_inside_cargo_workspace(path) {
458                return None;
459            }
460
461            let name = self.extract_rust_project_name(&cargo_toml, errors);
462
463            let build_arts = vec![BuildArtifacts {
464                path: path.join("target"),
465                size: 0, // Will be calculated later
466            }];
467
468            return Some(Project::new(
469                ProjectType::Rust,
470                path.to_path_buf(),
471                build_arts,
472                name,
473            ));
474        }
475
476        None
477    }
478
479    /// Return true if the given `Cargo.toml` declares a `[workspace]` section.
480    fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
481        fs::read_to_string(cargo_toml)
482            .is_ok_and(|content| content.lines().any(|line| line.trim() == "[workspace]"))
483    }
484
485    /// Return true if `path` is inside a Rust workspace (an ancestor directory
486    /// contains a `Cargo.toml` that declares `[workspace]`).
487    fn is_inside_cargo_workspace(path: &Path) -> bool {
488        path.ancestors()
489            .skip(1) // skip `path` itself
490            .any(|ancestor| {
491                let cargo_toml = ancestor.join("Cargo.toml");
492                cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
493            })
494    }
495
496    /// Extract the project name from a Cargo.toml file.
497    ///
498    /// This method performs simple TOML parsing to extract the project name
499    /// from a Rust project's `Cargo.toml` file. It uses a line-by-line approach
500    /// rather than a full TOML parser for simplicity and performance.
501    ///
502    /// # Arguments
503    ///
504    /// * `cargo_toml` - Path to the Cargo.toml file
505    /// * `errors` - Shared error collection for reporting parsing issues
506    ///
507    /// # Returns
508    ///
509    /// - `Some(String)` containing the project name if successfully extracted
510    /// - `None` if the name cannot be found or parsed
511    ///
512    /// # Parsing Strategy
513    ///
514    /// The method looks for lines matching the pattern `name = "project_name"`
515    /// and extracts the quoted string value. This trivial approach handles
516    /// most common cases without requiring a full TOML parser.
517    fn extract_rust_project_name(
518        &self,
519        cargo_toml: &Path,
520        errors: &Arc<Mutex<Vec<String>>>,
521    ) -> Option<String> {
522        let content = self.read_file_content(cargo_toml, errors)?;
523        Self::parse_toml_name_field(&content)
524    }
525
526    /// Extract a quoted string value from a line.
527    fn extract_quoted_value(line: &str) -> Option<String> {
528        let start = line.find('"')?;
529        let end = line.rfind('"')?;
530
531        if start == end {
532            return None;
533        }
534
535        Some(line[start + 1..end].to_string())
536    }
537
538    /// Extract the name from a single TOML line if it contains a name field.
539    fn extract_name_from_line(line: &str) -> Option<String> {
540        if !Self::is_name_line(line) {
541            return None;
542        }
543
544        Self::extract_quoted_value(line)
545    }
546
547    /// Extract the project name from a package.json file.
548    ///
549    /// This method parses a Node.js project's `package.json` file to extract
550    /// the project name. It uses full JSON parsing to handle the file format
551    /// correctly and safely.
552    ///
553    /// # Arguments
554    ///
555    /// * `package_json` - Path to the package.json file
556    /// * `errors` - Shared error collection for reporting parsing issues
557    ///
558    /// # Returns
559    ///
560    /// - `Some(String)` containing the project name if successfully extracted
561    /// - `None` if the name cannot be found, parsed, or the file is invalid
562    ///
563    /// # Error Handling
564    ///
565    /// This method handles both file I/O errors and JSON parsing errors gracefully.
566    /// Errors are optionally reported to the shared error collection in verbose mode.
567    fn extract_node_project_name(
568        &self,
569        package_json: &Path,
570        errors: &Arc<Mutex<Vec<String>>>,
571    ) -> Option<String> {
572        match fs::read_to_string(package_json) {
573            Ok(content) => match from_str::<Value>(&content) {
574                Ok(json) => json
575                    .get("name")
576                    .and_then(|v| v.as_str())
577                    .map(std::string::ToString::to_string),
578                Err(e) => {
579                    if self.scan_options.verbose
580                        && let Ok(mut errs) = errors.lock()
581                    {
582                        errs.push(format!("Error parsing {}: {e}", package_json.display()));
583                    }
584                    None
585                }
586            },
587            Err(e) => {
588                if self.scan_options.verbose
589                    && let Ok(mut errs) = errors.lock()
590                {
591                    errs.push(format!("Error reading {}: {e}", package_json.display()));
592                }
593                None
594            }
595        }
596    }
597
598    /// Check if a line contains a name field assignment.
599    fn is_name_line(line: &str) -> bool {
600        line.starts_with("name") && line.contains('=')
601    }
602
603    /// Log a file reading error if verbose mode is enabled.
604    fn log_file_error(
605        &self,
606        file_path: &Path,
607        error: &std::io::Error,
608        errors: &Arc<Mutex<Vec<String>>>,
609    ) {
610        if self.scan_options.verbose
611            && let Ok(mut errs) = errors.lock()
612        {
613            errs.push(format!("Error reading {}: {error}", file_path.display()));
614        }
615    }
616
617    /// Parse the name field from TOML content.
618    fn parse_toml_name_field(content: &str) -> Option<String> {
619        for line in content.lines() {
620            if let Some(name) = Self::extract_name_from_line(line.trim()) {
621                return Some(name);
622            }
623        }
624        None
625    }
626
627    /// Read the content of a file and handle errors appropriately.
628    fn read_file_content(
629        &self,
630        file_path: &Path,
631        errors: &Arc<Mutex<Vec<String>>>,
632    ) -> Option<String> {
633        match fs::read_to_string(file_path) {
634            Ok(content) => Some(content),
635            Err(e) => {
636                self.log_file_error(file_path, &e, errors);
637                None
638            }
639        }
640    }
641
642    /// Determine if a directory entry should be scanned for projects.
643    ///
644    /// This method implements the filtering logic to decide whether a directory
645    /// should be traversed during the scanning process. It applies various
646    /// exclusion rules to improve performance and avoid scanning irrelevant
647    /// directories.
648    ///
649    /// # Arguments
650    ///
651    /// * `entry` - The directory entry to evaluate
652    ///
653    /// # Returns
654    ///
655    /// - `true` if the directory should be scanned
656    /// - `false` if the directory should be skipped
657    ///
658    /// # Exclusion Rules
659    ///
660    /// The following directories are excluded from scanning:
661    /// - Directories in the user-specified skip list
662    /// - Any directory inside a `node_modules/` directory (to avoid deep nesting)
663    /// - Hidden directories (starting with `.`) except `.cargo`
664    /// - Common build/temporary directories: `target`, `build`, `dist`, `out`, etc.
665    /// - Version control directories: `.git`, `.svn`, `.hg`
666    /// - Python cache and virtual environment directories
667    /// - Temporary directories: `temp`, `tmp`
668    /// - Go vendor directory
669    /// - Python pytest cache
670    /// - Python tox environments
671    /// - Python setuptools
672    /// - Python coverage files
673    /// - Node.js modules (already handled above but added for completeness)
674    /// - .NET `obj/` directory
675    fn should_scan_entry(&self, entry: &DirEntry) -> bool {
676        let path = entry.path();
677
678        // Early return if path is in skip list
679        if self.is_path_in_skip_list(path) {
680            return false;
681        }
682
683        // Skip any directory inside a node_modules directory
684        if path
685            .ancestors()
686            .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
687        {
688            return false;
689        }
690
691        // Skip hidden directories (except .cargo for Rust)
692        if Self::is_hidden_directory_to_skip(path) {
693            return false;
694        }
695
696        // Skip common non-project directories
697        !Self::is_excluded_directory(path)
698    }
699
700    /// Check if a path is in the skip list
701    fn is_path_in_skip_list(&self, path: &Path) -> bool {
702        self.scan_options.skip.iter().any(|skip| {
703            path.components().any(|component| {
704                component
705                    .as_os_str()
706                    .to_str()
707                    .is_some_and(|name| name == skip.to_string_lossy())
708            })
709        })
710    }
711
712    /// Check if directory is hidden and should be skipped
713    fn is_hidden_directory_to_skip(path: &Path) -> bool {
714        path.file_name()
715            .and_then(|n| n.to_str())
716            .is_some_and(|name| name.starts_with('.') && name != ".cargo")
717    }
718
719    /// Check if directory is in the excluded list
720    fn is_excluded_directory(path: &Path) -> bool {
721        let excluded_dirs = [
722            "target",
723            "build",
724            "dist",
725            "out",
726            ".git",
727            ".svn",
728            ".hg",
729            "__pycache__",
730            "venv",
731            ".venv",
732            "env",
733            ".env",
734            "temp",
735            "tmp",
736            "vendor",
737            ".pytest_cache",
738            ".tox",
739            ".eggs",
740            ".coverage",
741            "node_modules",
742            "obj",
743            "_build",
744            "zig-cache",
745            "zig-out",
746            "dist-newstyle",
747        ];
748
749        path.file_name()
750            .and_then(|n| n.to_str())
751            .is_some_and(|name| excluded_dirs.contains(&name))
752    }
753
754    /// Detect a Python project in the specified directory.
755    ///
756    /// This method checks for Python configuration files and associated cache directories.
757    /// It looks for multiple build artifacts that can be cleaned.
758    ///
759    /// # Arguments
760    ///
761    /// * `path` - Directory path to check for a Python project
762    /// * `errors` - Shared error collection for reporting parsing issues
763    ///
764    /// # Returns
765    ///
766    /// - `Some(Project)` if a valid Python project is detected
767    /// - `None` if the directory doesn't contain a Python project
768    ///
769    /// # Detection Criteria
770    ///
771    /// A Python project is identified by having:
772    /// 1. At least one of: requirements.txt, setup.py, pyproject.toml, setup.cfg, Pipfile
773    /// 2. At least one of the cache/build directories: `__pycache__`, `.pytest_cache`, venv, .venv, build, dist, .eggs
774    fn detect_python_project(
775        &self,
776        path: &Path,
777        errors: &Arc<Mutex<Vec<String>>>,
778    ) -> Option<Project> {
779        let config_files = [
780            "requirements.txt",
781            "setup.py",
782            "pyproject.toml",
783            "setup.cfg",
784            "Pipfile",
785            "pipenv.lock",
786            "poetry.lock",
787        ];
788
789        let build_dirs = [
790            "__pycache__",
791            ".pytest_cache",
792            "venv",
793            ".venv",
794            "build",
795            "dist",
796            ".eggs",
797            ".tox",
798            ".coverage",
799        ];
800
801        // Check if any config file exists
802        let has_config = config_files.iter().any(|&file| path.join(file).exists());
803
804        if !has_config {
805            return None;
806        }
807
808        // Collect all existing cache/build directories.
809        let mut build_arts: Vec<BuildArtifacts> = build_dirs
810            .iter()
811            .filter_map(|&dir_name| {
812                let dir_path = path.join(dir_name);
813                if dir_path.exists() && dir_path.is_dir() {
814                    let size = crate::utils::calculate_dir_size(&dir_path);
815                    Some(BuildArtifacts {
816                        path: dir_path,
817                        size,
818                    })
819                } else {
820                    None
821                }
822            })
823            .collect();
824
825        // Also collect any *.egg-info directories present in the project root.
826        if let Ok(entries) = std::fs::read_dir(path) {
827            for entry in entries.flatten() {
828                let entry_path = entry.path();
829                if entry_path.is_dir()
830                    && entry_path
831                        .file_name()
832                        .and_then(|n| n.to_str())
833                        .is_some_and(|n| n.ends_with(".egg-info"))
834                {
835                    let size = crate::utils::calculate_dir_size(&entry_path);
836                    build_arts.push(BuildArtifacts {
837                        path: entry_path,
838                        size,
839                    });
840                }
841            }
842        }
843
844        if build_arts.is_empty() {
845            return None;
846        }
847
848        let name = self.extract_python_project_name(path, errors);
849
850        Some(Project::new(
851            ProjectType::Python,
852            path.to_path_buf(),
853            build_arts,
854            name,
855        ))
856    }
857
858    /// Detect a Go project in the specified directory.
859    ///
860    /// This method checks for the presence of both `go.mod` and `vendor/`
861    /// directory to identify a Go project. If found, it attempts to extract
862    /// the project name from the `go.mod` file.
863    ///
864    /// # Arguments
865    ///
866    /// * `path` - Directory path to check for a Go project
867    /// * `errors` - Shared error collection for reporting parsing issues
868    ///
869    /// # Returns
870    ///
871    /// - `Some(Project)` if a valid Go project is detected
872    /// - `None` if the directory doesn't contain a Go project
873    ///
874    /// # Detection Criteria
875    ///
876    /// 1. `go.mod` file exists in directory
877    /// 2. `vendor/` subdirectory exists in directory
878    /// 3. The project name is extracted from `go.mod` if possible
879    fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
880        let go_mod = path.join("go.mod");
881        let vendor_dir = path.join("vendor");
882
883        if go_mod.exists() && vendor_dir.exists() {
884            let name = self.extract_go_project_name(&go_mod, errors);
885
886            let build_arts = vec![BuildArtifacts {
887                path: path.join("vendor"),
888                size: 0, // Will be calculated later
889            }];
890
891            return Some(Project::new(
892                ProjectType::Go,
893                path.to_path_buf(),
894                build_arts,
895                name,
896            ));
897        }
898
899        None
900    }
901
902    /// Extract the project name from a Python project directory.
903    ///
904    /// This method attempts to extract the project name from various Python
905    /// configuration files in order of preference.
906    ///
907    /// # Arguments
908    ///
909    /// * `path` - Path to the Python project directory
910    /// * `errors` - Shared error collection for reporting parsing issues
911    ///
912    /// # Returns
913    ///
914    /// - `Some(String)` containing the project name if successfully extracted
915    /// - `None` if the name cannot be found or parsed
916    ///
917    /// # Extraction Order
918    ///
919    /// 1. pyproject.toml (from [project] name or [tool.poetry] name)
920    /// 2. setup.py (from name= parameter)
921    /// 3. setup.cfg (from [metadata] name)
922    /// 4. Use directory name as a fallback
923    fn extract_python_project_name(
924        &self,
925        path: &Path,
926        errors: &Arc<Mutex<Vec<String>>>,
927    ) -> Option<String> {
928        // Try files in order of preference
929        self.try_extract_from_pyproject_toml(path, errors)
930            .or_else(|| self.try_extract_from_setup_py(path, errors))
931            .or_else(|| self.try_extract_from_setup_cfg(path, errors))
932            .or_else(|| Self::fallback_to_directory_name(path))
933    }
934
935    /// Try to extract project name from pyproject.toml
936    fn try_extract_from_pyproject_toml(
937        &self,
938        path: &Path,
939        errors: &Arc<Mutex<Vec<String>>>,
940    ) -> Option<String> {
941        let pyproject_toml = path.join("pyproject.toml");
942        if !pyproject_toml.exists() {
943            return None;
944        }
945
946        let content = self.read_file_content(&pyproject_toml, errors)?;
947        Self::extract_name_from_toml_like_content(&content)
948    }
949
950    /// Try to extract project name from setup.py
951    fn try_extract_from_setup_py(
952        &self,
953        path: &Path,
954        errors: &Arc<Mutex<Vec<String>>>,
955    ) -> Option<String> {
956        let setup_py = path.join("setup.py");
957        if !setup_py.exists() {
958            return None;
959        }
960
961        let content = self.read_file_content(&setup_py, errors)?;
962        Self::extract_name_from_python_content(&content)
963    }
964
965    /// Try to extract project name from setup.cfg
966    fn try_extract_from_setup_cfg(
967        &self,
968        path: &Path,
969        errors: &Arc<Mutex<Vec<String>>>,
970    ) -> Option<String> {
971        let setup_cfg = path.join("setup.cfg");
972        if !setup_cfg.exists() {
973            return None;
974        }
975
976        let content = self.read_file_content(&setup_cfg, errors)?;
977        Self::extract_name_from_cfg_content(&content)
978    }
979
980    /// Extract name from TOML-like content (pyproject.toml)
981    fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
982        content
983            .lines()
984            .map(str::trim)
985            .find(|line| line.starts_with("name") && line.contains('='))
986            .and_then(Self::extract_quoted_value)
987    }
988
989    /// Extract name from Python content (setup.py)
990    fn extract_name_from_python_content(content: &str) -> Option<String> {
991        content
992            .lines()
993            .map(str::trim)
994            .find(|line| line.contains("name") && line.contains('='))
995            .and_then(Self::extract_quoted_value)
996    }
997
998    /// Extract name from INI-style configuration content (setup.cfg)
999    fn extract_name_from_cfg_content(content: &str) -> Option<String> {
1000        let mut in_metadata_section = false;
1001
1002        for line in content.lines() {
1003            let line = line.trim();
1004
1005            if line == "[metadata]" {
1006                in_metadata_section = true;
1007            } else if line.starts_with('[') && line.ends_with(']') {
1008                in_metadata_section = false;
1009            } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
1010                return line.split('=').nth(1).map(|name| name.trim().to_string());
1011            }
1012        }
1013
1014        None
1015    }
1016
1017    /// Fallback to directory name
1018    fn fallback_to_directory_name(path: &Path) -> Option<String> {
1019        path.file_name()
1020            .and_then(|name| name.to_str())
1021            .map(std::string::ToString::to_string)
1022    }
1023
1024    /// Extract the project name from a `go.mod` file.
1025    ///
1026    /// This method parses a Go project's `go.mod` file to extract
1027    /// the module name, which typically represents the project.
1028    ///
1029    /// # Arguments
1030    ///
1031    /// * `go_mod` - Path to the `go.mod` file
1032    /// * `errors` - Shared error collection for reporting parsing issues
1033    ///
1034    /// # Returns
1035    ///
1036    /// - `Some(String)` containing the module name if successfully extracted
1037    /// - `None` if the name cannot be found or parsed
1038    ///
1039    /// # Parsing Strategy
1040    ///
1041    /// The method looks for the first line starting with `module ` and extracts
1042    /// the module path. For better display, it takes the last component of the path.
1043    fn extract_go_project_name(
1044        &self,
1045        go_mod: &Path,
1046        errors: &Arc<Mutex<Vec<String>>>,
1047    ) -> Option<String> {
1048        let content = self.read_file_content(go_mod, errors)?;
1049
1050        for line in content.lines() {
1051            let line = line.trim();
1052            if line.starts_with("module ") {
1053                let module_path = line.strip_prefix("module ")?.trim();
1054
1055                // Take the last component of the module path for a cleaner name
1056                if let Some(name) = module_path.split('/').next_back() {
1057                    return Some(name.to_string());
1058                }
1059
1060                return Some(module_path.to_string());
1061            }
1062        }
1063
1064        None
1065    }
1066
1067    /// Detect a Java/Kotlin project in the specified directory.
1068    ///
1069    /// This method checks for Maven (`pom.xml`) or Gradle (`build.gradle`,
1070    /// `build.gradle.kts`) configuration files and their associated build output
1071    /// directories (`target/` for Maven, `build/` for Gradle).
1072    ///
1073    /// # Detection Criteria
1074    ///
1075    /// 1. `pom.xml` + `target/` directory (Maven)
1076    /// 2. `build.gradle` or `build.gradle.kts` + `build/` directory (Gradle)
1077    fn detect_java_project(
1078        &self,
1079        path: &Path,
1080        errors: &Arc<Mutex<Vec<String>>>,
1081    ) -> Option<Project> {
1082        let pom_xml = path.join("pom.xml");
1083        let target_dir = path.join("target");
1084
1085        // Maven project: pom.xml + target/
1086        if pom_xml.exists() && target_dir.exists() {
1087            let name = self.extract_java_maven_project_name(&pom_xml, errors);
1088
1089            let build_arts = vec![BuildArtifacts {
1090                path: target_dir,
1091                size: 0,
1092            }];
1093
1094            return Some(Project::new(
1095                ProjectType::Java,
1096                path.to_path_buf(),
1097                build_arts,
1098                name,
1099            ));
1100        }
1101
1102        // Gradle project: build.gradle(.kts) + build/
1103        let has_gradle =
1104            path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1105        let build_dir = path.join("build");
1106
1107        if has_gradle && build_dir.exists() {
1108            let name = self.extract_java_gradle_project_name(path, errors);
1109
1110            let build_arts = vec![BuildArtifacts {
1111                path: build_dir,
1112                size: 0,
1113            }];
1114
1115            return Some(Project::new(
1116                ProjectType::Java,
1117                path.to_path_buf(),
1118                build_arts,
1119                name,
1120            ));
1121        }
1122
1123        None
1124    }
1125
1126    /// Extract the project name from a Maven `pom.xml` file.
1127    ///
1128    /// Looks for `<artifactId>` tags and extracts the text content.
1129    fn extract_java_maven_project_name(
1130        &self,
1131        pom_xml: &Path,
1132        errors: &Arc<Mutex<Vec<String>>>,
1133    ) -> Option<String> {
1134        let content = self.read_file_content(pom_xml, errors)?;
1135
1136        for line in content.lines() {
1137            let trimmed = line.trim();
1138            if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1139                let name = trimmed
1140                    .strip_prefix("<artifactId>")?
1141                    .strip_suffix("</artifactId>")?;
1142                return Some(name.to_string());
1143            }
1144        }
1145
1146        None
1147    }
1148
1149    /// Extract the project name from a Gradle project.
1150    ///
1151    /// Looks for `settings.gradle` or `settings.gradle.kts` and extracts
1152    /// the `rootProject.name` value. Falls back to directory name.
1153    fn extract_java_gradle_project_name(
1154        &self,
1155        path: &Path,
1156        errors: &Arc<Mutex<Vec<String>>>,
1157    ) -> Option<String> {
1158        for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1159            let settings_path = path.join(settings_file);
1160            if settings_path.exists()
1161                && let Some(content) = self.read_file_content(&settings_path, errors)
1162            {
1163                for line in content.lines() {
1164                    let trimmed = line.trim();
1165                    if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1166                        return Self::extract_quoted_value(trimmed).or_else(|| {
1167                            trimmed
1168                                .split('=')
1169                                .nth(1)
1170                                .map(|s| s.trim().trim_matches('\'').to_string())
1171                        });
1172                    }
1173                }
1174            }
1175        }
1176
1177        Self::fallback_to_directory_name(path)
1178    }
1179
1180    /// Detect a C/C++ project in the specified directory.
1181    ///
1182    /// This method checks for `CMakeLists.txt` or `Makefile` alongside a `build/`
1183    /// directory to identify C/C++ projects.
1184    ///
1185    /// # Detection Criteria
1186    ///
1187    /// 1. `CMakeLists.txt` + `build/` directory (`CMake`)
1188    /// 2. `Makefile` + `build/` directory (`Make`)
1189    fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1190        let build_dir = path.join("build");
1191
1192        if !build_dir.exists() {
1193            return None;
1194        }
1195
1196        let cmake_file = path.join("CMakeLists.txt");
1197        let makefile = path.join("Makefile");
1198
1199        if cmake_file.exists() || makefile.exists() {
1200            let name = if cmake_file.exists() {
1201                self.extract_cpp_cmake_project_name(&cmake_file, errors)
1202            } else {
1203                Self::fallback_to_directory_name(path)
1204            };
1205
1206            let build_arts = vec![BuildArtifacts {
1207                path: build_dir,
1208                size: 0,
1209            }];
1210
1211            return Some(Project::new(
1212                ProjectType::Cpp,
1213                path.to_path_buf(),
1214                build_arts,
1215                name,
1216            ));
1217        }
1218
1219        None
1220    }
1221
1222    /// Extract the project name from a `CMakeLists.txt` file.
1223    ///
1224    /// Looks for `project(name` patterns and extracts the project name.
1225    fn extract_cpp_cmake_project_name(
1226        &self,
1227        cmake_file: &Path,
1228        errors: &Arc<Mutex<Vec<String>>>,
1229    ) -> Option<String> {
1230        let content = self.read_file_content(cmake_file, errors)?;
1231
1232        for line in content.lines() {
1233            let trimmed = line.trim();
1234            if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1235                let inner = trimmed
1236                    .trim_start_matches("project(")
1237                    .trim_start_matches("PROJECT(")
1238                    .trim_end_matches(')')
1239                    .trim();
1240
1241                // The project name is the first word/token
1242                let name = inner.split_whitespace().next()?;
1243                // Remove possible surrounding quotes
1244                let name = name.trim_matches('"').trim_matches('\'');
1245                if !name.is_empty() {
1246                    return Some(name.to_string());
1247                }
1248            }
1249        }
1250
1251        Self::fallback_to_directory_name(cmake_file.parent()?)
1252    }
1253
1254    /// Detect a Swift project in the specified directory.
1255    ///
1256    /// This method checks for a `Package.swift` manifest and the `.build/`
1257    /// directory to identify Swift Package Manager projects.
1258    ///
1259    /// # Detection Criteria
1260    ///
1261    /// 1. `Package.swift` file exists
1262    /// 2. `.build/` directory exists
1263    fn detect_swift_project(
1264        &self,
1265        path: &Path,
1266        errors: &Arc<Mutex<Vec<String>>>,
1267    ) -> Option<Project> {
1268        let package_swift = path.join("Package.swift");
1269        let build_dir = path.join(".build");
1270
1271        if package_swift.exists() && build_dir.exists() {
1272            let name = self.extract_swift_project_name(&package_swift, errors);
1273
1274            let build_arts = vec![BuildArtifacts {
1275                path: build_dir,
1276                size: 0,
1277            }];
1278
1279            return Some(Project::new(
1280                ProjectType::Swift,
1281                path.to_path_buf(),
1282                build_arts,
1283                name,
1284            ));
1285        }
1286
1287        None
1288    }
1289
1290    /// Extract the project name from a `Package.swift` file.
1291    ///
1292    /// Looks for `name:` inside the `Package(` initializer.
1293    fn extract_swift_project_name(
1294        &self,
1295        package_swift: &Path,
1296        errors: &Arc<Mutex<Vec<String>>>,
1297    ) -> Option<String> {
1298        let content = self.read_file_content(package_swift, errors)?;
1299
1300        for line in content.lines() {
1301            let trimmed = line.trim();
1302            if trimmed.contains("name:") {
1303                return Self::extract_quoted_value(trimmed);
1304            }
1305        }
1306
1307        Self::fallback_to_directory_name(package_swift.parent()?)
1308    }
1309
1310    /// Detect a .NET/C# project in the specified directory.
1311    ///
1312    /// This method checks for `.csproj` files alongside `bin/` and/or `obj/`
1313    /// directories to identify .NET projects.
1314    ///
1315    /// # Detection Criteria
1316    ///
1317    /// 1. At least one `.csproj` file exists in the directory
1318    /// 2. At least one of `bin/` or `obj/` directories exists
1319    fn detect_dotnet_project(path: &Path) -> Option<Project> {
1320        let bin_dir = path.join("bin");
1321        let obj_dir = path.join("obj");
1322
1323        let has_build_dir = bin_dir.exists() || obj_dir.exists();
1324        if !has_build_dir {
1325            return None;
1326        }
1327
1328        let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1329
1330        // Collect bin/ and obj/ as separate build artifacts (both when present).
1331        let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1332            (true, true) => {
1333                let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1334                let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1335                vec![
1336                    BuildArtifacts {
1337                        path: bin_dir,
1338                        size: bin_size,
1339                    },
1340                    BuildArtifacts {
1341                        path: obj_dir,
1342                        size: obj_size,
1343                    },
1344                ]
1345            }
1346            (true, false) => vec![BuildArtifacts {
1347                path: bin_dir,
1348                size: 0,
1349            }],
1350            (false, true) => vec![BuildArtifacts {
1351                path: obj_dir,
1352                size: 0,
1353            }],
1354            (false, false) => return None,
1355        };
1356
1357        let name = csproj_file
1358            .file_stem()
1359            .and_then(|s| s.to_str())
1360            .map(std::string::ToString::to_string);
1361
1362        Some(Project::new(
1363            ProjectType::DotNet,
1364            path.to_path_buf(),
1365            build_arts,
1366            name,
1367        ))
1368    }
1369
1370    /// Find the first file with a given extension in a directory.
1371    fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1372        let entries = fs::read_dir(dir).ok()?;
1373        for entry in entries.flatten() {
1374            let path = entry.path();
1375            if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1376                return Some(path);
1377            }
1378        }
1379        None
1380    }
1381
1382    /// Detect a Deno project in the specified directory.
1383    ///
1384    /// This method checks for a `deno.json` or `deno.jsonc` manifest alongside a
1385    /// `vendor/` directory (from `deno vendor`) or a `node_modules/` directory
1386    /// (Deno 2 npm support without a `package.json` to avoid overlap with Node.js).
1387    ///
1388    /// Deno detection runs before Node.js so that a project with `deno.json` and
1389    /// `node_modules/` (but no `package.json`) is classified as Deno.
1390    fn detect_deno_project(
1391        &self,
1392        path: &Path,
1393        errors: &Arc<Mutex<Vec<String>>>,
1394    ) -> Option<Project> {
1395        let deno_json = path.join("deno.json");
1396        let deno_jsonc = path.join("deno.jsonc");
1397
1398        if !deno_json.exists() && !deno_jsonc.exists() {
1399            return None;
1400        }
1401
1402        let config_path = if deno_json.exists() {
1403            deno_json
1404        } else {
1405            deno_jsonc
1406        };
1407
1408        // vendor/ directory (created by `deno vendor`)
1409        let vendor_dir = path.join("vendor");
1410        if vendor_dir.exists() {
1411            let name = self.extract_deno_project_name(&config_path, errors);
1412            return Some(Project::new(
1413                ProjectType::Deno,
1414                path.to_path_buf(),
1415                vec![BuildArtifacts {
1416                    path: vendor_dir,
1417                    size: 0,
1418                }],
1419                name,
1420            ));
1421        }
1422
1423        // node_modules/ (Deno 2 npm support) — only when no package.json exists
1424        let node_modules = path.join("node_modules");
1425        if node_modules.exists() && !path.join("package.json").exists() {
1426            let name = self.extract_deno_project_name(&config_path, errors);
1427            return Some(Project::new(
1428                ProjectType::Deno,
1429                path.to_path_buf(),
1430                vec![BuildArtifacts {
1431                    path: node_modules,
1432                    size: 0,
1433                }],
1434                name,
1435            ));
1436        }
1437
1438        None
1439    }
1440
1441    /// Extract the project name from a `deno.json` or `deno.jsonc` file.
1442    ///
1443    /// Parses the JSON file and reads the top-level `"name"` field.
1444    /// Falls back to the directory name if the field is absent or the file cannot be parsed.
1445    fn extract_deno_project_name(
1446        &self,
1447        config_path: &Path,
1448        errors: &Arc<Mutex<Vec<String>>>,
1449    ) -> Option<String> {
1450        match fs::read_to_string(config_path) {
1451            Ok(content) => {
1452                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1453                    && let Some(name) = json.get("name").and_then(|v| v.as_str())
1454                {
1455                    return Some(name.to_string());
1456                }
1457                Self::fallback_to_directory_name(config_path.parent()?)
1458            }
1459            Err(e) => {
1460                self.log_file_error(config_path, &e, errors);
1461                Self::fallback_to_directory_name(config_path.parent()?)
1462            }
1463        }
1464    }
1465
1466    /// Detect a Ruby project in the specified directory.
1467    ///
1468    /// This method checks for a `Gemfile` alongside a `.bundle/` or `vendor/bundle/`
1469    /// directory. When both exist the larger one is selected as the primary artifact.
1470    ///
1471    /// # Detection Criteria
1472    ///
1473    /// 1. `Gemfile` file exists in directory
1474    /// 2. At least one of `.bundle/` or `vendor/bundle/` directories exists
1475    fn detect_ruby_project(
1476        &self,
1477        path: &Path,
1478        errors: &Arc<Mutex<Vec<String>>>,
1479    ) -> Option<Project> {
1480        let gemfile = path.join("Gemfile");
1481        if !gemfile.exists() {
1482            return None;
1483        }
1484
1485        let bundle_dir = path.join(".bundle");
1486        let vendor_bundle_dir = path.join("vendor").join("bundle");
1487
1488        let build_arts: Vec<BuildArtifacts> =
1489            match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1490                (true, true) => {
1491                    let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1492                    let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1493                    vec![
1494                        BuildArtifacts {
1495                            path: bundle_dir,
1496                            size: bundle_size,
1497                        },
1498                        BuildArtifacts {
1499                            path: vendor_bundle_dir,
1500                            size: vendor_size,
1501                        },
1502                    ]
1503                }
1504                (true, false) => vec![BuildArtifacts {
1505                    path: bundle_dir,
1506                    size: 0,
1507                }],
1508                (false, true) => vec![BuildArtifacts {
1509                    path: vendor_bundle_dir,
1510                    size: 0,
1511                }],
1512                (false, false) => return None,
1513            };
1514
1515        let name = self.extract_ruby_project_name(path, errors);
1516
1517        Some(Project::new(
1518            ProjectType::Ruby,
1519            path.to_path_buf(),
1520            build_arts,
1521            name,
1522        ))
1523    }
1524
1525    /// Extract the project name from a Ruby project directory.
1526    ///
1527    /// Looks for a `.gemspec` file and parses the `spec.name` or `s.name` assignment.
1528    /// Falls back to the directory name.
1529    fn extract_ruby_project_name(
1530        &self,
1531        path: &Path,
1532        errors: &Arc<Mutex<Vec<String>>>,
1533    ) -> Option<String> {
1534        let entries = fs::read_dir(path).ok()?;
1535        for entry in entries.flatten() {
1536            let entry_path = entry.path();
1537            if entry_path.is_file()
1538                && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1539                && let Some(content) = self.read_file_content(&entry_path, errors)
1540            {
1541                for line in content.lines() {
1542                    let trimmed = line.trim();
1543                    if trimmed.contains(".name")
1544                        && trimmed.contains('=')
1545                        && let Some(name) = Self::extract_quoted_value(trimmed)
1546                    {
1547                        return Some(name);
1548                    }
1549                }
1550            }
1551        }
1552
1553        Self::fallback_to_directory_name(path)
1554    }
1555
1556    /// Detect an Elixir project in the specified directory.
1557    ///
1558    /// This method checks for the presence of both `mix.exs` and `_build/`
1559    /// to identify an Elixir/Mix project.
1560    ///
1561    /// # Detection Criteria
1562    ///
1563    /// 1. `mix.exs` file exists in directory
1564    /// 2. `_build/` subdirectory exists in directory
1565    fn detect_elixir_project(
1566        &self,
1567        path: &Path,
1568        errors: &Arc<Mutex<Vec<String>>>,
1569    ) -> Option<Project> {
1570        let mix_exs = path.join("mix.exs");
1571        let build_dir = path.join("_build");
1572
1573        if mix_exs.exists() && build_dir.exists() {
1574            let name = self.extract_elixir_project_name(&mix_exs, errors);
1575
1576            return Some(Project::new(
1577                ProjectType::Elixir,
1578                path.to_path_buf(),
1579                vec![BuildArtifacts {
1580                    path: build_dir,
1581                    size: 0,
1582                }],
1583                name,
1584            ));
1585        }
1586
1587        None
1588    }
1589
1590    /// Extract the project name from a `mix.exs` file.
1591    ///
1592    /// Looks for the `app: :atom_name` pattern inside the Mix project definition.
1593    /// Falls back to the directory name.
1594    fn extract_elixir_project_name(
1595        &self,
1596        mix_exs: &Path,
1597        errors: &Arc<Mutex<Vec<String>>>,
1598    ) -> Option<String> {
1599        let content = self.read_file_content(mix_exs, errors)?;
1600
1601        for line in content.lines() {
1602            let trimmed = line.trim();
1603            if trimmed.contains("app:")
1604                && let Some(pos) = trimmed.find("app:")
1605            {
1606                let after = trimmed[pos + 4..].trim_start();
1607                if let Some(atom) = after.strip_prefix(':') {
1608                    // Elixir atom names consist of alphanumeric chars and underscores
1609                    let name: String = atom
1610                        .chars()
1611                        .take_while(|c| c.is_alphanumeric() || *c == '_')
1612                        .collect();
1613                    if !name.is_empty() {
1614                        return Some(name);
1615                    }
1616                }
1617            }
1618        }
1619
1620        Self::fallback_to_directory_name(mix_exs.parent()?)
1621    }
1622
1623    /// Detect a PHP project in the specified directory.
1624    ///
1625    /// This method checks for the presence of both `composer.json` and `vendor/`
1626    /// to identify a PHP/Composer project.
1627    ///
1628    /// # Detection Criteria
1629    ///
1630    /// 1. `composer.json` file exists in directory
1631    /// 2. `vendor/` subdirectory exists in directory
1632    fn detect_php_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1633        let composer_json = path.join("composer.json");
1634        let vendor_dir = path.join("vendor");
1635
1636        if composer_json.exists() && vendor_dir.exists() {
1637            let name = self.extract_php_project_name(&composer_json, errors);
1638
1639            return Some(Project::new(
1640                ProjectType::Php,
1641                path.to_path_buf(),
1642                vec![BuildArtifacts {
1643                    path: vendor_dir,
1644                    size: 0,
1645                }],
1646                name,
1647            ));
1648        }
1649
1650        None
1651    }
1652
1653    /// Extract the project name from a `composer.json` file.
1654    ///
1655    /// Parses the JSON and reads the top-level `"name"` field.
1656    /// The name is typically `vendor/package`; only the package component is returned.
1657    /// Falls back to the directory name if the field is absent or the file cannot be parsed.
1658    fn extract_php_project_name(
1659        &self,
1660        composer_json: &Path,
1661        errors: &Arc<Mutex<Vec<String>>>,
1662    ) -> Option<String> {
1663        match fs::read_to_string(composer_json) {
1664            Ok(content) => {
1665                if let Ok(json) = from_str::<Value>(&content)
1666                    && let Some(name) = json.get("name").and_then(|v| v.as_str())
1667                {
1668                    // composer.json name is "vendor/package"; return just the package part.
1669                    let package = name.split('/').next_back().unwrap_or(name);
1670                    return Some(package.to_string());
1671                }
1672                Self::fallback_to_directory_name(composer_json.parent()?)
1673            }
1674            Err(e) => {
1675                self.log_file_error(composer_json, &e, errors);
1676                Self::fallback_to_directory_name(composer_json.parent()?)
1677            }
1678        }
1679    }
1680
1681    /// Detect a Haskell project in the specified directory.
1682    ///
1683    /// Supports both Stack and Cabal build systems:
1684    /// - Stack: `stack.yaml` + `.stack-work/`
1685    /// - Cabal: (`cabal.project` or `*.cabal` file) + `dist-newstyle/`
1686    fn detect_haskell_project(
1687        &self,
1688        path: &Path,
1689        errors: &Arc<Mutex<Vec<String>>>,
1690    ) -> Option<Project> {
1691        // Stack project: stack.yaml + .stack-work/
1692        let stack_yaml = path.join("stack.yaml");
1693        let stack_work = path.join(".stack-work");
1694
1695        if stack_yaml.exists() && stack_work.exists() {
1696            let name = self.extract_haskell_project_name(path, errors);
1697            return Some(Project::new(
1698                ProjectType::Haskell,
1699                path.to_path_buf(),
1700                vec![BuildArtifacts {
1701                    path: stack_work,
1702                    size: 0,
1703                }],
1704                name,
1705            ));
1706        }
1707
1708        // Cabal project: (cabal.project OR *.cabal file) + dist-newstyle/
1709        let dist_newstyle = path.join("dist-newstyle");
1710        if dist_newstyle.exists() {
1711            let has_cabal_project = path.join("cabal.project").exists();
1712            let has_cabal_file = Self::find_file_with_extension(path, "cabal").is_some();
1713
1714            if has_cabal_project || has_cabal_file {
1715                let name = self.extract_haskell_project_name(path, errors);
1716                return Some(Project::new(
1717                    ProjectType::Haskell,
1718                    path.to_path_buf(),
1719                    vec![BuildArtifacts {
1720                        path: dist_newstyle,
1721                        size: 0,
1722                    }],
1723                    name,
1724                ));
1725            }
1726        }
1727
1728        None
1729    }
1730
1731    /// Extract the project name from a Haskell project directory.
1732    ///
1733    /// Looks for a `*.cabal` file and reads the `name:` field from it.
1734    /// Also checks `package.yaml` (hpack). Falls back to the directory name.
1735    fn extract_haskell_project_name(
1736        &self,
1737        path: &Path,
1738        errors: &Arc<Mutex<Vec<String>>>,
1739    ) -> Option<String> {
1740        // Try *.cabal file first
1741        if let Some(cabal_file) = Self::find_file_with_extension(path, "cabal")
1742            && let Some(content) = self.read_file_content(&cabal_file, errors)
1743        {
1744            for line in content.lines() {
1745                let trimmed = line.trim();
1746                if let Some(rest) = trimmed.strip_prefix("name:") {
1747                    let name = rest.trim().to_string();
1748                    if !name.is_empty() {
1749                        return Some(name);
1750                    }
1751                }
1752            }
1753        }
1754
1755        // Try package.yaml (hpack)
1756        let package_yaml = path.join("package.yaml");
1757        if package_yaml.exists()
1758            && let Some(content) = self.read_file_content(&package_yaml, errors)
1759        {
1760            for line in content.lines() {
1761                let trimmed = line.trim();
1762                if let Some(rest) = trimmed.strip_prefix("name:") {
1763                    let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1764                    if !name.is_empty() {
1765                        return Some(name);
1766                    }
1767                }
1768            }
1769        }
1770
1771        Self::fallback_to_directory_name(path)
1772    }
1773
1774    /// Detect a Dart/Flutter project in the specified directory.
1775    ///
1776    /// This method checks for a `pubspec.yaml` manifest alongside `.dart_tool/`
1777    /// and/or `build/` directories.
1778    ///
1779    /// # Detection Criteria
1780    ///
1781    /// 1. `pubspec.yaml` file exists in directory
1782    /// 2. At least one of `.dart_tool/` or `build/` exists
1783    fn detect_dart_project(
1784        &self,
1785        path: &Path,
1786        errors: &Arc<Mutex<Vec<String>>>,
1787    ) -> Option<Project> {
1788        let pubspec_yaml = path.join("pubspec.yaml");
1789        if !pubspec_yaml.exists() {
1790            return None;
1791        }
1792
1793        let dart_tool = path.join(".dart_tool");
1794        let build_dir = path.join("build");
1795
1796        let build_arts: Vec<BuildArtifacts> = match (dart_tool.exists(), build_dir.exists()) {
1797            (true, true) => {
1798                let dart_size = crate::utils::calculate_dir_size(&dart_tool);
1799                let build_size = crate::utils::calculate_dir_size(&build_dir);
1800                vec![
1801                    BuildArtifacts {
1802                        path: dart_tool,
1803                        size: dart_size,
1804                    },
1805                    BuildArtifacts {
1806                        path: build_dir,
1807                        size: build_size,
1808                    },
1809                ]
1810            }
1811            (true, false) => vec![BuildArtifacts {
1812                path: dart_tool,
1813                size: 0,
1814            }],
1815            (false, true) => vec![BuildArtifacts {
1816                path: build_dir,
1817                size: 0,
1818            }],
1819            (false, false) => return None,
1820        };
1821
1822        let name = self.extract_dart_project_name(&pubspec_yaml, errors);
1823
1824        Some(Project::new(
1825            ProjectType::Dart,
1826            path.to_path_buf(),
1827            build_arts,
1828            name,
1829        ))
1830    }
1831
1832    /// Extract the project name from a `pubspec.yaml` file.
1833    ///
1834    /// Reads the `name:` field from the YAML file using simple line parsing.
1835    /// Falls back to the directory name.
1836    fn extract_dart_project_name(
1837        &self,
1838        pubspec_yaml: &Path,
1839        errors: &Arc<Mutex<Vec<String>>>,
1840    ) -> Option<String> {
1841        let content = self.read_file_content(pubspec_yaml, errors)?;
1842
1843        for line in content.lines() {
1844            let trimmed = line.trim();
1845            if let Some(rest) = trimmed.strip_prefix("name:") {
1846                let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1847                if !name.is_empty() {
1848                    return Some(name);
1849                }
1850            }
1851        }
1852
1853        Self::fallback_to_directory_name(pubspec_yaml.parent()?)
1854    }
1855
1856    /// Detect a Zig project in the specified directory.
1857    ///
1858    /// This method checks for a `build.zig` file alongside `zig-cache/` and/or
1859    /// `zig-out/` directories.
1860    ///
1861    /// # Detection Criteria
1862    ///
1863    /// 1. `build.zig` file exists in directory
1864    /// 2. At least one of `zig-cache/` or `zig-out/` exists
1865    fn detect_zig_project(path: &Path) -> Option<Project> {
1866        let build_zig = path.join("build.zig");
1867        if !build_zig.exists() {
1868            return None;
1869        }
1870
1871        let zig_cache = path.join("zig-cache");
1872        let zig_out = path.join("zig-out");
1873
1874        let build_arts: Vec<BuildArtifacts> = match (zig_cache.exists(), zig_out.exists()) {
1875            (true, true) => {
1876                let cache_size = crate::utils::calculate_dir_size(&zig_cache);
1877                let out_size = crate::utils::calculate_dir_size(&zig_out);
1878                vec![
1879                    BuildArtifacts {
1880                        path: zig_cache,
1881                        size: cache_size,
1882                    },
1883                    BuildArtifacts {
1884                        path: zig_out,
1885                        size: out_size,
1886                    },
1887                ]
1888            }
1889            (true, false) => vec![BuildArtifacts {
1890                path: zig_cache,
1891                size: 0,
1892            }],
1893            (false, true) => vec![BuildArtifacts {
1894                path: zig_out,
1895                size: 0,
1896            }],
1897            (false, false) => return None,
1898        };
1899
1900        let name = Self::fallback_to_directory_name(path);
1901
1902        Some(Project::new(
1903            ProjectType::Zig,
1904            path.to_path_buf(),
1905            build_arts,
1906            name,
1907        ))
1908    }
1909
1910    /// Detect a Scala project in the specified directory.
1911    ///
1912    /// This method checks for the presence of both `build.sbt` and `target/`
1913    /// to identify an sbt-based Scala project.
1914    ///
1915    /// # Detection Criteria
1916    ///
1917    /// 1. `build.sbt` file exists in directory
1918    /// 2. `target/` subdirectory exists in directory
1919    fn detect_scala_project(
1920        &self,
1921        path: &Path,
1922        errors: &Arc<Mutex<Vec<String>>>,
1923    ) -> Option<Project> {
1924        let build_sbt = path.join("build.sbt");
1925        let target_dir = path.join("target");
1926
1927        if build_sbt.exists() && target_dir.exists() {
1928            let name = self.extract_scala_project_name(&build_sbt, errors);
1929
1930            return Some(Project::new(
1931                ProjectType::Scala,
1932                path.to_path_buf(),
1933                vec![BuildArtifacts {
1934                    path: target_dir,
1935                    size: 0,
1936                }],
1937                name,
1938            ));
1939        }
1940
1941        None
1942    }
1943
1944    /// Extract the project name from a `build.sbt` file.
1945    ///
1946    /// Looks for a `name := "..."` assignment in the file.
1947    /// Falls back to the directory name.
1948    fn extract_scala_project_name(
1949        &self,
1950        build_sbt: &Path,
1951        errors: &Arc<Mutex<Vec<String>>>,
1952    ) -> Option<String> {
1953        let content = self.read_file_content(build_sbt, errors)?;
1954
1955        for line in content.lines() {
1956            let trimmed = line.trim();
1957            if trimmed.starts_with("name")
1958                && trimmed.contains(":=")
1959                && let Some(name) = Self::extract_quoted_value(trimmed)
1960            {
1961                return Some(name);
1962            }
1963        }
1964
1965        Self::fallback_to_directory_name(build_sbt.parent()?)
1966    }
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971    use super::*;
1972    use std::path::PathBuf;
1973    use tempfile::TempDir;
1974
1975    /// Create a scanner with default options and the given filter.
1976    fn default_scanner(filter: ProjectFilter) -> Scanner {
1977        Scanner::new(
1978            ScanOptions {
1979                verbose: false,
1980                threads: 1,
1981                skip: vec![],
1982                max_depth: None,
1983            },
1984            filter,
1985        )
1986    }
1987
1988    /// Helper to create a file with content, ensuring parent dirs exist.
1989    fn create_file(path: &Path, content: &str) -> anyhow::Result<()> {
1990        if let Some(parent) = path.parent() {
1991            fs::create_dir_all(parent)?;
1992        }
1993        fs::write(path, content)?;
1994        Ok(())
1995    }
1996
1997    // ── Static helper method tests ──────────────────────────────────────
1998
1999    #[test]
2000    fn test_is_hidden_directory_to_skip() {
2001        // Hidden directories should be skipped
2002        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2003            "/some/.hidden"
2004        )));
2005        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2006            "/some/.git"
2007        )));
2008        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2009            "/some/.svn"
2010        )));
2011        assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
2012
2013        // .cargo is the special exception — should NOT be skipped
2014        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2015            "/home/user/.cargo"
2016        )));
2017        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
2018
2019        // Non-hidden directories should not be skipped
2020        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2021            "/some/visible"
2022        )));
2023        assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
2024    }
2025
2026    #[test]
2027    fn test_is_excluded_directory() {
2028        // Build/artifact directories should be excluded
2029        assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
2030        assert!(Scanner::is_excluded_directory(Path::new(
2031            "/some/node_modules"
2032        )));
2033        assert!(Scanner::is_excluded_directory(Path::new(
2034            "/some/__pycache__"
2035        )));
2036        assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
2037        assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
2038        assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
2039        assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
2040
2041        // VCS directories should be excluded
2042        assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
2043        assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
2044        assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
2045
2046        // Python-specific directories
2047        assert!(Scanner::is_excluded_directory(Path::new(
2048            "/some/.pytest_cache"
2049        )));
2050        assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
2051        assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
2052        assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
2053
2054        // Virtual environments
2055        assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
2056        assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
2057        assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
2058        assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
2059
2060        // Temp directories
2061        assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
2062        assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
2063
2064        // Non-excluded directories
2065        assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
2066        assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
2067        assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
2068        assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
2069    }
2070
2071    #[test]
2072    fn test_extract_quoted_value() {
2073        assert_eq!(
2074            Scanner::extract_quoted_value(r#"name = "my-project""#),
2075            Some("my-project".to_string())
2076        );
2077        assert_eq!(
2078            Scanner::extract_quoted_value(r#"name = "with spaces""#),
2079            Some("with spaces".to_string())
2080        );
2081        assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
2082        // Single quote mark is not a pair
2083        assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
2084    }
2085
2086    #[test]
2087    fn test_is_name_line() {
2088        assert!(Scanner::is_name_line("name = \"test\""));
2089        assert!(Scanner::is_name_line("name=\"test\""));
2090        assert!(!Scanner::is_name_line("version = \"1.0\""));
2091        assert!(!Scanner::is_name_line("# name = \"commented\""));
2092        assert!(!Scanner::is_name_line("name: \"yaml style\""));
2093    }
2094
2095    #[test]
2096    fn test_parse_toml_name_field() {
2097        let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
2098        assert_eq!(
2099            Scanner::parse_toml_name_field(content),
2100            Some("test-project".to_string())
2101        );
2102
2103        let no_name = "[package]\nversion = \"0.1.0\"\n";
2104        assert_eq!(Scanner::parse_toml_name_field(no_name), None);
2105
2106        let empty = "";
2107        assert_eq!(Scanner::parse_toml_name_field(empty), None);
2108    }
2109
2110    #[test]
2111    fn test_extract_name_from_cfg_content() {
2112        let content = "[metadata]\nname = my-package\nversion = 1.0\n";
2113        assert_eq!(
2114            Scanner::extract_name_from_cfg_content(content),
2115            Some("my-package".to_string())
2116        );
2117
2118        // Name in wrong section should not be found
2119        let wrong_section = "[options]\nname = not-this\n";
2120        assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
2121
2122        // Multiple sections — name must be in [metadata]
2123        let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
2124        assert_eq!(
2125            Scanner::extract_name_from_cfg_content(multi),
2126            Some("correct".to_string())
2127        );
2128    }
2129
2130    #[test]
2131    fn test_extract_name_from_python_content() {
2132        let content = "from setuptools import setup\nsetup(\n    name=\"my-pkg\",\n)\n";
2133        assert_eq!(
2134            Scanner::extract_name_from_python_content(content),
2135            Some("my-pkg".to_string())
2136        );
2137
2138        let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
2139        assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
2140    }
2141
2142    #[test]
2143    fn test_fallback_to_directory_name() {
2144        assert_eq!(
2145            Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
2146            Some("project-name".to_string())
2147        );
2148        assert_eq!(
2149            Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
2150            Some("my_app".to_string())
2151        );
2152    }
2153
2154    #[test]
2155    fn test_is_path_in_skip_list() {
2156        let scanner = Scanner::new(
2157            ScanOptions {
2158                verbose: false,
2159                threads: 1,
2160                skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
2161                max_depth: None,
2162            },
2163            ProjectFilter::All,
2164        );
2165
2166        assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
2167        assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
2168        assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
2169        assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
2170    }
2171
2172    #[test]
2173    fn test_is_path_in_empty_skip_list() {
2174        let scanner = default_scanner(ProjectFilter::All);
2175        assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
2176    }
2177
2178    // ── Scanning with special path characters ───────────────────────────
2179
2180    #[test]
2181    fn test_scan_directory_with_spaces_in_path() -> anyhow::Result<()> {
2182        let tmp = TempDir::new()?;
2183        let base = tmp.path().join("path with spaces");
2184        fs::create_dir_all(&base)?;
2185
2186        let project = base.join("my project");
2187        create_file(
2188            &project.join("Cargo.toml"),
2189            "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
2190        )?;
2191        create_file(&project.join("target/dummy"), "content")?;
2192
2193        let scanner = default_scanner(ProjectFilter::Rust);
2194        let projects = scanner.scan_directory(&base);
2195        assert_eq!(projects.len(), 1);
2196        assert_eq!(projects[0].name.as_deref(), Some("spaced"));
2197        Ok(())
2198    }
2199
2200    #[test]
2201    fn test_scan_directory_with_unicode_names() -> anyhow::Result<()> {
2202        let tmp = TempDir::new()?;
2203        let base = tmp.path();
2204
2205        let project = base.join("プロジェクト");
2206        create_file(
2207            &project.join("package.json"),
2208            r#"{"name": "unicode-project"}"#,
2209        )?;
2210        create_file(&project.join("node_modules/dep.js"), "module.exports = {};")?;
2211
2212        let scanner = default_scanner(ProjectFilter::Node);
2213        let projects = scanner.scan_directory(base);
2214        assert_eq!(projects.len(), 1);
2215        assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
2216        Ok(())
2217    }
2218
2219    #[test]
2220    fn test_scan_directory_with_special_characters_in_name() -> anyhow::Result<()> {
2221        let tmp = TempDir::new()?;
2222        let base = tmp.path();
2223
2224        let project = base.join("project-with-dashes_and_underscores.v2");
2225        create_file(
2226            &project.join("Cargo.toml"),
2227            "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
2228        )?;
2229        create_file(&project.join("target/dummy"), "content")?;
2230
2231        let scanner = default_scanner(ProjectFilter::Rust);
2232        let projects = scanner.scan_directory(base);
2233        assert_eq!(projects.len(), 1);
2234        assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
2235        Ok(())
2236    }
2237
2238    // ── Unix-specific scanning tests ────────────────────────────────────
2239
2240    #[test]
2241    #[cfg(unix)]
2242    fn test_hidden_directory_itself_not_detected_as_project_unix() -> anyhow::Result<()> {
2243        let tmp = TempDir::new()?;
2244        let base = tmp.path();
2245
2246        // A hidden directory with Cargo.toml + target/ directly inside it
2247        // should NOT be detected because the .hidden entry is filtered by
2248        // is_hidden_directory_to_skip. However, non-hidden children inside
2249        // hidden dirs CAN still be found because WalkDir descends into them.
2250        let hidden = base.join(".hidden-project");
2251        create_file(
2252            &hidden.join("Cargo.toml"),
2253            "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
2254        )?;
2255        create_file(&hidden.join("target/dummy"), "content")?;
2256
2257        // A visible project should be found
2258        let visible = base.join("visible-project");
2259        create_file(
2260            &visible.join("Cargo.toml"),
2261            "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
2262        )?;
2263        create_file(&visible.join("target/dummy"), "content")?;
2264
2265        let scanner = default_scanner(ProjectFilter::Rust);
2266        let projects = scanner.scan_directory(base);
2267
2268        // Only the visible project should be found; the hidden one is excluded
2269        // because its directory name starts with '.'
2270        assert_eq!(projects.len(), 1);
2271        assert_eq!(projects[0].name.as_deref(), Some("visible"));
2272        Ok(())
2273    }
2274
2275    #[test]
2276    #[cfg(unix)]
2277    fn test_projects_inside_hidden_dirs_are_still_traversed_unix() -> anyhow::Result<()> {
2278        let tmp = TempDir::new()?;
2279        let base = tmp.path();
2280
2281        // A non-hidden project nested inside a hidden directory.
2282        // WalkDir still descends into .hidden, so the child project IS found.
2283        let nested = base.join(".hidden-parent/visible-child");
2284        create_file(
2285            &nested.join("Cargo.toml"),
2286            "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
2287        )?;
2288        create_file(&nested.join("target/dummy"), "content")?;
2289
2290        let scanner = default_scanner(ProjectFilter::Rust);
2291        let projects = scanner.scan_directory(base);
2292
2293        // The child project has a non-hidden name, so it IS detected
2294        assert_eq!(projects.len(), 1);
2295        assert_eq!(projects[0].name.as_deref(), Some("nested"));
2296        Ok(())
2297    }
2298
2299    #[test]
2300    #[cfg(unix)]
2301    fn test_dotcargo_directory_not_skipped_unix() {
2302        // .cargo is the exception — hidden but should NOT be skipped.
2303        // Verify via the static method.
2304        assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2305            "/home/user/.cargo"
2306        )));
2307
2308        // Other dot-dirs ARE skipped
2309        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2310            "/home/user/.local"
2311        )));
2312        assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2313            "/home/user/.npm"
2314        )));
2315    }
2316
2317    // ── Python project detection tests ──────────────────────────────────
2318
2319    #[test]
2320    fn test_detect_python_with_pyproject_toml() -> anyhow::Result<()> {
2321        let tmp = TempDir::new()?;
2322        let base = tmp.path();
2323
2324        let project = base.join("py-project");
2325        create_file(
2326            &project.join("pyproject.toml"),
2327            "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
2328        )?;
2329        let pycache = project.join("__pycache__");
2330        fs::create_dir_all(&pycache)?;
2331        create_file(&pycache.join("module.pyc"), "bytecode")?;
2332
2333        let scanner = default_scanner(ProjectFilter::Python);
2334        let projects = scanner.scan_directory(base);
2335        assert_eq!(projects.len(), 1);
2336        assert_eq!(projects[0].kind, ProjectType::Python);
2337        Ok(())
2338    }
2339
2340    #[test]
2341    fn test_detect_python_with_setup_py() -> anyhow::Result<()> {
2342        let tmp = TempDir::new()?;
2343        let base = tmp.path();
2344
2345        let project = base.join("setup-project");
2346        create_file(
2347            &project.join("setup.py"),
2348            "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
2349        )?;
2350        let pycache = project.join("__pycache__");
2351        fs::create_dir_all(&pycache)?;
2352        create_file(&pycache.join("module.pyc"), "bytecode")?;
2353
2354        let scanner = default_scanner(ProjectFilter::Python);
2355        let projects = scanner.scan_directory(base);
2356        assert_eq!(projects.len(), 1);
2357        Ok(())
2358    }
2359
2360    #[test]
2361    fn test_detect_python_with_pipfile() -> anyhow::Result<()> {
2362        let tmp = TempDir::new()?;
2363        let base = tmp.path();
2364
2365        let project = base.join("pipenv-project");
2366        create_file(
2367            &project.join("Pipfile"),
2368            "[[source]]\nurl = \"https://pypi.org/simple\"",
2369        )?;
2370        let pycache = project.join("__pycache__");
2371        fs::create_dir_all(&pycache)?;
2372        create_file(&pycache.join("module.pyc"), "bytecode")?;
2373
2374        let scanner = default_scanner(ProjectFilter::Python);
2375        let projects = scanner.scan_directory(base);
2376        assert_eq!(projects.len(), 1);
2377        Ok(())
2378    }
2379
2380    // ── Go project detection tests ──────────────────────────────────────
2381
2382    #[test]
2383    fn test_detect_go_extracts_module_name() -> anyhow::Result<()> {
2384        let tmp = TempDir::new()?;
2385        let base = tmp.path();
2386
2387        let project = base.join("go-service");
2388        create_file(
2389            &project.join("go.mod"),
2390            "module github.com/user/my-service\n\ngo 1.21\n",
2391        )?;
2392        let vendor = project.join("vendor");
2393        fs::create_dir_all(&vendor)?;
2394        create_file(&vendor.join("modules.txt"), "vendor manifest")?;
2395
2396        let scanner = default_scanner(ProjectFilter::Go);
2397        let projects = scanner.scan_directory(base);
2398        assert_eq!(projects.len(), 1);
2399        // Should extract last path component as name
2400        assert_eq!(projects[0].name.as_deref(), Some("my-service"));
2401        Ok(())
2402    }
2403
2404    // ── Java/Kotlin project detection tests ────────────────────────────
2405
2406    #[test]
2407    fn test_detect_java_maven_project() -> anyhow::Result<()> {
2408        let tmp = TempDir::new()?;
2409        let base = tmp.path();
2410
2411        let project = base.join("java-maven");
2412        create_file(
2413            &project.join("pom.xml"),
2414            "<project>\n  <artifactId>my-java-app</artifactId>\n</project>",
2415        )?;
2416        create_file(&project.join("target/classes/Main.class"), "bytecode")?;
2417
2418        let scanner = default_scanner(ProjectFilter::Java);
2419        let projects = scanner.scan_directory(base);
2420        assert_eq!(projects.len(), 1);
2421        assert_eq!(projects[0].kind, ProjectType::Java);
2422        assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
2423        Ok(())
2424    }
2425
2426    #[test]
2427    fn test_detect_java_gradle_project() -> anyhow::Result<()> {
2428        let tmp = TempDir::new()?;
2429        let base = tmp.path();
2430
2431        let project = base.join("java-gradle");
2432        create_file(&project.join("build.gradle"), "apply plugin: 'java'")?;
2433        create_file(
2434            &project.join("settings.gradle"),
2435            "rootProject.name = \"my-gradle-app\"",
2436        )?;
2437        create_file(&project.join("build/classes/main/Main.class"), "bytecode")?;
2438
2439        let scanner = default_scanner(ProjectFilter::Java);
2440        let projects = scanner.scan_directory(base);
2441        assert_eq!(projects.len(), 1);
2442        assert_eq!(projects[0].kind, ProjectType::Java);
2443        assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2444        Ok(())
2445    }
2446
2447    #[test]
2448    fn test_detect_java_gradle_kts_project() -> anyhow::Result<()> {
2449        let tmp = TempDir::new()?;
2450        let base = tmp.path();
2451
2452        let project = base.join("kotlin-gradle");
2453        create_file(
2454            &project.join("build.gradle.kts"),
2455            "plugins { kotlin(\"jvm\") }",
2456        )?;
2457        create_file(
2458            &project.join("settings.gradle.kts"),
2459            "rootProject.name = \"my-kotlin-app\"",
2460        )?;
2461        create_file(
2462            &project.join("build/classes/kotlin/main/MainKt.class"),
2463            "bytecode",
2464        )?;
2465
2466        let scanner = default_scanner(ProjectFilter::Java);
2467        let projects = scanner.scan_directory(base);
2468        assert_eq!(projects.len(), 1);
2469        assert_eq!(projects[0].kind, ProjectType::Java);
2470        assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2471        Ok(())
2472    }
2473
2474    // ── C/C++ project detection tests ────────────────────────────────────
2475
2476    #[test]
2477    fn test_detect_cpp_cmake_project() -> anyhow::Result<()> {
2478        let tmp = TempDir::new()?;
2479        let base = tmp.path();
2480
2481        let project = base.join("cpp-cmake");
2482        create_file(
2483            &project.join("CMakeLists.txt"),
2484            "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2485        )?;
2486        create_file(&project.join("build/CMakeCache.txt"), "cache")?;
2487
2488        let scanner = default_scanner(ProjectFilter::Cpp);
2489        let projects = scanner.scan_directory(base);
2490        assert_eq!(projects.len(), 1);
2491        assert_eq!(projects[0].kind, ProjectType::Cpp);
2492        assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2493        Ok(())
2494    }
2495
2496    #[test]
2497    fn test_detect_cpp_makefile_project() -> anyhow::Result<()> {
2498        let tmp = TempDir::new()?;
2499        let base = tmp.path();
2500
2501        let project = base.join("cpp-make");
2502        create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp")?;
2503        create_file(&project.join("build/main.o"), "object")?;
2504
2505        let scanner = default_scanner(ProjectFilter::Cpp);
2506        let projects = scanner.scan_directory(base);
2507        assert_eq!(projects.len(), 1);
2508        assert_eq!(projects[0].kind, ProjectType::Cpp);
2509        Ok(())
2510    }
2511
2512    // ── Swift project detection tests ────────────────────────────────────
2513
2514    #[test]
2515    fn test_detect_swift_project() -> anyhow::Result<()> {
2516        let tmp = TempDir::new()?;
2517        let base = tmp.path();
2518
2519        let project = base.join("swift-pkg");
2520        create_file(
2521            &project.join("Package.swift"),
2522            "let package = Package(\n    name: \"my-swift-lib\",\n    targets: []\n)",
2523        )?;
2524        create_file(&project.join(".build/debug/my-swift-lib"), "binary")?;
2525
2526        let scanner = default_scanner(ProjectFilter::Swift);
2527        let projects = scanner.scan_directory(base);
2528        assert_eq!(projects.len(), 1);
2529        assert_eq!(projects[0].kind, ProjectType::Swift);
2530        assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2531        Ok(())
2532    }
2533
2534    // ── .NET/C# project detection tests ──────────────────────────────────
2535
2536    #[test]
2537    fn test_detect_dotnet_project() -> anyhow::Result<()> {
2538        let tmp = TempDir::new()?;
2539        let base = tmp.path();
2540
2541        let project = base.join("dotnet-app");
2542        create_file(
2543            &project.join("MyApp.csproj"),
2544            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2545        )?;
2546        create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly")?;
2547        create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate")?;
2548
2549        let scanner = default_scanner(ProjectFilter::DotNet);
2550        let projects = scanner.scan_directory(base);
2551        assert_eq!(projects.len(), 1);
2552        assert_eq!(projects[0].kind, ProjectType::DotNet);
2553        assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2554        Ok(())
2555    }
2556
2557    #[test]
2558    fn test_detect_dotnet_project_obj_only() -> anyhow::Result<()> {
2559        let tmp = TempDir::new()?;
2560        let base = tmp.path();
2561
2562        let project = base.join("dotnet-obj-only");
2563        create_file(
2564            &project.join("Lib.csproj"),
2565            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2566        )?;
2567        create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate")?;
2568
2569        let scanner = default_scanner(ProjectFilter::DotNet);
2570        let projects = scanner.scan_directory(base);
2571        assert_eq!(projects.len(), 1);
2572        assert_eq!(projects[0].kind, ProjectType::DotNet);
2573        assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2574        Ok(())
2575    }
2576
2577    // ── Excluded directory tests ─────────────────────────────────────────
2578
2579    #[test]
2580    fn test_obj_directory_is_excluded() {
2581        assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2582    }
2583
2584    // ── Cross-platform calculate_build_dir_size ─────────────────────────
2585
2586    #[test]
2587    fn test_calculate_build_dir_size_empty() -> anyhow::Result<()> {
2588        let tmp = TempDir::new()?;
2589        let empty_dir = tmp.path().join("empty");
2590        fs::create_dir_all(&empty_dir)?;
2591
2592        assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2593        Ok(())
2594    }
2595
2596    #[test]
2597    fn test_calculate_build_dir_size_nonexistent() {
2598        assert_eq!(
2599            Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2600            0
2601        );
2602    }
2603
2604    #[test]
2605    fn test_calculate_build_dir_size_with_nested_files() -> anyhow::Result<()> {
2606        let tmp = TempDir::new()?;
2607        let dir = tmp.path().join("nested");
2608
2609        create_file(&dir.join("file1.txt"), "hello")?; // 5 bytes
2610        create_file(&dir.join("sub/file2.txt"), "world!")?; // 6 bytes
2611        create_file(&dir.join("sub/deep/file3.txt"), "!")?; // 1 byte
2612
2613        let size = Scanner::calculate_build_dir_size(&dir);
2614        assert_eq!(size, 12);
2615        Ok(())
2616    }
2617
2618    // ── Quiet mode ──────────────────────────────────────────────────────
2619
2620    #[test]
2621    fn test_scanner_quiet_mode() -> anyhow::Result<()> {
2622        let tmp = TempDir::new()?;
2623        let base = tmp.path();
2624
2625        let project = base.join("quiet-project");
2626        create_file(
2627            &project.join("Cargo.toml"),
2628            "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2629        )?;
2630        create_file(&project.join("target/dummy"), "content")?;
2631
2632        let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2633        let projects = scanner.scan_directory(base);
2634        assert_eq!(projects.len(), 1);
2635        Ok(())
2636    }
2637
2638    // ── Ruby project detection tests ─────────────────────────────────────
2639
2640    #[test]
2641    fn test_detect_ruby_with_vendor_bundle() -> anyhow::Result<()> {
2642        let tmp = TempDir::new()?;
2643        let base = tmp.path();
2644
2645        let project = base.join("ruby-project");
2646        create_file(
2647            &project.join("Gemfile"),
2648            "source 'https://rubygems.org'\ngem 'rails'",
2649        )?;
2650        create_file(
2651            &project.join("my-app.gemspec"),
2652            "Gem::Specification.new do |spec|\n  spec.name = \"my-ruby-gem\"\nend",
2653        )?;
2654        create_file(
2655            &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2656            "# rails",
2657        )?;
2658
2659        let scanner = default_scanner(ProjectFilter::Ruby);
2660        let projects = scanner.scan_directory(base);
2661        assert_eq!(projects.len(), 1);
2662        assert_eq!(projects[0].kind, ProjectType::Ruby);
2663        assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2664        Ok(())
2665    }
2666
2667    #[test]
2668    fn test_detect_ruby_with_dot_bundle() -> anyhow::Result<()> {
2669        let tmp = TempDir::new()?;
2670        let base = tmp.path();
2671
2672        let project = base.join("ruby-dot-bundle");
2673        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2674        create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack")?;
2675
2676        let scanner = default_scanner(ProjectFilter::Ruby);
2677        let projects = scanner.scan_directory(base);
2678        assert_eq!(projects.len(), 1);
2679        assert_eq!(projects[0].kind, ProjectType::Ruby);
2680        Ok(())
2681    }
2682
2683    #[test]
2684    fn test_detect_ruby_no_artifact_not_detected() -> anyhow::Result<()> {
2685        let tmp = TempDir::new()?;
2686        let base = tmp.path();
2687
2688        // Gemfile exists but no .bundle/ or vendor/bundle/
2689        let project = base.join("gemfile-only");
2690        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2691
2692        let scanner = default_scanner(ProjectFilter::Ruby);
2693        let projects = scanner.scan_directory(base);
2694        assert_eq!(projects.len(), 0);
2695        Ok(())
2696    }
2697
2698    #[test]
2699    fn test_detect_ruby_fallback_to_dir_name() -> anyhow::Result<()> {
2700        let tmp = TempDir::new()?;
2701        let base = tmp.path();
2702
2703        let project = base.join("my-ruby-app");
2704        create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2705        create_file(
2706            &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2707            "# sinatra",
2708        )?;
2709
2710        let scanner = default_scanner(ProjectFilter::Ruby);
2711        let projects = scanner.scan_directory(base);
2712        assert_eq!(projects.len(), 1);
2713        assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2714        Ok(())
2715    }
2716
2717    // ── Elixir project detection tests ───────────────────────────────────
2718
2719    #[test]
2720    fn test_detect_elixir_project() -> anyhow::Result<()> {
2721        let tmp = TempDir::new()?;
2722        let base = tmp.path();
2723
2724        let project = base.join("elixir-project");
2725        create_file(
2726            &project.join("mix.exs"),
2727            "defmodule MyApp.MixProject do\n  def project do\n    [app: :my_app,\n     version: \"0.1.0\"]\n  end\nend",
2728        )?;
2729        create_file(
2730            &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2731            "# build",
2732        )?;
2733
2734        let scanner = default_scanner(ProjectFilter::Elixir);
2735        let projects = scanner.scan_directory(base);
2736        assert_eq!(projects.len(), 1);
2737        assert_eq!(projects[0].kind, ProjectType::Elixir);
2738        assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2739        Ok(())
2740    }
2741
2742    #[test]
2743    fn test_detect_elixir_no_build_not_detected() -> anyhow::Result<()> {
2744        let tmp = TempDir::new()?;
2745        let base = tmp.path();
2746
2747        let project = base.join("mix-only");
2748        create_file(
2749            &project.join("mix.exs"),
2750            "defmodule MixOnly.MixProject do\n  def project do\n    [app: :mix_only]\n  end\nend",
2751        )?;
2752
2753        let scanner = default_scanner(ProjectFilter::Elixir);
2754        let projects = scanner.scan_directory(base);
2755        assert_eq!(projects.len(), 0);
2756        Ok(())
2757    }
2758
2759    #[test]
2760    fn test_detect_elixir_fallback_to_dir_name() -> anyhow::Result<()> {
2761        let tmp = TempDir::new()?;
2762        let base = tmp.path();
2763
2764        let project = base.join("my_elixir_project");
2765        create_file(&project.join("mix.exs"), "# minimal mix.exs without app:")?;
2766        create_file(
2767            &project.join("_build/prod/lib/my_elixir_project.beam"),
2768            "bytecode",
2769        )?;
2770
2771        let scanner = default_scanner(ProjectFilter::Elixir);
2772        let projects = scanner.scan_directory(base);
2773        assert_eq!(projects.len(), 1);
2774        assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2775        Ok(())
2776    }
2777
2778    // ── Deno project detection tests ─────────────────────────────────────
2779
2780    #[test]
2781    fn test_detect_deno_with_vendor() -> anyhow::Result<()> {
2782        let tmp = TempDir::new()?;
2783        let base = tmp.path();
2784
2785        let project = base.join("deno-project");
2786        create_file(
2787            &project.join("deno.json"),
2788            r#"{"name": "my-deno-app", "imports": {}}"#,
2789        )?;
2790        create_file(&project.join("vendor/modules.json"), "{}")?;
2791
2792        let scanner = default_scanner(ProjectFilter::Deno);
2793        let projects = scanner.scan_directory(base);
2794        assert_eq!(projects.len(), 1);
2795        assert_eq!(projects[0].kind, ProjectType::Deno);
2796        assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2797        Ok(())
2798    }
2799
2800    #[test]
2801    fn test_detect_deno_jsonc_config() -> anyhow::Result<()> {
2802        let tmp = TempDir::new()?;
2803        let base = tmp.path();
2804
2805        let project = base.join("deno-jsonc-project");
2806        create_file(
2807            &project.join("deno.jsonc"),
2808            r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2809        )?;
2810        create_file(&project.join("vendor/modules.json"), "{}")?;
2811
2812        let scanner = default_scanner(ProjectFilter::Deno);
2813        let projects = scanner.scan_directory(base);
2814        assert_eq!(projects.len(), 1);
2815        assert_eq!(projects[0].kind, ProjectType::Deno);
2816        assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2817        Ok(())
2818    }
2819
2820    #[test]
2821    fn test_detect_deno_node_modules_without_package_json() -> anyhow::Result<()> {
2822        let tmp = TempDir::new()?;
2823        let base = tmp.path();
2824
2825        let project = base.join("deno-npm-project");
2826        create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#)?;
2827        create_file(
2828            &project.join("node_modules/.deno/lodash/index.js"),
2829            "// lodash",
2830        )?;
2831
2832        let scanner = default_scanner(ProjectFilter::Deno);
2833        let projects = scanner.scan_directory(base);
2834        assert_eq!(projects.len(), 1);
2835        assert_eq!(projects[0].kind, ProjectType::Deno);
2836        Ok(())
2837    }
2838
2839    #[test]
2840    fn test_detect_deno_node_modules_with_package_json_becomes_node() -> anyhow::Result<()> {
2841        let tmp = TempDir::new()?;
2842        let base = tmp.path();
2843
2844        // deno.json + package.json + node_modules → Node project (not Deno)
2845        let project = base.join("ambiguous-project");
2846        create_file(&project.join("deno.json"), r"{}")?;
2847        create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#)?;
2848        create_file(&project.join("node_modules/dep/index.js"), "// dep")?;
2849
2850        let scanner = default_scanner(ProjectFilter::All);
2851        let projects = scanner.scan_directory(base);
2852        assert_eq!(projects.len(), 1);
2853        assert_eq!(projects[0].kind, ProjectType::Node);
2854        Ok(())
2855    }
2856
2857    #[test]
2858    fn test_detect_deno_no_artifact_not_detected() -> anyhow::Result<()> {
2859        let tmp = TempDir::new()?;
2860        let base = tmp.path();
2861
2862        let project = base.join("deno-no-artifact");
2863        create_file(&project.join("deno.json"), r"{}")?;
2864
2865        let scanner = default_scanner(ProjectFilter::Deno);
2866        let projects = scanner.scan_directory(base);
2867        assert_eq!(projects.len(), 0);
2868        Ok(())
2869    }
2870
2871    #[test]
2872    fn test_build_directory_is_excluded() {
2873        assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2874    }
2875
2876    // ── Rust workspace awareness tests ─────────────────────────────────
2877
2878    #[test]
2879    fn test_is_cargo_workspace_root() -> anyhow::Result<()> {
2880        let tmp = TempDir::new()?;
2881        let cargo_toml = tmp.path().join("Cargo.toml");
2882
2883        // A workspace root must contain a bare `[workspace]` section header.
2884        create_file(
2885            &cargo_toml,
2886            "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2887        )?;
2888        assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2889
2890        // A regular package Cargo.toml is not a workspace root.
2891        create_file(
2892            &cargo_toml,
2893            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2894        )?;
2895        assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2896
2897        // A non-existent file returns false.
2898        assert!(!Scanner::is_cargo_workspace_root(Path::new(
2899            "/nonexistent/Cargo.toml"
2900        )));
2901        Ok(())
2902    }
2903
2904    #[test]
2905    fn test_workspace_root_detected() -> anyhow::Result<()> {
2906        let tmp = TempDir::new()?;
2907        let base = tmp.path();
2908
2909        // Workspace root: has [workspace] in Cargo.toml and a target/ dir with content.
2910        let workspace = base.join("my-workspace");
2911        create_file(
2912            &workspace.join("Cargo.toml"),
2913            "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2914        )?;
2915        create_file(&workspace.join("target/dummy"), "content")?;
2916
2917        let scanner = default_scanner(ProjectFilter::Rust);
2918        let projects = scanner.scan_directory(base);
2919
2920        assert_eq!(projects.len(), 1);
2921        assert_eq!(projects[0].root_path, workspace);
2922        Ok(())
2923    }
2924
2925    #[test]
2926    fn test_workspace_member_with_own_target_skipped() -> anyhow::Result<()> {
2927        let tmp = TempDir::new()?;
2928        let base = tmp.path();
2929
2930        // Workspace root with content in target/.
2931        let workspace = base.join("my-workspace");
2932        create_file(
2933            &workspace.join("Cargo.toml"),
2934            "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2935        )?;
2936        create_file(&workspace.join("target/dummy"), "content")?;
2937
2938        // Workspace member that also happens to have its own target/ directory.
2939        let member = workspace.join("crate-a");
2940        create_file(
2941            &member.join("Cargo.toml"),
2942            "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2943        )?;
2944        create_file(&member.join("target/dummy"), "content")?;
2945
2946        let scanner = default_scanner(ProjectFilter::Rust);
2947        let projects = scanner.scan_directory(base);
2948
2949        // Only the workspace root should be reported; the member must be skipped.
2950        assert_eq!(projects.len(), 1);
2951        assert_eq!(projects[0].root_path, workspace);
2952        Ok(())
2953    }
2954
2955    // ── PHP project detection tests ───────────────────────────────────────
2956
2957    #[test]
2958    fn test_detect_php_project() -> anyhow::Result<()> {
2959        let tmp = TempDir::new()?;
2960        let base = tmp.path();
2961
2962        let project = base.join("php-project");
2963        create_file(
2964            &project.join("composer.json"),
2965            r#"{"name": "acme/my-php-app", "require": {}}"#,
2966        )?;
2967        create_file(&project.join("vendor/autoload.php"), "<?php // autoloader")?;
2968
2969        let scanner = default_scanner(ProjectFilter::Php);
2970        let projects = scanner.scan_directory(base);
2971        assert_eq!(projects.len(), 1);
2972        assert_eq!(projects[0].kind, ProjectType::Php);
2973        // Only the package component (after /) should be returned.
2974        assert_eq!(projects[0].name.as_deref(), Some("my-php-app"));
2975        Ok(())
2976    }
2977
2978    #[test]
2979    fn test_detect_php_no_vendor_not_detected() -> anyhow::Result<()> {
2980        let tmp = TempDir::new()?;
2981        let base = tmp.path();
2982
2983        let project = base.join("php-no-vendor");
2984        create_file(&project.join("composer.json"), r#"{"name": "acme/my-app"}"#)?;
2985
2986        let scanner = default_scanner(ProjectFilter::Php);
2987        let projects = scanner.scan_directory(base);
2988        assert_eq!(projects.len(), 0);
2989        Ok(())
2990    }
2991
2992    #[test]
2993    fn test_detect_php_fallback_to_dir_name() -> anyhow::Result<()> {
2994        let tmp = TempDir::new()?;
2995        let base = tmp.path();
2996
2997        let project = base.join("my-php-project");
2998        // composer.json without a "name" field
2999        create_file(&project.join("composer.json"), r#"{"require": {}}"#)?;
3000        create_file(&project.join("vendor/autoload.php"), "<?php")?;
3001
3002        let scanner = default_scanner(ProjectFilter::Php);
3003        let projects = scanner.scan_directory(base);
3004        assert_eq!(projects.len(), 1);
3005        assert_eq!(projects[0].name.as_deref(), Some("my-php-project"));
3006        Ok(())
3007    }
3008
3009    // ── Haskell project detection tests ──────────────────────────────────
3010
3011    #[test]
3012    fn test_detect_haskell_stack_project() -> anyhow::Result<()> {
3013        let tmp = TempDir::new()?;
3014        let base = tmp.path();
3015
3016        let project = base.join("haskell-stack");
3017        create_file(
3018            &project.join("stack.yaml"),
3019            "resolver: lts-21.0\npackages:\n  - .",
3020        )?;
3021        create_file(
3022            &project.join("my-haskell-lib.cabal"),
3023            "name:          my-haskell-lib\nversion:       0.1.0.0\n",
3024        )?;
3025        create_file(
3026            &project.join(".stack-work/dist/x86_64-linux/ghc-9.4.7/build/Main.o"),
3027            "object",
3028        )?;
3029
3030        let scanner = default_scanner(ProjectFilter::Haskell);
3031        let projects = scanner.scan_directory(base);
3032        assert_eq!(projects.len(), 1);
3033        assert_eq!(projects[0].kind, ProjectType::Haskell);
3034        assert_eq!(projects[0].name.as_deref(), Some("my-haskell-lib"));
3035        Ok(())
3036    }
3037
3038    #[test]
3039    fn test_detect_haskell_cabal_project() -> anyhow::Result<()> {
3040        let tmp = TempDir::new()?;
3041        let base = tmp.path();
3042
3043        let project = base.join("haskell-cabal");
3044        create_file(&project.join("cabal.project"), "packages: .\n")?;
3045        create_file(
3046            &project.join("my-cabal-lib.cabal"),
3047            "name:          my-cabal-lib\nversion:       0.1.0.0\n",
3048        )?;
3049        create_file(
3050            &project.join(
3051                "dist-newstyle/build/x86_64-linux/ghc-9.4.7/my-cabal-lib-0.1.0.0/build/Main.o",
3052            ),
3053            "object",
3054        )?;
3055
3056        let scanner = default_scanner(ProjectFilter::Haskell);
3057        let projects = scanner.scan_directory(base);
3058        assert_eq!(projects.len(), 1);
3059        assert_eq!(projects[0].kind, ProjectType::Haskell);
3060        assert_eq!(projects[0].name.as_deref(), Some("my-cabal-lib"));
3061        Ok(())
3062    }
3063
3064    #[test]
3065    fn test_detect_haskell_no_artifact_not_detected() -> anyhow::Result<()> {
3066        let tmp = TempDir::new()?;
3067        let base = tmp.path();
3068
3069        let project = base.join("haskell-no-artifact");
3070        create_file(&project.join("stack.yaml"), "resolver: lts-21.0")?;
3071
3072        let scanner = default_scanner(ProjectFilter::Haskell);
3073        let projects = scanner.scan_directory(base);
3074        assert_eq!(projects.len(), 0);
3075        Ok(())
3076    }
3077
3078    // ── Dart/Flutter project detection tests ─────────────────────────────
3079
3080    #[test]
3081    fn test_detect_dart_project_with_dart_tool() -> anyhow::Result<()> {
3082        let tmp = TempDir::new()?;
3083        let base = tmp.path();
3084
3085        let project = base.join("dart-project");
3086        create_file(
3087            &project.join("pubspec.yaml"),
3088            "name: my_dart_app\nversion: 1.0.0\n",
3089        )?;
3090        create_file(
3091            &project.join(".dart_tool/package_config.json"),
3092            r#"{"configVersion": 2}"#,
3093        )?;
3094
3095        let scanner = default_scanner(ProjectFilter::Dart);
3096        let projects = scanner.scan_directory(base);
3097        assert_eq!(projects.len(), 1);
3098        assert_eq!(projects[0].kind, ProjectType::Dart);
3099        assert_eq!(projects[0].name.as_deref(), Some("my_dart_app"));
3100        Ok(())
3101    }
3102
3103    #[test]
3104    fn test_detect_dart_project_with_build_dir() -> anyhow::Result<()> {
3105        let tmp = TempDir::new()?;
3106        let base = tmp.path();
3107
3108        let project = base.join("flutter-project");
3109        create_file(
3110            &project.join("pubspec.yaml"),
3111            "name: my_flutter_app\nversion: 1.0.0\n",
3112        )?;
3113        create_file(
3114            &project.join("build/flutter_assets/AssetManifest.json"),
3115            "{}",
3116        )?;
3117
3118        let scanner = default_scanner(ProjectFilter::Dart);
3119        let projects = scanner.scan_directory(base);
3120        assert_eq!(projects.len(), 1);
3121        assert_eq!(projects[0].kind, ProjectType::Dart);
3122        assert_eq!(projects[0].name.as_deref(), Some("my_flutter_app"));
3123        Ok(())
3124    }
3125
3126    #[test]
3127    fn test_detect_dart_no_artifact_not_detected() -> anyhow::Result<()> {
3128        let tmp = TempDir::new()?;
3129        let base = tmp.path();
3130
3131        let project = base.join("pubspec-only");
3132        create_file(&project.join("pubspec.yaml"), "name: empty_project\n")?;
3133
3134        let scanner = default_scanner(ProjectFilter::Dart);
3135        let projects = scanner.scan_directory(base);
3136        assert_eq!(projects.len(), 0);
3137        Ok(())
3138    }
3139
3140    // ── Zig project detection tests ───────────────────────────────────────
3141
3142    #[test]
3143    fn test_detect_zig_project_with_cache() -> anyhow::Result<()> {
3144        let tmp = TempDir::new()?;
3145        let base = tmp.path();
3146
3147        let project = base.join("zig-project");
3148        create_file(
3149            &project.join("build.zig"),
3150            "const std = @import(\"std\");\npub fn build(b: *std.Build) void {}\n",
3151        )?;
3152        create_file(&project.join("zig-cache/h/abc123.h"), "// generated")?;
3153
3154        let scanner = default_scanner(ProjectFilter::Zig);
3155        let projects = scanner.scan_directory(base);
3156        assert_eq!(projects.len(), 1);
3157        assert_eq!(projects[0].kind, ProjectType::Zig);
3158        // Zig falls back to directory name.
3159        assert_eq!(projects[0].name.as_deref(), Some("zig-project"));
3160        Ok(())
3161    }
3162
3163    #[test]
3164    fn test_detect_zig_project_with_out_dir() -> anyhow::Result<()> {
3165        let tmp = TempDir::new()?;
3166        let base = tmp.path();
3167
3168        let project = base.join("zig-out-project");
3169        create_file(&project.join("build.zig"), "// zig build script")?;
3170        create_file(&project.join("zig-out/bin/my-app"), "binary")?;
3171
3172        let scanner = default_scanner(ProjectFilter::Zig);
3173        let projects = scanner.scan_directory(base);
3174        assert_eq!(projects.len(), 1);
3175        assert_eq!(projects[0].kind, ProjectType::Zig);
3176        Ok(())
3177    }
3178
3179    #[test]
3180    fn test_detect_zig_no_artifact_not_detected() -> anyhow::Result<()> {
3181        let tmp = TempDir::new()?;
3182        let base = tmp.path();
3183
3184        let project = base.join("zig-no-artifact");
3185        create_file(&project.join("build.zig"), "// zig build script")?;
3186
3187        let scanner = default_scanner(ProjectFilter::Zig);
3188        let projects = scanner.scan_directory(base);
3189        assert_eq!(projects.len(), 0);
3190        Ok(())
3191    }
3192
3193    #[test]
3194    fn test_zig_cache_directory_is_excluded() {
3195        assert!(Scanner::is_excluded_directory(Path::new("/some/zig-cache")));
3196        assert!(Scanner::is_excluded_directory(Path::new("/some/zig-out")));
3197        assert!(Scanner::is_excluded_directory(Path::new(
3198            "/some/dist-newstyle"
3199        )));
3200    }
3201
3202    // ── Scala project detection tests ─────────────────────────────────────
3203
3204    #[test]
3205    fn test_detect_scala_project() -> anyhow::Result<()> {
3206        let tmp = TempDir::new()?;
3207        let base = tmp.path();
3208
3209        let project = base.join("scala-project");
3210        create_file(
3211            &project.join("build.sbt"),
3212            "name := \"my-scala-app\"\nscalaVersion := \"3.3.0\"\n",
3213        )?;
3214        create_file(
3215            &project.join("target/scala-3.3.0/classes/Main.class"),
3216            "bytecode",
3217        )?;
3218
3219        let scanner = default_scanner(ProjectFilter::Scala);
3220        let projects = scanner.scan_directory(base);
3221        assert_eq!(projects.len(), 1);
3222        assert_eq!(projects[0].kind, ProjectType::Scala);
3223        assert_eq!(projects[0].name.as_deref(), Some("my-scala-app"));
3224        Ok(())
3225    }
3226
3227    #[test]
3228    fn test_detect_scala_no_target_not_detected() -> anyhow::Result<()> {
3229        let tmp = TempDir::new()?;
3230        let base = tmp.path();
3231
3232        let project = base.join("sbt-only");
3233        create_file(&project.join("build.sbt"), "name := \"unbuilt-project\"\n")?;
3234
3235        let scanner = default_scanner(ProjectFilter::Scala);
3236        let projects = scanner.scan_directory(base);
3237        assert_eq!(projects.len(), 0);
3238        Ok(())
3239    }
3240
3241    #[test]
3242    fn test_detect_scala_fallback_to_dir_name() -> anyhow::Result<()> {
3243        let tmp = TempDir::new()?;
3244        let base = tmp.path();
3245
3246        let project = base.join("my-scala-project");
3247        // build.sbt without a name := field
3248        create_file(&project.join("build.sbt"), "scalaVersion := \"3.3.0\"\n")?;
3249        create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3250
3251        let scanner = default_scanner(ProjectFilter::Scala);
3252        let projects = scanner.scan_directory(base);
3253        assert_eq!(projects.len(), 1);
3254        assert_eq!(projects[0].name.as_deref(), Some("my-scala-project"));
3255        Ok(())
3256    }
3257
3258    #[test]
3259    fn test_scala_detected_before_java_for_build_sbt_projects() -> anyhow::Result<()> {
3260        let tmp = TempDir::new()?;
3261        let base = tmp.path();
3262
3263        // A project with both build.sbt and pom.xml should be detected as Scala.
3264        let project = base.join("scala-maven-project");
3265        create_file(&project.join("build.sbt"), "name := \"scala-maven\"\n")?;
3266        create_file(
3267            &project.join("pom.xml"),
3268            "<project><artifactId>scala-maven</artifactId></project>",
3269        )?;
3270        create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3271
3272        let scanner = default_scanner(ProjectFilter::All);
3273        let projects = scanner.scan_directory(base);
3274        assert_eq!(projects.len(), 1);
3275        assert_eq!(projects[0].kind, ProjectType::Scala);
3276        Ok(())
3277    }
3278}