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