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 walker = self.scan_options.max_depth.map_or_else(
147 || WalkDir::new(root),
148 |depth| WalkDir::new(root).max_depth(depth),
149 );
150
151 let potential_projects: Vec<_> = walker
152 .into_iter()
153 .filter_map(Result::ok)
154 .filter(|entry| self.should_scan_entry(entry))
155 .collect::<Vec<_>>()
156 .into_par_iter()
157 .filter_map(|entry| {
158 let result = self.detect_project(&entry, &errors);
159 if result.is_some() {
160 let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
161 progress_clone.set_message(format!("Scanning... {n} found"));
162 }
163 result
164 })
165 .collect();
166
167 progress.finish_with_message("✅ Directory scan complete");
168
169 let projects_with_sizes: Vec<_> = potential_projects
171 .into_par_iter()
172 .filter_map(|mut project| {
173 for artifact in &mut project.build_arts {
174 if artifact.size == 0 {
175 artifact.size = Self::calculate_build_dir_size(&artifact.path);
176 }
177 }
178
179 if project.total_size() > 0 {
180 Some(project)
181 } else {
182 None
183 }
184 })
185 .collect();
186
187 if self.scan_options.verbose {
189 let errors = errors.lock().unwrap();
190 for error in errors.iter() {
191 eprintln!("{}", error.red());
192 }
193 }
194
195 projects_with_sizes
196 }
197
198 fn calculate_build_dir_size(path: &Path) -> u64 {
219 if !path.exists() {
220 return 0;
221 }
222
223 crate::utils::calculate_dir_size(path)
224 }
225
226 fn detect_node_project(
248 &self,
249 path: &Path,
250 errors: &Arc<Mutex<Vec<String>>>,
251 ) -> Option<Project> {
252 let package_json = path.join("package.json");
253 let node_modules = path.join("node_modules");
254
255 if package_json.exists() && node_modules.exists() {
256 let name = self.extract_node_project_name(&package_json, errors);
257
258 let build_arts = vec![BuildArtifacts {
259 path: path.join("node_modules"),
260 size: 0, }];
262
263 return Some(Project::new(
264 ProjectType::Node,
265 path.to_path_buf(),
266 build_arts,
267 name,
268 ));
269 }
270
271 None
272 }
273
274 fn detect_project(
309 &self,
310 entry: &DirEntry,
311 errors: &Arc<Mutex<Vec<String>>>,
312 ) -> Option<Project> {
313 let path = entry.path();
314
315 if !entry.file_type().is_dir() {
316 return None;
317 }
318
319 self.try_detect(ProjectFilter::Rust, || {
324 self.detect_rust_project(path, errors)
325 })
326 .or_else(|| {
327 self.try_detect(ProjectFilter::Deno, || {
328 self.detect_deno_project(path, errors)
329 })
330 })
331 .or_else(|| {
332 self.try_detect(ProjectFilter::Node, || {
333 self.detect_node_project(path, errors)
334 })
335 })
336 .or_else(|| {
337 self.try_detect(ProjectFilter::Scala, || {
338 self.detect_scala_project(path, errors)
339 })
340 })
341 .or_else(|| {
342 self.try_detect(ProjectFilter::Java, || {
343 self.detect_java_project(path, errors)
344 })
345 })
346 .or_else(|| {
347 self.try_detect(ProjectFilter::Swift, || {
348 self.detect_swift_project(path, errors)
349 })
350 })
351 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
352 .or_else(|| {
353 self.try_detect(ProjectFilter::Python, || {
354 self.detect_python_project(path, errors)
355 })
356 })
357 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
358 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
359 .or_else(|| {
360 self.try_detect(ProjectFilter::Ruby, || {
361 self.detect_ruby_project(path, errors)
362 })
363 })
364 .or_else(|| {
365 self.try_detect(ProjectFilter::Elixir, || {
366 self.detect_elixir_project(path, errors)
367 })
368 })
369 .or_else(|| self.try_detect(ProjectFilter::Php, || self.detect_php_project(path, errors)))
370 .or_else(|| {
371 self.try_detect(ProjectFilter::Haskell, || {
372 self.detect_haskell_project(path, errors)
373 })
374 })
375 .or_else(|| {
376 self.try_detect(ProjectFilter::Dart, || {
377 self.detect_dart_project(path, errors)
378 })
379 })
380 .or_else(|| self.try_detect(ProjectFilter::Zig, || Self::detect_zig_project(path)))
381 }
382
383 fn try_detect(
388 &self,
389 filter: ProjectFilter,
390 detect: impl FnOnce() -> Option<Project>,
391 ) -> Option<Project> {
392 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
393 detect()
394 } else {
395 None
396 }
397 }
398
399 fn detect_rust_project(
421 &self,
422 path: &Path,
423 errors: &Arc<Mutex<Vec<String>>>,
424 ) -> Option<Project> {
425 let cargo_toml = path.join("Cargo.toml");
426 let target_dir = path.join("target");
427
428 if cargo_toml.exists() && target_dir.exists() {
429 if Self::is_inside_cargo_workspace(path) {
431 return None;
432 }
433
434 let name = self.extract_rust_project_name(&cargo_toml, errors);
435
436 let build_arts = vec![BuildArtifacts {
437 path: path.join("target"),
438 size: 0, }];
440
441 return Some(Project::new(
442 ProjectType::Rust,
443 path.to_path_buf(),
444 build_arts,
445 name,
446 ));
447 }
448
449 None
450 }
451
452 fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
454 fs::read_to_string(cargo_toml)
455 .map(|content| content.lines().any(|line| line.trim() == "[workspace]"))
456 .unwrap_or(false)
457 }
458
459 fn is_inside_cargo_workspace(path: &Path) -> bool {
462 path.ancestors()
463 .skip(1) .any(|ancestor| {
465 let cargo_toml = ancestor.join("Cargo.toml");
466 cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
467 })
468 }
469
470 fn extract_rust_project_name(
492 &self,
493 cargo_toml: &Path,
494 errors: &Arc<Mutex<Vec<String>>>,
495 ) -> Option<String> {
496 let content = self.read_file_content(cargo_toml, errors)?;
497 Self::parse_toml_name_field(&content)
498 }
499
500 fn extract_quoted_value(line: &str) -> Option<String> {
502 let start = line.find('"')?;
503 let end = line.rfind('"')?;
504
505 if start == end {
506 return None;
507 }
508
509 Some(line[start + 1..end].to_string())
510 }
511
512 fn extract_name_from_line(line: &str) -> Option<String> {
514 if !Self::is_name_line(line) {
515 return None;
516 }
517
518 Self::extract_quoted_value(line)
519 }
520
521 fn extract_node_project_name(
542 &self,
543 package_json: &Path,
544 errors: &Arc<Mutex<Vec<String>>>,
545 ) -> Option<String> {
546 match fs::read_to_string(package_json) {
547 Ok(content) => match from_str::<Value>(&content) {
548 Ok(json) => json
549 .get("name")
550 .and_then(|v| v.as_str())
551 .map(std::string::ToString::to_string),
552 Err(e) => {
553 if self.scan_options.verbose {
554 errors
555 .lock()
556 .unwrap()
557 .push(format!("Error parsing {}: {e}", package_json.display()));
558 }
559 None
560 }
561 },
562 Err(e) => {
563 if self.scan_options.verbose {
564 errors
565 .lock()
566 .unwrap()
567 .push(format!("Error reading {}: {e}", package_json.display()));
568 }
569 None
570 }
571 }
572 }
573
574 fn is_name_line(line: &str) -> bool {
576 line.starts_with("name") && line.contains('=')
577 }
578
579 fn log_file_error(
581 &self,
582 file_path: &Path,
583 error: &std::io::Error,
584 errors: &Arc<Mutex<Vec<String>>>,
585 ) {
586 if self.scan_options.verbose {
587 errors
588 .lock()
589 .unwrap()
590 .push(format!("Error reading {}: {error}", file_path.display()));
591 }
592 }
593
594 fn parse_toml_name_field(content: &str) -> Option<String> {
596 for line in content.lines() {
597 if let Some(name) = Self::extract_name_from_line(line.trim()) {
598 return Some(name);
599 }
600 }
601 None
602 }
603
604 fn read_file_content(
606 &self,
607 file_path: &Path,
608 errors: &Arc<Mutex<Vec<String>>>,
609 ) -> Option<String> {
610 match fs::read_to_string(file_path) {
611 Ok(content) => Some(content),
612 Err(e) => {
613 self.log_file_error(file_path, &e, errors);
614 None
615 }
616 }
617 }
618
619 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
653 let path = entry.path();
654
655 if self.is_path_in_skip_list(path) {
657 return false;
658 }
659
660 if path
662 .ancestors()
663 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
664 {
665 return false;
666 }
667
668 if Self::is_hidden_directory_to_skip(path) {
670 return false;
671 }
672
673 !Self::is_excluded_directory(path)
675 }
676
677 fn is_path_in_skip_list(&self, path: &Path) -> bool {
679 self.scan_options.skip.iter().any(|skip| {
680 path.components().any(|component| {
681 component
682 .as_os_str()
683 .to_str()
684 .is_some_and(|name| name == skip.to_string_lossy())
685 })
686 })
687 }
688
689 fn is_hidden_directory_to_skip(path: &Path) -> bool {
691 path.file_name()
692 .and_then(|n| n.to_str())
693 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
694 }
695
696 fn is_excluded_directory(path: &Path) -> bool {
698 let excluded_dirs = [
699 "target",
700 "build",
701 "dist",
702 "out",
703 ".git",
704 ".svn",
705 ".hg",
706 "__pycache__",
707 "venv",
708 ".venv",
709 "env",
710 ".env",
711 "temp",
712 "tmp",
713 "vendor",
714 ".pytest_cache",
715 ".tox",
716 ".eggs",
717 ".coverage",
718 "node_modules",
719 "obj",
720 "_build",
721 "zig-cache",
722 "zig-out",
723 "dist-newstyle",
724 ];
725
726 path.file_name()
727 .and_then(|n| n.to_str())
728 .is_some_and(|name| excluded_dirs.contains(&name))
729 }
730
731 fn detect_python_project(
752 &self,
753 path: &Path,
754 errors: &Arc<Mutex<Vec<String>>>,
755 ) -> Option<Project> {
756 let config_files = [
757 "requirements.txt",
758 "setup.py",
759 "pyproject.toml",
760 "setup.cfg",
761 "Pipfile",
762 "pipenv.lock",
763 "poetry.lock",
764 ];
765
766 let build_dirs = [
767 "__pycache__",
768 ".pytest_cache",
769 "venv",
770 ".venv",
771 "build",
772 "dist",
773 ".eggs",
774 ".tox",
775 ".coverage",
776 ];
777
778 let has_config = config_files.iter().any(|&file| path.join(file).exists());
780
781 if !has_config {
782 return None;
783 }
784
785 let mut build_arts: Vec<BuildArtifacts> = build_dirs
787 .iter()
788 .filter_map(|&dir_name| {
789 let dir_path = path.join(dir_name);
790 if dir_path.exists() && dir_path.is_dir() {
791 let size = crate::utils::calculate_dir_size(&dir_path);
792 Some(BuildArtifacts {
793 path: dir_path,
794 size,
795 })
796 } else {
797 None
798 }
799 })
800 .collect();
801
802 if let Ok(entries) = std::fs::read_dir(path) {
804 for entry in entries.flatten() {
805 let entry_path = entry.path();
806 if entry_path.is_dir()
807 && entry_path
808 .file_name()
809 .and_then(|n| n.to_str())
810 .is_some_and(|n| n.ends_with(".egg-info"))
811 {
812 let size = crate::utils::calculate_dir_size(&entry_path);
813 build_arts.push(BuildArtifacts {
814 path: entry_path,
815 size,
816 });
817 }
818 }
819 }
820
821 if build_arts.is_empty() {
822 return None;
823 }
824
825 let name = self.extract_python_project_name(path, errors);
826
827 Some(Project::new(
828 ProjectType::Python,
829 path.to_path_buf(),
830 build_arts,
831 name,
832 ))
833 }
834
835 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
857 let go_mod = path.join("go.mod");
858 let vendor_dir = path.join("vendor");
859
860 if go_mod.exists() && vendor_dir.exists() {
861 let name = self.extract_go_project_name(&go_mod, errors);
862
863 let build_arts = vec![BuildArtifacts {
864 path: path.join("vendor"),
865 size: 0, }];
867
868 return Some(Project::new(
869 ProjectType::Go,
870 path.to_path_buf(),
871 build_arts,
872 name,
873 ));
874 }
875
876 None
877 }
878
879 fn extract_python_project_name(
901 &self,
902 path: &Path,
903 errors: &Arc<Mutex<Vec<String>>>,
904 ) -> Option<String> {
905 self.try_extract_from_pyproject_toml(path, errors)
907 .or_else(|| self.try_extract_from_setup_py(path, errors))
908 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
909 .or_else(|| Self::fallback_to_directory_name(path))
910 }
911
912 fn try_extract_from_pyproject_toml(
914 &self,
915 path: &Path,
916 errors: &Arc<Mutex<Vec<String>>>,
917 ) -> Option<String> {
918 let pyproject_toml = path.join("pyproject.toml");
919 if !pyproject_toml.exists() {
920 return None;
921 }
922
923 let content = self.read_file_content(&pyproject_toml, errors)?;
924 Self::extract_name_from_toml_like_content(&content)
925 }
926
927 fn try_extract_from_setup_py(
929 &self,
930 path: &Path,
931 errors: &Arc<Mutex<Vec<String>>>,
932 ) -> Option<String> {
933 let setup_py = path.join("setup.py");
934 if !setup_py.exists() {
935 return None;
936 }
937
938 let content = self.read_file_content(&setup_py, errors)?;
939 Self::extract_name_from_python_content(&content)
940 }
941
942 fn try_extract_from_setup_cfg(
944 &self,
945 path: &Path,
946 errors: &Arc<Mutex<Vec<String>>>,
947 ) -> Option<String> {
948 let setup_cfg = path.join("setup.cfg");
949 if !setup_cfg.exists() {
950 return None;
951 }
952
953 let content = self.read_file_content(&setup_cfg, errors)?;
954 Self::extract_name_from_cfg_content(&content)
955 }
956
957 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
959 content
960 .lines()
961 .map(str::trim)
962 .find(|line| line.starts_with("name") && line.contains('='))
963 .and_then(Self::extract_quoted_value)
964 }
965
966 fn extract_name_from_python_content(content: &str) -> Option<String> {
968 content
969 .lines()
970 .map(str::trim)
971 .find(|line| line.contains("name") && line.contains('='))
972 .and_then(Self::extract_quoted_value)
973 }
974
975 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
977 let mut in_metadata_section = false;
978
979 for line in content.lines() {
980 let line = line.trim();
981
982 if line == "[metadata]" {
983 in_metadata_section = true;
984 } else if line.starts_with('[') && line.ends_with(']') {
985 in_metadata_section = false;
986 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
987 return line.split('=').nth(1).map(|name| name.trim().to_string());
988 }
989 }
990
991 None
992 }
993
994 fn fallback_to_directory_name(path: &Path) -> Option<String> {
996 path.file_name()
997 .and_then(|name| name.to_str())
998 .map(std::string::ToString::to_string)
999 }
1000
1001 fn extract_go_project_name(
1021 &self,
1022 go_mod: &Path,
1023 errors: &Arc<Mutex<Vec<String>>>,
1024 ) -> Option<String> {
1025 let content = self.read_file_content(go_mod, errors)?;
1026
1027 for line in content.lines() {
1028 let line = line.trim();
1029 if line.starts_with("module ") {
1030 let module_path = line.strip_prefix("module ")?.trim();
1031
1032 if let Some(name) = module_path.split('/').next_back() {
1034 return Some(name.to_string());
1035 }
1036
1037 return Some(module_path.to_string());
1038 }
1039 }
1040
1041 None
1042 }
1043
1044 fn detect_java_project(
1055 &self,
1056 path: &Path,
1057 errors: &Arc<Mutex<Vec<String>>>,
1058 ) -> Option<Project> {
1059 let pom_xml = path.join("pom.xml");
1060 let target_dir = path.join("target");
1061
1062 if pom_xml.exists() && target_dir.exists() {
1064 let name = self.extract_java_maven_project_name(&pom_xml, errors);
1065
1066 let build_arts = vec![BuildArtifacts {
1067 path: target_dir,
1068 size: 0,
1069 }];
1070
1071 return Some(Project::new(
1072 ProjectType::Java,
1073 path.to_path_buf(),
1074 build_arts,
1075 name,
1076 ));
1077 }
1078
1079 let has_gradle =
1081 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1082 let build_dir = path.join("build");
1083
1084 if has_gradle && build_dir.exists() {
1085 let name = self.extract_java_gradle_project_name(path, errors);
1086
1087 let build_arts = vec![BuildArtifacts {
1088 path: build_dir,
1089 size: 0,
1090 }];
1091
1092 return Some(Project::new(
1093 ProjectType::Java,
1094 path.to_path_buf(),
1095 build_arts,
1096 name,
1097 ));
1098 }
1099
1100 None
1101 }
1102
1103 fn extract_java_maven_project_name(
1107 &self,
1108 pom_xml: &Path,
1109 errors: &Arc<Mutex<Vec<String>>>,
1110 ) -> Option<String> {
1111 let content = self.read_file_content(pom_xml, errors)?;
1112
1113 for line in content.lines() {
1114 let trimmed = line.trim();
1115 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1116 let name = trimmed
1117 .strip_prefix("<artifactId>")?
1118 .strip_suffix("</artifactId>")?;
1119 return Some(name.to_string());
1120 }
1121 }
1122
1123 None
1124 }
1125
1126 fn extract_java_gradle_project_name(
1131 &self,
1132 path: &Path,
1133 errors: &Arc<Mutex<Vec<String>>>,
1134 ) -> Option<String> {
1135 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1136 let settings_path = path.join(settings_file);
1137 if settings_path.exists()
1138 && let Some(content) = self.read_file_content(&settings_path, errors)
1139 {
1140 for line in content.lines() {
1141 let trimmed = line.trim();
1142 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1143 return Self::extract_quoted_value(trimmed).or_else(|| {
1144 trimmed
1145 .split('=')
1146 .nth(1)
1147 .map(|s| s.trim().trim_matches('\'').to_string())
1148 });
1149 }
1150 }
1151 }
1152 }
1153
1154 Self::fallback_to_directory_name(path)
1155 }
1156
1157 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1167 let build_dir = path.join("build");
1168
1169 if !build_dir.exists() {
1170 return None;
1171 }
1172
1173 let cmake_file = path.join("CMakeLists.txt");
1174 let makefile = path.join("Makefile");
1175
1176 if cmake_file.exists() || makefile.exists() {
1177 let name = if cmake_file.exists() {
1178 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1179 } else {
1180 Self::fallback_to_directory_name(path)
1181 };
1182
1183 let build_arts = vec![BuildArtifacts {
1184 path: build_dir,
1185 size: 0,
1186 }];
1187
1188 return Some(Project::new(
1189 ProjectType::Cpp,
1190 path.to_path_buf(),
1191 build_arts,
1192 name,
1193 ));
1194 }
1195
1196 None
1197 }
1198
1199 fn extract_cpp_cmake_project_name(
1203 &self,
1204 cmake_file: &Path,
1205 errors: &Arc<Mutex<Vec<String>>>,
1206 ) -> Option<String> {
1207 let content = self.read_file_content(cmake_file, errors)?;
1208
1209 for line in content.lines() {
1210 let trimmed = line.trim();
1211 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1212 let inner = trimmed
1213 .trim_start_matches("project(")
1214 .trim_start_matches("PROJECT(")
1215 .trim_end_matches(')')
1216 .trim();
1217
1218 let name = inner.split_whitespace().next()?;
1220 let name = name.trim_matches('"').trim_matches('\'');
1222 if !name.is_empty() {
1223 return Some(name.to_string());
1224 }
1225 }
1226 }
1227
1228 Self::fallback_to_directory_name(cmake_file.parent()?)
1229 }
1230
1231 fn detect_swift_project(
1241 &self,
1242 path: &Path,
1243 errors: &Arc<Mutex<Vec<String>>>,
1244 ) -> Option<Project> {
1245 let package_swift = path.join("Package.swift");
1246 let build_dir = path.join(".build");
1247
1248 if package_swift.exists() && build_dir.exists() {
1249 let name = self.extract_swift_project_name(&package_swift, errors);
1250
1251 let build_arts = vec![BuildArtifacts {
1252 path: build_dir,
1253 size: 0,
1254 }];
1255
1256 return Some(Project::new(
1257 ProjectType::Swift,
1258 path.to_path_buf(),
1259 build_arts,
1260 name,
1261 ));
1262 }
1263
1264 None
1265 }
1266
1267 fn extract_swift_project_name(
1271 &self,
1272 package_swift: &Path,
1273 errors: &Arc<Mutex<Vec<String>>>,
1274 ) -> Option<String> {
1275 let content = self.read_file_content(package_swift, errors)?;
1276
1277 for line in content.lines() {
1278 let trimmed = line.trim();
1279 if trimmed.contains("name:") {
1280 return Self::extract_quoted_value(trimmed);
1281 }
1282 }
1283
1284 Self::fallback_to_directory_name(package_swift.parent()?)
1285 }
1286
1287 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1297 let bin_dir = path.join("bin");
1298 let obj_dir = path.join("obj");
1299
1300 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1301 if !has_build_dir {
1302 return None;
1303 }
1304
1305 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1306
1307 let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1309 (true, true) => {
1310 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1311 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1312 vec![
1313 BuildArtifacts {
1314 path: bin_dir,
1315 size: bin_size,
1316 },
1317 BuildArtifacts {
1318 path: obj_dir,
1319 size: obj_size,
1320 },
1321 ]
1322 }
1323 (true, false) => vec![BuildArtifacts {
1324 path: bin_dir,
1325 size: 0,
1326 }],
1327 (false, true) => vec![BuildArtifacts {
1328 path: obj_dir,
1329 size: 0,
1330 }],
1331 (false, false) => return None,
1332 };
1333
1334 let name = csproj_file
1335 .file_stem()
1336 .and_then(|s| s.to_str())
1337 .map(std::string::ToString::to_string);
1338
1339 Some(Project::new(
1340 ProjectType::DotNet,
1341 path.to_path_buf(),
1342 build_arts,
1343 name,
1344 ))
1345 }
1346
1347 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1349 let entries = fs::read_dir(dir).ok()?;
1350 for entry in entries.flatten() {
1351 let path = entry.path();
1352 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1353 return Some(path);
1354 }
1355 }
1356 None
1357 }
1358
1359 fn detect_deno_project(
1368 &self,
1369 path: &Path,
1370 errors: &Arc<Mutex<Vec<String>>>,
1371 ) -> Option<Project> {
1372 let deno_json = path.join("deno.json");
1373 let deno_jsonc = path.join("deno.jsonc");
1374
1375 if !deno_json.exists() && !deno_jsonc.exists() {
1376 return None;
1377 }
1378
1379 let config_path = if deno_json.exists() {
1380 deno_json
1381 } else {
1382 deno_jsonc
1383 };
1384
1385 let vendor_dir = path.join("vendor");
1387 if vendor_dir.exists() {
1388 let name = self.extract_deno_project_name(&config_path, errors);
1389 return Some(Project::new(
1390 ProjectType::Deno,
1391 path.to_path_buf(),
1392 vec![BuildArtifacts {
1393 path: vendor_dir,
1394 size: 0,
1395 }],
1396 name,
1397 ));
1398 }
1399
1400 let node_modules = path.join("node_modules");
1402 if node_modules.exists() && !path.join("package.json").exists() {
1403 let name = self.extract_deno_project_name(&config_path, errors);
1404 return Some(Project::new(
1405 ProjectType::Deno,
1406 path.to_path_buf(),
1407 vec![BuildArtifacts {
1408 path: node_modules,
1409 size: 0,
1410 }],
1411 name,
1412 ));
1413 }
1414
1415 None
1416 }
1417
1418 fn extract_deno_project_name(
1423 &self,
1424 config_path: &Path,
1425 errors: &Arc<Mutex<Vec<String>>>,
1426 ) -> Option<String> {
1427 match fs::read_to_string(config_path) {
1428 Ok(content) => {
1429 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1430 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1431 {
1432 return Some(name.to_string());
1433 }
1434 Self::fallback_to_directory_name(config_path.parent()?)
1435 }
1436 Err(e) => {
1437 self.log_file_error(config_path, &e, errors);
1438 Self::fallback_to_directory_name(config_path.parent()?)
1439 }
1440 }
1441 }
1442
1443 fn detect_ruby_project(
1453 &self,
1454 path: &Path,
1455 errors: &Arc<Mutex<Vec<String>>>,
1456 ) -> Option<Project> {
1457 let gemfile = path.join("Gemfile");
1458 if !gemfile.exists() {
1459 return None;
1460 }
1461
1462 let bundle_dir = path.join(".bundle");
1463 let vendor_bundle_dir = path.join("vendor").join("bundle");
1464
1465 let build_arts: Vec<BuildArtifacts> =
1466 match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1467 (true, true) => {
1468 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1469 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1470 vec![
1471 BuildArtifacts {
1472 path: bundle_dir,
1473 size: bundle_size,
1474 },
1475 BuildArtifacts {
1476 path: vendor_bundle_dir,
1477 size: vendor_size,
1478 },
1479 ]
1480 }
1481 (true, false) => vec![BuildArtifacts {
1482 path: bundle_dir,
1483 size: 0,
1484 }],
1485 (false, true) => vec![BuildArtifacts {
1486 path: vendor_bundle_dir,
1487 size: 0,
1488 }],
1489 (false, false) => return None,
1490 };
1491
1492 let name = self.extract_ruby_project_name(path, errors);
1493
1494 Some(Project::new(
1495 ProjectType::Ruby,
1496 path.to_path_buf(),
1497 build_arts,
1498 name,
1499 ))
1500 }
1501
1502 fn extract_ruby_project_name(
1507 &self,
1508 path: &Path,
1509 errors: &Arc<Mutex<Vec<String>>>,
1510 ) -> Option<String> {
1511 let entries = fs::read_dir(path).ok()?;
1512 for entry in entries.flatten() {
1513 let entry_path = entry.path();
1514 if entry_path.is_file()
1515 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1516 && let Some(content) = self.read_file_content(&entry_path, errors)
1517 {
1518 for line in content.lines() {
1519 let trimmed = line.trim();
1520 if trimmed.contains(".name")
1521 && trimmed.contains('=')
1522 && let Some(name) = Self::extract_quoted_value(trimmed)
1523 {
1524 return Some(name);
1525 }
1526 }
1527 }
1528 }
1529
1530 Self::fallback_to_directory_name(path)
1531 }
1532
1533 fn detect_elixir_project(
1543 &self,
1544 path: &Path,
1545 errors: &Arc<Mutex<Vec<String>>>,
1546 ) -> Option<Project> {
1547 let mix_exs = path.join("mix.exs");
1548 let build_dir = path.join("_build");
1549
1550 if mix_exs.exists() && build_dir.exists() {
1551 let name = self.extract_elixir_project_name(&mix_exs, errors);
1552
1553 return Some(Project::new(
1554 ProjectType::Elixir,
1555 path.to_path_buf(),
1556 vec![BuildArtifacts {
1557 path: build_dir,
1558 size: 0,
1559 }],
1560 name,
1561 ));
1562 }
1563
1564 None
1565 }
1566
1567 fn extract_elixir_project_name(
1572 &self,
1573 mix_exs: &Path,
1574 errors: &Arc<Mutex<Vec<String>>>,
1575 ) -> Option<String> {
1576 let content = self.read_file_content(mix_exs, errors)?;
1577
1578 for line in content.lines() {
1579 let trimmed = line.trim();
1580 if trimmed.contains("app:")
1581 && let Some(pos) = trimmed.find("app:")
1582 {
1583 let after = trimmed[pos + 4..].trim_start();
1584 if let Some(atom) = after.strip_prefix(':') {
1585 let name: String = atom
1587 .chars()
1588 .take_while(|c| c.is_alphanumeric() || *c == '_')
1589 .collect();
1590 if !name.is_empty() {
1591 return Some(name);
1592 }
1593 }
1594 }
1595 }
1596
1597 Self::fallback_to_directory_name(mix_exs.parent()?)
1598 }
1599
1600 fn detect_php_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1610 let composer_json = path.join("composer.json");
1611 let vendor_dir = path.join("vendor");
1612
1613 if composer_json.exists() && vendor_dir.exists() {
1614 let name = self.extract_php_project_name(&composer_json, errors);
1615
1616 return Some(Project::new(
1617 ProjectType::Php,
1618 path.to_path_buf(),
1619 vec![BuildArtifacts {
1620 path: vendor_dir,
1621 size: 0,
1622 }],
1623 name,
1624 ));
1625 }
1626
1627 None
1628 }
1629
1630 fn extract_php_project_name(
1636 &self,
1637 composer_json: &Path,
1638 errors: &Arc<Mutex<Vec<String>>>,
1639 ) -> Option<String> {
1640 match fs::read_to_string(composer_json) {
1641 Ok(content) => {
1642 if let Ok(json) = from_str::<Value>(&content)
1643 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1644 {
1645 let package = name.split('/').next_back().unwrap_or(name);
1647 return Some(package.to_string());
1648 }
1649 Self::fallback_to_directory_name(composer_json.parent()?)
1650 }
1651 Err(e) => {
1652 self.log_file_error(composer_json, &e, errors);
1653 Self::fallback_to_directory_name(composer_json.parent()?)
1654 }
1655 }
1656 }
1657
1658 fn detect_haskell_project(
1664 &self,
1665 path: &Path,
1666 errors: &Arc<Mutex<Vec<String>>>,
1667 ) -> Option<Project> {
1668 let stack_yaml = path.join("stack.yaml");
1670 let stack_work = path.join(".stack-work");
1671
1672 if stack_yaml.exists() && stack_work.exists() {
1673 let name = self.extract_haskell_project_name(path, errors);
1674 return Some(Project::new(
1675 ProjectType::Haskell,
1676 path.to_path_buf(),
1677 vec![BuildArtifacts {
1678 path: stack_work,
1679 size: 0,
1680 }],
1681 name,
1682 ));
1683 }
1684
1685 let dist_newstyle = path.join("dist-newstyle");
1687 if dist_newstyle.exists() {
1688 let has_cabal_project = path.join("cabal.project").exists();
1689 let has_cabal_file = Self::find_file_with_extension(path, "cabal").is_some();
1690
1691 if has_cabal_project || has_cabal_file {
1692 let name = self.extract_haskell_project_name(path, errors);
1693 return Some(Project::new(
1694 ProjectType::Haskell,
1695 path.to_path_buf(),
1696 vec![BuildArtifacts {
1697 path: dist_newstyle,
1698 size: 0,
1699 }],
1700 name,
1701 ));
1702 }
1703 }
1704
1705 None
1706 }
1707
1708 fn extract_haskell_project_name(
1713 &self,
1714 path: &Path,
1715 errors: &Arc<Mutex<Vec<String>>>,
1716 ) -> Option<String> {
1717 if let Some(cabal_file) = Self::find_file_with_extension(path, "cabal")
1719 && let Some(content) = self.read_file_content(&cabal_file, errors)
1720 {
1721 for line in content.lines() {
1722 let trimmed = line.trim();
1723 if let Some(rest) = trimmed.strip_prefix("name:") {
1724 let name = rest.trim().to_string();
1725 if !name.is_empty() {
1726 return Some(name);
1727 }
1728 }
1729 }
1730 }
1731
1732 let package_yaml = path.join("package.yaml");
1734 if package_yaml.exists()
1735 && let Some(content) = self.read_file_content(&package_yaml, errors)
1736 {
1737 for line in content.lines() {
1738 let trimmed = line.trim();
1739 if let Some(rest) = trimmed.strip_prefix("name:") {
1740 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1741 if !name.is_empty() {
1742 return Some(name);
1743 }
1744 }
1745 }
1746 }
1747
1748 Self::fallback_to_directory_name(path)
1749 }
1750
1751 fn detect_dart_project(
1761 &self,
1762 path: &Path,
1763 errors: &Arc<Mutex<Vec<String>>>,
1764 ) -> Option<Project> {
1765 let pubspec_yaml = path.join("pubspec.yaml");
1766 if !pubspec_yaml.exists() {
1767 return None;
1768 }
1769
1770 let dart_tool = path.join(".dart_tool");
1771 let build_dir = path.join("build");
1772
1773 let build_arts: Vec<BuildArtifacts> = match (dart_tool.exists(), build_dir.exists()) {
1774 (true, true) => {
1775 let dart_size = crate::utils::calculate_dir_size(&dart_tool);
1776 let build_size = crate::utils::calculate_dir_size(&build_dir);
1777 vec![
1778 BuildArtifacts {
1779 path: dart_tool,
1780 size: dart_size,
1781 },
1782 BuildArtifacts {
1783 path: build_dir,
1784 size: build_size,
1785 },
1786 ]
1787 }
1788 (true, false) => vec![BuildArtifacts {
1789 path: dart_tool,
1790 size: 0,
1791 }],
1792 (false, true) => vec![BuildArtifacts {
1793 path: build_dir,
1794 size: 0,
1795 }],
1796 (false, false) => return None,
1797 };
1798
1799 let name = self.extract_dart_project_name(&pubspec_yaml, errors);
1800
1801 Some(Project::new(
1802 ProjectType::Dart,
1803 path.to_path_buf(),
1804 build_arts,
1805 name,
1806 ))
1807 }
1808
1809 fn extract_dart_project_name(
1814 &self,
1815 pubspec_yaml: &Path,
1816 errors: &Arc<Mutex<Vec<String>>>,
1817 ) -> Option<String> {
1818 let content = self.read_file_content(pubspec_yaml, errors)?;
1819
1820 for line in content.lines() {
1821 let trimmed = line.trim();
1822 if let Some(rest) = trimmed.strip_prefix("name:") {
1823 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1824 if !name.is_empty() {
1825 return Some(name);
1826 }
1827 }
1828 }
1829
1830 Self::fallback_to_directory_name(pubspec_yaml.parent()?)
1831 }
1832
1833 fn detect_zig_project(path: &Path) -> Option<Project> {
1843 let build_zig = path.join("build.zig");
1844 if !build_zig.exists() {
1845 return None;
1846 }
1847
1848 let zig_cache = path.join("zig-cache");
1849 let zig_out = path.join("zig-out");
1850
1851 let build_arts: Vec<BuildArtifacts> = match (zig_cache.exists(), zig_out.exists()) {
1852 (true, true) => {
1853 let cache_size = crate::utils::calculate_dir_size(&zig_cache);
1854 let out_size = crate::utils::calculate_dir_size(&zig_out);
1855 vec![
1856 BuildArtifacts {
1857 path: zig_cache,
1858 size: cache_size,
1859 },
1860 BuildArtifacts {
1861 path: zig_out,
1862 size: out_size,
1863 },
1864 ]
1865 }
1866 (true, false) => vec![BuildArtifacts {
1867 path: zig_cache,
1868 size: 0,
1869 }],
1870 (false, true) => vec![BuildArtifacts {
1871 path: zig_out,
1872 size: 0,
1873 }],
1874 (false, false) => return None,
1875 };
1876
1877 let name = Self::fallback_to_directory_name(path);
1878
1879 Some(Project::new(
1880 ProjectType::Zig,
1881 path.to_path_buf(),
1882 build_arts,
1883 name,
1884 ))
1885 }
1886
1887 fn detect_scala_project(
1897 &self,
1898 path: &Path,
1899 errors: &Arc<Mutex<Vec<String>>>,
1900 ) -> Option<Project> {
1901 let build_sbt = path.join("build.sbt");
1902 let target_dir = path.join("target");
1903
1904 if build_sbt.exists() && target_dir.exists() {
1905 let name = self.extract_scala_project_name(&build_sbt, errors);
1906
1907 return Some(Project::new(
1908 ProjectType::Scala,
1909 path.to_path_buf(),
1910 vec![BuildArtifacts {
1911 path: target_dir,
1912 size: 0,
1913 }],
1914 name,
1915 ));
1916 }
1917
1918 None
1919 }
1920
1921 fn extract_scala_project_name(
1926 &self,
1927 build_sbt: &Path,
1928 errors: &Arc<Mutex<Vec<String>>>,
1929 ) -> Option<String> {
1930 let content = self.read_file_content(build_sbt, errors)?;
1931
1932 for line in content.lines() {
1933 let trimmed = line.trim();
1934 if trimmed.starts_with("name")
1935 && trimmed.contains(":=")
1936 && let Some(name) = Self::extract_quoted_value(trimmed)
1937 {
1938 return Some(name);
1939 }
1940 }
1941
1942 Self::fallback_to_directory_name(build_sbt.parent()?)
1943 }
1944}
1945
1946#[cfg(test)]
1947mod tests {
1948 use super::*;
1949 use std::path::PathBuf;
1950 use tempfile::TempDir;
1951
1952 fn default_scanner(filter: ProjectFilter) -> Scanner {
1954 Scanner::new(
1955 ScanOptions {
1956 verbose: false,
1957 threads: 1,
1958 skip: vec![],
1959 max_depth: None,
1960 },
1961 filter,
1962 )
1963 }
1964
1965 fn create_file(path: &Path, content: &str) {
1967 if let Some(parent) = path.parent() {
1968 fs::create_dir_all(parent).unwrap();
1969 }
1970 fs::write(path, content).unwrap();
1971 }
1972
1973 #[test]
1976 fn test_is_hidden_directory_to_skip() {
1977 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1979 "/some/.hidden"
1980 )));
1981 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1982 "/some/.git"
1983 )));
1984 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1985 "/some/.svn"
1986 )));
1987 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1988
1989 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1991 "/home/user/.cargo"
1992 )));
1993 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1994
1995 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1997 "/some/visible"
1998 )));
1999 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
2000 }
2001
2002 #[test]
2003 fn test_is_excluded_directory() {
2004 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
2006 assert!(Scanner::is_excluded_directory(Path::new(
2007 "/some/node_modules"
2008 )));
2009 assert!(Scanner::is_excluded_directory(Path::new(
2010 "/some/__pycache__"
2011 )));
2012 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
2013 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
2014 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
2015 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
2016
2017 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
2019 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
2020 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
2021
2022 assert!(Scanner::is_excluded_directory(Path::new(
2024 "/some/.pytest_cache"
2025 )));
2026 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
2027 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
2028 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
2029
2030 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
2032 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
2033 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
2034 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
2035
2036 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
2038 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
2039
2040 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
2042 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
2043 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
2044 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
2045 }
2046
2047 #[test]
2048 fn test_extract_quoted_value() {
2049 assert_eq!(
2050 Scanner::extract_quoted_value(r#"name = "my-project""#),
2051 Some("my-project".to_string())
2052 );
2053 assert_eq!(
2054 Scanner::extract_quoted_value(r#"name = "with spaces""#),
2055 Some("with spaces".to_string())
2056 );
2057 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
2058 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
2060 }
2061
2062 #[test]
2063 fn test_is_name_line() {
2064 assert!(Scanner::is_name_line("name = \"test\""));
2065 assert!(Scanner::is_name_line("name=\"test\""));
2066 assert!(!Scanner::is_name_line("version = \"1.0\""));
2067 assert!(!Scanner::is_name_line("# name = \"commented\""));
2068 assert!(!Scanner::is_name_line("name: \"yaml style\""));
2069 }
2070
2071 #[test]
2072 fn test_parse_toml_name_field() {
2073 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
2074 assert_eq!(
2075 Scanner::parse_toml_name_field(content),
2076 Some("test-project".to_string())
2077 );
2078
2079 let no_name = "[package]\nversion = \"0.1.0\"\n";
2080 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
2081
2082 let empty = "";
2083 assert_eq!(Scanner::parse_toml_name_field(empty), None);
2084 }
2085
2086 #[test]
2087 fn test_extract_name_from_cfg_content() {
2088 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
2089 assert_eq!(
2090 Scanner::extract_name_from_cfg_content(content),
2091 Some("my-package".to_string())
2092 );
2093
2094 let wrong_section = "[options]\nname = not-this\n";
2096 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
2097
2098 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
2100 assert_eq!(
2101 Scanner::extract_name_from_cfg_content(multi),
2102 Some("correct".to_string())
2103 );
2104 }
2105
2106 #[test]
2107 fn test_extract_name_from_python_content() {
2108 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
2109 assert_eq!(
2110 Scanner::extract_name_from_python_content(content),
2111 Some("my-pkg".to_string())
2112 );
2113
2114 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
2115 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
2116 }
2117
2118 #[test]
2119 fn test_fallback_to_directory_name() {
2120 assert_eq!(
2121 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
2122 Some("project-name".to_string())
2123 );
2124 assert_eq!(
2125 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
2126 Some("my_app".to_string())
2127 );
2128 }
2129
2130 #[test]
2131 fn test_is_path_in_skip_list() {
2132 let scanner = Scanner::new(
2133 ScanOptions {
2134 verbose: false,
2135 threads: 1,
2136 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
2137 max_depth: None,
2138 },
2139 ProjectFilter::All,
2140 );
2141
2142 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
2143 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
2144 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
2145 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
2146 }
2147
2148 #[test]
2149 fn test_is_path_in_empty_skip_list() {
2150 let scanner = default_scanner(ProjectFilter::All);
2151 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
2152 }
2153
2154 #[test]
2157 fn test_scan_directory_with_spaces_in_path() {
2158 let tmp = TempDir::new().unwrap();
2159 let base = tmp.path().join("path with spaces");
2160 fs::create_dir_all(&base).unwrap();
2161
2162 let project = base.join("my project");
2163 create_file(
2164 &project.join("Cargo.toml"),
2165 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
2166 );
2167 create_file(&project.join("target/dummy"), "content");
2168
2169 let scanner = default_scanner(ProjectFilter::Rust);
2170 let projects = scanner.scan_directory(&base);
2171 assert_eq!(projects.len(), 1);
2172 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
2173 }
2174
2175 #[test]
2176 fn test_scan_directory_with_unicode_names() {
2177 let tmp = TempDir::new().unwrap();
2178 let base = tmp.path();
2179
2180 let project = base.join("プロジェクト");
2181 create_file(
2182 &project.join("package.json"),
2183 r#"{"name": "unicode-project"}"#,
2184 );
2185 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
2186
2187 let scanner = default_scanner(ProjectFilter::Node);
2188 let projects = scanner.scan_directory(base);
2189 assert_eq!(projects.len(), 1);
2190 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
2191 }
2192
2193 #[test]
2194 fn test_scan_directory_with_special_characters_in_name() {
2195 let tmp = TempDir::new().unwrap();
2196 let base = tmp.path();
2197
2198 let project = base.join("project-with-dashes_and_underscores.v2");
2199 create_file(
2200 &project.join("Cargo.toml"),
2201 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
2202 );
2203 create_file(&project.join("target/dummy"), "content");
2204
2205 let scanner = default_scanner(ProjectFilter::Rust);
2206 let projects = scanner.scan_directory(base);
2207 assert_eq!(projects.len(), 1);
2208 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
2209 }
2210
2211 #[test]
2214 #[cfg(unix)]
2215 fn test_hidden_directory_itself_not_detected_as_project_unix() {
2216 let tmp = TempDir::new().unwrap();
2217 let base = tmp.path();
2218
2219 let hidden = base.join(".hidden-project");
2224 create_file(
2225 &hidden.join("Cargo.toml"),
2226 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
2227 );
2228 create_file(&hidden.join("target/dummy"), "content");
2229
2230 let visible = base.join("visible-project");
2232 create_file(
2233 &visible.join("Cargo.toml"),
2234 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
2235 );
2236 create_file(&visible.join("target/dummy"), "content");
2237
2238 let scanner = default_scanner(ProjectFilter::Rust);
2239 let projects = scanner.scan_directory(base);
2240
2241 assert_eq!(projects.len(), 1);
2244 assert_eq!(projects[0].name.as_deref(), Some("visible"));
2245 }
2246
2247 #[test]
2248 #[cfg(unix)]
2249 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
2250 let tmp = TempDir::new().unwrap();
2251 let base = tmp.path();
2252
2253 let nested = base.join(".hidden-parent/visible-child");
2256 create_file(
2257 &nested.join("Cargo.toml"),
2258 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
2259 );
2260 create_file(&nested.join("target/dummy"), "content");
2261
2262 let scanner = default_scanner(ProjectFilter::Rust);
2263 let projects = scanner.scan_directory(base);
2264
2265 assert_eq!(projects.len(), 1);
2267 assert_eq!(projects[0].name.as_deref(), Some("nested"));
2268 }
2269
2270 #[test]
2271 #[cfg(unix)]
2272 fn test_dotcargo_directory_not_skipped_unix() {
2273 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2276 "/home/user/.cargo"
2277 )));
2278
2279 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2281 "/home/user/.local"
2282 )));
2283 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2284 "/home/user/.npm"
2285 )));
2286 }
2287
2288 #[test]
2291 fn test_detect_python_with_pyproject_toml() {
2292 let tmp = TempDir::new().unwrap();
2293 let base = tmp.path();
2294
2295 let project = base.join("py-project");
2296 create_file(
2297 &project.join("pyproject.toml"),
2298 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
2299 );
2300 let pycache = project.join("__pycache__");
2301 fs::create_dir_all(&pycache).unwrap();
2302 create_file(&pycache.join("module.pyc"), "bytecode");
2303
2304 let scanner = default_scanner(ProjectFilter::Python);
2305 let projects = scanner.scan_directory(base);
2306 assert_eq!(projects.len(), 1);
2307 assert_eq!(projects[0].kind, ProjectType::Python);
2308 }
2309
2310 #[test]
2311 fn test_detect_python_with_setup_py() {
2312 let tmp = TempDir::new().unwrap();
2313 let base = tmp.path();
2314
2315 let project = base.join("setup-project");
2316 create_file(
2317 &project.join("setup.py"),
2318 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
2319 );
2320 let pycache = project.join("__pycache__");
2321 fs::create_dir_all(&pycache).unwrap();
2322 create_file(&pycache.join("module.pyc"), "bytecode");
2323
2324 let scanner = default_scanner(ProjectFilter::Python);
2325 let projects = scanner.scan_directory(base);
2326 assert_eq!(projects.len(), 1);
2327 }
2328
2329 #[test]
2330 fn test_detect_python_with_pipfile() {
2331 let tmp = TempDir::new().unwrap();
2332 let base = tmp.path();
2333
2334 let project = base.join("pipenv-project");
2335 create_file(
2336 &project.join("Pipfile"),
2337 "[[source]]\nurl = \"https://pypi.org/simple\"",
2338 );
2339 let pycache = project.join("__pycache__");
2340 fs::create_dir_all(&pycache).unwrap();
2341 create_file(&pycache.join("module.pyc"), "bytecode");
2342
2343 let scanner = default_scanner(ProjectFilter::Python);
2344 let projects = scanner.scan_directory(base);
2345 assert_eq!(projects.len(), 1);
2346 }
2347
2348 #[test]
2351 fn test_detect_go_extracts_module_name() {
2352 let tmp = TempDir::new().unwrap();
2353 let base = tmp.path();
2354
2355 let project = base.join("go-service");
2356 create_file(
2357 &project.join("go.mod"),
2358 "module github.com/user/my-service\n\ngo 1.21\n",
2359 );
2360 let vendor = project.join("vendor");
2361 fs::create_dir_all(&vendor).unwrap();
2362 create_file(&vendor.join("modules.txt"), "vendor manifest");
2363
2364 let scanner = default_scanner(ProjectFilter::Go);
2365 let projects = scanner.scan_directory(base);
2366 assert_eq!(projects.len(), 1);
2367 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
2369 }
2370
2371 #[test]
2374 fn test_detect_java_maven_project() {
2375 let tmp = TempDir::new().unwrap();
2376 let base = tmp.path();
2377
2378 let project = base.join("java-maven");
2379 create_file(
2380 &project.join("pom.xml"),
2381 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
2382 );
2383 create_file(&project.join("target/classes/Main.class"), "bytecode");
2384
2385 let scanner = default_scanner(ProjectFilter::Java);
2386 let projects = scanner.scan_directory(base);
2387 assert_eq!(projects.len(), 1);
2388 assert_eq!(projects[0].kind, ProjectType::Java);
2389 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
2390 }
2391
2392 #[test]
2393 fn test_detect_java_gradle_project() {
2394 let tmp = TempDir::new().unwrap();
2395 let base = tmp.path();
2396
2397 let project = base.join("java-gradle");
2398 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
2399 create_file(
2400 &project.join("settings.gradle"),
2401 "rootProject.name = \"my-gradle-app\"",
2402 );
2403 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
2404
2405 let scanner = default_scanner(ProjectFilter::Java);
2406 let projects = scanner.scan_directory(base);
2407 assert_eq!(projects.len(), 1);
2408 assert_eq!(projects[0].kind, ProjectType::Java);
2409 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2410 }
2411
2412 #[test]
2413 fn test_detect_java_gradle_kts_project() {
2414 let tmp = TempDir::new().unwrap();
2415 let base = tmp.path();
2416
2417 let project = base.join("kotlin-gradle");
2418 create_file(
2419 &project.join("build.gradle.kts"),
2420 "plugins { kotlin(\"jvm\") }",
2421 );
2422 create_file(
2423 &project.join("settings.gradle.kts"),
2424 "rootProject.name = \"my-kotlin-app\"",
2425 );
2426 create_file(
2427 &project.join("build/classes/kotlin/main/MainKt.class"),
2428 "bytecode",
2429 );
2430
2431 let scanner = default_scanner(ProjectFilter::Java);
2432 let projects = scanner.scan_directory(base);
2433 assert_eq!(projects.len(), 1);
2434 assert_eq!(projects[0].kind, ProjectType::Java);
2435 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2436 }
2437
2438 #[test]
2441 fn test_detect_cpp_cmake_project() {
2442 let tmp = TempDir::new().unwrap();
2443 let base = tmp.path();
2444
2445 let project = base.join("cpp-cmake");
2446 create_file(
2447 &project.join("CMakeLists.txt"),
2448 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2449 );
2450 create_file(&project.join("build/CMakeCache.txt"), "cache");
2451
2452 let scanner = default_scanner(ProjectFilter::Cpp);
2453 let projects = scanner.scan_directory(base);
2454 assert_eq!(projects.len(), 1);
2455 assert_eq!(projects[0].kind, ProjectType::Cpp);
2456 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2457 }
2458
2459 #[test]
2460 fn test_detect_cpp_makefile_project() {
2461 let tmp = TempDir::new().unwrap();
2462 let base = tmp.path();
2463
2464 let project = base.join("cpp-make");
2465 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2466 create_file(&project.join("build/main.o"), "object");
2467
2468 let scanner = default_scanner(ProjectFilter::Cpp);
2469 let projects = scanner.scan_directory(base);
2470 assert_eq!(projects.len(), 1);
2471 assert_eq!(projects[0].kind, ProjectType::Cpp);
2472 }
2473
2474 #[test]
2477 fn test_detect_swift_project() {
2478 let tmp = TempDir::new().unwrap();
2479 let base = tmp.path();
2480
2481 let project = base.join("swift-pkg");
2482 create_file(
2483 &project.join("Package.swift"),
2484 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2485 );
2486 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2487
2488 let scanner = default_scanner(ProjectFilter::Swift);
2489 let projects = scanner.scan_directory(base);
2490 assert_eq!(projects.len(), 1);
2491 assert_eq!(projects[0].kind, ProjectType::Swift);
2492 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2493 }
2494
2495 #[test]
2498 fn test_detect_dotnet_project() {
2499 let tmp = TempDir::new().unwrap();
2500 let base = tmp.path();
2501
2502 let project = base.join("dotnet-app");
2503 create_file(
2504 &project.join("MyApp.csproj"),
2505 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2506 );
2507 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2508 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2509
2510 let scanner = default_scanner(ProjectFilter::DotNet);
2511 let projects = scanner.scan_directory(base);
2512 assert_eq!(projects.len(), 1);
2513 assert_eq!(projects[0].kind, ProjectType::DotNet);
2514 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2515 }
2516
2517 #[test]
2518 fn test_detect_dotnet_project_obj_only() {
2519 let tmp = TempDir::new().unwrap();
2520 let base = tmp.path();
2521
2522 let project = base.join("dotnet-obj-only");
2523 create_file(
2524 &project.join("Lib.csproj"),
2525 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2526 );
2527 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2528
2529 let scanner = default_scanner(ProjectFilter::DotNet);
2530 let projects = scanner.scan_directory(base);
2531 assert_eq!(projects.len(), 1);
2532 assert_eq!(projects[0].kind, ProjectType::DotNet);
2533 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2534 }
2535
2536 #[test]
2539 fn test_obj_directory_is_excluded() {
2540 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2541 }
2542
2543 #[test]
2546 fn test_calculate_build_dir_size_empty() {
2547 let tmp = TempDir::new().unwrap();
2548 let empty_dir = tmp.path().join("empty");
2549 fs::create_dir_all(&empty_dir).unwrap();
2550
2551 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2552 }
2553
2554 #[test]
2555 fn test_calculate_build_dir_size_nonexistent() {
2556 assert_eq!(
2557 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2558 0
2559 );
2560 }
2561
2562 #[test]
2563 fn test_calculate_build_dir_size_with_nested_files() {
2564 let tmp = TempDir::new().unwrap();
2565 let dir = tmp.path().join("nested");
2566
2567 create_file(&dir.join("file1.txt"), "hello"); create_file(&dir.join("sub/file2.txt"), "world!"); create_file(&dir.join("sub/deep/file3.txt"), "!"); let size = Scanner::calculate_build_dir_size(&dir);
2572 assert_eq!(size, 12);
2573 }
2574
2575 #[test]
2578 fn test_scanner_quiet_mode() {
2579 let tmp = TempDir::new().unwrap();
2580 let base = tmp.path();
2581
2582 let project = base.join("quiet-project");
2583 create_file(
2584 &project.join("Cargo.toml"),
2585 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2586 );
2587 create_file(&project.join("target/dummy"), "content");
2588
2589 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2590 let projects = scanner.scan_directory(base);
2591 assert_eq!(projects.len(), 1);
2592 }
2593
2594 #[test]
2597 fn test_detect_ruby_with_vendor_bundle() {
2598 let tmp = TempDir::new().unwrap();
2599 let base = tmp.path();
2600
2601 let project = base.join("ruby-project");
2602 create_file(
2603 &project.join("Gemfile"),
2604 "source 'https://rubygems.org'\ngem 'rails'",
2605 );
2606 create_file(
2607 &project.join("my-app.gemspec"),
2608 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2609 );
2610 create_file(
2611 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2612 "# rails",
2613 );
2614
2615 let scanner = default_scanner(ProjectFilter::Ruby);
2616 let projects = scanner.scan_directory(base);
2617 assert_eq!(projects.len(), 1);
2618 assert_eq!(projects[0].kind, ProjectType::Ruby);
2619 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2620 }
2621
2622 #[test]
2623 fn test_detect_ruby_with_dot_bundle() {
2624 let tmp = TempDir::new().unwrap();
2625 let base = tmp.path();
2626
2627 let project = base.join("ruby-dot-bundle");
2628 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2629 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2630
2631 let scanner = default_scanner(ProjectFilter::Ruby);
2632 let projects = scanner.scan_directory(base);
2633 assert_eq!(projects.len(), 1);
2634 assert_eq!(projects[0].kind, ProjectType::Ruby);
2635 }
2636
2637 #[test]
2638 fn test_detect_ruby_no_artifact_not_detected() {
2639 let tmp = TempDir::new().unwrap();
2640 let base = tmp.path();
2641
2642 let project = base.join("gemfile-only");
2644 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2645
2646 let scanner = default_scanner(ProjectFilter::Ruby);
2647 let projects = scanner.scan_directory(base);
2648 assert_eq!(projects.len(), 0);
2649 }
2650
2651 #[test]
2652 fn test_detect_ruby_fallback_to_dir_name() {
2653 let tmp = TempDir::new().unwrap();
2654 let base = tmp.path();
2655
2656 let project = base.join("my-ruby-app");
2657 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2658 create_file(
2659 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2660 "# sinatra",
2661 );
2662
2663 let scanner = default_scanner(ProjectFilter::Ruby);
2664 let projects = scanner.scan_directory(base);
2665 assert_eq!(projects.len(), 1);
2666 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2667 }
2668
2669 #[test]
2672 fn test_detect_elixir_project() {
2673 let tmp = TempDir::new().unwrap();
2674 let base = tmp.path();
2675
2676 let project = base.join("elixir-project");
2677 create_file(
2678 &project.join("mix.exs"),
2679 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2680 );
2681 create_file(
2682 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2683 "# build",
2684 );
2685
2686 let scanner = default_scanner(ProjectFilter::Elixir);
2687 let projects = scanner.scan_directory(base);
2688 assert_eq!(projects.len(), 1);
2689 assert_eq!(projects[0].kind, ProjectType::Elixir);
2690 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2691 }
2692
2693 #[test]
2694 fn test_detect_elixir_no_build_not_detected() {
2695 let tmp = TempDir::new().unwrap();
2696 let base = tmp.path();
2697
2698 let project = base.join("mix-only");
2699 create_file(
2700 &project.join("mix.exs"),
2701 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2702 );
2703
2704 let scanner = default_scanner(ProjectFilter::Elixir);
2705 let projects = scanner.scan_directory(base);
2706 assert_eq!(projects.len(), 0);
2707 }
2708
2709 #[test]
2710 fn test_detect_elixir_fallback_to_dir_name() {
2711 let tmp = TempDir::new().unwrap();
2712 let base = tmp.path();
2713
2714 let project = base.join("my_elixir_project");
2715 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2716 create_file(
2717 &project.join("_build/prod/lib/my_elixir_project.beam"),
2718 "bytecode",
2719 );
2720
2721 let scanner = default_scanner(ProjectFilter::Elixir);
2722 let projects = scanner.scan_directory(base);
2723 assert_eq!(projects.len(), 1);
2724 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2725 }
2726
2727 #[test]
2730 fn test_detect_deno_with_vendor() {
2731 let tmp = TempDir::new().unwrap();
2732 let base = tmp.path();
2733
2734 let project = base.join("deno-project");
2735 create_file(
2736 &project.join("deno.json"),
2737 r#"{"name": "my-deno-app", "imports": {}}"#,
2738 );
2739 create_file(&project.join("vendor/modules.json"), "{}");
2740
2741 let scanner = default_scanner(ProjectFilter::Deno);
2742 let projects = scanner.scan_directory(base);
2743 assert_eq!(projects.len(), 1);
2744 assert_eq!(projects[0].kind, ProjectType::Deno);
2745 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2746 }
2747
2748 #[test]
2749 fn test_detect_deno_jsonc_config() {
2750 let tmp = TempDir::new().unwrap();
2751 let base = tmp.path();
2752
2753 let project = base.join("deno-jsonc-project");
2754 create_file(
2755 &project.join("deno.jsonc"),
2756 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2757 );
2758 create_file(&project.join("vendor/modules.json"), "{}");
2759
2760 let scanner = default_scanner(ProjectFilter::Deno);
2761 let projects = scanner.scan_directory(base);
2762 assert_eq!(projects.len(), 1);
2763 assert_eq!(projects[0].kind, ProjectType::Deno);
2764 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2765 }
2766
2767 #[test]
2768 fn test_detect_deno_node_modules_without_package_json() {
2769 let tmp = TempDir::new().unwrap();
2770 let base = tmp.path();
2771
2772 let project = base.join("deno-npm-project");
2773 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2774 create_file(
2775 &project.join("node_modules/.deno/lodash/index.js"),
2776 "// lodash",
2777 );
2778
2779 let scanner = default_scanner(ProjectFilter::Deno);
2780 let projects = scanner.scan_directory(base);
2781 assert_eq!(projects.len(), 1);
2782 assert_eq!(projects[0].kind, ProjectType::Deno);
2783 }
2784
2785 #[test]
2786 fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2787 let tmp = TempDir::new().unwrap();
2788 let base = tmp.path();
2789
2790 let project = base.join("ambiguous-project");
2792 create_file(&project.join("deno.json"), r"{}");
2793 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2794 create_file(&project.join("node_modules/dep/index.js"), "// dep");
2795
2796 let scanner = default_scanner(ProjectFilter::All);
2797 let projects = scanner.scan_directory(base);
2798 assert_eq!(projects.len(), 1);
2799 assert_eq!(projects[0].kind, ProjectType::Node);
2800 }
2801
2802 #[test]
2803 fn test_detect_deno_no_artifact_not_detected() {
2804 let tmp = TempDir::new().unwrap();
2805 let base = tmp.path();
2806
2807 let project = base.join("deno-no-artifact");
2808 create_file(&project.join("deno.json"), r"{}");
2809
2810 let scanner = default_scanner(ProjectFilter::Deno);
2811 let projects = scanner.scan_directory(base);
2812 assert_eq!(projects.len(), 0);
2813 }
2814
2815 #[test]
2816 fn test_build_directory_is_excluded() {
2817 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2818 }
2819
2820 #[test]
2823 fn test_is_cargo_workspace_root() {
2824 let tmp = TempDir::new().unwrap();
2825 let cargo_toml = tmp.path().join("Cargo.toml");
2826
2827 create_file(
2829 &cargo_toml,
2830 "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2831 );
2832 assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2833
2834 create_file(
2836 &cargo_toml,
2837 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2838 );
2839 assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2840
2841 assert!(!Scanner::is_cargo_workspace_root(Path::new(
2843 "/nonexistent/Cargo.toml"
2844 )));
2845 }
2846
2847 #[test]
2848 fn test_workspace_root_detected() {
2849 let tmp = TempDir::new().unwrap();
2850 let base = tmp.path();
2851
2852 let workspace = base.join("my-workspace");
2854 create_file(
2855 &workspace.join("Cargo.toml"),
2856 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2857 );
2858 create_file(&workspace.join("target/dummy"), "content");
2859
2860 let scanner = default_scanner(ProjectFilter::Rust);
2861 let projects = scanner.scan_directory(base);
2862
2863 assert_eq!(projects.len(), 1);
2864 assert_eq!(projects[0].root_path, workspace);
2865 }
2866
2867 #[test]
2868 fn test_workspace_member_with_own_target_skipped() {
2869 let tmp = TempDir::new().unwrap();
2870 let base = tmp.path();
2871
2872 let workspace = base.join("my-workspace");
2874 create_file(
2875 &workspace.join("Cargo.toml"),
2876 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2877 );
2878 create_file(&workspace.join("target/dummy"), "content");
2879
2880 let member = workspace.join("crate-a");
2882 create_file(
2883 &member.join("Cargo.toml"),
2884 "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2885 );
2886 create_file(&member.join("target/dummy"), "content");
2887
2888 let scanner = default_scanner(ProjectFilter::Rust);
2889 let projects = scanner.scan_directory(base);
2890
2891 assert_eq!(projects.len(), 1);
2893 assert_eq!(projects[0].root_path, workspace);
2894 }
2895
2896 #[test]
2899 fn test_detect_php_project() {
2900 let tmp = TempDir::new().unwrap();
2901 let base = tmp.path();
2902
2903 let project = base.join("php-project");
2904 create_file(
2905 &project.join("composer.json"),
2906 r#"{"name": "acme/my-php-app", "require": {}}"#,
2907 );
2908 create_file(&project.join("vendor/autoload.php"), "<?php // autoloader");
2909
2910 let scanner = default_scanner(ProjectFilter::Php);
2911 let projects = scanner.scan_directory(base);
2912 assert_eq!(projects.len(), 1);
2913 assert_eq!(projects[0].kind, ProjectType::Php);
2914 assert_eq!(projects[0].name.as_deref(), Some("my-php-app"));
2916 }
2917
2918 #[test]
2919 fn test_detect_php_no_vendor_not_detected() {
2920 let tmp = TempDir::new().unwrap();
2921 let base = tmp.path();
2922
2923 let project = base.join("php-no-vendor");
2924 create_file(&project.join("composer.json"), r#"{"name": "acme/my-app"}"#);
2925
2926 let scanner = default_scanner(ProjectFilter::Php);
2927 let projects = scanner.scan_directory(base);
2928 assert_eq!(projects.len(), 0);
2929 }
2930
2931 #[test]
2932 fn test_detect_php_fallback_to_dir_name() {
2933 let tmp = TempDir::new().unwrap();
2934 let base = tmp.path();
2935
2936 let project = base.join("my-php-project");
2937 create_file(&project.join("composer.json"), r#"{"require": {}}"#);
2939 create_file(&project.join("vendor/autoload.php"), "<?php");
2940
2941 let scanner = default_scanner(ProjectFilter::Php);
2942 let projects = scanner.scan_directory(base);
2943 assert_eq!(projects.len(), 1);
2944 assert_eq!(projects[0].name.as_deref(), Some("my-php-project"));
2945 }
2946
2947 #[test]
2950 fn test_detect_haskell_stack_project() {
2951 let tmp = TempDir::new().unwrap();
2952 let base = tmp.path();
2953
2954 let project = base.join("haskell-stack");
2955 create_file(
2956 &project.join("stack.yaml"),
2957 "resolver: lts-21.0\npackages:\n - .",
2958 );
2959 create_file(
2960 &project.join("my-haskell-lib.cabal"),
2961 "name: my-haskell-lib\nversion: 0.1.0.0\n",
2962 );
2963 create_file(
2964 &project.join(".stack-work/dist/x86_64-linux/ghc-9.4.7/build/Main.o"),
2965 "object",
2966 );
2967
2968 let scanner = default_scanner(ProjectFilter::Haskell);
2969 let projects = scanner.scan_directory(base);
2970 assert_eq!(projects.len(), 1);
2971 assert_eq!(projects[0].kind, ProjectType::Haskell);
2972 assert_eq!(projects[0].name.as_deref(), Some("my-haskell-lib"));
2973 }
2974
2975 #[test]
2976 fn test_detect_haskell_cabal_project() {
2977 let tmp = TempDir::new().unwrap();
2978 let base = tmp.path();
2979
2980 let project = base.join("haskell-cabal");
2981 create_file(&project.join("cabal.project"), "packages: .\n");
2982 create_file(
2983 &project.join("my-cabal-lib.cabal"),
2984 "name: my-cabal-lib\nversion: 0.1.0.0\n",
2985 );
2986 create_file(
2987 &project.join(
2988 "dist-newstyle/build/x86_64-linux/ghc-9.4.7/my-cabal-lib-0.1.0.0/build/Main.o",
2989 ),
2990 "object",
2991 );
2992
2993 let scanner = default_scanner(ProjectFilter::Haskell);
2994 let projects = scanner.scan_directory(base);
2995 assert_eq!(projects.len(), 1);
2996 assert_eq!(projects[0].kind, ProjectType::Haskell);
2997 assert_eq!(projects[0].name.as_deref(), Some("my-cabal-lib"));
2998 }
2999
3000 #[test]
3001 fn test_detect_haskell_no_artifact_not_detected() {
3002 let tmp = TempDir::new().unwrap();
3003 let base = tmp.path();
3004
3005 let project = base.join("haskell-no-artifact");
3006 create_file(&project.join("stack.yaml"), "resolver: lts-21.0");
3007
3008 let scanner = default_scanner(ProjectFilter::Haskell);
3009 let projects = scanner.scan_directory(base);
3010 assert_eq!(projects.len(), 0);
3011 }
3012
3013 #[test]
3016 fn test_detect_dart_project_with_dart_tool() {
3017 let tmp = TempDir::new().unwrap();
3018 let base = tmp.path();
3019
3020 let project = base.join("dart-project");
3021 create_file(
3022 &project.join("pubspec.yaml"),
3023 "name: my_dart_app\nversion: 1.0.0\n",
3024 );
3025 create_file(
3026 &project.join(".dart_tool/package_config.json"),
3027 r#"{"configVersion": 2}"#,
3028 );
3029
3030 let scanner = default_scanner(ProjectFilter::Dart);
3031 let projects = scanner.scan_directory(base);
3032 assert_eq!(projects.len(), 1);
3033 assert_eq!(projects[0].kind, ProjectType::Dart);
3034 assert_eq!(projects[0].name.as_deref(), Some("my_dart_app"));
3035 }
3036
3037 #[test]
3038 fn test_detect_dart_project_with_build_dir() {
3039 let tmp = TempDir::new().unwrap();
3040 let base = tmp.path();
3041
3042 let project = base.join("flutter-project");
3043 create_file(
3044 &project.join("pubspec.yaml"),
3045 "name: my_flutter_app\nversion: 1.0.0\n",
3046 );
3047 create_file(
3048 &project.join("build/flutter_assets/AssetManifest.json"),
3049 "{}",
3050 );
3051
3052 let scanner = default_scanner(ProjectFilter::Dart);
3053 let projects = scanner.scan_directory(base);
3054 assert_eq!(projects.len(), 1);
3055 assert_eq!(projects[0].kind, ProjectType::Dart);
3056 assert_eq!(projects[0].name.as_deref(), Some("my_flutter_app"));
3057 }
3058
3059 #[test]
3060 fn test_detect_dart_no_artifact_not_detected() {
3061 let tmp = TempDir::new().unwrap();
3062 let base = tmp.path();
3063
3064 let project = base.join("pubspec-only");
3065 create_file(&project.join("pubspec.yaml"), "name: empty_project\n");
3066
3067 let scanner = default_scanner(ProjectFilter::Dart);
3068 let projects = scanner.scan_directory(base);
3069 assert_eq!(projects.len(), 0);
3070 }
3071
3072 #[test]
3075 fn test_detect_zig_project_with_cache() {
3076 let tmp = TempDir::new().unwrap();
3077 let base = tmp.path();
3078
3079 let project = base.join("zig-project");
3080 create_file(
3081 &project.join("build.zig"),
3082 "const std = @import(\"std\");\npub fn build(b: *std.Build) void {}\n",
3083 );
3084 create_file(&project.join("zig-cache/h/abc123.h"), "// generated");
3085
3086 let scanner = default_scanner(ProjectFilter::Zig);
3087 let projects = scanner.scan_directory(base);
3088 assert_eq!(projects.len(), 1);
3089 assert_eq!(projects[0].kind, ProjectType::Zig);
3090 assert_eq!(projects[0].name.as_deref(), Some("zig-project"));
3092 }
3093
3094 #[test]
3095 fn test_detect_zig_project_with_out_dir() {
3096 let tmp = TempDir::new().unwrap();
3097 let base = tmp.path();
3098
3099 let project = base.join("zig-out-project");
3100 create_file(&project.join("build.zig"), "// zig build script");
3101 create_file(&project.join("zig-out/bin/my-app"), "binary");
3102
3103 let scanner = default_scanner(ProjectFilter::Zig);
3104 let projects = scanner.scan_directory(base);
3105 assert_eq!(projects.len(), 1);
3106 assert_eq!(projects[0].kind, ProjectType::Zig);
3107 }
3108
3109 #[test]
3110 fn test_detect_zig_no_artifact_not_detected() {
3111 let tmp = TempDir::new().unwrap();
3112 let base = tmp.path();
3113
3114 let project = base.join("zig-no-artifact");
3115 create_file(&project.join("build.zig"), "// zig build script");
3116
3117 let scanner = default_scanner(ProjectFilter::Zig);
3118 let projects = scanner.scan_directory(base);
3119 assert_eq!(projects.len(), 0);
3120 }
3121
3122 #[test]
3123 fn test_zig_cache_directory_is_excluded() {
3124 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-cache")));
3125 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-out")));
3126 assert!(Scanner::is_excluded_directory(Path::new(
3127 "/some/dist-newstyle"
3128 )));
3129 }
3130
3131 #[test]
3134 fn test_detect_scala_project() {
3135 let tmp = TempDir::new().unwrap();
3136 let base = tmp.path();
3137
3138 let project = base.join("scala-project");
3139 create_file(
3140 &project.join("build.sbt"),
3141 "name := \"my-scala-app\"\nscalaVersion := \"3.3.0\"\n",
3142 );
3143 create_file(
3144 &project.join("target/scala-3.3.0/classes/Main.class"),
3145 "bytecode",
3146 );
3147
3148 let scanner = default_scanner(ProjectFilter::Scala);
3149 let projects = scanner.scan_directory(base);
3150 assert_eq!(projects.len(), 1);
3151 assert_eq!(projects[0].kind, ProjectType::Scala);
3152 assert_eq!(projects[0].name.as_deref(), Some("my-scala-app"));
3153 }
3154
3155 #[test]
3156 fn test_detect_scala_no_target_not_detected() {
3157 let tmp = TempDir::new().unwrap();
3158 let base = tmp.path();
3159
3160 let project = base.join("sbt-only");
3161 create_file(&project.join("build.sbt"), "name := \"unbuilt-project\"\n");
3162
3163 let scanner = default_scanner(ProjectFilter::Scala);
3164 let projects = scanner.scan_directory(base);
3165 assert_eq!(projects.len(), 0);
3166 }
3167
3168 #[test]
3169 fn test_detect_scala_fallback_to_dir_name() {
3170 let tmp = TempDir::new().unwrap();
3171 let base = tmp.path();
3172
3173 let project = base.join("my-scala-project");
3174 create_file(&project.join("build.sbt"), "scalaVersion := \"3.3.0\"\n");
3176 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode");
3177
3178 let scanner = default_scanner(ProjectFilter::Scala);
3179 let projects = scanner.scan_directory(base);
3180 assert_eq!(projects.len(), 1);
3181 assert_eq!(projects[0].name.as_deref(), Some("my-scala-project"));
3182 }
3183
3184 #[test]
3185 fn test_scala_detected_before_java_for_build_sbt_projects() {
3186 let tmp = TempDir::new().unwrap();
3187 let base = tmp.path();
3188
3189 let project = base.join("scala-maven-project");
3191 create_file(&project.join("build.sbt"), "name := \"scala-maven\"\n");
3192 create_file(
3193 &project.join("pom.xml"),
3194 "<project><artifactId>scala-maven</artifactId></project>",
3195 );
3196 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode");
3197
3198 let scanner = default_scanner(ProjectFilter::All);
3199 let projects = scanner.scan_directory(base);
3200 assert_eq!(projects.len(), 1);
3201 assert_eq!(projects[0].kind, ProjectType::Scala);
3202 }
3203}