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 let name = self.extract_rust_project_name(&cargo_toml, errors);
402
403 let build_arts = BuildArtifacts {
404 path: path.join("target"),
405 size: 0, };
407
408 return Some(Project::new(
409 ProjectType::Rust,
410 path.to_path_buf(),
411 build_arts,
412 name,
413 ));
414 }
415
416 None
417 }
418
419 fn extract_rust_project_name(
441 &self,
442 cargo_toml: &Path,
443 errors: &Arc<Mutex<Vec<String>>>,
444 ) -> Option<String> {
445 let content = self.read_file_content(cargo_toml, errors)?;
446 Self::parse_toml_name_field(&content)
447 }
448
449 fn extract_quoted_value(line: &str) -> Option<String> {
451 let start = line.find('"')?;
452 let end = line.rfind('"')?;
453
454 if start == end {
455 return None;
456 }
457
458 Some(line[start + 1..end].to_string())
459 }
460
461 fn extract_name_from_line(line: &str) -> Option<String> {
463 if !Self::is_name_line(line) {
464 return None;
465 }
466
467 Self::extract_quoted_value(line)
468 }
469
470 fn extract_node_project_name(
491 &self,
492 package_json: &Path,
493 errors: &Arc<Mutex<Vec<String>>>,
494 ) -> Option<String> {
495 match fs::read_to_string(package_json) {
496 Ok(content) => match from_str::<Value>(&content) {
497 Ok(json) => json
498 .get("name")
499 .and_then(|v| v.as_str())
500 .map(std::string::ToString::to_string),
501 Err(e) => {
502 if self.scan_options.verbose {
503 errors
504 .lock()
505 .unwrap()
506 .push(format!("Error parsing {}: {e}", package_json.display()));
507 }
508 None
509 }
510 },
511 Err(e) => {
512 if self.scan_options.verbose {
513 errors
514 .lock()
515 .unwrap()
516 .push(format!("Error reading {}: {e}", package_json.display()));
517 }
518 None
519 }
520 }
521 }
522
523 fn is_name_line(line: &str) -> bool {
525 line.starts_with("name") && line.contains('=')
526 }
527
528 fn log_file_error(
530 &self,
531 file_path: &Path,
532 error: &std::io::Error,
533 errors: &Arc<Mutex<Vec<String>>>,
534 ) {
535 if self.scan_options.verbose {
536 errors
537 .lock()
538 .unwrap()
539 .push(format!("Error reading {}: {error}", file_path.display()));
540 }
541 }
542
543 fn parse_toml_name_field(content: &str) -> Option<String> {
545 for line in content.lines() {
546 if let Some(name) = Self::extract_name_from_line(line.trim()) {
547 return Some(name);
548 }
549 }
550 None
551 }
552
553 fn read_file_content(
555 &self,
556 file_path: &Path,
557 errors: &Arc<Mutex<Vec<String>>>,
558 ) -> Option<String> {
559 match fs::read_to_string(file_path) {
560 Ok(content) => Some(content),
561 Err(e) => {
562 self.log_file_error(file_path, &e, errors);
563 None
564 }
565 }
566 }
567
568 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
602 let path = entry.path();
603
604 if self.is_path_in_skip_list(path) {
606 return false;
607 }
608
609 if path
611 .ancestors()
612 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
613 {
614 return false;
615 }
616
617 if Self::is_hidden_directory_to_skip(path) {
619 return false;
620 }
621
622 !Self::is_excluded_directory(path)
624 }
625
626 fn is_path_in_skip_list(&self, path: &Path) -> bool {
628 self.scan_options.skip.iter().any(|skip| {
629 path.components().any(|component| {
630 component
631 .as_os_str()
632 .to_str()
633 .is_some_and(|name| name == skip.to_string_lossy())
634 })
635 })
636 }
637
638 fn is_hidden_directory_to_skip(path: &Path) -> bool {
640 path.file_name()
641 .and_then(|n| n.to_str())
642 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
643 }
644
645 fn is_excluded_directory(path: &Path) -> bool {
647 let excluded_dirs = [
648 "target",
649 "build",
650 "dist",
651 "out",
652 ".git",
653 ".svn",
654 ".hg",
655 "__pycache__",
656 "venv",
657 ".venv",
658 "env",
659 ".env",
660 "temp",
661 "tmp",
662 "vendor",
663 ".pytest_cache",
664 ".tox",
665 ".eggs",
666 ".coverage",
667 "node_modules",
668 "obj",
669 "_build",
670 ];
671
672 path.file_name()
673 .and_then(|n| n.to_str())
674 .is_some_and(|name| excluded_dirs.contains(&name))
675 }
676
677 fn detect_python_project(
698 &self,
699 path: &Path,
700 errors: &Arc<Mutex<Vec<String>>>,
701 ) -> Option<Project> {
702 let config_files = [
703 "requirements.txt",
704 "setup.py",
705 "pyproject.toml",
706 "setup.cfg",
707 "Pipfile",
708 "pipenv.lock",
709 "poetry.lock",
710 ];
711
712 let build_dirs = [
713 "__pycache__",
714 ".pytest_cache",
715 "venv",
716 ".venv",
717 "build",
718 "dist",
719 ".eggs",
720 ".tox",
721 ".coverage",
722 ];
723
724 let has_config = config_files.iter().any(|&file| path.join(file).exists());
726
727 if !has_config {
728 return None;
729 }
730
731 let mut largest_build_dir = None;
733 let mut largest_size = 0;
734
735 for &dir_name in &build_dirs {
736 let dir_path = path.join(dir_name);
737
738 if dir_path.exists() && dir_path.is_dir() {
739 let size = crate::utils::calculate_dir_size(&dir_path);
740 if size > largest_size {
741 largest_size = size;
742 largest_build_dir = Some(dir_path);
743 }
744 }
745 }
746
747 if let Some(build_path) = largest_build_dir {
748 let name = self.extract_python_project_name(path, errors);
749
750 let build_arts = BuildArtifacts {
751 path: build_path,
752 size: largest_size,
753 };
754
755 return Some(Project::new(
756 ProjectType::Python,
757 path.to_path_buf(),
758 build_arts,
759 name,
760 ));
761 }
762
763 None
764 }
765
766 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
788 let go_mod = path.join("go.mod");
789 let vendor_dir = path.join("vendor");
790
791 if go_mod.exists() && vendor_dir.exists() {
792 let name = self.extract_go_project_name(&go_mod, errors);
793
794 let build_arts = BuildArtifacts {
795 path: path.join("vendor"),
796 size: 0, };
798
799 return Some(Project::new(
800 ProjectType::Go,
801 path.to_path_buf(),
802 build_arts,
803 name,
804 ));
805 }
806
807 None
808 }
809
810 fn extract_python_project_name(
832 &self,
833 path: &Path,
834 errors: &Arc<Mutex<Vec<String>>>,
835 ) -> Option<String> {
836 self.try_extract_from_pyproject_toml(path, errors)
838 .or_else(|| self.try_extract_from_setup_py(path, errors))
839 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
840 .or_else(|| Self::fallback_to_directory_name(path))
841 }
842
843 fn try_extract_from_pyproject_toml(
845 &self,
846 path: &Path,
847 errors: &Arc<Mutex<Vec<String>>>,
848 ) -> Option<String> {
849 let pyproject_toml = path.join("pyproject.toml");
850 if !pyproject_toml.exists() {
851 return None;
852 }
853
854 let content = self.read_file_content(&pyproject_toml, errors)?;
855 Self::extract_name_from_toml_like_content(&content)
856 }
857
858 fn try_extract_from_setup_py(
860 &self,
861 path: &Path,
862 errors: &Arc<Mutex<Vec<String>>>,
863 ) -> Option<String> {
864 let setup_py = path.join("setup.py");
865 if !setup_py.exists() {
866 return None;
867 }
868
869 let content = self.read_file_content(&setup_py, errors)?;
870 Self::extract_name_from_python_content(&content)
871 }
872
873 fn try_extract_from_setup_cfg(
875 &self,
876 path: &Path,
877 errors: &Arc<Mutex<Vec<String>>>,
878 ) -> Option<String> {
879 let setup_cfg = path.join("setup.cfg");
880 if !setup_cfg.exists() {
881 return None;
882 }
883
884 let content = self.read_file_content(&setup_cfg, errors)?;
885 Self::extract_name_from_cfg_content(&content)
886 }
887
888 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
890 content
891 .lines()
892 .map(str::trim)
893 .find(|line| line.starts_with("name") && line.contains('='))
894 .and_then(Self::extract_quoted_value)
895 }
896
897 fn extract_name_from_python_content(content: &str) -> Option<String> {
899 content
900 .lines()
901 .map(str::trim)
902 .find(|line| line.contains("name") && line.contains('='))
903 .and_then(Self::extract_quoted_value)
904 }
905
906 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
908 let mut in_metadata_section = false;
909
910 for line in content.lines() {
911 let line = line.trim();
912
913 if line == "[metadata]" {
914 in_metadata_section = true;
915 } else if line.starts_with('[') && line.ends_with(']') {
916 in_metadata_section = false;
917 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
918 return line.split('=').nth(1).map(|name| name.trim().to_string());
919 }
920 }
921
922 None
923 }
924
925 fn fallback_to_directory_name(path: &Path) -> Option<String> {
927 path.file_name()
928 .and_then(|name| name.to_str())
929 .map(std::string::ToString::to_string)
930 }
931
932 fn extract_go_project_name(
952 &self,
953 go_mod: &Path,
954 errors: &Arc<Mutex<Vec<String>>>,
955 ) -> Option<String> {
956 let content = self.read_file_content(go_mod, errors)?;
957
958 for line in content.lines() {
959 let line = line.trim();
960 if line.starts_with("module ") {
961 let module_path = line.strip_prefix("module ")?.trim();
962
963 if let Some(name) = module_path.split('/').next_back() {
965 return Some(name.to_string());
966 }
967
968 return Some(module_path.to_string());
969 }
970 }
971
972 None
973 }
974
975 fn detect_java_project(
986 &self,
987 path: &Path,
988 errors: &Arc<Mutex<Vec<String>>>,
989 ) -> Option<Project> {
990 let pom_xml = path.join("pom.xml");
991 let target_dir = path.join("target");
992
993 if pom_xml.exists() && target_dir.exists() {
995 let name = self.extract_java_maven_project_name(&pom_xml, errors);
996
997 let build_arts = BuildArtifacts {
998 path: target_dir,
999 size: 0,
1000 };
1001
1002 return Some(Project::new(
1003 ProjectType::Java,
1004 path.to_path_buf(),
1005 build_arts,
1006 name,
1007 ));
1008 }
1009
1010 let has_gradle =
1012 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1013 let build_dir = path.join("build");
1014
1015 if has_gradle && build_dir.exists() {
1016 let name = self.extract_java_gradle_project_name(path, errors);
1017
1018 let build_arts = BuildArtifacts {
1019 path: build_dir,
1020 size: 0,
1021 };
1022
1023 return Some(Project::new(
1024 ProjectType::Java,
1025 path.to_path_buf(),
1026 build_arts,
1027 name,
1028 ));
1029 }
1030
1031 None
1032 }
1033
1034 fn extract_java_maven_project_name(
1038 &self,
1039 pom_xml: &Path,
1040 errors: &Arc<Mutex<Vec<String>>>,
1041 ) -> Option<String> {
1042 let content = self.read_file_content(pom_xml, errors)?;
1043
1044 for line in content.lines() {
1045 let trimmed = line.trim();
1046 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1047 let name = trimmed
1048 .strip_prefix("<artifactId>")?
1049 .strip_suffix("</artifactId>")?;
1050 return Some(name.to_string());
1051 }
1052 }
1053
1054 None
1055 }
1056
1057 fn extract_java_gradle_project_name(
1062 &self,
1063 path: &Path,
1064 errors: &Arc<Mutex<Vec<String>>>,
1065 ) -> Option<String> {
1066 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1067 let settings_path = path.join(settings_file);
1068 if settings_path.exists()
1069 && let Some(content) = self.read_file_content(&settings_path, errors)
1070 {
1071 for line in content.lines() {
1072 let trimmed = line.trim();
1073 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1074 return Self::extract_quoted_value(trimmed).or_else(|| {
1075 trimmed
1076 .split('=')
1077 .nth(1)
1078 .map(|s| s.trim().trim_matches('\'').to_string())
1079 });
1080 }
1081 }
1082 }
1083 }
1084
1085 Self::fallback_to_directory_name(path)
1086 }
1087
1088 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1098 let build_dir = path.join("build");
1099
1100 if !build_dir.exists() {
1101 return None;
1102 }
1103
1104 let cmake_file = path.join("CMakeLists.txt");
1105 let makefile = path.join("Makefile");
1106
1107 if cmake_file.exists() || makefile.exists() {
1108 let name = if cmake_file.exists() {
1109 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1110 } else {
1111 Self::fallback_to_directory_name(path)
1112 };
1113
1114 let build_arts = BuildArtifacts {
1115 path: build_dir,
1116 size: 0,
1117 };
1118
1119 return Some(Project::new(
1120 ProjectType::Cpp,
1121 path.to_path_buf(),
1122 build_arts,
1123 name,
1124 ));
1125 }
1126
1127 None
1128 }
1129
1130 fn extract_cpp_cmake_project_name(
1134 &self,
1135 cmake_file: &Path,
1136 errors: &Arc<Mutex<Vec<String>>>,
1137 ) -> Option<String> {
1138 let content = self.read_file_content(cmake_file, errors)?;
1139
1140 for line in content.lines() {
1141 let trimmed = line.trim();
1142 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1143 let inner = trimmed
1144 .trim_start_matches("project(")
1145 .trim_start_matches("PROJECT(")
1146 .trim_end_matches(')')
1147 .trim();
1148
1149 let name = inner.split_whitespace().next()?;
1151 let name = name.trim_matches('"').trim_matches('\'');
1153 if !name.is_empty() {
1154 return Some(name.to_string());
1155 }
1156 }
1157 }
1158
1159 Self::fallback_to_directory_name(cmake_file.parent()?)
1160 }
1161
1162 fn detect_swift_project(
1172 &self,
1173 path: &Path,
1174 errors: &Arc<Mutex<Vec<String>>>,
1175 ) -> Option<Project> {
1176 let package_swift = path.join("Package.swift");
1177 let build_dir = path.join(".build");
1178
1179 if package_swift.exists() && build_dir.exists() {
1180 let name = self.extract_swift_project_name(&package_swift, errors);
1181
1182 let build_arts = BuildArtifacts {
1183 path: build_dir,
1184 size: 0,
1185 };
1186
1187 return Some(Project::new(
1188 ProjectType::Swift,
1189 path.to_path_buf(),
1190 build_arts,
1191 name,
1192 ));
1193 }
1194
1195 None
1196 }
1197
1198 fn extract_swift_project_name(
1202 &self,
1203 package_swift: &Path,
1204 errors: &Arc<Mutex<Vec<String>>>,
1205 ) -> Option<String> {
1206 let content = self.read_file_content(package_swift, errors)?;
1207
1208 for line in content.lines() {
1209 let trimmed = line.trim();
1210 if trimmed.contains("name:") {
1211 return Self::extract_quoted_value(trimmed);
1212 }
1213 }
1214
1215 Self::fallback_to_directory_name(package_swift.parent()?)
1216 }
1217
1218 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1228 let bin_dir = path.join("bin");
1229 let obj_dir = path.join("obj");
1230
1231 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1232 if !has_build_dir {
1233 return None;
1234 }
1235
1236 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1237
1238 let (build_path, precomputed_size) = match (bin_dir.exists(), obj_dir.exists()) {
1240 (true, true) => {
1241 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1242 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1243 if obj_size >= bin_size {
1244 (obj_dir, obj_size)
1245 } else {
1246 (bin_dir, bin_size)
1247 }
1248 }
1249 (true, false) => (bin_dir, 0),
1250 (false, true) => (obj_dir, 0),
1251 (false, false) => return None,
1252 };
1253
1254 let name = csproj_file
1255 .file_stem()
1256 .and_then(|s| s.to_str())
1257 .map(std::string::ToString::to_string);
1258
1259 let build_arts = BuildArtifacts {
1260 path: build_path,
1261 size: precomputed_size,
1262 };
1263
1264 Some(Project::new(
1265 ProjectType::DotNet,
1266 path.to_path_buf(),
1267 build_arts,
1268 name,
1269 ))
1270 }
1271
1272 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1274 let entries = fs::read_dir(dir).ok()?;
1275 for entry in entries.flatten() {
1276 let path = entry.path();
1277 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1278 return Some(path);
1279 }
1280 }
1281 None
1282 }
1283
1284 fn detect_deno_project(
1293 &self,
1294 path: &Path,
1295 errors: &Arc<Mutex<Vec<String>>>,
1296 ) -> Option<Project> {
1297 let deno_json = path.join("deno.json");
1298 let deno_jsonc = path.join("deno.jsonc");
1299
1300 if !deno_json.exists() && !deno_jsonc.exists() {
1301 return None;
1302 }
1303
1304 let config_path = if deno_json.exists() {
1305 deno_json
1306 } else {
1307 deno_jsonc
1308 };
1309
1310 let vendor_dir = path.join("vendor");
1312 if vendor_dir.exists() {
1313 let name = self.extract_deno_project_name(&config_path, errors);
1314 return Some(Project::new(
1315 ProjectType::Deno,
1316 path.to_path_buf(),
1317 BuildArtifacts {
1318 path: vendor_dir,
1319 size: 0,
1320 },
1321 name,
1322 ));
1323 }
1324
1325 let node_modules = path.join("node_modules");
1327 if node_modules.exists() && !path.join("package.json").exists() {
1328 let name = self.extract_deno_project_name(&config_path, errors);
1329 return Some(Project::new(
1330 ProjectType::Deno,
1331 path.to_path_buf(),
1332 BuildArtifacts {
1333 path: node_modules,
1334 size: 0,
1335 },
1336 name,
1337 ));
1338 }
1339
1340 None
1341 }
1342
1343 fn extract_deno_project_name(
1348 &self,
1349 config_path: &Path,
1350 errors: &Arc<Mutex<Vec<String>>>,
1351 ) -> Option<String> {
1352 match fs::read_to_string(config_path) {
1353 Ok(content) => {
1354 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1355 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1356 {
1357 return Some(name.to_string());
1358 }
1359 Self::fallback_to_directory_name(config_path.parent()?)
1360 }
1361 Err(e) => {
1362 self.log_file_error(config_path, &e, errors);
1363 Self::fallback_to_directory_name(config_path.parent()?)
1364 }
1365 }
1366 }
1367
1368 fn detect_ruby_project(
1378 &self,
1379 path: &Path,
1380 errors: &Arc<Mutex<Vec<String>>>,
1381 ) -> Option<Project> {
1382 let gemfile = path.join("Gemfile");
1383 if !gemfile.exists() {
1384 return None;
1385 }
1386
1387 let bundle_dir = path.join(".bundle");
1388 let vendor_bundle_dir = path.join("vendor").join("bundle");
1389
1390 let (build_path, precomputed_size) = match (bundle_dir.exists(), vendor_bundle_dir.exists())
1391 {
1392 (true, true) => {
1393 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1394 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1395 if vendor_size >= bundle_size {
1396 (vendor_bundle_dir, vendor_size)
1397 } else {
1398 (bundle_dir, bundle_size)
1399 }
1400 }
1401 (true, false) => (bundle_dir, 0),
1402 (false, true) => (vendor_bundle_dir, 0),
1403 (false, false) => return None,
1404 };
1405
1406 let name = self.extract_ruby_project_name(path, errors);
1407
1408 Some(Project::new(
1409 ProjectType::Ruby,
1410 path.to_path_buf(),
1411 BuildArtifacts {
1412 path: build_path,
1413 size: precomputed_size,
1414 },
1415 name,
1416 ))
1417 }
1418
1419 fn extract_ruby_project_name(
1424 &self,
1425 path: &Path,
1426 errors: &Arc<Mutex<Vec<String>>>,
1427 ) -> Option<String> {
1428 let entries = fs::read_dir(path).ok()?;
1429 for entry in entries.flatten() {
1430 let entry_path = entry.path();
1431 if entry_path.is_file()
1432 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1433 && let Some(content) = self.read_file_content(&entry_path, errors)
1434 {
1435 for line in content.lines() {
1436 let trimmed = line.trim();
1437 if trimmed.contains(".name")
1438 && trimmed.contains('=')
1439 && let Some(name) = Self::extract_quoted_value(trimmed)
1440 {
1441 return Some(name);
1442 }
1443 }
1444 }
1445 }
1446
1447 Self::fallback_to_directory_name(path)
1448 }
1449
1450 fn detect_elixir_project(
1460 &self,
1461 path: &Path,
1462 errors: &Arc<Mutex<Vec<String>>>,
1463 ) -> Option<Project> {
1464 let mix_exs = path.join("mix.exs");
1465 let build_dir = path.join("_build");
1466
1467 if mix_exs.exists() && build_dir.exists() {
1468 let name = self.extract_elixir_project_name(&mix_exs, errors);
1469
1470 return Some(Project::new(
1471 ProjectType::Elixir,
1472 path.to_path_buf(),
1473 BuildArtifacts {
1474 path: build_dir,
1475 size: 0,
1476 },
1477 name,
1478 ));
1479 }
1480
1481 None
1482 }
1483
1484 fn extract_elixir_project_name(
1489 &self,
1490 mix_exs: &Path,
1491 errors: &Arc<Mutex<Vec<String>>>,
1492 ) -> Option<String> {
1493 let content = self.read_file_content(mix_exs, errors)?;
1494
1495 for line in content.lines() {
1496 let trimmed = line.trim();
1497 if trimmed.contains("app:")
1498 && let Some(pos) = trimmed.find("app:")
1499 {
1500 let after = trimmed[pos + 4..].trim_start();
1501 if let Some(atom) = after.strip_prefix(':') {
1502 let name: String = atom
1504 .chars()
1505 .take_while(|c| c.is_alphanumeric() || *c == '_')
1506 .collect();
1507 if !name.is_empty() {
1508 return Some(name);
1509 }
1510 }
1511 }
1512 }
1513
1514 Self::fallback_to_directory_name(mix_exs.parent()?)
1515 }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521 use std::path::PathBuf;
1522 use tempfile::TempDir;
1523
1524 fn default_scanner(filter: ProjectFilter) -> Scanner {
1526 Scanner::new(
1527 ScanOptions {
1528 verbose: false,
1529 threads: 1,
1530 skip: vec![],
1531 },
1532 filter,
1533 )
1534 }
1535
1536 fn create_file(path: &Path, content: &str) {
1538 if let Some(parent) = path.parent() {
1539 fs::create_dir_all(parent).unwrap();
1540 }
1541 fs::write(path, content).unwrap();
1542 }
1543
1544 #[test]
1547 fn test_is_hidden_directory_to_skip() {
1548 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1550 "/some/.hidden"
1551 )));
1552 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1553 "/some/.git"
1554 )));
1555 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1556 "/some/.svn"
1557 )));
1558 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1559
1560 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1562 "/home/user/.cargo"
1563 )));
1564 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1565
1566 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1568 "/some/visible"
1569 )));
1570 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1571 }
1572
1573 #[test]
1574 fn test_is_excluded_directory() {
1575 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1577 assert!(Scanner::is_excluded_directory(Path::new(
1578 "/some/node_modules"
1579 )));
1580 assert!(Scanner::is_excluded_directory(Path::new(
1581 "/some/__pycache__"
1582 )));
1583 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1584 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1585 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1586 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1587
1588 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1590 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1591 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1592
1593 assert!(Scanner::is_excluded_directory(Path::new(
1595 "/some/.pytest_cache"
1596 )));
1597 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1598 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1599 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1600
1601 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1603 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1604 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1605 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1606
1607 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1609 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1610
1611 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1613 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1614 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1615 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1616 }
1617
1618 #[test]
1619 fn test_extract_quoted_value() {
1620 assert_eq!(
1621 Scanner::extract_quoted_value(r#"name = "my-project""#),
1622 Some("my-project".to_string())
1623 );
1624 assert_eq!(
1625 Scanner::extract_quoted_value(r#"name = "with spaces""#),
1626 Some("with spaces".to_string())
1627 );
1628 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1629 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1631 }
1632
1633 #[test]
1634 fn test_is_name_line() {
1635 assert!(Scanner::is_name_line("name = \"test\""));
1636 assert!(Scanner::is_name_line("name=\"test\""));
1637 assert!(!Scanner::is_name_line("version = \"1.0\""));
1638 assert!(!Scanner::is_name_line("# name = \"commented\""));
1639 assert!(!Scanner::is_name_line("name: \"yaml style\""));
1640 }
1641
1642 #[test]
1643 fn test_parse_toml_name_field() {
1644 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1645 assert_eq!(
1646 Scanner::parse_toml_name_field(content),
1647 Some("test-project".to_string())
1648 );
1649
1650 let no_name = "[package]\nversion = \"0.1.0\"\n";
1651 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1652
1653 let empty = "";
1654 assert_eq!(Scanner::parse_toml_name_field(empty), None);
1655 }
1656
1657 #[test]
1658 fn test_extract_name_from_cfg_content() {
1659 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1660 assert_eq!(
1661 Scanner::extract_name_from_cfg_content(content),
1662 Some("my-package".to_string())
1663 );
1664
1665 let wrong_section = "[options]\nname = not-this\n";
1667 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1668
1669 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1671 assert_eq!(
1672 Scanner::extract_name_from_cfg_content(multi),
1673 Some("correct".to_string())
1674 );
1675 }
1676
1677 #[test]
1678 fn test_extract_name_from_python_content() {
1679 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
1680 assert_eq!(
1681 Scanner::extract_name_from_python_content(content),
1682 Some("my-pkg".to_string())
1683 );
1684
1685 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1686 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1687 }
1688
1689 #[test]
1690 fn test_fallback_to_directory_name() {
1691 assert_eq!(
1692 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1693 Some("project-name".to_string())
1694 );
1695 assert_eq!(
1696 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1697 Some("my_app".to_string())
1698 );
1699 }
1700
1701 #[test]
1702 fn test_is_path_in_skip_list() {
1703 let scanner = Scanner::new(
1704 ScanOptions {
1705 verbose: false,
1706 threads: 1,
1707 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1708 },
1709 ProjectFilter::All,
1710 );
1711
1712 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1713 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1714 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1715 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1716 }
1717
1718 #[test]
1719 fn test_is_path_in_empty_skip_list() {
1720 let scanner = default_scanner(ProjectFilter::All);
1721 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1722 }
1723
1724 #[test]
1727 fn test_scan_directory_with_spaces_in_path() {
1728 let tmp = TempDir::new().unwrap();
1729 let base = tmp.path().join("path with spaces");
1730 fs::create_dir_all(&base).unwrap();
1731
1732 let project = base.join("my project");
1733 create_file(
1734 &project.join("Cargo.toml"),
1735 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1736 );
1737 create_file(&project.join("target/dummy"), "content");
1738
1739 let scanner = default_scanner(ProjectFilter::Rust);
1740 let projects = scanner.scan_directory(&base);
1741 assert_eq!(projects.len(), 1);
1742 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1743 }
1744
1745 #[test]
1746 fn test_scan_directory_with_unicode_names() {
1747 let tmp = TempDir::new().unwrap();
1748 let base = tmp.path();
1749
1750 let project = base.join("プロジェクト");
1751 create_file(
1752 &project.join("package.json"),
1753 r#"{"name": "unicode-project"}"#,
1754 );
1755 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1756
1757 let scanner = default_scanner(ProjectFilter::Node);
1758 let projects = scanner.scan_directory(base);
1759 assert_eq!(projects.len(), 1);
1760 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1761 }
1762
1763 #[test]
1764 fn test_scan_directory_with_special_characters_in_name() {
1765 let tmp = TempDir::new().unwrap();
1766 let base = tmp.path();
1767
1768 let project = base.join("project-with-dashes_and_underscores.v2");
1769 create_file(
1770 &project.join("Cargo.toml"),
1771 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1772 );
1773 create_file(&project.join("target/dummy"), "content");
1774
1775 let scanner = default_scanner(ProjectFilter::Rust);
1776 let projects = scanner.scan_directory(base);
1777 assert_eq!(projects.len(), 1);
1778 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1779 }
1780
1781 #[test]
1784 #[cfg(unix)]
1785 fn test_hidden_directory_itself_not_detected_as_project_unix() {
1786 let tmp = TempDir::new().unwrap();
1787 let base = tmp.path();
1788
1789 let hidden = base.join(".hidden-project");
1794 create_file(
1795 &hidden.join("Cargo.toml"),
1796 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1797 );
1798 create_file(&hidden.join("target/dummy"), "content");
1799
1800 let visible = base.join("visible-project");
1802 create_file(
1803 &visible.join("Cargo.toml"),
1804 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1805 );
1806 create_file(&visible.join("target/dummy"), "content");
1807
1808 let scanner = default_scanner(ProjectFilter::Rust);
1809 let projects = scanner.scan_directory(base);
1810
1811 assert_eq!(projects.len(), 1);
1814 assert_eq!(projects[0].name.as_deref(), Some("visible"));
1815 }
1816
1817 #[test]
1818 #[cfg(unix)]
1819 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1820 let tmp = TempDir::new().unwrap();
1821 let base = tmp.path();
1822
1823 let nested = base.join(".hidden-parent/visible-child");
1826 create_file(
1827 &nested.join("Cargo.toml"),
1828 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1829 );
1830 create_file(&nested.join("target/dummy"), "content");
1831
1832 let scanner = default_scanner(ProjectFilter::Rust);
1833 let projects = scanner.scan_directory(base);
1834
1835 assert_eq!(projects.len(), 1);
1837 assert_eq!(projects[0].name.as_deref(), Some("nested"));
1838 }
1839
1840 #[test]
1841 #[cfg(unix)]
1842 fn test_dotcargo_directory_not_skipped_unix() {
1843 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1846 "/home/user/.cargo"
1847 )));
1848
1849 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1851 "/home/user/.local"
1852 )));
1853 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1854 "/home/user/.npm"
1855 )));
1856 }
1857
1858 #[test]
1861 fn test_detect_python_with_pyproject_toml() {
1862 let tmp = TempDir::new().unwrap();
1863 let base = tmp.path();
1864
1865 let project = base.join("py-project");
1866 create_file(
1867 &project.join("pyproject.toml"),
1868 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1869 );
1870 let pycache = project.join("__pycache__");
1871 fs::create_dir_all(&pycache).unwrap();
1872 create_file(&pycache.join("module.pyc"), "bytecode");
1873
1874 let scanner = default_scanner(ProjectFilter::Python);
1875 let projects = scanner.scan_directory(base);
1876 assert_eq!(projects.len(), 1);
1877 assert_eq!(projects[0].kind, ProjectType::Python);
1878 }
1879
1880 #[test]
1881 fn test_detect_python_with_setup_py() {
1882 let tmp = TempDir::new().unwrap();
1883 let base = tmp.path();
1884
1885 let project = base.join("setup-project");
1886 create_file(
1887 &project.join("setup.py"),
1888 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1889 );
1890 let pycache = project.join("__pycache__");
1891 fs::create_dir_all(&pycache).unwrap();
1892 create_file(&pycache.join("module.pyc"), "bytecode");
1893
1894 let scanner = default_scanner(ProjectFilter::Python);
1895 let projects = scanner.scan_directory(base);
1896 assert_eq!(projects.len(), 1);
1897 }
1898
1899 #[test]
1900 fn test_detect_python_with_pipfile() {
1901 let tmp = TempDir::new().unwrap();
1902 let base = tmp.path();
1903
1904 let project = base.join("pipenv-project");
1905 create_file(
1906 &project.join("Pipfile"),
1907 "[[source]]\nurl = \"https://pypi.org/simple\"",
1908 );
1909 let pycache = project.join("__pycache__");
1910 fs::create_dir_all(&pycache).unwrap();
1911 create_file(&pycache.join("module.pyc"), "bytecode");
1912
1913 let scanner = default_scanner(ProjectFilter::Python);
1914 let projects = scanner.scan_directory(base);
1915 assert_eq!(projects.len(), 1);
1916 }
1917
1918 #[test]
1921 fn test_detect_go_extracts_module_name() {
1922 let tmp = TempDir::new().unwrap();
1923 let base = tmp.path();
1924
1925 let project = base.join("go-service");
1926 create_file(
1927 &project.join("go.mod"),
1928 "module github.com/user/my-service\n\ngo 1.21\n",
1929 );
1930 let vendor = project.join("vendor");
1931 fs::create_dir_all(&vendor).unwrap();
1932 create_file(&vendor.join("modules.txt"), "vendor manifest");
1933
1934 let scanner = default_scanner(ProjectFilter::Go);
1935 let projects = scanner.scan_directory(base);
1936 assert_eq!(projects.len(), 1);
1937 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1939 }
1940
1941 #[test]
1944 fn test_detect_java_maven_project() {
1945 let tmp = TempDir::new().unwrap();
1946 let base = tmp.path();
1947
1948 let project = base.join("java-maven");
1949 create_file(
1950 &project.join("pom.xml"),
1951 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
1952 );
1953 create_file(&project.join("target/classes/Main.class"), "bytecode");
1954
1955 let scanner = default_scanner(ProjectFilter::Java);
1956 let projects = scanner.scan_directory(base);
1957 assert_eq!(projects.len(), 1);
1958 assert_eq!(projects[0].kind, ProjectType::Java);
1959 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
1960 }
1961
1962 #[test]
1963 fn test_detect_java_gradle_project() {
1964 let tmp = TempDir::new().unwrap();
1965 let base = tmp.path();
1966
1967 let project = base.join("java-gradle");
1968 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
1969 create_file(
1970 &project.join("settings.gradle"),
1971 "rootProject.name = \"my-gradle-app\"",
1972 );
1973 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
1974
1975 let scanner = default_scanner(ProjectFilter::Java);
1976 let projects = scanner.scan_directory(base);
1977 assert_eq!(projects.len(), 1);
1978 assert_eq!(projects[0].kind, ProjectType::Java);
1979 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
1980 }
1981
1982 #[test]
1983 fn test_detect_java_gradle_kts_project() {
1984 let tmp = TempDir::new().unwrap();
1985 let base = tmp.path();
1986
1987 let project = base.join("kotlin-gradle");
1988 create_file(
1989 &project.join("build.gradle.kts"),
1990 "plugins { kotlin(\"jvm\") }",
1991 );
1992 create_file(
1993 &project.join("settings.gradle.kts"),
1994 "rootProject.name = \"my-kotlin-app\"",
1995 );
1996 create_file(
1997 &project.join("build/classes/kotlin/main/MainKt.class"),
1998 "bytecode",
1999 );
2000
2001 let scanner = default_scanner(ProjectFilter::Java);
2002 let projects = scanner.scan_directory(base);
2003 assert_eq!(projects.len(), 1);
2004 assert_eq!(projects[0].kind, ProjectType::Java);
2005 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2006 }
2007
2008 #[test]
2011 fn test_detect_cpp_cmake_project() {
2012 let tmp = TempDir::new().unwrap();
2013 let base = tmp.path();
2014
2015 let project = base.join("cpp-cmake");
2016 create_file(
2017 &project.join("CMakeLists.txt"),
2018 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2019 );
2020 create_file(&project.join("build/CMakeCache.txt"), "cache");
2021
2022 let scanner = default_scanner(ProjectFilter::Cpp);
2023 let projects = scanner.scan_directory(base);
2024 assert_eq!(projects.len(), 1);
2025 assert_eq!(projects[0].kind, ProjectType::Cpp);
2026 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2027 }
2028
2029 #[test]
2030 fn test_detect_cpp_makefile_project() {
2031 let tmp = TempDir::new().unwrap();
2032 let base = tmp.path();
2033
2034 let project = base.join("cpp-make");
2035 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2036 create_file(&project.join("build/main.o"), "object");
2037
2038 let scanner = default_scanner(ProjectFilter::Cpp);
2039 let projects = scanner.scan_directory(base);
2040 assert_eq!(projects.len(), 1);
2041 assert_eq!(projects[0].kind, ProjectType::Cpp);
2042 }
2043
2044 #[test]
2047 fn test_detect_swift_project() {
2048 let tmp = TempDir::new().unwrap();
2049 let base = tmp.path();
2050
2051 let project = base.join("swift-pkg");
2052 create_file(
2053 &project.join("Package.swift"),
2054 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2055 );
2056 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2057
2058 let scanner = default_scanner(ProjectFilter::Swift);
2059 let projects = scanner.scan_directory(base);
2060 assert_eq!(projects.len(), 1);
2061 assert_eq!(projects[0].kind, ProjectType::Swift);
2062 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2063 }
2064
2065 #[test]
2068 fn test_detect_dotnet_project() {
2069 let tmp = TempDir::new().unwrap();
2070 let base = tmp.path();
2071
2072 let project = base.join("dotnet-app");
2073 create_file(
2074 &project.join("MyApp.csproj"),
2075 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2076 );
2077 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2078 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2079
2080 let scanner = default_scanner(ProjectFilter::DotNet);
2081 let projects = scanner.scan_directory(base);
2082 assert_eq!(projects.len(), 1);
2083 assert_eq!(projects[0].kind, ProjectType::DotNet);
2084 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2085 }
2086
2087 #[test]
2088 fn test_detect_dotnet_project_obj_only() {
2089 let tmp = TempDir::new().unwrap();
2090 let base = tmp.path();
2091
2092 let project = base.join("dotnet-obj-only");
2093 create_file(
2094 &project.join("Lib.csproj"),
2095 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2096 );
2097 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2098
2099 let scanner = default_scanner(ProjectFilter::DotNet);
2100 let projects = scanner.scan_directory(base);
2101 assert_eq!(projects.len(), 1);
2102 assert_eq!(projects[0].kind, ProjectType::DotNet);
2103 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2104 }
2105
2106 #[test]
2109 fn test_obj_directory_is_excluded() {
2110 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2111 }
2112
2113 #[test]
2116 fn test_calculate_build_dir_size_empty() {
2117 let tmp = TempDir::new().unwrap();
2118 let empty_dir = tmp.path().join("empty");
2119 fs::create_dir_all(&empty_dir).unwrap();
2120
2121 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2122 }
2123
2124 #[test]
2125 fn test_calculate_build_dir_size_nonexistent() {
2126 assert_eq!(
2127 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2128 0
2129 );
2130 }
2131
2132 #[test]
2133 fn test_calculate_build_dir_size_with_nested_files() {
2134 let tmp = TempDir::new().unwrap();
2135 let dir = tmp.path().join("nested");
2136
2137 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);
2142 assert_eq!(size, 12);
2143 }
2144
2145 #[test]
2148 fn test_scanner_quiet_mode() {
2149 let tmp = TempDir::new().unwrap();
2150 let base = tmp.path();
2151
2152 let project = base.join("quiet-project");
2153 create_file(
2154 &project.join("Cargo.toml"),
2155 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2156 );
2157 create_file(&project.join("target/dummy"), "content");
2158
2159 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2160 let projects = scanner.scan_directory(base);
2161 assert_eq!(projects.len(), 1);
2162 }
2163
2164 #[test]
2167 fn test_detect_ruby_with_vendor_bundle() {
2168 let tmp = TempDir::new().unwrap();
2169 let base = tmp.path();
2170
2171 let project = base.join("ruby-project");
2172 create_file(
2173 &project.join("Gemfile"),
2174 "source 'https://rubygems.org'\ngem 'rails'",
2175 );
2176 create_file(
2177 &project.join("my-app.gemspec"),
2178 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2179 );
2180 create_file(
2181 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2182 "# rails",
2183 );
2184
2185 let scanner = default_scanner(ProjectFilter::Ruby);
2186 let projects = scanner.scan_directory(base);
2187 assert_eq!(projects.len(), 1);
2188 assert_eq!(projects[0].kind, ProjectType::Ruby);
2189 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2190 }
2191
2192 #[test]
2193 fn test_detect_ruby_with_dot_bundle() {
2194 let tmp = TempDir::new().unwrap();
2195 let base = tmp.path();
2196
2197 let project = base.join("ruby-dot-bundle");
2198 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2199 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2200
2201 let scanner = default_scanner(ProjectFilter::Ruby);
2202 let projects = scanner.scan_directory(base);
2203 assert_eq!(projects.len(), 1);
2204 assert_eq!(projects[0].kind, ProjectType::Ruby);
2205 }
2206
2207 #[test]
2208 fn test_detect_ruby_no_artifact_not_detected() {
2209 let tmp = TempDir::new().unwrap();
2210 let base = tmp.path();
2211
2212 let project = base.join("gemfile-only");
2214 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2215
2216 let scanner = default_scanner(ProjectFilter::Ruby);
2217 let projects = scanner.scan_directory(base);
2218 assert_eq!(projects.len(), 0);
2219 }
2220
2221 #[test]
2222 fn test_detect_ruby_fallback_to_dir_name() {
2223 let tmp = TempDir::new().unwrap();
2224 let base = tmp.path();
2225
2226 let project = base.join("my-ruby-app");
2227 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2228 create_file(
2229 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2230 "# sinatra",
2231 );
2232
2233 let scanner = default_scanner(ProjectFilter::Ruby);
2234 let projects = scanner.scan_directory(base);
2235 assert_eq!(projects.len(), 1);
2236 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2237 }
2238
2239 #[test]
2242 fn test_detect_elixir_project() {
2243 let tmp = TempDir::new().unwrap();
2244 let base = tmp.path();
2245
2246 let project = base.join("elixir-project");
2247 create_file(
2248 &project.join("mix.exs"),
2249 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2250 );
2251 create_file(
2252 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2253 "# build",
2254 );
2255
2256 let scanner = default_scanner(ProjectFilter::Elixir);
2257 let projects = scanner.scan_directory(base);
2258 assert_eq!(projects.len(), 1);
2259 assert_eq!(projects[0].kind, ProjectType::Elixir);
2260 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2261 }
2262
2263 #[test]
2264 fn test_detect_elixir_no_build_not_detected() {
2265 let tmp = TempDir::new().unwrap();
2266 let base = tmp.path();
2267
2268 let project = base.join("mix-only");
2269 create_file(
2270 &project.join("mix.exs"),
2271 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2272 );
2273
2274 let scanner = default_scanner(ProjectFilter::Elixir);
2275 let projects = scanner.scan_directory(base);
2276 assert_eq!(projects.len(), 0);
2277 }
2278
2279 #[test]
2280 fn test_detect_elixir_fallback_to_dir_name() {
2281 let tmp = TempDir::new().unwrap();
2282 let base = tmp.path();
2283
2284 let project = base.join("my_elixir_project");
2285 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2286 create_file(
2287 &project.join("_build/prod/lib/my_elixir_project.beam"),
2288 "bytecode",
2289 );
2290
2291 let scanner = default_scanner(ProjectFilter::Elixir);
2292 let projects = scanner.scan_directory(base);
2293 assert_eq!(projects.len(), 1);
2294 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2295 }
2296
2297 #[test]
2300 fn test_detect_deno_with_vendor() {
2301 let tmp = TempDir::new().unwrap();
2302 let base = tmp.path();
2303
2304 let project = base.join("deno-project");
2305 create_file(
2306 &project.join("deno.json"),
2307 r#"{"name": "my-deno-app", "imports": {}}"#,
2308 );
2309 create_file(&project.join("vendor/modules.json"), "{}");
2310
2311 let scanner = default_scanner(ProjectFilter::Deno);
2312 let projects = scanner.scan_directory(base);
2313 assert_eq!(projects.len(), 1);
2314 assert_eq!(projects[0].kind, ProjectType::Deno);
2315 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2316 }
2317
2318 #[test]
2319 fn test_detect_deno_jsonc_config() {
2320 let tmp = TempDir::new().unwrap();
2321 let base = tmp.path();
2322
2323 let project = base.join("deno-jsonc-project");
2324 create_file(
2325 &project.join("deno.jsonc"),
2326 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2327 );
2328 create_file(&project.join("vendor/modules.json"), "{}");
2329
2330 let scanner = default_scanner(ProjectFilter::Deno);
2331 let projects = scanner.scan_directory(base);
2332 assert_eq!(projects.len(), 1);
2333 assert_eq!(projects[0].kind, ProjectType::Deno);
2334 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2335 }
2336
2337 #[test]
2338 fn test_detect_deno_node_modules_without_package_json() {
2339 let tmp = TempDir::new().unwrap();
2340 let base = tmp.path();
2341
2342 let project = base.join("deno-npm-project");
2343 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2344 create_file(
2345 &project.join("node_modules/.deno/lodash/index.js"),
2346 "// lodash",
2347 );
2348
2349 let scanner = default_scanner(ProjectFilter::Deno);
2350 let projects = scanner.scan_directory(base);
2351 assert_eq!(projects.len(), 1);
2352 assert_eq!(projects[0].kind, ProjectType::Deno);
2353 }
2354
2355 #[test]
2356 fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2357 let tmp = TempDir::new().unwrap();
2358 let base = tmp.path();
2359
2360 let project = base.join("ambiguous-project");
2362 create_file(&project.join("deno.json"), r"{}");
2363 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2364 create_file(&project.join("node_modules/dep/index.js"), "// dep");
2365
2366 let scanner = default_scanner(ProjectFilter::All);
2367 let projects = scanner.scan_directory(base);
2368 assert_eq!(projects.len(), 1);
2369 assert_eq!(projects[0].kind, ProjectType::Node);
2370 }
2371
2372 #[test]
2373 fn test_detect_deno_no_artifact_not_detected() {
2374 let tmp = TempDir::new().unwrap();
2375 let base = tmp.path();
2376
2377 let project = base.join("deno-no-artifact");
2378 create_file(&project.join("deno.json"), r"{}");
2379
2380 let scanner = default_scanner(ProjectFilter::Deno);
2381 let projects = scanner.scan_directory(base);
2382 assert_eq!(projects.len(), 0);
2383 }
2384
2385 #[test]
2386 fn test_build_directory_is_excluded() {
2387 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2388 }
2389}