1use std::{
9 fs,
10 path::{Path, PathBuf},
11 sync::{
12 Arc, Mutex,
13 atomic::{AtomicUsize, Ordering},
14 },
15};
16
17use colored::Colorize;
18use indicatif::{ProgressBar, ProgressStyle};
19use rayon::prelude::*;
20use serde_json::{Value, from_str};
21use walkdir::{DirEntry, WalkDir};
22
23use crate::{
24 config::{ProjectFilter, ScanOptions},
25 project::{BuildArtifacts, Project, ProjectType},
26};
27
28#[derive(Debug)]
35pub struct Scanner {
36 scan_options: ScanOptions,
38
39 project_filter: ProjectFilter,
41
42 quiet: bool,
44}
45
46impl Scanner {
47 #[must_use]
71 pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
72 Self {
73 scan_options,
74 project_filter,
75 quiet: false,
76 }
77 }
78
79 #[must_use]
84 pub const fn with_quiet(mut self, quiet: bool) -> Self {
85 self.quiet = quiet;
86 self
87 }
88
89 pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
126 let errors = Arc::new(Mutex::new(Vec::<String>::new()));
127
128 let progress = if self.quiet {
129 ProgressBar::hidden()
130 } else {
131 let pb = ProgressBar::new_spinner();
132 if let Ok(style) = ProgressStyle::default_spinner().template("{spinner:.green} {msg}") {
133 pb.set_style(style);
134 }
135 pb.set_message("Scanning...");
136 pb.enable_steady_tick(std::time::Duration::from_millis(100));
137 pb
138 };
139
140 let found_count = Arc::new(AtomicUsize::new(0));
141 let progress_clone = progress.clone();
142 let count_clone = Arc::clone(&found_count);
143
144 let walker = self.scan_options.max_depth.map_or_else(
146 || WalkDir::new(root),
147 |depth| WalkDir::new(root).max_depth(depth),
148 );
149
150 let potential_projects: Vec<_> = walker
151 .into_iter()
152 .filter_map(Result::ok)
153 .filter(|entry| self.should_scan_entry(entry))
154 .collect::<Vec<_>>()
155 .into_par_iter()
156 .filter_map(|entry| {
157 let result = self.detect_project(&entry, &errors);
158 if result.is_some() {
159 let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
160 progress_clone.set_message(format!("Scanning... {n} found"));
161 }
162 result
163 })
164 .collect();
165
166 progress.finish_with_message("[OK] Directory scan complete");
167
168 let projects_with_sizes: Vec<_> = potential_projects
170 .into_par_iter()
171 .filter_map(|mut project| {
172 for artifact in &mut project.build_arts {
173 if artifact.size == 0 {
174 artifact.size = Self::calculate_build_dir_size(&artifact.path);
175 }
176 }
177
178 if project.total_size() > 0 {
179 Some(project)
180 } else {
181 None
182 }
183 })
184 .collect();
185
186 if self.scan_options.verbose
188 && let Ok(errors) = errors.lock()
189 {
190 for error in errors.iter() {
191 eprintln!("{}", error.red());
192 }
193 }
194
195 projects_with_sizes
196 }
197
198 #[must_use]
211 pub fn scan_directories(&self, roots: &[PathBuf]) -> Vec<Project> {
212 use std::collections::HashSet;
213 let mut seen: HashSet<PathBuf> = HashSet::new();
214 let mut result = Vec::new();
215 for root in roots {
216 for project in self.scan_directory(root) {
217 if seen.insert(project.root_path.clone()) {
218 result.push(project);
219 }
220 }
221 }
222 result
223 }
224
225 fn calculate_build_dir_size(path: &Path) -> u64 {
246 if !path.exists() {
247 return 0;
248 }
249
250 crate::utils::calculate_dir_size(path)
251 }
252
253 fn detect_node_project(
275 &self,
276 path: &Path,
277 errors: &Arc<Mutex<Vec<String>>>,
278 ) -> Option<Project> {
279 let package_json = path.join("package.json");
280 let node_modules = path.join("node_modules");
281
282 if package_json.exists() && node_modules.exists() {
283 let name = self.extract_node_project_name(&package_json, errors);
284
285 let build_arts = vec![BuildArtifacts {
286 path: path.join("node_modules"),
287 size: 0, }];
289
290 return Some(Project::new(
291 ProjectType::Node,
292 path.to_path_buf(),
293 build_arts,
294 name,
295 ));
296 }
297
298 None
299 }
300
301 fn detect_project(
336 &self,
337 entry: &DirEntry,
338 errors: &Arc<Mutex<Vec<String>>>,
339 ) -> Option<Project> {
340 let path = entry.path();
341
342 if !entry.file_type().is_dir() {
343 return None;
344 }
345
346 self.try_detect(ProjectFilter::Rust, || {
351 self.detect_rust_project(path, errors)
352 })
353 .or_else(|| {
354 self.try_detect(ProjectFilter::Deno, || {
355 self.detect_deno_project(path, errors)
356 })
357 })
358 .or_else(|| {
359 self.try_detect(ProjectFilter::Node, || {
360 self.detect_node_project(path, errors)
361 })
362 })
363 .or_else(|| {
364 self.try_detect(ProjectFilter::Scala, || {
365 self.detect_scala_project(path, errors)
366 })
367 })
368 .or_else(|| {
369 self.try_detect(ProjectFilter::Java, || {
370 self.detect_java_project(path, errors)
371 })
372 })
373 .or_else(|| {
374 self.try_detect(ProjectFilter::Swift, || {
375 self.detect_swift_project(path, errors)
376 })
377 })
378 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
379 .or_else(|| {
380 self.try_detect(ProjectFilter::Python, || {
381 self.detect_python_project(path, errors)
382 })
383 })
384 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
385 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
386 .or_else(|| {
387 self.try_detect(ProjectFilter::Ruby, || {
388 self.detect_ruby_project(path, errors)
389 })
390 })
391 .or_else(|| {
392 self.try_detect(ProjectFilter::Elixir, || {
393 self.detect_elixir_project(path, errors)
394 })
395 })
396 .or_else(|| self.try_detect(ProjectFilter::Php, || self.detect_php_project(path, errors)))
397 .or_else(|| {
398 self.try_detect(ProjectFilter::Haskell, || {
399 self.detect_haskell_project(path, errors)
400 })
401 })
402 .or_else(|| {
403 self.try_detect(ProjectFilter::Dart, || {
404 self.detect_dart_project(path, errors)
405 })
406 })
407 .or_else(|| self.try_detect(ProjectFilter::Zig, || Self::detect_zig_project(path)))
408 }
409
410 fn try_detect(
415 &self,
416 filter: ProjectFilter,
417 detect: impl FnOnce() -> Option<Project>,
418 ) -> Option<Project> {
419 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
420 detect()
421 } else {
422 None
423 }
424 }
425
426 fn detect_rust_project(
448 &self,
449 path: &Path,
450 errors: &Arc<Mutex<Vec<String>>>,
451 ) -> Option<Project> {
452 let cargo_toml = path.join("Cargo.toml");
453 let target_dir = path.join("target");
454
455 if cargo_toml.exists() && target_dir.exists() {
456 if Self::is_inside_cargo_workspace(path) {
458 return None;
459 }
460
461 let name = self.extract_rust_project_name(&cargo_toml, errors);
462
463 let build_arts = vec![BuildArtifacts {
464 path: path.join("target"),
465 size: 0, }];
467
468 return Some(Project::new(
469 ProjectType::Rust,
470 path.to_path_buf(),
471 build_arts,
472 name,
473 ));
474 }
475
476 None
477 }
478
479 fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
481 fs::read_to_string(cargo_toml)
482 .map(|content| content.lines().any(|line| line.trim() == "[workspace]"))
483 .unwrap_or(false)
484 }
485
486 fn is_inside_cargo_workspace(path: &Path) -> bool {
489 path.ancestors()
490 .skip(1) .any(|ancestor| {
492 let cargo_toml = ancestor.join("Cargo.toml");
493 cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
494 })
495 }
496
497 fn extract_rust_project_name(
519 &self,
520 cargo_toml: &Path,
521 errors: &Arc<Mutex<Vec<String>>>,
522 ) -> Option<String> {
523 let content = self.read_file_content(cargo_toml, errors)?;
524 Self::parse_toml_name_field(&content)
525 }
526
527 fn extract_quoted_value(line: &str) -> Option<String> {
529 let start = line.find('"')?;
530 let end = line.rfind('"')?;
531
532 if start == end {
533 return None;
534 }
535
536 Some(line[start + 1..end].to_string())
537 }
538
539 fn extract_name_from_line(line: &str) -> Option<String> {
541 if !Self::is_name_line(line) {
542 return None;
543 }
544
545 Self::extract_quoted_value(line)
546 }
547
548 fn extract_node_project_name(
569 &self,
570 package_json: &Path,
571 errors: &Arc<Mutex<Vec<String>>>,
572 ) -> Option<String> {
573 match fs::read_to_string(package_json) {
574 Ok(content) => match from_str::<Value>(&content) {
575 Ok(json) => json
576 .get("name")
577 .and_then(|v| v.as_str())
578 .map(std::string::ToString::to_string),
579 Err(e) => {
580 if self.scan_options.verbose
581 && let Ok(mut errs) = errors.lock()
582 {
583 errs.push(format!("Error parsing {}: {e}", package_json.display()));
584 }
585 None
586 }
587 },
588 Err(e) => {
589 if self.scan_options.verbose
590 && let Ok(mut errs) = errors.lock()
591 {
592 errs.push(format!("Error reading {}: {e}", package_json.display()));
593 }
594 None
595 }
596 }
597 }
598
599 fn is_name_line(line: &str) -> bool {
601 line.starts_with("name") && line.contains('=')
602 }
603
604 fn log_file_error(
606 &self,
607 file_path: &Path,
608 error: &std::io::Error,
609 errors: &Arc<Mutex<Vec<String>>>,
610 ) {
611 if self.scan_options.verbose
612 && let Ok(mut errs) = errors.lock()
613 {
614 errs.push(format!("Error reading {}: {error}", file_path.display()));
615 }
616 }
617
618 fn parse_toml_name_field(content: &str) -> Option<String> {
620 for line in content.lines() {
621 if let Some(name) = Self::extract_name_from_line(line.trim()) {
622 return Some(name);
623 }
624 }
625 None
626 }
627
628 fn read_file_content(
630 &self,
631 file_path: &Path,
632 errors: &Arc<Mutex<Vec<String>>>,
633 ) -> Option<String> {
634 match fs::read_to_string(file_path) {
635 Ok(content) => Some(content),
636 Err(e) => {
637 self.log_file_error(file_path, &e, errors);
638 None
639 }
640 }
641 }
642
643 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
677 let path = entry.path();
678
679 if self.is_path_in_skip_list(path) {
681 return false;
682 }
683
684 if path
686 .ancestors()
687 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
688 {
689 return false;
690 }
691
692 if Self::is_hidden_directory_to_skip(path) {
694 return false;
695 }
696
697 !Self::is_excluded_directory(path)
699 }
700
701 fn is_path_in_skip_list(&self, path: &Path) -> bool {
703 self.scan_options.skip.iter().any(|skip| {
704 path.components().any(|component| {
705 component
706 .as_os_str()
707 .to_str()
708 .is_some_and(|name| name == skip.to_string_lossy())
709 })
710 })
711 }
712
713 fn is_hidden_directory_to_skip(path: &Path) -> bool {
715 path.file_name()
716 .and_then(|n| n.to_str())
717 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
718 }
719
720 fn is_excluded_directory(path: &Path) -> bool {
722 let excluded_dirs = [
723 "target",
724 "build",
725 "dist",
726 "out",
727 ".git",
728 ".svn",
729 ".hg",
730 "__pycache__",
731 "venv",
732 ".venv",
733 "env",
734 ".env",
735 "temp",
736 "tmp",
737 "vendor",
738 ".pytest_cache",
739 ".tox",
740 ".eggs",
741 ".coverage",
742 "node_modules",
743 "obj",
744 "_build",
745 "zig-cache",
746 "zig-out",
747 "dist-newstyle",
748 ];
749
750 path.file_name()
751 .and_then(|n| n.to_str())
752 .is_some_and(|name| excluded_dirs.contains(&name))
753 }
754
755 fn detect_python_project(
776 &self,
777 path: &Path,
778 errors: &Arc<Mutex<Vec<String>>>,
779 ) -> Option<Project> {
780 let config_files = [
781 "requirements.txt",
782 "setup.py",
783 "pyproject.toml",
784 "setup.cfg",
785 "Pipfile",
786 "pipenv.lock",
787 "poetry.lock",
788 ];
789
790 let build_dirs = [
791 "__pycache__",
792 ".pytest_cache",
793 "venv",
794 ".venv",
795 "build",
796 "dist",
797 ".eggs",
798 ".tox",
799 ".coverage",
800 ];
801
802 let has_config = config_files.iter().any(|&file| path.join(file).exists());
804
805 if !has_config {
806 return None;
807 }
808
809 let mut build_arts: Vec<BuildArtifacts> = build_dirs
811 .iter()
812 .filter_map(|&dir_name| {
813 let dir_path = path.join(dir_name);
814 if dir_path.exists() && dir_path.is_dir() {
815 let size = crate::utils::calculate_dir_size(&dir_path);
816 Some(BuildArtifacts {
817 path: dir_path,
818 size,
819 })
820 } else {
821 None
822 }
823 })
824 .collect();
825
826 if let Ok(entries) = std::fs::read_dir(path) {
828 for entry in entries.flatten() {
829 let entry_path = entry.path();
830 if entry_path.is_dir()
831 && entry_path
832 .file_name()
833 .and_then(|n| n.to_str())
834 .is_some_and(|n| n.ends_with(".egg-info"))
835 {
836 let size = crate::utils::calculate_dir_size(&entry_path);
837 build_arts.push(BuildArtifacts {
838 path: entry_path,
839 size,
840 });
841 }
842 }
843 }
844
845 if build_arts.is_empty() {
846 return None;
847 }
848
849 let name = self.extract_python_project_name(path, errors);
850
851 Some(Project::new(
852 ProjectType::Python,
853 path.to_path_buf(),
854 build_arts,
855 name,
856 ))
857 }
858
859 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
881 let go_mod = path.join("go.mod");
882 let vendor_dir = path.join("vendor");
883
884 if go_mod.exists() && vendor_dir.exists() {
885 let name = self.extract_go_project_name(&go_mod, errors);
886
887 let build_arts = vec![BuildArtifacts {
888 path: path.join("vendor"),
889 size: 0, }];
891
892 return Some(Project::new(
893 ProjectType::Go,
894 path.to_path_buf(),
895 build_arts,
896 name,
897 ));
898 }
899
900 None
901 }
902
903 fn extract_python_project_name(
925 &self,
926 path: &Path,
927 errors: &Arc<Mutex<Vec<String>>>,
928 ) -> Option<String> {
929 self.try_extract_from_pyproject_toml(path, errors)
931 .or_else(|| self.try_extract_from_setup_py(path, errors))
932 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
933 .or_else(|| Self::fallback_to_directory_name(path))
934 }
935
936 fn try_extract_from_pyproject_toml(
938 &self,
939 path: &Path,
940 errors: &Arc<Mutex<Vec<String>>>,
941 ) -> Option<String> {
942 let pyproject_toml = path.join("pyproject.toml");
943 if !pyproject_toml.exists() {
944 return None;
945 }
946
947 let content = self.read_file_content(&pyproject_toml, errors)?;
948 Self::extract_name_from_toml_like_content(&content)
949 }
950
951 fn try_extract_from_setup_py(
953 &self,
954 path: &Path,
955 errors: &Arc<Mutex<Vec<String>>>,
956 ) -> Option<String> {
957 let setup_py = path.join("setup.py");
958 if !setup_py.exists() {
959 return None;
960 }
961
962 let content = self.read_file_content(&setup_py, errors)?;
963 Self::extract_name_from_python_content(&content)
964 }
965
966 fn try_extract_from_setup_cfg(
968 &self,
969 path: &Path,
970 errors: &Arc<Mutex<Vec<String>>>,
971 ) -> Option<String> {
972 let setup_cfg = path.join("setup.cfg");
973 if !setup_cfg.exists() {
974 return None;
975 }
976
977 let content = self.read_file_content(&setup_cfg, errors)?;
978 Self::extract_name_from_cfg_content(&content)
979 }
980
981 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
983 content
984 .lines()
985 .map(str::trim)
986 .find(|line| line.starts_with("name") && line.contains('='))
987 .and_then(Self::extract_quoted_value)
988 }
989
990 fn extract_name_from_python_content(content: &str) -> Option<String> {
992 content
993 .lines()
994 .map(str::trim)
995 .find(|line| line.contains("name") && line.contains('='))
996 .and_then(Self::extract_quoted_value)
997 }
998
999 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
1001 let mut in_metadata_section = false;
1002
1003 for line in content.lines() {
1004 let line = line.trim();
1005
1006 if line == "[metadata]" {
1007 in_metadata_section = true;
1008 } else if line.starts_with('[') && line.ends_with(']') {
1009 in_metadata_section = false;
1010 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
1011 return line.split('=').nth(1).map(|name| name.trim().to_string());
1012 }
1013 }
1014
1015 None
1016 }
1017
1018 fn fallback_to_directory_name(path: &Path) -> Option<String> {
1020 path.file_name()
1021 .and_then(|name| name.to_str())
1022 .map(std::string::ToString::to_string)
1023 }
1024
1025 fn extract_go_project_name(
1045 &self,
1046 go_mod: &Path,
1047 errors: &Arc<Mutex<Vec<String>>>,
1048 ) -> Option<String> {
1049 let content = self.read_file_content(go_mod, errors)?;
1050
1051 for line in content.lines() {
1052 let line = line.trim();
1053 if line.starts_with("module ") {
1054 let module_path = line.strip_prefix("module ")?.trim();
1055
1056 if let Some(name) = module_path.split('/').next_back() {
1058 return Some(name.to_string());
1059 }
1060
1061 return Some(module_path.to_string());
1062 }
1063 }
1064
1065 None
1066 }
1067
1068 fn detect_java_project(
1079 &self,
1080 path: &Path,
1081 errors: &Arc<Mutex<Vec<String>>>,
1082 ) -> Option<Project> {
1083 let pom_xml = path.join("pom.xml");
1084 let target_dir = path.join("target");
1085
1086 if pom_xml.exists() && target_dir.exists() {
1088 let name = self.extract_java_maven_project_name(&pom_xml, errors);
1089
1090 let build_arts = vec![BuildArtifacts {
1091 path: target_dir,
1092 size: 0,
1093 }];
1094
1095 return Some(Project::new(
1096 ProjectType::Java,
1097 path.to_path_buf(),
1098 build_arts,
1099 name,
1100 ));
1101 }
1102
1103 let has_gradle =
1105 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1106 let build_dir = path.join("build");
1107
1108 if has_gradle && build_dir.exists() {
1109 let name = self.extract_java_gradle_project_name(path, errors);
1110
1111 let build_arts = vec![BuildArtifacts {
1112 path: build_dir,
1113 size: 0,
1114 }];
1115
1116 return Some(Project::new(
1117 ProjectType::Java,
1118 path.to_path_buf(),
1119 build_arts,
1120 name,
1121 ));
1122 }
1123
1124 None
1125 }
1126
1127 fn extract_java_maven_project_name(
1131 &self,
1132 pom_xml: &Path,
1133 errors: &Arc<Mutex<Vec<String>>>,
1134 ) -> Option<String> {
1135 let content = self.read_file_content(pom_xml, errors)?;
1136
1137 for line in content.lines() {
1138 let trimmed = line.trim();
1139 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1140 let name = trimmed
1141 .strip_prefix("<artifactId>")?
1142 .strip_suffix("</artifactId>")?;
1143 return Some(name.to_string());
1144 }
1145 }
1146
1147 None
1148 }
1149
1150 fn extract_java_gradle_project_name(
1155 &self,
1156 path: &Path,
1157 errors: &Arc<Mutex<Vec<String>>>,
1158 ) -> Option<String> {
1159 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1160 let settings_path = path.join(settings_file);
1161 if settings_path.exists()
1162 && let Some(content) = self.read_file_content(&settings_path, errors)
1163 {
1164 for line in content.lines() {
1165 let trimmed = line.trim();
1166 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1167 return Self::extract_quoted_value(trimmed).or_else(|| {
1168 trimmed
1169 .split('=')
1170 .nth(1)
1171 .map(|s| s.trim().trim_matches('\'').to_string())
1172 });
1173 }
1174 }
1175 }
1176 }
1177
1178 Self::fallback_to_directory_name(path)
1179 }
1180
1181 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1191 let build_dir = path.join("build");
1192
1193 if !build_dir.exists() {
1194 return None;
1195 }
1196
1197 let cmake_file = path.join("CMakeLists.txt");
1198 let makefile = path.join("Makefile");
1199
1200 if cmake_file.exists() || makefile.exists() {
1201 let name = if cmake_file.exists() {
1202 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1203 } else {
1204 Self::fallback_to_directory_name(path)
1205 };
1206
1207 let build_arts = vec![BuildArtifacts {
1208 path: build_dir,
1209 size: 0,
1210 }];
1211
1212 return Some(Project::new(
1213 ProjectType::Cpp,
1214 path.to_path_buf(),
1215 build_arts,
1216 name,
1217 ));
1218 }
1219
1220 None
1221 }
1222
1223 fn extract_cpp_cmake_project_name(
1227 &self,
1228 cmake_file: &Path,
1229 errors: &Arc<Mutex<Vec<String>>>,
1230 ) -> Option<String> {
1231 let content = self.read_file_content(cmake_file, errors)?;
1232
1233 for line in content.lines() {
1234 let trimmed = line.trim();
1235 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1236 let inner = trimmed
1237 .trim_start_matches("project(")
1238 .trim_start_matches("PROJECT(")
1239 .trim_end_matches(')')
1240 .trim();
1241
1242 let name = inner.split_whitespace().next()?;
1244 let name = name.trim_matches('"').trim_matches('\'');
1246 if !name.is_empty() {
1247 return Some(name.to_string());
1248 }
1249 }
1250 }
1251
1252 Self::fallback_to_directory_name(cmake_file.parent()?)
1253 }
1254
1255 fn detect_swift_project(
1265 &self,
1266 path: &Path,
1267 errors: &Arc<Mutex<Vec<String>>>,
1268 ) -> Option<Project> {
1269 let package_swift = path.join("Package.swift");
1270 let build_dir = path.join(".build");
1271
1272 if package_swift.exists() && build_dir.exists() {
1273 let name = self.extract_swift_project_name(&package_swift, errors);
1274
1275 let build_arts = vec![BuildArtifacts {
1276 path: build_dir,
1277 size: 0,
1278 }];
1279
1280 return Some(Project::new(
1281 ProjectType::Swift,
1282 path.to_path_buf(),
1283 build_arts,
1284 name,
1285 ));
1286 }
1287
1288 None
1289 }
1290
1291 fn extract_swift_project_name(
1295 &self,
1296 package_swift: &Path,
1297 errors: &Arc<Mutex<Vec<String>>>,
1298 ) -> Option<String> {
1299 let content = self.read_file_content(package_swift, errors)?;
1300
1301 for line in content.lines() {
1302 let trimmed = line.trim();
1303 if trimmed.contains("name:") {
1304 return Self::extract_quoted_value(trimmed);
1305 }
1306 }
1307
1308 Self::fallback_to_directory_name(package_swift.parent()?)
1309 }
1310
1311 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1321 let bin_dir = path.join("bin");
1322 let obj_dir = path.join("obj");
1323
1324 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1325 if !has_build_dir {
1326 return None;
1327 }
1328
1329 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1330
1331 let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1333 (true, true) => {
1334 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1335 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1336 vec![
1337 BuildArtifacts {
1338 path: bin_dir,
1339 size: bin_size,
1340 },
1341 BuildArtifacts {
1342 path: obj_dir,
1343 size: obj_size,
1344 },
1345 ]
1346 }
1347 (true, false) => vec![BuildArtifacts {
1348 path: bin_dir,
1349 size: 0,
1350 }],
1351 (false, true) => vec![BuildArtifacts {
1352 path: obj_dir,
1353 size: 0,
1354 }],
1355 (false, false) => return None,
1356 };
1357
1358 let name = csproj_file
1359 .file_stem()
1360 .and_then(|s| s.to_str())
1361 .map(std::string::ToString::to_string);
1362
1363 Some(Project::new(
1364 ProjectType::DotNet,
1365 path.to_path_buf(),
1366 build_arts,
1367 name,
1368 ))
1369 }
1370
1371 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1373 let entries = fs::read_dir(dir).ok()?;
1374 for entry in entries.flatten() {
1375 let path = entry.path();
1376 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1377 return Some(path);
1378 }
1379 }
1380 None
1381 }
1382
1383 fn detect_deno_project(
1392 &self,
1393 path: &Path,
1394 errors: &Arc<Mutex<Vec<String>>>,
1395 ) -> Option<Project> {
1396 let deno_json = path.join("deno.json");
1397 let deno_jsonc = path.join("deno.jsonc");
1398
1399 if !deno_json.exists() && !deno_jsonc.exists() {
1400 return None;
1401 }
1402
1403 let config_path = if deno_json.exists() {
1404 deno_json
1405 } else {
1406 deno_jsonc
1407 };
1408
1409 let vendor_dir = path.join("vendor");
1411 if vendor_dir.exists() {
1412 let name = self.extract_deno_project_name(&config_path, errors);
1413 return Some(Project::new(
1414 ProjectType::Deno,
1415 path.to_path_buf(),
1416 vec![BuildArtifacts {
1417 path: vendor_dir,
1418 size: 0,
1419 }],
1420 name,
1421 ));
1422 }
1423
1424 let node_modules = path.join("node_modules");
1426 if node_modules.exists() && !path.join("package.json").exists() {
1427 let name = self.extract_deno_project_name(&config_path, errors);
1428 return Some(Project::new(
1429 ProjectType::Deno,
1430 path.to_path_buf(),
1431 vec![BuildArtifacts {
1432 path: node_modules,
1433 size: 0,
1434 }],
1435 name,
1436 ));
1437 }
1438
1439 None
1440 }
1441
1442 fn extract_deno_project_name(
1447 &self,
1448 config_path: &Path,
1449 errors: &Arc<Mutex<Vec<String>>>,
1450 ) -> Option<String> {
1451 match fs::read_to_string(config_path) {
1452 Ok(content) => {
1453 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1454 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1455 {
1456 return Some(name.to_string());
1457 }
1458 Self::fallback_to_directory_name(config_path.parent()?)
1459 }
1460 Err(e) => {
1461 self.log_file_error(config_path, &e, errors);
1462 Self::fallback_to_directory_name(config_path.parent()?)
1463 }
1464 }
1465 }
1466
1467 fn detect_ruby_project(
1477 &self,
1478 path: &Path,
1479 errors: &Arc<Mutex<Vec<String>>>,
1480 ) -> Option<Project> {
1481 let gemfile = path.join("Gemfile");
1482 if !gemfile.exists() {
1483 return None;
1484 }
1485
1486 let bundle_dir = path.join(".bundle");
1487 let vendor_bundle_dir = path.join("vendor").join("bundle");
1488
1489 let build_arts: Vec<BuildArtifacts> =
1490 match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1491 (true, true) => {
1492 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1493 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1494 vec![
1495 BuildArtifacts {
1496 path: bundle_dir,
1497 size: bundle_size,
1498 },
1499 BuildArtifacts {
1500 path: vendor_bundle_dir,
1501 size: vendor_size,
1502 },
1503 ]
1504 }
1505 (true, false) => vec![BuildArtifacts {
1506 path: bundle_dir,
1507 size: 0,
1508 }],
1509 (false, true) => vec![BuildArtifacts {
1510 path: vendor_bundle_dir,
1511 size: 0,
1512 }],
1513 (false, false) => return None,
1514 };
1515
1516 let name = self.extract_ruby_project_name(path, errors);
1517
1518 Some(Project::new(
1519 ProjectType::Ruby,
1520 path.to_path_buf(),
1521 build_arts,
1522 name,
1523 ))
1524 }
1525
1526 fn extract_ruby_project_name(
1531 &self,
1532 path: &Path,
1533 errors: &Arc<Mutex<Vec<String>>>,
1534 ) -> Option<String> {
1535 let entries = fs::read_dir(path).ok()?;
1536 for entry in entries.flatten() {
1537 let entry_path = entry.path();
1538 if entry_path.is_file()
1539 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1540 && let Some(content) = self.read_file_content(&entry_path, errors)
1541 {
1542 for line in content.lines() {
1543 let trimmed = line.trim();
1544 if trimmed.contains(".name")
1545 && trimmed.contains('=')
1546 && let Some(name) = Self::extract_quoted_value(trimmed)
1547 {
1548 return Some(name);
1549 }
1550 }
1551 }
1552 }
1553
1554 Self::fallback_to_directory_name(path)
1555 }
1556
1557 fn detect_elixir_project(
1567 &self,
1568 path: &Path,
1569 errors: &Arc<Mutex<Vec<String>>>,
1570 ) -> Option<Project> {
1571 let mix_exs = path.join("mix.exs");
1572 let build_dir = path.join("_build");
1573
1574 if mix_exs.exists() && build_dir.exists() {
1575 let name = self.extract_elixir_project_name(&mix_exs, errors);
1576
1577 return Some(Project::new(
1578 ProjectType::Elixir,
1579 path.to_path_buf(),
1580 vec![BuildArtifacts {
1581 path: build_dir,
1582 size: 0,
1583 }],
1584 name,
1585 ));
1586 }
1587
1588 None
1589 }
1590
1591 fn extract_elixir_project_name(
1596 &self,
1597 mix_exs: &Path,
1598 errors: &Arc<Mutex<Vec<String>>>,
1599 ) -> Option<String> {
1600 let content = self.read_file_content(mix_exs, errors)?;
1601
1602 for line in content.lines() {
1603 let trimmed = line.trim();
1604 if trimmed.contains("app:")
1605 && let Some(pos) = trimmed.find("app:")
1606 {
1607 let after = trimmed[pos + 4..].trim_start();
1608 if let Some(atom) = after.strip_prefix(':') {
1609 let name: String = atom
1611 .chars()
1612 .take_while(|c| c.is_alphanumeric() || *c == '_')
1613 .collect();
1614 if !name.is_empty() {
1615 return Some(name);
1616 }
1617 }
1618 }
1619 }
1620
1621 Self::fallback_to_directory_name(mix_exs.parent()?)
1622 }
1623
1624 fn detect_php_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1634 let composer_json = path.join("composer.json");
1635 let vendor_dir = path.join("vendor");
1636
1637 if composer_json.exists() && vendor_dir.exists() {
1638 let name = self.extract_php_project_name(&composer_json, errors);
1639
1640 return Some(Project::new(
1641 ProjectType::Php,
1642 path.to_path_buf(),
1643 vec![BuildArtifacts {
1644 path: vendor_dir,
1645 size: 0,
1646 }],
1647 name,
1648 ));
1649 }
1650
1651 None
1652 }
1653
1654 fn extract_php_project_name(
1660 &self,
1661 composer_json: &Path,
1662 errors: &Arc<Mutex<Vec<String>>>,
1663 ) -> Option<String> {
1664 match fs::read_to_string(composer_json) {
1665 Ok(content) => {
1666 if let Ok(json) = from_str::<Value>(&content)
1667 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1668 {
1669 let package = name.split('/').next_back().unwrap_or(name);
1671 return Some(package.to_string());
1672 }
1673 Self::fallback_to_directory_name(composer_json.parent()?)
1674 }
1675 Err(e) => {
1676 self.log_file_error(composer_json, &e, errors);
1677 Self::fallback_to_directory_name(composer_json.parent()?)
1678 }
1679 }
1680 }
1681
1682 fn detect_haskell_project(
1688 &self,
1689 path: &Path,
1690 errors: &Arc<Mutex<Vec<String>>>,
1691 ) -> Option<Project> {
1692 let stack_yaml = path.join("stack.yaml");
1694 let stack_work = path.join(".stack-work");
1695
1696 if stack_yaml.exists() && stack_work.exists() {
1697 let name = self.extract_haskell_project_name(path, errors);
1698 return Some(Project::new(
1699 ProjectType::Haskell,
1700 path.to_path_buf(),
1701 vec![BuildArtifacts {
1702 path: stack_work,
1703 size: 0,
1704 }],
1705 name,
1706 ));
1707 }
1708
1709 let dist_newstyle = path.join("dist-newstyle");
1711 if dist_newstyle.exists() {
1712 let has_cabal_project = path.join("cabal.project").exists();
1713 let has_cabal_file = Self::find_file_with_extension(path, "cabal").is_some();
1714
1715 if has_cabal_project || has_cabal_file {
1716 let name = self.extract_haskell_project_name(path, errors);
1717 return Some(Project::new(
1718 ProjectType::Haskell,
1719 path.to_path_buf(),
1720 vec![BuildArtifacts {
1721 path: dist_newstyle,
1722 size: 0,
1723 }],
1724 name,
1725 ));
1726 }
1727 }
1728
1729 None
1730 }
1731
1732 fn extract_haskell_project_name(
1737 &self,
1738 path: &Path,
1739 errors: &Arc<Mutex<Vec<String>>>,
1740 ) -> Option<String> {
1741 if let Some(cabal_file) = Self::find_file_with_extension(path, "cabal")
1743 && let Some(content) = self.read_file_content(&cabal_file, errors)
1744 {
1745 for line in content.lines() {
1746 let trimmed = line.trim();
1747 if let Some(rest) = trimmed.strip_prefix("name:") {
1748 let name = rest.trim().to_string();
1749 if !name.is_empty() {
1750 return Some(name);
1751 }
1752 }
1753 }
1754 }
1755
1756 let package_yaml = path.join("package.yaml");
1758 if package_yaml.exists()
1759 && let Some(content) = self.read_file_content(&package_yaml, errors)
1760 {
1761 for line in content.lines() {
1762 let trimmed = line.trim();
1763 if let Some(rest) = trimmed.strip_prefix("name:") {
1764 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1765 if !name.is_empty() {
1766 return Some(name);
1767 }
1768 }
1769 }
1770 }
1771
1772 Self::fallback_to_directory_name(path)
1773 }
1774
1775 fn detect_dart_project(
1785 &self,
1786 path: &Path,
1787 errors: &Arc<Mutex<Vec<String>>>,
1788 ) -> Option<Project> {
1789 let pubspec_yaml = path.join("pubspec.yaml");
1790 if !pubspec_yaml.exists() {
1791 return None;
1792 }
1793
1794 let dart_tool = path.join(".dart_tool");
1795 let build_dir = path.join("build");
1796
1797 let build_arts: Vec<BuildArtifacts> = match (dart_tool.exists(), build_dir.exists()) {
1798 (true, true) => {
1799 let dart_size = crate::utils::calculate_dir_size(&dart_tool);
1800 let build_size = crate::utils::calculate_dir_size(&build_dir);
1801 vec![
1802 BuildArtifacts {
1803 path: dart_tool,
1804 size: dart_size,
1805 },
1806 BuildArtifacts {
1807 path: build_dir,
1808 size: build_size,
1809 },
1810 ]
1811 }
1812 (true, false) => vec![BuildArtifacts {
1813 path: dart_tool,
1814 size: 0,
1815 }],
1816 (false, true) => vec![BuildArtifacts {
1817 path: build_dir,
1818 size: 0,
1819 }],
1820 (false, false) => return None,
1821 };
1822
1823 let name = self.extract_dart_project_name(&pubspec_yaml, errors);
1824
1825 Some(Project::new(
1826 ProjectType::Dart,
1827 path.to_path_buf(),
1828 build_arts,
1829 name,
1830 ))
1831 }
1832
1833 fn extract_dart_project_name(
1838 &self,
1839 pubspec_yaml: &Path,
1840 errors: &Arc<Mutex<Vec<String>>>,
1841 ) -> Option<String> {
1842 let content = self.read_file_content(pubspec_yaml, errors)?;
1843
1844 for line in content.lines() {
1845 let trimmed = line.trim();
1846 if let Some(rest) = trimmed.strip_prefix("name:") {
1847 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1848 if !name.is_empty() {
1849 return Some(name);
1850 }
1851 }
1852 }
1853
1854 Self::fallback_to_directory_name(pubspec_yaml.parent()?)
1855 }
1856
1857 fn detect_zig_project(path: &Path) -> Option<Project> {
1867 let build_zig = path.join("build.zig");
1868 if !build_zig.exists() {
1869 return None;
1870 }
1871
1872 let zig_cache = path.join("zig-cache");
1873 let zig_out = path.join("zig-out");
1874
1875 let build_arts: Vec<BuildArtifacts> = match (zig_cache.exists(), zig_out.exists()) {
1876 (true, true) => {
1877 let cache_size = crate::utils::calculate_dir_size(&zig_cache);
1878 let out_size = crate::utils::calculate_dir_size(&zig_out);
1879 vec![
1880 BuildArtifacts {
1881 path: zig_cache,
1882 size: cache_size,
1883 },
1884 BuildArtifacts {
1885 path: zig_out,
1886 size: out_size,
1887 },
1888 ]
1889 }
1890 (true, false) => vec![BuildArtifacts {
1891 path: zig_cache,
1892 size: 0,
1893 }],
1894 (false, true) => vec![BuildArtifacts {
1895 path: zig_out,
1896 size: 0,
1897 }],
1898 (false, false) => return None,
1899 };
1900
1901 let name = Self::fallback_to_directory_name(path);
1902
1903 Some(Project::new(
1904 ProjectType::Zig,
1905 path.to_path_buf(),
1906 build_arts,
1907 name,
1908 ))
1909 }
1910
1911 fn detect_scala_project(
1921 &self,
1922 path: &Path,
1923 errors: &Arc<Mutex<Vec<String>>>,
1924 ) -> Option<Project> {
1925 let build_sbt = path.join("build.sbt");
1926 let target_dir = path.join("target");
1927
1928 if build_sbt.exists() && target_dir.exists() {
1929 let name = self.extract_scala_project_name(&build_sbt, errors);
1930
1931 return Some(Project::new(
1932 ProjectType::Scala,
1933 path.to_path_buf(),
1934 vec![BuildArtifacts {
1935 path: target_dir,
1936 size: 0,
1937 }],
1938 name,
1939 ));
1940 }
1941
1942 None
1943 }
1944
1945 fn extract_scala_project_name(
1950 &self,
1951 build_sbt: &Path,
1952 errors: &Arc<Mutex<Vec<String>>>,
1953 ) -> Option<String> {
1954 let content = self.read_file_content(build_sbt, errors)?;
1955
1956 for line in content.lines() {
1957 let trimmed = line.trim();
1958 if trimmed.starts_with("name")
1959 && trimmed.contains(":=")
1960 && let Some(name) = Self::extract_quoted_value(trimmed)
1961 {
1962 return Some(name);
1963 }
1964 }
1965
1966 Self::fallback_to_directory_name(build_sbt.parent()?)
1967 }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972 use super::*;
1973 use std::path::PathBuf;
1974 use tempfile::TempDir;
1975
1976 fn default_scanner(filter: ProjectFilter) -> Scanner {
1978 Scanner::new(
1979 ScanOptions {
1980 verbose: false,
1981 threads: 1,
1982 skip: vec![],
1983 max_depth: None,
1984 },
1985 filter,
1986 )
1987 }
1988
1989 fn create_file(path: &Path, content: &str) -> anyhow::Result<()> {
1991 if let Some(parent) = path.parent() {
1992 fs::create_dir_all(parent)?;
1993 }
1994 fs::write(path, content)?;
1995 Ok(())
1996 }
1997
1998 #[test]
2001 fn test_is_hidden_directory_to_skip() {
2002 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2004 "/some/.hidden"
2005 )));
2006 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2007 "/some/.git"
2008 )));
2009 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2010 "/some/.svn"
2011 )));
2012 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
2013
2014 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2016 "/home/user/.cargo"
2017 )));
2018 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
2019
2020 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2022 "/some/visible"
2023 )));
2024 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
2025 }
2026
2027 #[test]
2028 fn test_is_excluded_directory() {
2029 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
2031 assert!(Scanner::is_excluded_directory(Path::new(
2032 "/some/node_modules"
2033 )));
2034 assert!(Scanner::is_excluded_directory(Path::new(
2035 "/some/__pycache__"
2036 )));
2037 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
2038 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
2039 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
2040 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
2041
2042 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
2044 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
2045 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
2046
2047 assert!(Scanner::is_excluded_directory(Path::new(
2049 "/some/.pytest_cache"
2050 )));
2051 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
2052 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
2053 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
2054
2055 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
2057 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
2058 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
2059 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
2060
2061 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
2063 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
2064
2065 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
2067 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
2068 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
2069 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
2070 }
2071
2072 #[test]
2073 fn test_extract_quoted_value() {
2074 assert_eq!(
2075 Scanner::extract_quoted_value(r#"name = "my-project""#),
2076 Some("my-project".to_string())
2077 );
2078 assert_eq!(
2079 Scanner::extract_quoted_value(r#"name = "with spaces""#),
2080 Some("with spaces".to_string())
2081 );
2082 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
2083 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
2085 }
2086
2087 #[test]
2088 fn test_is_name_line() {
2089 assert!(Scanner::is_name_line("name = \"test\""));
2090 assert!(Scanner::is_name_line("name=\"test\""));
2091 assert!(!Scanner::is_name_line("version = \"1.0\""));
2092 assert!(!Scanner::is_name_line("# name = \"commented\""));
2093 assert!(!Scanner::is_name_line("name: \"yaml style\""));
2094 }
2095
2096 #[test]
2097 fn test_parse_toml_name_field() {
2098 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
2099 assert_eq!(
2100 Scanner::parse_toml_name_field(content),
2101 Some("test-project".to_string())
2102 );
2103
2104 let no_name = "[package]\nversion = \"0.1.0\"\n";
2105 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
2106
2107 let empty = "";
2108 assert_eq!(Scanner::parse_toml_name_field(empty), None);
2109 }
2110
2111 #[test]
2112 fn test_extract_name_from_cfg_content() {
2113 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
2114 assert_eq!(
2115 Scanner::extract_name_from_cfg_content(content),
2116 Some("my-package".to_string())
2117 );
2118
2119 let wrong_section = "[options]\nname = not-this\n";
2121 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
2122
2123 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
2125 assert_eq!(
2126 Scanner::extract_name_from_cfg_content(multi),
2127 Some("correct".to_string())
2128 );
2129 }
2130
2131 #[test]
2132 fn test_extract_name_from_python_content() {
2133 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
2134 assert_eq!(
2135 Scanner::extract_name_from_python_content(content),
2136 Some("my-pkg".to_string())
2137 );
2138
2139 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
2140 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
2141 }
2142
2143 #[test]
2144 fn test_fallback_to_directory_name() {
2145 assert_eq!(
2146 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
2147 Some("project-name".to_string())
2148 );
2149 assert_eq!(
2150 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
2151 Some("my_app".to_string())
2152 );
2153 }
2154
2155 #[test]
2156 fn test_is_path_in_skip_list() {
2157 let scanner = Scanner::new(
2158 ScanOptions {
2159 verbose: false,
2160 threads: 1,
2161 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
2162 max_depth: None,
2163 },
2164 ProjectFilter::All,
2165 );
2166
2167 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
2168 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
2169 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
2170 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
2171 }
2172
2173 #[test]
2174 fn test_is_path_in_empty_skip_list() {
2175 let scanner = default_scanner(ProjectFilter::All);
2176 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
2177 }
2178
2179 #[test]
2182 fn test_scan_directory_with_spaces_in_path() -> anyhow::Result<()> {
2183 let tmp = TempDir::new()?;
2184 let base = tmp.path().join("path with spaces");
2185 fs::create_dir_all(&base)?;
2186
2187 let project = base.join("my project");
2188 create_file(
2189 &project.join("Cargo.toml"),
2190 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
2191 )?;
2192 create_file(&project.join("target/dummy"), "content")?;
2193
2194 let scanner = default_scanner(ProjectFilter::Rust);
2195 let projects = scanner.scan_directory(&base);
2196 assert_eq!(projects.len(), 1);
2197 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
2198 Ok(())
2199 }
2200
2201 #[test]
2202 fn test_scan_directory_with_unicode_names() -> anyhow::Result<()> {
2203 let tmp = TempDir::new()?;
2204 let base = tmp.path();
2205
2206 let project = base.join("プロジェクト");
2207 create_file(
2208 &project.join("package.json"),
2209 r#"{"name": "unicode-project"}"#,
2210 )?;
2211 create_file(&project.join("node_modules/dep.js"), "module.exports = {};")?;
2212
2213 let scanner = default_scanner(ProjectFilter::Node);
2214 let projects = scanner.scan_directory(base);
2215 assert_eq!(projects.len(), 1);
2216 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
2217 Ok(())
2218 }
2219
2220 #[test]
2221 fn test_scan_directory_with_special_characters_in_name() -> anyhow::Result<()> {
2222 let tmp = TempDir::new()?;
2223 let base = tmp.path();
2224
2225 let project = base.join("project-with-dashes_and_underscores.v2");
2226 create_file(
2227 &project.join("Cargo.toml"),
2228 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
2229 )?;
2230 create_file(&project.join("target/dummy"), "content")?;
2231
2232 let scanner = default_scanner(ProjectFilter::Rust);
2233 let projects = scanner.scan_directory(base);
2234 assert_eq!(projects.len(), 1);
2235 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
2236 Ok(())
2237 }
2238
2239 #[test]
2242 #[cfg(unix)]
2243 fn test_hidden_directory_itself_not_detected_as_project_unix() -> anyhow::Result<()> {
2244 let tmp = TempDir::new()?;
2245 let base = tmp.path();
2246
2247 let hidden = base.join(".hidden-project");
2252 create_file(
2253 &hidden.join("Cargo.toml"),
2254 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
2255 )?;
2256 create_file(&hidden.join("target/dummy"), "content")?;
2257
2258 let visible = base.join("visible-project");
2260 create_file(
2261 &visible.join("Cargo.toml"),
2262 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
2263 )?;
2264 create_file(&visible.join("target/dummy"), "content")?;
2265
2266 let scanner = default_scanner(ProjectFilter::Rust);
2267 let projects = scanner.scan_directory(base);
2268
2269 assert_eq!(projects.len(), 1);
2272 assert_eq!(projects[0].name.as_deref(), Some("visible"));
2273 Ok(())
2274 }
2275
2276 #[test]
2277 #[cfg(unix)]
2278 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() -> anyhow::Result<()> {
2279 let tmp = TempDir::new()?;
2280 let base = tmp.path();
2281
2282 let nested = base.join(".hidden-parent/visible-child");
2285 create_file(
2286 &nested.join("Cargo.toml"),
2287 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
2288 )?;
2289 create_file(&nested.join("target/dummy"), "content")?;
2290
2291 let scanner = default_scanner(ProjectFilter::Rust);
2292 let projects = scanner.scan_directory(base);
2293
2294 assert_eq!(projects.len(), 1);
2296 assert_eq!(projects[0].name.as_deref(), Some("nested"));
2297 Ok(())
2298 }
2299
2300 #[test]
2301 #[cfg(unix)]
2302 fn test_dotcargo_directory_not_skipped_unix() {
2303 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2306 "/home/user/.cargo"
2307 )));
2308
2309 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2311 "/home/user/.local"
2312 )));
2313 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2314 "/home/user/.npm"
2315 )));
2316 }
2317
2318 #[test]
2321 fn test_detect_python_with_pyproject_toml() -> anyhow::Result<()> {
2322 let tmp = TempDir::new()?;
2323 let base = tmp.path();
2324
2325 let project = base.join("py-project");
2326 create_file(
2327 &project.join("pyproject.toml"),
2328 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
2329 )?;
2330 let pycache = project.join("__pycache__");
2331 fs::create_dir_all(&pycache)?;
2332 create_file(&pycache.join("module.pyc"), "bytecode")?;
2333
2334 let scanner = default_scanner(ProjectFilter::Python);
2335 let projects = scanner.scan_directory(base);
2336 assert_eq!(projects.len(), 1);
2337 assert_eq!(projects[0].kind, ProjectType::Python);
2338 Ok(())
2339 }
2340
2341 #[test]
2342 fn test_detect_python_with_setup_py() -> anyhow::Result<()> {
2343 let tmp = TempDir::new()?;
2344 let base = tmp.path();
2345
2346 let project = base.join("setup-project");
2347 create_file(
2348 &project.join("setup.py"),
2349 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
2350 )?;
2351 let pycache = project.join("__pycache__");
2352 fs::create_dir_all(&pycache)?;
2353 create_file(&pycache.join("module.pyc"), "bytecode")?;
2354
2355 let scanner = default_scanner(ProjectFilter::Python);
2356 let projects = scanner.scan_directory(base);
2357 assert_eq!(projects.len(), 1);
2358 Ok(())
2359 }
2360
2361 #[test]
2362 fn test_detect_python_with_pipfile() -> anyhow::Result<()> {
2363 let tmp = TempDir::new()?;
2364 let base = tmp.path();
2365
2366 let project = base.join("pipenv-project");
2367 create_file(
2368 &project.join("Pipfile"),
2369 "[[source]]\nurl = \"https://pypi.org/simple\"",
2370 )?;
2371 let pycache = project.join("__pycache__");
2372 fs::create_dir_all(&pycache)?;
2373 create_file(&pycache.join("module.pyc"), "bytecode")?;
2374
2375 let scanner = default_scanner(ProjectFilter::Python);
2376 let projects = scanner.scan_directory(base);
2377 assert_eq!(projects.len(), 1);
2378 Ok(())
2379 }
2380
2381 #[test]
2384 fn test_detect_go_extracts_module_name() -> anyhow::Result<()> {
2385 let tmp = TempDir::new()?;
2386 let base = tmp.path();
2387
2388 let project = base.join("go-service");
2389 create_file(
2390 &project.join("go.mod"),
2391 "module github.com/user/my-service\n\ngo 1.21\n",
2392 )?;
2393 let vendor = project.join("vendor");
2394 fs::create_dir_all(&vendor)?;
2395 create_file(&vendor.join("modules.txt"), "vendor manifest")?;
2396
2397 let scanner = default_scanner(ProjectFilter::Go);
2398 let projects = scanner.scan_directory(base);
2399 assert_eq!(projects.len(), 1);
2400 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
2402 Ok(())
2403 }
2404
2405 #[test]
2408 fn test_detect_java_maven_project() -> anyhow::Result<()> {
2409 let tmp = TempDir::new()?;
2410 let base = tmp.path();
2411
2412 let project = base.join("java-maven");
2413 create_file(
2414 &project.join("pom.xml"),
2415 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
2416 )?;
2417 create_file(&project.join("target/classes/Main.class"), "bytecode")?;
2418
2419 let scanner = default_scanner(ProjectFilter::Java);
2420 let projects = scanner.scan_directory(base);
2421 assert_eq!(projects.len(), 1);
2422 assert_eq!(projects[0].kind, ProjectType::Java);
2423 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
2424 Ok(())
2425 }
2426
2427 #[test]
2428 fn test_detect_java_gradle_project() -> anyhow::Result<()> {
2429 let tmp = TempDir::new()?;
2430 let base = tmp.path();
2431
2432 let project = base.join("java-gradle");
2433 create_file(&project.join("build.gradle"), "apply plugin: 'java'")?;
2434 create_file(
2435 &project.join("settings.gradle"),
2436 "rootProject.name = \"my-gradle-app\"",
2437 )?;
2438 create_file(&project.join("build/classes/main/Main.class"), "bytecode")?;
2439
2440 let scanner = default_scanner(ProjectFilter::Java);
2441 let projects = scanner.scan_directory(base);
2442 assert_eq!(projects.len(), 1);
2443 assert_eq!(projects[0].kind, ProjectType::Java);
2444 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2445 Ok(())
2446 }
2447
2448 #[test]
2449 fn test_detect_java_gradle_kts_project() -> anyhow::Result<()> {
2450 let tmp = TempDir::new()?;
2451 let base = tmp.path();
2452
2453 let project = base.join("kotlin-gradle");
2454 create_file(
2455 &project.join("build.gradle.kts"),
2456 "plugins { kotlin(\"jvm\") }",
2457 )?;
2458 create_file(
2459 &project.join("settings.gradle.kts"),
2460 "rootProject.name = \"my-kotlin-app\"",
2461 )?;
2462 create_file(
2463 &project.join("build/classes/kotlin/main/MainKt.class"),
2464 "bytecode",
2465 )?;
2466
2467 let scanner = default_scanner(ProjectFilter::Java);
2468 let projects = scanner.scan_directory(base);
2469 assert_eq!(projects.len(), 1);
2470 assert_eq!(projects[0].kind, ProjectType::Java);
2471 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2472 Ok(())
2473 }
2474
2475 #[test]
2478 fn test_detect_cpp_cmake_project() -> anyhow::Result<()> {
2479 let tmp = TempDir::new()?;
2480 let base = tmp.path();
2481
2482 let project = base.join("cpp-cmake");
2483 create_file(
2484 &project.join("CMakeLists.txt"),
2485 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2486 )?;
2487 create_file(&project.join("build/CMakeCache.txt"), "cache")?;
2488
2489 let scanner = default_scanner(ProjectFilter::Cpp);
2490 let projects = scanner.scan_directory(base);
2491 assert_eq!(projects.len(), 1);
2492 assert_eq!(projects[0].kind, ProjectType::Cpp);
2493 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2494 Ok(())
2495 }
2496
2497 #[test]
2498 fn test_detect_cpp_makefile_project() -> anyhow::Result<()> {
2499 let tmp = TempDir::new()?;
2500 let base = tmp.path();
2501
2502 let project = base.join("cpp-make");
2503 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp")?;
2504 create_file(&project.join("build/main.o"), "object")?;
2505
2506 let scanner = default_scanner(ProjectFilter::Cpp);
2507 let projects = scanner.scan_directory(base);
2508 assert_eq!(projects.len(), 1);
2509 assert_eq!(projects[0].kind, ProjectType::Cpp);
2510 Ok(())
2511 }
2512
2513 #[test]
2516 fn test_detect_swift_project() -> anyhow::Result<()> {
2517 let tmp = TempDir::new()?;
2518 let base = tmp.path();
2519
2520 let project = base.join("swift-pkg");
2521 create_file(
2522 &project.join("Package.swift"),
2523 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2524 )?;
2525 create_file(&project.join(".build/debug/my-swift-lib"), "binary")?;
2526
2527 let scanner = default_scanner(ProjectFilter::Swift);
2528 let projects = scanner.scan_directory(base);
2529 assert_eq!(projects.len(), 1);
2530 assert_eq!(projects[0].kind, ProjectType::Swift);
2531 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2532 Ok(())
2533 }
2534
2535 #[test]
2538 fn test_detect_dotnet_project() -> anyhow::Result<()> {
2539 let tmp = TempDir::new()?;
2540 let base = tmp.path();
2541
2542 let project = base.join("dotnet-app");
2543 create_file(
2544 &project.join("MyApp.csproj"),
2545 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2546 )?;
2547 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly")?;
2548 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate")?;
2549
2550 let scanner = default_scanner(ProjectFilter::DotNet);
2551 let projects = scanner.scan_directory(base);
2552 assert_eq!(projects.len(), 1);
2553 assert_eq!(projects[0].kind, ProjectType::DotNet);
2554 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2555 Ok(())
2556 }
2557
2558 #[test]
2559 fn test_detect_dotnet_project_obj_only() -> anyhow::Result<()> {
2560 let tmp = TempDir::new()?;
2561 let base = tmp.path();
2562
2563 let project = base.join("dotnet-obj-only");
2564 create_file(
2565 &project.join("Lib.csproj"),
2566 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2567 )?;
2568 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate")?;
2569
2570 let scanner = default_scanner(ProjectFilter::DotNet);
2571 let projects = scanner.scan_directory(base);
2572 assert_eq!(projects.len(), 1);
2573 assert_eq!(projects[0].kind, ProjectType::DotNet);
2574 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2575 Ok(())
2576 }
2577
2578 #[test]
2581 fn test_obj_directory_is_excluded() {
2582 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2583 }
2584
2585 #[test]
2588 fn test_calculate_build_dir_size_empty() -> anyhow::Result<()> {
2589 let tmp = TempDir::new()?;
2590 let empty_dir = tmp.path().join("empty");
2591 fs::create_dir_all(&empty_dir)?;
2592
2593 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2594 Ok(())
2595 }
2596
2597 #[test]
2598 fn test_calculate_build_dir_size_nonexistent() {
2599 assert_eq!(
2600 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2601 0
2602 );
2603 }
2604
2605 #[test]
2606 fn test_calculate_build_dir_size_with_nested_files() -> anyhow::Result<()> {
2607 let tmp = TempDir::new()?;
2608 let dir = tmp.path().join("nested");
2609
2610 create_file(&dir.join("file1.txt"), "hello")?; create_file(&dir.join("sub/file2.txt"), "world!")?; create_file(&dir.join("sub/deep/file3.txt"), "!")?; let size = Scanner::calculate_build_dir_size(&dir);
2615 assert_eq!(size, 12);
2616 Ok(())
2617 }
2618
2619 #[test]
2622 fn test_scanner_quiet_mode() -> anyhow::Result<()> {
2623 let tmp = TempDir::new()?;
2624 let base = tmp.path();
2625
2626 let project = base.join("quiet-project");
2627 create_file(
2628 &project.join("Cargo.toml"),
2629 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2630 )?;
2631 create_file(&project.join("target/dummy"), "content")?;
2632
2633 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2634 let projects = scanner.scan_directory(base);
2635 assert_eq!(projects.len(), 1);
2636 Ok(())
2637 }
2638
2639 #[test]
2642 fn test_detect_ruby_with_vendor_bundle() -> anyhow::Result<()> {
2643 let tmp = TempDir::new()?;
2644 let base = tmp.path();
2645
2646 let project = base.join("ruby-project");
2647 create_file(
2648 &project.join("Gemfile"),
2649 "source 'https://rubygems.org'\ngem 'rails'",
2650 )?;
2651 create_file(
2652 &project.join("my-app.gemspec"),
2653 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2654 )?;
2655 create_file(
2656 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2657 "# rails",
2658 )?;
2659
2660 let scanner = default_scanner(ProjectFilter::Ruby);
2661 let projects = scanner.scan_directory(base);
2662 assert_eq!(projects.len(), 1);
2663 assert_eq!(projects[0].kind, ProjectType::Ruby);
2664 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2665 Ok(())
2666 }
2667
2668 #[test]
2669 fn test_detect_ruby_with_dot_bundle() -> anyhow::Result<()> {
2670 let tmp = TempDir::new()?;
2671 let base = tmp.path();
2672
2673 let project = base.join("ruby-dot-bundle");
2674 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2675 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack")?;
2676
2677 let scanner = default_scanner(ProjectFilter::Ruby);
2678 let projects = scanner.scan_directory(base);
2679 assert_eq!(projects.len(), 1);
2680 assert_eq!(projects[0].kind, ProjectType::Ruby);
2681 Ok(())
2682 }
2683
2684 #[test]
2685 fn test_detect_ruby_no_artifact_not_detected() -> anyhow::Result<()> {
2686 let tmp = TempDir::new()?;
2687 let base = tmp.path();
2688
2689 let project = base.join("gemfile-only");
2691 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2692
2693 let scanner = default_scanner(ProjectFilter::Ruby);
2694 let projects = scanner.scan_directory(base);
2695 assert_eq!(projects.len(), 0);
2696 Ok(())
2697 }
2698
2699 #[test]
2700 fn test_detect_ruby_fallback_to_dir_name() -> anyhow::Result<()> {
2701 let tmp = TempDir::new()?;
2702 let base = tmp.path();
2703
2704 let project = base.join("my-ruby-app");
2705 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2706 create_file(
2707 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2708 "# sinatra",
2709 )?;
2710
2711 let scanner = default_scanner(ProjectFilter::Ruby);
2712 let projects = scanner.scan_directory(base);
2713 assert_eq!(projects.len(), 1);
2714 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2715 Ok(())
2716 }
2717
2718 #[test]
2721 fn test_detect_elixir_project() -> anyhow::Result<()> {
2722 let tmp = TempDir::new()?;
2723 let base = tmp.path();
2724
2725 let project = base.join("elixir-project");
2726 create_file(
2727 &project.join("mix.exs"),
2728 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2729 )?;
2730 create_file(
2731 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2732 "# build",
2733 )?;
2734
2735 let scanner = default_scanner(ProjectFilter::Elixir);
2736 let projects = scanner.scan_directory(base);
2737 assert_eq!(projects.len(), 1);
2738 assert_eq!(projects[0].kind, ProjectType::Elixir);
2739 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2740 Ok(())
2741 }
2742
2743 #[test]
2744 fn test_detect_elixir_no_build_not_detected() -> anyhow::Result<()> {
2745 let tmp = TempDir::new()?;
2746 let base = tmp.path();
2747
2748 let project = base.join("mix-only");
2749 create_file(
2750 &project.join("mix.exs"),
2751 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2752 )?;
2753
2754 let scanner = default_scanner(ProjectFilter::Elixir);
2755 let projects = scanner.scan_directory(base);
2756 assert_eq!(projects.len(), 0);
2757 Ok(())
2758 }
2759
2760 #[test]
2761 fn test_detect_elixir_fallback_to_dir_name() -> anyhow::Result<()> {
2762 let tmp = TempDir::new()?;
2763 let base = tmp.path();
2764
2765 let project = base.join("my_elixir_project");
2766 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:")?;
2767 create_file(
2768 &project.join("_build/prod/lib/my_elixir_project.beam"),
2769 "bytecode",
2770 )?;
2771
2772 let scanner = default_scanner(ProjectFilter::Elixir);
2773 let projects = scanner.scan_directory(base);
2774 assert_eq!(projects.len(), 1);
2775 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2776 Ok(())
2777 }
2778
2779 #[test]
2782 fn test_detect_deno_with_vendor() -> anyhow::Result<()> {
2783 let tmp = TempDir::new()?;
2784 let base = tmp.path();
2785
2786 let project = base.join("deno-project");
2787 create_file(
2788 &project.join("deno.json"),
2789 r#"{"name": "my-deno-app", "imports": {}}"#,
2790 )?;
2791 create_file(&project.join("vendor/modules.json"), "{}")?;
2792
2793 let scanner = default_scanner(ProjectFilter::Deno);
2794 let projects = scanner.scan_directory(base);
2795 assert_eq!(projects.len(), 1);
2796 assert_eq!(projects[0].kind, ProjectType::Deno);
2797 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2798 Ok(())
2799 }
2800
2801 #[test]
2802 fn test_detect_deno_jsonc_config() -> anyhow::Result<()> {
2803 let tmp = TempDir::new()?;
2804 let base = tmp.path();
2805
2806 let project = base.join("deno-jsonc-project");
2807 create_file(
2808 &project.join("deno.jsonc"),
2809 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2810 )?;
2811 create_file(&project.join("vendor/modules.json"), "{}")?;
2812
2813 let scanner = default_scanner(ProjectFilter::Deno);
2814 let projects = scanner.scan_directory(base);
2815 assert_eq!(projects.len(), 1);
2816 assert_eq!(projects[0].kind, ProjectType::Deno);
2817 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2818 Ok(())
2819 }
2820
2821 #[test]
2822 fn test_detect_deno_node_modules_without_package_json() -> anyhow::Result<()> {
2823 let tmp = TempDir::new()?;
2824 let base = tmp.path();
2825
2826 let project = base.join("deno-npm-project");
2827 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#)?;
2828 create_file(
2829 &project.join("node_modules/.deno/lodash/index.js"),
2830 "// lodash",
2831 )?;
2832
2833 let scanner = default_scanner(ProjectFilter::Deno);
2834 let projects = scanner.scan_directory(base);
2835 assert_eq!(projects.len(), 1);
2836 assert_eq!(projects[0].kind, ProjectType::Deno);
2837 Ok(())
2838 }
2839
2840 #[test]
2841 fn test_detect_deno_node_modules_with_package_json_becomes_node() -> anyhow::Result<()> {
2842 let tmp = TempDir::new()?;
2843 let base = tmp.path();
2844
2845 let project = base.join("ambiguous-project");
2847 create_file(&project.join("deno.json"), r"{}")?;
2848 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#)?;
2849 create_file(&project.join("node_modules/dep/index.js"), "// dep")?;
2850
2851 let scanner = default_scanner(ProjectFilter::All);
2852 let projects = scanner.scan_directory(base);
2853 assert_eq!(projects.len(), 1);
2854 assert_eq!(projects[0].kind, ProjectType::Node);
2855 Ok(())
2856 }
2857
2858 #[test]
2859 fn test_detect_deno_no_artifact_not_detected() -> anyhow::Result<()> {
2860 let tmp = TempDir::new()?;
2861 let base = tmp.path();
2862
2863 let project = base.join("deno-no-artifact");
2864 create_file(&project.join("deno.json"), r"{}")?;
2865
2866 let scanner = default_scanner(ProjectFilter::Deno);
2867 let projects = scanner.scan_directory(base);
2868 assert_eq!(projects.len(), 0);
2869 Ok(())
2870 }
2871
2872 #[test]
2873 fn test_build_directory_is_excluded() {
2874 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2875 }
2876
2877 #[test]
2880 fn test_is_cargo_workspace_root() -> anyhow::Result<()> {
2881 let tmp = TempDir::new()?;
2882 let cargo_toml = tmp.path().join("Cargo.toml");
2883
2884 create_file(
2886 &cargo_toml,
2887 "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2888 )?;
2889 assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2890
2891 create_file(
2893 &cargo_toml,
2894 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2895 )?;
2896 assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2897
2898 assert!(!Scanner::is_cargo_workspace_root(Path::new(
2900 "/nonexistent/Cargo.toml"
2901 )));
2902 Ok(())
2903 }
2904
2905 #[test]
2906 fn test_workspace_root_detected() -> anyhow::Result<()> {
2907 let tmp = TempDir::new()?;
2908 let base = tmp.path();
2909
2910 let workspace = base.join("my-workspace");
2912 create_file(
2913 &workspace.join("Cargo.toml"),
2914 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2915 )?;
2916 create_file(&workspace.join("target/dummy"), "content")?;
2917
2918 let scanner = default_scanner(ProjectFilter::Rust);
2919 let projects = scanner.scan_directory(base);
2920
2921 assert_eq!(projects.len(), 1);
2922 assert_eq!(projects[0].root_path, workspace);
2923 Ok(())
2924 }
2925
2926 #[test]
2927 fn test_workspace_member_with_own_target_skipped() -> anyhow::Result<()> {
2928 let tmp = TempDir::new()?;
2929 let base = tmp.path();
2930
2931 let workspace = base.join("my-workspace");
2933 create_file(
2934 &workspace.join("Cargo.toml"),
2935 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2936 )?;
2937 create_file(&workspace.join("target/dummy"), "content")?;
2938
2939 let member = workspace.join("crate-a");
2941 create_file(
2942 &member.join("Cargo.toml"),
2943 "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2944 )?;
2945 create_file(&member.join("target/dummy"), "content")?;
2946
2947 let scanner = default_scanner(ProjectFilter::Rust);
2948 let projects = scanner.scan_directory(base);
2949
2950 assert_eq!(projects.len(), 1);
2952 assert_eq!(projects[0].root_path, workspace);
2953 Ok(())
2954 }
2955
2956 #[test]
2959 fn test_detect_php_project() -> anyhow::Result<()> {
2960 let tmp = TempDir::new()?;
2961 let base = tmp.path();
2962
2963 let project = base.join("php-project");
2964 create_file(
2965 &project.join("composer.json"),
2966 r#"{"name": "acme/my-php-app", "require": {}}"#,
2967 )?;
2968 create_file(&project.join("vendor/autoload.php"), "<?php // autoloader")?;
2969
2970 let scanner = default_scanner(ProjectFilter::Php);
2971 let projects = scanner.scan_directory(base);
2972 assert_eq!(projects.len(), 1);
2973 assert_eq!(projects[0].kind, ProjectType::Php);
2974 assert_eq!(projects[0].name.as_deref(), Some("my-php-app"));
2976 Ok(())
2977 }
2978
2979 #[test]
2980 fn test_detect_php_no_vendor_not_detected() -> anyhow::Result<()> {
2981 let tmp = TempDir::new()?;
2982 let base = tmp.path();
2983
2984 let project = base.join("php-no-vendor");
2985 create_file(&project.join("composer.json"), r#"{"name": "acme/my-app"}"#)?;
2986
2987 let scanner = default_scanner(ProjectFilter::Php);
2988 let projects = scanner.scan_directory(base);
2989 assert_eq!(projects.len(), 0);
2990 Ok(())
2991 }
2992
2993 #[test]
2994 fn test_detect_php_fallback_to_dir_name() -> anyhow::Result<()> {
2995 let tmp = TempDir::new()?;
2996 let base = tmp.path();
2997
2998 let project = base.join("my-php-project");
2999 create_file(&project.join("composer.json"), r#"{"require": {}}"#)?;
3001 create_file(&project.join("vendor/autoload.php"), "<?php")?;
3002
3003 let scanner = default_scanner(ProjectFilter::Php);
3004 let projects = scanner.scan_directory(base);
3005 assert_eq!(projects.len(), 1);
3006 assert_eq!(projects[0].name.as_deref(), Some("my-php-project"));
3007 Ok(())
3008 }
3009
3010 #[test]
3013 fn test_detect_haskell_stack_project() -> anyhow::Result<()> {
3014 let tmp = TempDir::new()?;
3015 let base = tmp.path();
3016
3017 let project = base.join("haskell-stack");
3018 create_file(
3019 &project.join("stack.yaml"),
3020 "resolver: lts-21.0\npackages:\n - .",
3021 )?;
3022 create_file(
3023 &project.join("my-haskell-lib.cabal"),
3024 "name: my-haskell-lib\nversion: 0.1.0.0\n",
3025 )?;
3026 create_file(
3027 &project.join(".stack-work/dist/x86_64-linux/ghc-9.4.7/build/Main.o"),
3028 "object",
3029 )?;
3030
3031 let scanner = default_scanner(ProjectFilter::Haskell);
3032 let projects = scanner.scan_directory(base);
3033 assert_eq!(projects.len(), 1);
3034 assert_eq!(projects[0].kind, ProjectType::Haskell);
3035 assert_eq!(projects[0].name.as_deref(), Some("my-haskell-lib"));
3036 Ok(())
3037 }
3038
3039 #[test]
3040 fn test_detect_haskell_cabal_project() -> anyhow::Result<()> {
3041 let tmp = TempDir::new()?;
3042 let base = tmp.path();
3043
3044 let project = base.join("haskell-cabal");
3045 create_file(&project.join("cabal.project"), "packages: .\n")?;
3046 create_file(
3047 &project.join("my-cabal-lib.cabal"),
3048 "name: my-cabal-lib\nversion: 0.1.0.0\n",
3049 )?;
3050 create_file(
3051 &project.join(
3052 "dist-newstyle/build/x86_64-linux/ghc-9.4.7/my-cabal-lib-0.1.0.0/build/Main.o",
3053 ),
3054 "object",
3055 )?;
3056
3057 let scanner = default_scanner(ProjectFilter::Haskell);
3058 let projects = scanner.scan_directory(base);
3059 assert_eq!(projects.len(), 1);
3060 assert_eq!(projects[0].kind, ProjectType::Haskell);
3061 assert_eq!(projects[0].name.as_deref(), Some("my-cabal-lib"));
3062 Ok(())
3063 }
3064
3065 #[test]
3066 fn test_detect_haskell_no_artifact_not_detected() -> anyhow::Result<()> {
3067 let tmp = TempDir::new()?;
3068 let base = tmp.path();
3069
3070 let project = base.join("haskell-no-artifact");
3071 create_file(&project.join("stack.yaml"), "resolver: lts-21.0")?;
3072
3073 let scanner = default_scanner(ProjectFilter::Haskell);
3074 let projects = scanner.scan_directory(base);
3075 assert_eq!(projects.len(), 0);
3076 Ok(())
3077 }
3078
3079 #[test]
3082 fn test_detect_dart_project_with_dart_tool() -> anyhow::Result<()> {
3083 let tmp = TempDir::new()?;
3084 let base = tmp.path();
3085
3086 let project = base.join("dart-project");
3087 create_file(
3088 &project.join("pubspec.yaml"),
3089 "name: my_dart_app\nversion: 1.0.0\n",
3090 )?;
3091 create_file(
3092 &project.join(".dart_tool/package_config.json"),
3093 r#"{"configVersion": 2}"#,
3094 )?;
3095
3096 let scanner = default_scanner(ProjectFilter::Dart);
3097 let projects = scanner.scan_directory(base);
3098 assert_eq!(projects.len(), 1);
3099 assert_eq!(projects[0].kind, ProjectType::Dart);
3100 assert_eq!(projects[0].name.as_deref(), Some("my_dart_app"));
3101 Ok(())
3102 }
3103
3104 #[test]
3105 fn test_detect_dart_project_with_build_dir() -> anyhow::Result<()> {
3106 let tmp = TempDir::new()?;
3107 let base = tmp.path();
3108
3109 let project = base.join("flutter-project");
3110 create_file(
3111 &project.join("pubspec.yaml"),
3112 "name: my_flutter_app\nversion: 1.0.0\n",
3113 )?;
3114 create_file(
3115 &project.join("build/flutter_assets/AssetManifest.json"),
3116 "{}",
3117 )?;
3118
3119 let scanner = default_scanner(ProjectFilter::Dart);
3120 let projects = scanner.scan_directory(base);
3121 assert_eq!(projects.len(), 1);
3122 assert_eq!(projects[0].kind, ProjectType::Dart);
3123 assert_eq!(projects[0].name.as_deref(), Some("my_flutter_app"));
3124 Ok(())
3125 }
3126
3127 #[test]
3128 fn test_detect_dart_no_artifact_not_detected() -> anyhow::Result<()> {
3129 let tmp = TempDir::new()?;
3130 let base = tmp.path();
3131
3132 let project = base.join("pubspec-only");
3133 create_file(&project.join("pubspec.yaml"), "name: empty_project\n")?;
3134
3135 let scanner = default_scanner(ProjectFilter::Dart);
3136 let projects = scanner.scan_directory(base);
3137 assert_eq!(projects.len(), 0);
3138 Ok(())
3139 }
3140
3141 #[test]
3144 fn test_detect_zig_project_with_cache() -> anyhow::Result<()> {
3145 let tmp = TempDir::new()?;
3146 let base = tmp.path();
3147
3148 let project = base.join("zig-project");
3149 create_file(
3150 &project.join("build.zig"),
3151 "const std = @import(\"std\");\npub fn build(b: *std.Build) void {}\n",
3152 )?;
3153 create_file(&project.join("zig-cache/h/abc123.h"), "// generated")?;
3154
3155 let scanner = default_scanner(ProjectFilter::Zig);
3156 let projects = scanner.scan_directory(base);
3157 assert_eq!(projects.len(), 1);
3158 assert_eq!(projects[0].kind, ProjectType::Zig);
3159 assert_eq!(projects[0].name.as_deref(), Some("zig-project"));
3161 Ok(())
3162 }
3163
3164 #[test]
3165 fn test_detect_zig_project_with_out_dir() -> anyhow::Result<()> {
3166 let tmp = TempDir::new()?;
3167 let base = tmp.path();
3168
3169 let project = base.join("zig-out-project");
3170 create_file(&project.join("build.zig"), "// zig build script")?;
3171 create_file(&project.join("zig-out/bin/my-app"), "binary")?;
3172
3173 let scanner = default_scanner(ProjectFilter::Zig);
3174 let projects = scanner.scan_directory(base);
3175 assert_eq!(projects.len(), 1);
3176 assert_eq!(projects[0].kind, ProjectType::Zig);
3177 Ok(())
3178 }
3179
3180 #[test]
3181 fn test_detect_zig_no_artifact_not_detected() -> anyhow::Result<()> {
3182 let tmp = TempDir::new()?;
3183 let base = tmp.path();
3184
3185 let project = base.join("zig-no-artifact");
3186 create_file(&project.join("build.zig"), "// zig build script")?;
3187
3188 let scanner = default_scanner(ProjectFilter::Zig);
3189 let projects = scanner.scan_directory(base);
3190 assert_eq!(projects.len(), 0);
3191 Ok(())
3192 }
3193
3194 #[test]
3195 fn test_zig_cache_directory_is_excluded() {
3196 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-cache")));
3197 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-out")));
3198 assert!(Scanner::is_excluded_directory(Path::new(
3199 "/some/dist-newstyle"
3200 )));
3201 }
3202
3203 #[test]
3206 fn test_detect_scala_project() -> anyhow::Result<()> {
3207 let tmp = TempDir::new()?;
3208 let base = tmp.path();
3209
3210 let project = base.join("scala-project");
3211 create_file(
3212 &project.join("build.sbt"),
3213 "name := \"my-scala-app\"\nscalaVersion := \"3.3.0\"\n",
3214 )?;
3215 create_file(
3216 &project.join("target/scala-3.3.0/classes/Main.class"),
3217 "bytecode",
3218 )?;
3219
3220 let scanner = default_scanner(ProjectFilter::Scala);
3221 let projects = scanner.scan_directory(base);
3222 assert_eq!(projects.len(), 1);
3223 assert_eq!(projects[0].kind, ProjectType::Scala);
3224 assert_eq!(projects[0].name.as_deref(), Some("my-scala-app"));
3225 Ok(())
3226 }
3227
3228 #[test]
3229 fn test_detect_scala_no_target_not_detected() -> anyhow::Result<()> {
3230 let tmp = TempDir::new()?;
3231 let base = tmp.path();
3232
3233 let project = base.join("sbt-only");
3234 create_file(&project.join("build.sbt"), "name := \"unbuilt-project\"\n")?;
3235
3236 let scanner = default_scanner(ProjectFilter::Scala);
3237 let projects = scanner.scan_directory(base);
3238 assert_eq!(projects.len(), 0);
3239 Ok(())
3240 }
3241
3242 #[test]
3243 fn test_detect_scala_fallback_to_dir_name() -> anyhow::Result<()> {
3244 let tmp = TempDir::new()?;
3245 let base = tmp.path();
3246
3247 let project = base.join("my-scala-project");
3248 create_file(&project.join("build.sbt"), "scalaVersion := \"3.3.0\"\n")?;
3250 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3251
3252 let scanner = default_scanner(ProjectFilter::Scala);
3253 let projects = scanner.scan_directory(base);
3254 assert_eq!(projects.len(), 1);
3255 assert_eq!(projects[0].name.as_deref(), Some("my-scala-project"));
3256 Ok(())
3257 }
3258
3259 #[test]
3260 fn test_scala_detected_before_java_for_build_sbt_projects() -> anyhow::Result<()> {
3261 let tmp = TempDir::new()?;
3262 let base = tmp.path();
3263
3264 let project = base.join("scala-maven-project");
3266 create_file(&project.join("build.sbt"), "name := \"scala-maven\"\n")?;
3267 create_file(
3268 &project.join("pom.xml"),
3269 "<project><artifactId>scala-maven</artifactId></project>",
3270 )?;
3271 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3272
3273 let scanner = default_scanner(ProjectFilter::All);
3274 let projects = scanner.scan_directory(base);
3275 assert_eq!(projects.len(), 1);
3276 assert_eq!(projects[0].kind, ProjectType::Scala);
3277 Ok(())
3278 }
3279}