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