Skip to main content

clean_dev_dirs/
scanner.rs

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