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