1use 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
28pub struct Scanner {
35 scan_options: ScanOptions,
37
38 project_filter: ProjectFilter,
40
41 quiet: bool,
43}
44
45impl Scanner {
46 #[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 #[must_use]
83 pub const fn with_quiet(mut self, quiet: bool) -> Self {
84 self.quiet = quiet;
85 self
86 }
87
88 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 let potential_projects: Vec<_> = WalkDir::new(root)
147 .into_iter()
148 .filter_map(Result::ok)
149 .filter(|entry| self.should_scan_entry(entry))
150 .collect::<Vec<_>>()
151 .into_par_iter()
152 .filter_map(|entry| {
153 let result = self.detect_project(&entry, &errors);
154 if result.is_some() {
155 let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
156 progress_clone.set_message(format!("Scanning... {n} found"));
157 }
158 result
159 })
160 .collect();
161
162 progress.finish_with_message("✅ Directory scan complete");
163
164 let projects_with_sizes: Vec<_> = potential_projects
166 .into_par_iter()
167 .filter_map(|mut project| {
168 if project.build_arts.size == 0 {
169 project.build_arts.size =
170 Self::calculate_build_dir_size(&project.build_arts.path);
171 }
172
173 if project.build_arts.size > 0 {
174 Some(project)
175 } else {
176 None
177 }
178 })
179 .collect();
180
181 if self.scan_options.verbose {
183 let errors = errors.lock().unwrap();
184 for error in errors.iter() {
185 eprintln!("{}", error.red());
186 }
187 }
188
189 projects_with_sizes
190 }
191
192 fn calculate_build_dir_size(path: &Path) -> u64 {
213 if !path.exists() {
214 return 0;
215 }
216
217 crate::utils::calculate_dir_size(path)
218 }
219
220 fn detect_node_project(
242 &self,
243 path: &Path,
244 errors: &Arc<Mutex<Vec<String>>>,
245 ) -> Option<Project> {
246 let package_json = path.join("package.json");
247 let node_modules = path.join("node_modules");
248
249 if package_json.exists() && node_modules.exists() {
250 let name = self.extract_node_project_name(&package_json, errors);
251
252 let build_arts = BuildArtifacts {
253 path: path.join("node_modules"),
254 size: 0, };
256
257 return Some(Project::new(
258 ProjectType::Node,
259 path.to_path_buf(),
260 build_arts,
261 name,
262 ));
263 }
264
265 None
266 }
267
268 fn detect_project(
298 &self,
299 entry: &DirEntry,
300 errors: &Arc<Mutex<Vec<String>>>,
301 ) -> Option<Project> {
302 let path = entry.path();
303
304 if !entry.file_type().is_dir() {
305 return None;
306 }
307
308 self.try_detect(ProjectFilter::Rust, || {
313 self.detect_rust_project(path, errors)
314 })
315 .or_else(|| {
316 self.try_detect(ProjectFilter::Deno, || {
317 self.detect_deno_project(path, errors)
318 })
319 })
320 .or_else(|| {
321 self.try_detect(ProjectFilter::Node, || {
322 self.detect_node_project(path, errors)
323 })
324 })
325 .or_else(|| {
326 self.try_detect(ProjectFilter::Java, || {
327 self.detect_java_project(path, errors)
328 })
329 })
330 .or_else(|| {
331 self.try_detect(ProjectFilter::Swift, || {
332 self.detect_swift_project(path, errors)
333 })
334 })
335 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
336 .or_else(|| {
337 self.try_detect(ProjectFilter::Python, || {
338 self.detect_python_project(path, errors)
339 })
340 })
341 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
342 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
343 .or_else(|| {
344 self.try_detect(ProjectFilter::Ruby, || {
345 self.detect_ruby_project(path, errors)
346 })
347 })
348 .or_else(|| {
349 self.try_detect(ProjectFilter::Elixir, || {
350 self.detect_elixir_project(path, errors)
351 })
352 })
353 }
354
355 fn try_detect(
360 &self,
361 filter: ProjectFilter,
362 detect: impl FnOnce() -> Option<Project>,
363 ) -> Option<Project> {
364 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
365 detect()
366 } else {
367 None
368 }
369 }
370
371 fn detect_rust_project(
393 &self,
394 path: &Path,
395 errors: &Arc<Mutex<Vec<String>>>,
396 ) -> Option<Project> {
397 let cargo_toml = path.join("Cargo.toml");
398 let target_dir = path.join("target");
399
400 if cargo_toml.exists() && target_dir.exists() {
401 if Self::is_inside_cargo_workspace(path) {
403 return None;
404 }
405
406 let name = self.extract_rust_project_name(&cargo_toml, errors);
407
408 let build_arts = BuildArtifacts {
409 path: path.join("target"),
410 size: 0, };
412
413 return Some(Project::new(
414 ProjectType::Rust,
415 path.to_path_buf(),
416 build_arts,
417 name,
418 ));
419 }
420
421 None
422 }
423
424 fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
426 fs::read_to_string(cargo_toml)
427 .map(|content| content.lines().any(|line| line.trim() == "[workspace]"))
428 .unwrap_or(false)
429 }
430
431 fn is_inside_cargo_workspace(path: &Path) -> bool {
434 path.ancestors()
435 .skip(1) .any(|ancestor| {
437 let cargo_toml = ancestor.join("Cargo.toml");
438 cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
439 })
440 }
441
442 fn extract_rust_project_name(
464 &self,
465 cargo_toml: &Path,
466 errors: &Arc<Mutex<Vec<String>>>,
467 ) -> Option<String> {
468 let content = self.read_file_content(cargo_toml, errors)?;
469 Self::parse_toml_name_field(&content)
470 }
471
472 fn extract_quoted_value(line: &str) -> Option<String> {
474 let start = line.find('"')?;
475 let end = line.rfind('"')?;
476
477 if start == end {
478 return None;
479 }
480
481 Some(line[start + 1..end].to_string())
482 }
483
484 fn extract_name_from_line(line: &str) -> Option<String> {
486 if !Self::is_name_line(line) {
487 return None;
488 }
489
490 Self::extract_quoted_value(line)
491 }
492
493 fn extract_node_project_name(
514 &self,
515 package_json: &Path,
516 errors: &Arc<Mutex<Vec<String>>>,
517 ) -> Option<String> {
518 match fs::read_to_string(package_json) {
519 Ok(content) => match from_str::<Value>(&content) {
520 Ok(json) => json
521 .get("name")
522 .and_then(|v| v.as_str())
523 .map(std::string::ToString::to_string),
524 Err(e) => {
525 if self.scan_options.verbose {
526 errors
527 .lock()
528 .unwrap()
529 .push(format!("Error parsing {}: {e}", package_json.display()));
530 }
531 None
532 }
533 },
534 Err(e) => {
535 if self.scan_options.verbose {
536 errors
537 .lock()
538 .unwrap()
539 .push(format!("Error reading {}: {e}", package_json.display()));
540 }
541 None
542 }
543 }
544 }
545
546 fn is_name_line(line: &str) -> bool {
548 line.starts_with("name") && line.contains('=')
549 }
550
551 fn log_file_error(
553 &self,
554 file_path: &Path,
555 error: &std::io::Error,
556 errors: &Arc<Mutex<Vec<String>>>,
557 ) {
558 if self.scan_options.verbose {
559 errors
560 .lock()
561 .unwrap()
562 .push(format!("Error reading {}: {error}", file_path.display()));
563 }
564 }
565
566 fn parse_toml_name_field(content: &str) -> Option<String> {
568 for line in content.lines() {
569 if let Some(name) = Self::extract_name_from_line(line.trim()) {
570 return Some(name);
571 }
572 }
573 None
574 }
575
576 fn read_file_content(
578 &self,
579 file_path: &Path,
580 errors: &Arc<Mutex<Vec<String>>>,
581 ) -> Option<String> {
582 match fs::read_to_string(file_path) {
583 Ok(content) => Some(content),
584 Err(e) => {
585 self.log_file_error(file_path, &e, errors);
586 None
587 }
588 }
589 }
590
591 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
625 let path = entry.path();
626
627 if self.is_path_in_skip_list(path) {
629 return false;
630 }
631
632 if path
634 .ancestors()
635 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
636 {
637 return false;
638 }
639
640 if Self::is_hidden_directory_to_skip(path) {
642 return false;
643 }
644
645 !Self::is_excluded_directory(path)
647 }
648
649 fn is_path_in_skip_list(&self, path: &Path) -> bool {
651 self.scan_options.skip.iter().any(|skip| {
652 path.components().any(|component| {
653 component
654 .as_os_str()
655 .to_str()
656 .is_some_and(|name| name == skip.to_string_lossy())
657 })
658 })
659 }
660
661 fn is_hidden_directory_to_skip(path: &Path) -> bool {
663 path.file_name()
664 .and_then(|n| n.to_str())
665 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
666 }
667
668 fn is_excluded_directory(path: &Path) -> bool {
670 let excluded_dirs = [
671 "target",
672 "build",
673 "dist",
674 "out",
675 ".git",
676 ".svn",
677 ".hg",
678 "__pycache__",
679 "venv",
680 ".venv",
681 "env",
682 ".env",
683 "temp",
684 "tmp",
685 "vendor",
686 ".pytest_cache",
687 ".tox",
688 ".eggs",
689 ".coverage",
690 "node_modules",
691 "obj",
692 "_build",
693 ];
694
695 path.file_name()
696 .and_then(|n| n.to_str())
697 .is_some_and(|name| excluded_dirs.contains(&name))
698 }
699
700 fn detect_python_project(
721 &self,
722 path: &Path,
723 errors: &Arc<Mutex<Vec<String>>>,
724 ) -> Option<Project> {
725 let config_files = [
726 "requirements.txt",
727 "setup.py",
728 "pyproject.toml",
729 "setup.cfg",
730 "Pipfile",
731 "pipenv.lock",
732 "poetry.lock",
733 ];
734
735 let build_dirs = [
736 "__pycache__",
737 ".pytest_cache",
738 "venv",
739 ".venv",
740 "build",
741 "dist",
742 ".eggs",
743 ".tox",
744 ".coverage",
745 ];
746
747 let has_config = config_files.iter().any(|&file| path.join(file).exists());
749
750 if !has_config {
751 return None;
752 }
753
754 let mut largest_build_dir = None;
756 let mut largest_size = 0;
757
758 for &dir_name in &build_dirs {
759 let dir_path = path.join(dir_name);
760
761 if dir_path.exists() && dir_path.is_dir() {
762 let size = crate::utils::calculate_dir_size(&dir_path);
763 if size > largest_size {
764 largest_size = size;
765 largest_build_dir = Some(dir_path);
766 }
767 }
768 }
769
770 if let Some(build_path) = largest_build_dir {
771 let name = self.extract_python_project_name(path, errors);
772
773 let build_arts = BuildArtifacts {
774 path: build_path,
775 size: largest_size,
776 };
777
778 return Some(Project::new(
779 ProjectType::Python,
780 path.to_path_buf(),
781 build_arts,
782 name,
783 ));
784 }
785
786 None
787 }
788
789 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
811 let go_mod = path.join("go.mod");
812 let vendor_dir = path.join("vendor");
813
814 if go_mod.exists() && vendor_dir.exists() {
815 let name = self.extract_go_project_name(&go_mod, errors);
816
817 let build_arts = BuildArtifacts {
818 path: path.join("vendor"),
819 size: 0, };
821
822 return Some(Project::new(
823 ProjectType::Go,
824 path.to_path_buf(),
825 build_arts,
826 name,
827 ));
828 }
829
830 None
831 }
832
833 fn extract_python_project_name(
855 &self,
856 path: &Path,
857 errors: &Arc<Mutex<Vec<String>>>,
858 ) -> Option<String> {
859 self.try_extract_from_pyproject_toml(path, errors)
861 .or_else(|| self.try_extract_from_setup_py(path, errors))
862 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
863 .or_else(|| Self::fallback_to_directory_name(path))
864 }
865
866 fn try_extract_from_pyproject_toml(
868 &self,
869 path: &Path,
870 errors: &Arc<Mutex<Vec<String>>>,
871 ) -> Option<String> {
872 let pyproject_toml = path.join("pyproject.toml");
873 if !pyproject_toml.exists() {
874 return None;
875 }
876
877 let content = self.read_file_content(&pyproject_toml, errors)?;
878 Self::extract_name_from_toml_like_content(&content)
879 }
880
881 fn try_extract_from_setup_py(
883 &self,
884 path: &Path,
885 errors: &Arc<Mutex<Vec<String>>>,
886 ) -> Option<String> {
887 let setup_py = path.join("setup.py");
888 if !setup_py.exists() {
889 return None;
890 }
891
892 let content = self.read_file_content(&setup_py, errors)?;
893 Self::extract_name_from_python_content(&content)
894 }
895
896 fn try_extract_from_setup_cfg(
898 &self,
899 path: &Path,
900 errors: &Arc<Mutex<Vec<String>>>,
901 ) -> Option<String> {
902 let setup_cfg = path.join("setup.cfg");
903 if !setup_cfg.exists() {
904 return None;
905 }
906
907 let content = self.read_file_content(&setup_cfg, errors)?;
908 Self::extract_name_from_cfg_content(&content)
909 }
910
911 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
913 content
914 .lines()
915 .map(str::trim)
916 .find(|line| line.starts_with("name") && line.contains('='))
917 .and_then(Self::extract_quoted_value)
918 }
919
920 fn extract_name_from_python_content(content: &str) -> Option<String> {
922 content
923 .lines()
924 .map(str::trim)
925 .find(|line| line.contains("name") && line.contains('='))
926 .and_then(Self::extract_quoted_value)
927 }
928
929 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
931 let mut in_metadata_section = false;
932
933 for line in content.lines() {
934 let line = line.trim();
935
936 if line == "[metadata]" {
937 in_metadata_section = true;
938 } else if line.starts_with('[') && line.ends_with(']') {
939 in_metadata_section = false;
940 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
941 return line.split('=').nth(1).map(|name| name.trim().to_string());
942 }
943 }
944
945 None
946 }
947
948 fn fallback_to_directory_name(path: &Path) -> Option<String> {
950 path.file_name()
951 .and_then(|name| name.to_str())
952 .map(std::string::ToString::to_string)
953 }
954
955 fn extract_go_project_name(
975 &self,
976 go_mod: &Path,
977 errors: &Arc<Mutex<Vec<String>>>,
978 ) -> Option<String> {
979 let content = self.read_file_content(go_mod, errors)?;
980
981 for line in content.lines() {
982 let line = line.trim();
983 if line.starts_with("module ") {
984 let module_path = line.strip_prefix("module ")?.trim();
985
986 if let Some(name) = module_path.split('/').next_back() {
988 return Some(name.to_string());
989 }
990
991 return Some(module_path.to_string());
992 }
993 }
994
995 None
996 }
997
998 fn detect_java_project(
1009 &self,
1010 path: &Path,
1011 errors: &Arc<Mutex<Vec<String>>>,
1012 ) -> Option<Project> {
1013 let pom_xml = path.join("pom.xml");
1014 let target_dir = path.join("target");
1015
1016 if pom_xml.exists() && target_dir.exists() {
1018 let name = self.extract_java_maven_project_name(&pom_xml, errors);
1019
1020 let build_arts = BuildArtifacts {
1021 path: target_dir,
1022 size: 0,
1023 };
1024
1025 return Some(Project::new(
1026 ProjectType::Java,
1027 path.to_path_buf(),
1028 build_arts,
1029 name,
1030 ));
1031 }
1032
1033 let has_gradle =
1035 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1036 let build_dir = path.join("build");
1037
1038 if has_gradle && build_dir.exists() {
1039 let name = self.extract_java_gradle_project_name(path, errors);
1040
1041 let build_arts = BuildArtifacts {
1042 path: build_dir,
1043 size: 0,
1044 };
1045
1046 return Some(Project::new(
1047 ProjectType::Java,
1048 path.to_path_buf(),
1049 build_arts,
1050 name,
1051 ));
1052 }
1053
1054 None
1055 }
1056
1057 fn extract_java_maven_project_name(
1061 &self,
1062 pom_xml: &Path,
1063 errors: &Arc<Mutex<Vec<String>>>,
1064 ) -> Option<String> {
1065 let content = self.read_file_content(pom_xml, errors)?;
1066
1067 for line in content.lines() {
1068 let trimmed = line.trim();
1069 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1070 let name = trimmed
1071 .strip_prefix("<artifactId>")?
1072 .strip_suffix("</artifactId>")?;
1073 return Some(name.to_string());
1074 }
1075 }
1076
1077 None
1078 }
1079
1080 fn extract_java_gradle_project_name(
1085 &self,
1086 path: &Path,
1087 errors: &Arc<Mutex<Vec<String>>>,
1088 ) -> Option<String> {
1089 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1090 let settings_path = path.join(settings_file);
1091 if settings_path.exists()
1092 && let Some(content) = self.read_file_content(&settings_path, errors)
1093 {
1094 for line in content.lines() {
1095 let trimmed = line.trim();
1096 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1097 return Self::extract_quoted_value(trimmed).or_else(|| {
1098 trimmed
1099 .split('=')
1100 .nth(1)
1101 .map(|s| s.trim().trim_matches('\'').to_string())
1102 });
1103 }
1104 }
1105 }
1106 }
1107
1108 Self::fallback_to_directory_name(path)
1109 }
1110
1111 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1121 let build_dir = path.join("build");
1122
1123 if !build_dir.exists() {
1124 return None;
1125 }
1126
1127 let cmake_file = path.join("CMakeLists.txt");
1128 let makefile = path.join("Makefile");
1129
1130 if cmake_file.exists() || makefile.exists() {
1131 let name = if cmake_file.exists() {
1132 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1133 } else {
1134 Self::fallback_to_directory_name(path)
1135 };
1136
1137 let build_arts = BuildArtifacts {
1138 path: build_dir,
1139 size: 0,
1140 };
1141
1142 return Some(Project::new(
1143 ProjectType::Cpp,
1144 path.to_path_buf(),
1145 build_arts,
1146 name,
1147 ));
1148 }
1149
1150 None
1151 }
1152
1153 fn extract_cpp_cmake_project_name(
1157 &self,
1158 cmake_file: &Path,
1159 errors: &Arc<Mutex<Vec<String>>>,
1160 ) -> Option<String> {
1161 let content = self.read_file_content(cmake_file, errors)?;
1162
1163 for line in content.lines() {
1164 let trimmed = line.trim();
1165 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1166 let inner = trimmed
1167 .trim_start_matches("project(")
1168 .trim_start_matches("PROJECT(")
1169 .trim_end_matches(')')
1170 .trim();
1171
1172 let name = inner.split_whitespace().next()?;
1174 let name = name.trim_matches('"').trim_matches('\'');
1176 if !name.is_empty() {
1177 return Some(name.to_string());
1178 }
1179 }
1180 }
1181
1182 Self::fallback_to_directory_name(cmake_file.parent()?)
1183 }
1184
1185 fn detect_swift_project(
1195 &self,
1196 path: &Path,
1197 errors: &Arc<Mutex<Vec<String>>>,
1198 ) -> Option<Project> {
1199 let package_swift = path.join("Package.swift");
1200 let build_dir = path.join(".build");
1201
1202 if package_swift.exists() && build_dir.exists() {
1203 let name = self.extract_swift_project_name(&package_swift, errors);
1204
1205 let build_arts = BuildArtifacts {
1206 path: build_dir,
1207 size: 0,
1208 };
1209
1210 return Some(Project::new(
1211 ProjectType::Swift,
1212 path.to_path_buf(),
1213 build_arts,
1214 name,
1215 ));
1216 }
1217
1218 None
1219 }
1220
1221 fn extract_swift_project_name(
1225 &self,
1226 package_swift: &Path,
1227 errors: &Arc<Mutex<Vec<String>>>,
1228 ) -> Option<String> {
1229 let content = self.read_file_content(package_swift, errors)?;
1230
1231 for line in content.lines() {
1232 let trimmed = line.trim();
1233 if trimmed.contains("name:") {
1234 return Self::extract_quoted_value(trimmed);
1235 }
1236 }
1237
1238 Self::fallback_to_directory_name(package_swift.parent()?)
1239 }
1240
1241 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1251 let bin_dir = path.join("bin");
1252 let obj_dir = path.join("obj");
1253
1254 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1255 if !has_build_dir {
1256 return None;
1257 }
1258
1259 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1260
1261 let (build_path, precomputed_size) = match (bin_dir.exists(), obj_dir.exists()) {
1263 (true, true) => {
1264 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1265 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1266 if obj_size >= bin_size {
1267 (obj_dir, obj_size)
1268 } else {
1269 (bin_dir, bin_size)
1270 }
1271 }
1272 (true, false) => (bin_dir, 0),
1273 (false, true) => (obj_dir, 0),
1274 (false, false) => return None,
1275 };
1276
1277 let name = csproj_file
1278 .file_stem()
1279 .and_then(|s| s.to_str())
1280 .map(std::string::ToString::to_string);
1281
1282 let build_arts = BuildArtifacts {
1283 path: build_path,
1284 size: precomputed_size,
1285 };
1286
1287 Some(Project::new(
1288 ProjectType::DotNet,
1289 path.to_path_buf(),
1290 build_arts,
1291 name,
1292 ))
1293 }
1294
1295 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1297 let entries = fs::read_dir(dir).ok()?;
1298 for entry in entries.flatten() {
1299 let path = entry.path();
1300 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1301 return Some(path);
1302 }
1303 }
1304 None
1305 }
1306
1307 fn detect_deno_project(
1316 &self,
1317 path: &Path,
1318 errors: &Arc<Mutex<Vec<String>>>,
1319 ) -> Option<Project> {
1320 let deno_json = path.join("deno.json");
1321 let deno_jsonc = path.join("deno.jsonc");
1322
1323 if !deno_json.exists() && !deno_jsonc.exists() {
1324 return None;
1325 }
1326
1327 let config_path = if deno_json.exists() {
1328 deno_json
1329 } else {
1330 deno_jsonc
1331 };
1332
1333 let vendor_dir = path.join("vendor");
1335 if vendor_dir.exists() {
1336 let name = self.extract_deno_project_name(&config_path, errors);
1337 return Some(Project::new(
1338 ProjectType::Deno,
1339 path.to_path_buf(),
1340 BuildArtifacts {
1341 path: vendor_dir,
1342 size: 0,
1343 },
1344 name,
1345 ));
1346 }
1347
1348 let node_modules = path.join("node_modules");
1350 if node_modules.exists() && !path.join("package.json").exists() {
1351 let name = self.extract_deno_project_name(&config_path, errors);
1352 return Some(Project::new(
1353 ProjectType::Deno,
1354 path.to_path_buf(),
1355 BuildArtifacts {
1356 path: node_modules,
1357 size: 0,
1358 },
1359 name,
1360 ));
1361 }
1362
1363 None
1364 }
1365
1366 fn extract_deno_project_name(
1371 &self,
1372 config_path: &Path,
1373 errors: &Arc<Mutex<Vec<String>>>,
1374 ) -> Option<String> {
1375 match fs::read_to_string(config_path) {
1376 Ok(content) => {
1377 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1378 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1379 {
1380 return Some(name.to_string());
1381 }
1382 Self::fallback_to_directory_name(config_path.parent()?)
1383 }
1384 Err(e) => {
1385 self.log_file_error(config_path, &e, errors);
1386 Self::fallback_to_directory_name(config_path.parent()?)
1387 }
1388 }
1389 }
1390
1391 fn detect_ruby_project(
1401 &self,
1402 path: &Path,
1403 errors: &Arc<Mutex<Vec<String>>>,
1404 ) -> Option<Project> {
1405 let gemfile = path.join("Gemfile");
1406 if !gemfile.exists() {
1407 return None;
1408 }
1409
1410 let bundle_dir = path.join(".bundle");
1411 let vendor_bundle_dir = path.join("vendor").join("bundle");
1412
1413 let (build_path, precomputed_size) = match (bundle_dir.exists(), vendor_bundle_dir.exists())
1414 {
1415 (true, true) => {
1416 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1417 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1418 if vendor_size >= bundle_size {
1419 (vendor_bundle_dir, vendor_size)
1420 } else {
1421 (bundle_dir, bundle_size)
1422 }
1423 }
1424 (true, false) => (bundle_dir, 0),
1425 (false, true) => (vendor_bundle_dir, 0),
1426 (false, false) => return None,
1427 };
1428
1429 let name = self.extract_ruby_project_name(path, errors);
1430
1431 Some(Project::new(
1432 ProjectType::Ruby,
1433 path.to_path_buf(),
1434 BuildArtifacts {
1435 path: build_path,
1436 size: precomputed_size,
1437 },
1438 name,
1439 ))
1440 }
1441
1442 fn extract_ruby_project_name(
1447 &self,
1448 path: &Path,
1449 errors: &Arc<Mutex<Vec<String>>>,
1450 ) -> Option<String> {
1451 let entries = fs::read_dir(path).ok()?;
1452 for entry in entries.flatten() {
1453 let entry_path = entry.path();
1454 if entry_path.is_file()
1455 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1456 && let Some(content) = self.read_file_content(&entry_path, errors)
1457 {
1458 for line in content.lines() {
1459 let trimmed = line.trim();
1460 if trimmed.contains(".name")
1461 && trimmed.contains('=')
1462 && let Some(name) = Self::extract_quoted_value(trimmed)
1463 {
1464 return Some(name);
1465 }
1466 }
1467 }
1468 }
1469
1470 Self::fallback_to_directory_name(path)
1471 }
1472
1473 fn detect_elixir_project(
1483 &self,
1484 path: &Path,
1485 errors: &Arc<Mutex<Vec<String>>>,
1486 ) -> Option<Project> {
1487 let mix_exs = path.join("mix.exs");
1488 let build_dir = path.join("_build");
1489
1490 if mix_exs.exists() && build_dir.exists() {
1491 let name = self.extract_elixir_project_name(&mix_exs, errors);
1492
1493 return Some(Project::new(
1494 ProjectType::Elixir,
1495 path.to_path_buf(),
1496 BuildArtifacts {
1497 path: build_dir,
1498 size: 0,
1499 },
1500 name,
1501 ));
1502 }
1503
1504 None
1505 }
1506
1507 fn extract_elixir_project_name(
1512 &self,
1513 mix_exs: &Path,
1514 errors: &Arc<Mutex<Vec<String>>>,
1515 ) -> Option<String> {
1516 let content = self.read_file_content(mix_exs, errors)?;
1517
1518 for line in content.lines() {
1519 let trimmed = line.trim();
1520 if trimmed.contains("app:")
1521 && let Some(pos) = trimmed.find("app:")
1522 {
1523 let after = trimmed[pos + 4..].trim_start();
1524 if let Some(atom) = after.strip_prefix(':') {
1525 let name: String = atom
1527 .chars()
1528 .take_while(|c| c.is_alphanumeric() || *c == '_')
1529 .collect();
1530 if !name.is_empty() {
1531 return Some(name);
1532 }
1533 }
1534 }
1535 }
1536
1537 Self::fallback_to_directory_name(mix_exs.parent()?)
1538 }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544 use std::path::PathBuf;
1545 use tempfile::TempDir;
1546
1547 fn default_scanner(filter: ProjectFilter) -> Scanner {
1549 Scanner::new(
1550 ScanOptions {
1551 verbose: false,
1552 threads: 1,
1553 skip: vec![],
1554 },
1555 filter,
1556 )
1557 }
1558
1559 fn create_file(path: &Path, content: &str) {
1561 if let Some(parent) = path.parent() {
1562 fs::create_dir_all(parent).unwrap();
1563 }
1564 fs::write(path, content).unwrap();
1565 }
1566
1567 #[test]
1570 fn test_is_hidden_directory_to_skip() {
1571 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1573 "/some/.hidden"
1574 )));
1575 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1576 "/some/.git"
1577 )));
1578 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1579 "/some/.svn"
1580 )));
1581 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1582
1583 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1585 "/home/user/.cargo"
1586 )));
1587 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1588
1589 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1591 "/some/visible"
1592 )));
1593 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1594 }
1595
1596 #[test]
1597 fn test_is_excluded_directory() {
1598 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1600 assert!(Scanner::is_excluded_directory(Path::new(
1601 "/some/node_modules"
1602 )));
1603 assert!(Scanner::is_excluded_directory(Path::new(
1604 "/some/__pycache__"
1605 )));
1606 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1607 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1608 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1609 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1610
1611 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1613 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1614 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1615
1616 assert!(Scanner::is_excluded_directory(Path::new(
1618 "/some/.pytest_cache"
1619 )));
1620 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1621 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1622 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1623
1624 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1626 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1627 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1628 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1629
1630 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1632 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1633
1634 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1636 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1637 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1638 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1639 }
1640
1641 #[test]
1642 fn test_extract_quoted_value() {
1643 assert_eq!(
1644 Scanner::extract_quoted_value(r#"name = "my-project""#),
1645 Some("my-project".to_string())
1646 );
1647 assert_eq!(
1648 Scanner::extract_quoted_value(r#"name = "with spaces""#),
1649 Some("with spaces".to_string())
1650 );
1651 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1652 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1654 }
1655
1656 #[test]
1657 fn test_is_name_line() {
1658 assert!(Scanner::is_name_line("name = \"test\""));
1659 assert!(Scanner::is_name_line("name=\"test\""));
1660 assert!(!Scanner::is_name_line("version = \"1.0\""));
1661 assert!(!Scanner::is_name_line("# name = \"commented\""));
1662 assert!(!Scanner::is_name_line("name: \"yaml style\""));
1663 }
1664
1665 #[test]
1666 fn test_parse_toml_name_field() {
1667 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1668 assert_eq!(
1669 Scanner::parse_toml_name_field(content),
1670 Some("test-project".to_string())
1671 );
1672
1673 let no_name = "[package]\nversion = \"0.1.0\"\n";
1674 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1675
1676 let empty = "";
1677 assert_eq!(Scanner::parse_toml_name_field(empty), None);
1678 }
1679
1680 #[test]
1681 fn test_extract_name_from_cfg_content() {
1682 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1683 assert_eq!(
1684 Scanner::extract_name_from_cfg_content(content),
1685 Some("my-package".to_string())
1686 );
1687
1688 let wrong_section = "[options]\nname = not-this\n";
1690 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1691
1692 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1694 assert_eq!(
1695 Scanner::extract_name_from_cfg_content(multi),
1696 Some("correct".to_string())
1697 );
1698 }
1699
1700 #[test]
1701 fn test_extract_name_from_python_content() {
1702 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
1703 assert_eq!(
1704 Scanner::extract_name_from_python_content(content),
1705 Some("my-pkg".to_string())
1706 );
1707
1708 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1709 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1710 }
1711
1712 #[test]
1713 fn test_fallback_to_directory_name() {
1714 assert_eq!(
1715 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1716 Some("project-name".to_string())
1717 );
1718 assert_eq!(
1719 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1720 Some("my_app".to_string())
1721 );
1722 }
1723
1724 #[test]
1725 fn test_is_path_in_skip_list() {
1726 let scanner = Scanner::new(
1727 ScanOptions {
1728 verbose: false,
1729 threads: 1,
1730 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1731 },
1732 ProjectFilter::All,
1733 );
1734
1735 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1736 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1737 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1738 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1739 }
1740
1741 #[test]
1742 fn test_is_path_in_empty_skip_list() {
1743 let scanner = default_scanner(ProjectFilter::All);
1744 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1745 }
1746
1747 #[test]
1750 fn test_scan_directory_with_spaces_in_path() {
1751 let tmp = TempDir::new().unwrap();
1752 let base = tmp.path().join("path with spaces");
1753 fs::create_dir_all(&base).unwrap();
1754
1755 let project = base.join("my project");
1756 create_file(
1757 &project.join("Cargo.toml"),
1758 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1759 );
1760 create_file(&project.join("target/dummy"), "content");
1761
1762 let scanner = default_scanner(ProjectFilter::Rust);
1763 let projects = scanner.scan_directory(&base);
1764 assert_eq!(projects.len(), 1);
1765 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1766 }
1767
1768 #[test]
1769 fn test_scan_directory_with_unicode_names() {
1770 let tmp = TempDir::new().unwrap();
1771 let base = tmp.path();
1772
1773 let project = base.join("プロジェクト");
1774 create_file(
1775 &project.join("package.json"),
1776 r#"{"name": "unicode-project"}"#,
1777 );
1778 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1779
1780 let scanner = default_scanner(ProjectFilter::Node);
1781 let projects = scanner.scan_directory(base);
1782 assert_eq!(projects.len(), 1);
1783 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1784 }
1785
1786 #[test]
1787 fn test_scan_directory_with_special_characters_in_name() {
1788 let tmp = TempDir::new().unwrap();
1789 let base = tmp.path();
1790
1791 let project = base.join("project-with-dashes_and_underscores.v2");
1792 create_file(
1793 &project.join("Cargo.toml"),
1794 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1795 );
1796 create_file(&project.join("target/dummy"), "content");
1797
1798 let scanner = default_scanner(ProjectFilter::Rust);
1799 let projects = scanner.scan_directory(base);
1800 assert_eq!(projects.len(), 1);
1801 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1802 }
1803
1804 #[test]
1807 #[cfg(unix)]
1808 fn test_hidden_directory_itself_not_detected_as_project_unix() {
1809 let tmp = TempDir::new().unwrap();
1810 let base = tmp.path();
1811
1812 let hidden = base.join(".hidden-project");
1817 create_file(
1818 &hidden.join("Cargo.toml"),
1819 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1820 );
1821 create_file(&hidden.join("target/dummy"), "content");
1822
1823 let visible = base.join("visible-project");
1825 create_file(
1826 &visible.join("Cargo.toml"),
1827 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1828 );
1829 create_file(&visible.join("target/dummy"), "content");
1830
1831 let scanner = default_scanner(ProjectFilter::Rust);
1832 let projects = scanner.scan_directory(base);
1833
1834 assert_eq!(projects.len(), 1);
1837 assert_eq!(projects[0].name.as_deref(), Some("visible"));
1838 }
1839
1840 #[test]
1841 #[cfg(unix)]
1842 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1843 let tmp = TempDir::new().unwrap();
1844 let base = tmp.path();
1845
1846 let nested = base.join(".hidden-parent/visible-child");
1849 create_file(
1850 &nested.join("Cargo.toml"),
1851 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1852 );
1853 create_file(&nested.join("target/dummy"), "content");
1854
1855 let scanner = default_scanner(ProjectFilter::Rust);
1856 let projects = scanner.scan_directory(base);
1857
1858 assert_eq!(projects.len(), 1);
1860 assert_eq!(projects[0].name.as_deref(), Some("nested"));
1861 }
1862
1863 #[test]
1864 #[cfg(unix)]
1865 fn test_dotcargo_directory_not_skipped_unix() {
1866 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1869 "/home/user/.cargo"
1870 )));
1871
1872 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1874 "/home/user/.local"
1875 )));
1876 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1877 "/home/user/.npm"
1878 )));
1879 }
1880
1881 #[test]
1884 fn test_detect_python_with_pyproject_toml() {
1885 let tmp = TempDir::new().unwrap();
1886 let base = tmp.path();
1887
1888 let project = base.join("py-project");
1889 create_file(
1890 &project.join("pyproject.toml"),
1891 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1892 );
1893 let pycache = project.join("__pycache__");
1894 fs::create_dir_all(&pycache).unwrap();
1895 create_file(&pycache.join("module.pyc"), "bytecode");
1896
1897 let scanner = default_scanner(ProjectFilter::Python);
1898 let projects = scanner.scan_directory(base);
1899 assert_eq!(projects.len(), 1);
1900 assert_eq!(projects[0].kind, ProjectType::Python);
1901 }
1902
1903 #[test]
1904 fn test_detect_python_with_setup_py() {
1905 let tmp = TempDir::new().unwrap();
1906 let base = tmp.path();
1907
1908 let project = base.join("setup-project");
1909 create_file(
1910 &project.join("setup.py"),
1911 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1912 );
1913 let pycache = project.join("__pycache__");
1914 fs::create_dir_all(&pycache).unwrap();
1915 create_file(&pycache.join("module.pyc"), "bytecode");
1916
1917 let scanner = default_scanner(ProjectFilter::Python);
1918 let projects = scanner.scan_directory(base);
1919 assert_eq!(projects.len(), 1);
1920 }
1921
1922 #[test]
1923 fn test_detect_python_with_pipfile() {
1924 let tmp = TempDir::new().unwrap();
1925 let base = tmp.path();
1926
1927 let project = base.join("pipenv-project");
1928 create_file(
1929 &project.join("Pipfile"),
1930 "[[source]]\nurl = \"https://pypi.org/simple\"",
1931 );
1932 let pycache = project.join("__pycache__");
1933 fs::create_dir_all(&pycache).unwrap();
1934 create_file(&pycache.join("module.pyc"), "bytecode");
1935
1936 let scanner = default_scanner(ProjectFilter::Python);
1937 let projects = scanner.scan_directory(base);
1938 assert_eq!(projects.len(), 1);
1939 }
1940
1941 #[test]
1944 fn test_detect_go_extracts_module_name() {
1945 let tmp = TempDir::new().unwrap();
1946 let base = tmp.path();
1947
1948 let project = base.join("go-service");
1949 create_file(
1950 &project.join("go.mod"),
1951 "module github.com/user/my-service\n\ngo 1.21\n",
1952 );
1953 let vendor = project.join("vendor");
1954 fs::create_dir_all(&vendor).unwrap();
1955 create_file(&vendor.join("modules.txt"), "vendor manifest");
1956
1957 let scanner = default_scanner(ProjectFilter::Go);
1958 let projects = scanner.scan_directory(base);
1959 assert_eq!(projects.len(), 1);
1960 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1962 }
1963
1964 #[test]
1967 fn test_detect_java_maven_project() {
1968 let tmp = TempDir::new().unwrap();
1969 let base = tmp.path();
1970
1971 let project = base.join("java-maven");
1972 create_file(
1973 &project.join("pom.xml"),
1974 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
1975 );
1976 create_file(&project.join("target/classes/Main.class"), "bytecode");
1977
1978 let scanner = default_scanner(ProjectFilter::Java);
1979 let projects = scanner.scan_directory(base);
1980 assert_eq!(projects.len(), 1);
1981 assert_eq!(projects[0].kind, ProjectType::Java);
1982 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
1983 }
1984
1985 #[test]
1986 fn test_detect_java_gradle_project() {
1987 let tmp = TempDir::new().unwrap();
1988 let base = tmp.path();
1989
1990 let project = base.join("java-gradle");
1991 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
1992 create_file(
1993 &project.join("settings.gradle"),
1994 "rootProject.name = \"my-gradle-app\"",
1995 );
1996 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
1997
1998 let scanner = default_scanner(ProjectFilter::Java);
1999 let projects = scanner.scan_directory(base);
2000 assert_eq!(projects.len(), 1);
2001 assert_eq!(projects[0].kind, ProjectType::Java);
2002 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2003 }
2004
2005 #[test]
2006 fn test_detect_java_gradle_kts_project() {
2007 let tmp = TempDir::new().unwrap();
2008 let base = tmp.path();
2009
2010 let project = base.join("kotlin-gradle");
2011 create_file(
2012 &project.join("build.gradle.kts"),
2013 "plugins { kotlin(\"jvm\") }",
2014 );
2015 create_file(
2016 &project.join("settings.gradle.kts"),
2017 "rootProject.name = \"my-kotlin-app\"",
2018 );
2019 create_file(
2020 &project.join("build/classes/kotlin/main/MainKt.class"),
2021 "bytecode",
2022 );
2023
2024 let scanner = default_scanner(ProjectFilter::Java);
2025 let projects = scanner.scan_directory(base);
2026 assert_eq!(projects.len(), 1);
2027 assert_eq!(projects[0].kind, ProjectType::Java);
2028 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2029 }
2030
2031 #[test]
2034 fn test_detect_cpp_cmake_project() {
2035 let tmp = TempDir::new().unwrap();
2036 let base = tmp.path();
2037
2038 let project = base.join("cpp-cmake");
2039 create_file(
2040 &project.join("CMakeLists.txt"),
2041 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2042 );
2043 create_file(&project.join("build/CMakeCache.txt"), "cache");
2044
2045 let scanner = default_scanner(ProjectFilter::Cpp);
2046 let projects = scanner.scan_directory(base);
2047 assert_eq!(projects.len(), 1);
2048 assert_eq!(projects[0].kind, ProjectType::Cpp);
2049 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2050 }
2051
2052 #[test]
2053 fn test_detect_cpp_makefile_project() {
2054 let tmp = TempDir::new().unwrap();
2055 let base = tmp.path();
2056
2057 let project = base.join("cpp-make");
2058 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2059 create_file(&project.join("build/main.o"), "object");
2060
2061 let scanner = default_scanner(ProjectFilter::Cpp);
2062 let projects = scanner.scan_directory(base);
2063 assert_eq!(projects.len(), 1);
2064 assert_eq!(projects[0].kind, ProjectType::Cpp);
2065 }
2066
2067 #[test]
2070 fn test_detect_swift_project() {
2071 let tmp = TempDir::new().unwrap();
2072 let base = tmp.path();
2073
2074 let project = base.join("swift-pkg");
2075 create_file(
2076 &project.join("Package.swift"),
2077 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2078 );
2079 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2080
2081 let scanner = default_scanner(ProjectFilter::Swift);
2082 let projects = scanner.scan_directory(base);
2083 assert_eq!(projects.len(), 1);
2084 assert_eq!(projects[0].kind, ProjectType::Swift);
2085 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2086 }
2087
2088 #[test]
2091 fn test_detect_dotnet_project() {
2092 let tmp = TempDir::new().unwrap();
2093 let base = tmp.path();
2094
2095 let project = base.join("dotnet-app");
2096 create_file(
2097 &project.join("MyApp.csproj"),
2098 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2099 );
2100 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2101 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2102
2103 let scanner = default_scanner(ProjectFilter::DotNet);
2104 let projects = scanner.scan_directory(base);
2105 assert_eq!(projects.len(), 1);
2106 assert_eq!(projects[0].kind, ProjectType::DotNet);
2107 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2108 }
2109
2110 #[test]
2111 fn test_detect_dotnet_project_obj_only() {
2112 let tmp = TempDir::new().unwrap();
2113 let base = tmp.path();
2114
2115 let project = base.join("dotnet-obj-only");
2116 create_file(
2117 &project.join("Lib.csproj"),
2118 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2119 );
2120 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2121
2122 let scanner = default_scanner(ProjectFilter::DotNet);
2123 let projects = scanner.scan_directory(base);
2124 assert_eq!(projects.len(), 1);
2125 assert_eq!(projects[0].kind, ProjectType::DotNet);
2126 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2127 }
2128
2129 #[test]
2132 fn test_obj_directory_is_excluded() {
2133 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2134 }
2135
2136 #[test]
2139 fn test_calculate_build_dir_size_empty() {
2140 let tmp = TempDir::new().unwrap();
2141 let empty_dir = tmp.path().join("empty");
2142 fs::create_dir_all(&empty_dir).unwrap();
2143
2144 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2145 }
2146
2147 #[test]
2148 fn test_calculate_build_dir_size_nonexistent() {
2149 assert_eq!(
2150 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2151 0
2152 );
2153 }
2154
2155 #[test]
2156 fn test_calculate_build_dir_size_with_nested_files() {
2157 let tmp = TempDir::new().unwrap();
2158 let dir = tmp.path().join("nested");
2159
2160 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);
2165 assert_eq!(size, 12);
2166 }
2167
2168 #[test]
2171 fn test_scanner_quiet_mode() {
2172 let tmp = TempDir::new().unwrap();
2173 let base = tmp.path();
2174
2175 let project = base.join("quiet-project");
2176 create_file(
2177 &project.join("Cargo.toml"),
2178 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2179 );
2180 create_file(&project.join("target/dummy"), "content");
2181
2182 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2183 let projects = scanner.scan_directory(base);
2184 assert_eq!(projects.len(), 1);
2185 }
2186
2187 #[test]
2190 fn test_detect_ruby_with_vendor_bundle() {
2191 let tmp = TempDir::new().unwrap();
2192 let base = tmp.path();
2193
2194 let project = base.join("ruby-project");
2195 create_file(
2196 &project.join("Gemfile"),
2197 "source 'https://rubygems.org'\ngem 'rails'",
2198 );
2199 create_file(
2200 &project.join("my-app.gemspec"),
2201 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2202 );
2203 create_file(
2204 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2205 "# rails",
2206 );
2207
2208 let scanner = default_scanner(ProjectFilter::Ruby);
2209 let projects = scanner.scan_directory(base);
2210 assert_eq!(projects.len(), 1);
2211 assert_eq!(projects[0].kind, ProjectType::Ruby);
2212 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2213 }
2214
2215 #[test]
2216 fn test_detect_ruby_with_dot_bundle() {
2217 let tmp = TempDir::new().unwrap();
2218 let base = tmp.path();
2219
2220 let project = base.join("ruby-dot-bundle");
2221 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2222 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2223
2224 let scanner = default_scanner(ProjectFilter::Ruby);
2225 let projects = scanner.scan_directory(base);
2226 assert_eq!(projects.len(), 1);
2227 assert_eq!(projects[0].kind, ProjectType::Ruby);
2228 }
2229
2230 #[test]
2231 fn test_detect_ruby_no_artifact_not_detected() {
2232 let tmp = TempDir::new().unwrap();
2233 let base = tmp.path();
2234
2235 let project = base.join("gemfile-only");
2237 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2238
2239 let scanner = default_scanner(ProjectFilter::Ruby);
2240 let projects = scanner.scan_directory(base);
2241 assert_eq!(projects.len(), 0);
2242 }
2243
2244 #[test]
2245 fn test_detect_ruby_fallback_to_dir_name() {
2246 let tmp = TempDir::new().unwrap();
2247 let base = tmp.path();
2248
2249 let project = base.join("my-ruby-app");
2250 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2251 create_file(
2252 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2253 "# sinatra",
2254 );
2255
2256 let scanner = default_scanner(ProjectFilter::Ruby);
2257 let projects = scanner.scan_directory(base);
2258 assert_eq!(projects.len(), 1);
2259 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2260 }
2261
2262 #[test]
2265 fn test_detect_elixir_project() {
2266 let tmp = TempDir::new().unwrap();
2267 let base = tmp.path();
2268
2269 let project = base.join("elixir-project");
2270 create_file(
2271 &project.join("mix.exs"),
2272 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2273 );
2274 create_file(
2275 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2276 "# build",
2277 );
2278
2279 let scanner = default_scanner(ProjectFilter::Elixir);
2280 let projects = scanner.scan_directory(base);
2281 assert_eq!(projects.len(), 1);
2282 assert_eq!(projects[0].kind, ProjectType::Elixir);
2283 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2284 }
2285
2286 #[test]
2287 fn test_detect_elixir_no_build_not_detected() {
2288 let tmp = TempDir::new().unwrap();
2289 let base = tmp.path();
2290
2291 let project = base.join("mix-only");
2292 create_file(
2293 &project.join("mix.exs"),
2294 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2295 );
2296
2297 let scanner = default_scanner(ProjectFilter::Elixir);
2298 let projects = scanner.scan_directory(base);
2299 assert_eq!(projects.len(), 0);
2300 }
2301
2302 #[test]
2303 fn test_detect_elixir_fallback_to_dir_name() {
2304 let tmp = TempDir::new().unwrap();
2305 let base = tmp.path();
2306
2307 let project = base.join("my_elixir_project");
2308 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2309 create_file(
2310 &project.join("_build/prod/lib/my_elixir_project.beam"),
2311 "bytecode",
2312 );
2313
2314 let scanner = default_scanner(ProjectFilter::Elixir);
2315 let projects = scanner.scan_directory(base);
2316 assert_eq!(projects.len(), 1);
2317 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2318 }
2319
2320 #[test]
2323 fn test_detect_deno_with_vendor() {
2324 let tmp = TempDir::new().unwrap();
2325 let base = tmp.path();
2326
2327 let project = base.join("deno-project");
2328 create_file(
2329 &project.join("deno.json"),
2330 r#"{"name": "my-deno-app", "imports": {}}"#,
2331 );
2332 create_file(&project.join("vendor/modules.json"), "{}");
2333
2334 let scanner = default_scanner(ProjectFilter::Deno);
2335 let projects = scanner.scan_directory(base);
2336 assert_eq!(projects.len(), 1);
2337 assert_eq!(projects[0].kind, ProjectType::Deno);
2338 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2339 }
2340
2341 #[test]
2342 fn test_detect_deno_jsonc_config() {
2343 let tmp = TempDir::new().unwrap();
2344 let base = tmp.path();
2345
2346 let project = base.join("deno-jsonc-project");
2347 create_file(
2348 &project.join("deno.jsonc"),
2349 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2350 );
2351 create_file(&project.join("vendor/modules.json"), "{}");
2352
2353 let scanner = default_scanner(ProjectFilter::Deno);
2354 let projects = scanner.scan_directory(base);
2355 assert_eq!(projects.len(), 1);
2356 assert_eq!(projects[0].kind, ProjectType::Deno);
2357 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2358 }
2359
2360 #[test]
2361 fn test_detect_deno_node_modules_without_package_json() {
2362 let tmp = TempDir::new().unwrap();
2363 let base = tmp.path();
2364
2365 let project = base.join("deno-npm-project");
2366 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2367 create_file(
2368 &project.join("node_modules/.deno/lodash/index.js"),
2369 "// lodash",
2370 );
2371
2372 let scanner = default_scanner(ProjectFilter::Deno);
2373 let projects = scanner.scan_directory(base);
2374 assert_eq!(projects.len(), 1);
2375 assert_eq!(projects[0].kind, ProjectType::Deno);
2376 }
2377
2378 #[test]
2379 fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2380 let tmp = TempDir::new().unwrap();
2381 let base = tmp.path();
2382
2383 let project = base.join("ambiguous-project");
2385 create_file(&project.join("deno.json"), r"{}");
2386 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2387 create_file(&project.join("node_modules/dep/index.js"), "// dep");
2388
2389 let scanner = default_scanner(ProjectFilter::All);
2390 let projects = scanner.scan_directory(base);
2391 assert_eq!(projects.len(), 1);
2392 assert_eq!(projects[0].kind, ProjectType::Node);
2393 }
2394
2395 #[test]
2396 fn test_detect_deno_no_artifact_not_detected() {
2397 let tmp = TempDir::new().unwrap();
2398 let base = tmp.path();
2399
2400 let project = base.join("deno-no-artifact");
2401 create_file(&project.join("deno.json"), r"{}");
2402
2403 let scanner = default_scanner(ProjectFilter::Deno);
2404 let projects = scanner.scan_directory(base);
2405 assert_eq!(projects.len(), 0);
2406 }
2407
2408 #[test]
2409 fn test_build_directory_is_excluded() {
2410 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2411 }
2412
2413 #[test]
2416 fn test_is_cargo_workspace_root() {
2417 let tmp = TempDir::new().unwrap();
2418 let cargo_toml = tmp.path().join("Cargo.toml");
2419
2420 create_file(
2422 &cargo_toml,
2423 "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2424 );
2425 assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2426
2427 create_file(
2429 &cargo_toml,
2430 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2431 );
2432 assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2433
2434 assert!(!Scanner::is_cargo_workspace_root(Path::new(
2436 "/nonexistent/Cargo.toml"
2437 )));
2438 }
2439
2440 #[test]
2441 fn test_workspace_root_detected() {
2442 let tmp = TempDir::new().unwrap();
2443 let base = tmp.path();
2444
2445 let workspace = base.join("my-workspace");
2447 create_file(
2448 &workspace.join("Cargo.toml"),
2449 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2450 );
2451 create_file(&workspace.join("target/dummy"), "content");
2452
2453 let scanner = default_scanner(ProjectFilter::Rust);
2454 let projects = scanner.scan_directory(base);
2455
2456 assert_eq!(projects.len(), 1);
2457 assert_eq!(projects[0].root_path, workspace);
2458 }
2459
2460 #[test]
2461 fn test_workspace_member_with_own_target_skipped() {
2462 let tmp = TempDir::new().unwrap();
2463 let base = tmp.path();
2464
2465 let workspace = base.join("my-workspace");
2467 create_file(
2468 &workspace.join("Cargo.toml"),
2469 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2470 );
2471 create_file(&workspace.join("target/dummy"), "content");
2472
2473 let member = workspace.join("crate-a");
2475 create_file(
2476 &member.join("Cargo.toml"),
2477 "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2478 );
2479 create_file(&member.join("target/dummy"), "content");
2480
2481 let scanner = default_scanner(ProjectFilter::Rust);
2482 let projects = scanner.scan_directory(base);
2483
2484 assert_eq!(projects.len(), 1);
2486 assert_eq!(projects[0].root_path, workspace);
2487 }
2488}