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 jwalk::{Parallelism, WalkDir};
20use rayon::prelude::*;
21use serde_json::{Value, from_str};
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 mut walker = WalkDir::new(root).skip_hidden(false).process_read_dir(
148 |_depth, _path, _state, children| {
149 for child in children.iter_mut().flatten() {
155 if child.file_type.is_dir()
156 && child.file_name == *std::ffi::OsStr::new("node_modules")
157 {
158 child.read_children_path = None;
159 }
160 }
161 },
162 );
163
164 if self.scan_options.threads == 1 {
167 walker = walker.parallelism(Parallelism::Serial);
168 }
169 if let Some(depth) = self.scan_options.max_depth {
170 walker = walker.max_depth(depth);
171 }
172
173 let potential_projects: Vec<_> = walker
174 .into_iter()
175 .filter_map(Result::ok)
176 .map(|entry| (entry.path(), entry.file_type().is_dir()))
177 .filter(|(path, _)| self.should_scan_entry(path))
178 .collect::<Vec<_>>()
179 .into_par_iter()
180 .filter_map(|(path, is_dir)| {
181 let result = self.detect_project(&path, is_dir, &errors);
182 if result.is_some() {
183 let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
184 progress_clone.set_message(format!("Scanning... {n} found"));
185 }
186 result
187 })
188 .collect();
189
190 progress.finish_with_message("[OK] Directory scan complete");
191
192 let projects_with_sizes: Vec<_> = potential_projects
194 .into_par_iter()
195 .filter_map(|mut project| {
196 for artifact in &mut project.build_arts {
197 if artifact.size == 0 {
198 artifact.size = Self::calculate_build_dir_size(&artifact.path);
199 }
200 }
201
202 if project.total_size() > 0 {
203 Some(project)
204 } else {
205 None
206 }
207 })
208 .collect();
209
210 if self.scan_options.verbose
212 && let Ok(errors) = errors.lock()
213 {
214 for error in errors.iter() {
215 eprintln!("{}", error.red());
216 }
217 }
218
219 projects_with_sizes
220 }
221
222 #[must_use]
235 pub fn scan_directories(&self, roots: &[PathBuf]) -> Vec<Project> {
236 use std::collections::HashSet;
237 let mut seen: HashSet<PathBuf> = HashSet::new();
238 let mut result = Vec::new();
239 for root in roots {
240 for project in self.scan_directory(root) {
241 if seen.insert(project.root_path.clone()) {
242 result.push(project);
243 }
244 }
245 }
246 result
247 }
248
249 fn calculate_build_dir_size(path: &Path) -> u64 {
270 if !path.exists() {
271 return 0;
272 }
273
274 crate::utils::calculate_dir_size(path)
275 }
276
277 fn detect_node_project(
299 &self,
300 path: &Path,
301 errors: &Arc<Mutex<Vec<String>>>,
302 ) -> Option<Project> {
303 let package_json = path.join("package.json");
304 let node_modules = path.join("node_modules");
305
306 if package_json.exists() && node_modules.exists() {
307 let name = self.extract_node_project_name(&package_json, errors);
308
309 let build_arts = vec![BuildArtifacts {
310 path: path.join("node_modules"),
311 size: 0, }];
313
314 return Some(Project::new(
315 ProjectType::Node,
316 path.to_path_buf(),
317 build_arts,
318 name,
319 ));
320 }
321
322 None
323 }
324
325 fn detect_project(
360 &self,
361 path: &Path,
362 is_dir: bool,
363 errors: &Arc<Mutex<Vec<String>>>,
364 ) -> Option<Project> {
365 if !is_dir {
366 return None;
367 }
368
369 self.try_detect(ProjectFilter::Rust, || {
374 self.detect_rust_project(path, errors)
375 })
376 .or_else(|| {
377 self.try_detect(ProjectFilter::Deno, || {
378 self.detect_deno_project(path, errors)
379 })
380 })
381 .or_else(|| {
382 self.try_detect(ProjectFilter::Node, || {
383 self.detect_node_project(path, errors)
384 })
385 })
386 .or_else(|| {
387 self.try_detect(ProjectFilter::Scala, || {
388 self.detect_scala_project(path, errors)
389 })
390 })
391 .or_else(|| {
392 self.try_detect(ProjectFilter::Java, || {
393 self.detect_java_project(path, errors)
394 })
395 })
396 .or_else(|| {
397 self.try_detect(ProjectFilter::Swift, || {
398 self.detect_swift_project(path, errors)
399 })
400 })
401 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
402 .or_else(|| {
403 self.try_detect(ProjectFilter::Python, || {
404 self.detect_python_project(path, errors)
405 })
406 })
407 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
408 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
409 .or_else(|| {
410 self.try_detect(ProjectFilter::Ruby, || {
411 self.detect_ruby_project(path, errors)
412 })
413 })
414 .or_else(|| {
415 self.try_detect(ProjectFilter::Elixir, || {
416 self.detect_elixir_project(path, errors)
417 })
418 })
419 .or_else(|| self.try_detect(ProjectFilter::Php, || self.detect_php_project(path, errors)))
420 .or_else(|| {
421 self.try_detect(ProjectFilter::Haskell, || {
422 self.detect_haskell_project(path, errors)
423 })
424 })
425 .or_else(|| {
426 self.try_detect(ProjectFilter::Dart, || {
427 self.detect_dart_project(path, errors)
428 })
429 })
430 .or_else(|| self.try_detect(ProjectFilter::Zig, || Self::detect_zig_project(path)))
431 }
432
433 fn try_detect(
438 &self,
439 filter: ProjectFilter,
440 detect: impl FnOnce() -> Option<Project>,
441 ) -> Option<Project> {
442 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
443 detect()
444 } else {
445 None
446 }
447 }
448
449 fn detect_rust_project(
471 &self,
472 path: &Path,
473 errors: &Arc<Mutex<Vec<String>>>,
474 ) -> Option<Project> {
475 let cargo_toml = path.join("Cargo.toml");
476 let target_dir = path.join("target");
477
478 if cargo_toml.exists() && target_dir.exists() {
479 if Self::is_inside_cargo_workspace(path) {
481 return None;
482 }
483
484 let name = self.extract_rust_project_name(&cargo_toml, errors);
485
486 let build_arts = vec![BuildArtifacts {
487 path: path.join("target"),
488 size: 0, }];
490
491 return Some(Project::new(
492 ProjectType::Rust,
493 path.to_path_buf(),
494 build_arts,
495 name,
496 ));
497 }
498
499 None
500 }
501
502 fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
504 fs::read_to_string(cargo_toml)
505 .is_ok_and(|content| content.lines().any(|line| line.trim() == "[workspace]"))
506 }
507
508 fn is_inside_cargo_workspace(path: &Path) -> bool {
511 path.ancestors()
512 .skip(1) .any(|ancestor| {
514 let cargo_toml = ancestor.join("Cargo.toml");
515 cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
516 })
517 }
518
519 fn extract_rust_project_name(
541 &self,
542 cargo_toml: &Path,
543 errors: &Arc<Mutex<Vec<String>>>,
544 ) -> Option<String> {
545 let content = self.read_file_content(cargo_toml, errors)?;
546 Self::parse_toml_name_field(&content)
547 }
548
549 fn extract_quoted_value(line: &str) -> Option<String> {
551 let start = line.find('"')?;
552 let end = line.rfind('"')?;
553
554 if start == end {
555 return None;
556 }
557
558 Some(line[start + 1..end].to_string())
559 }
560
561 fn extract_name_from_line(line: &str) -> Option<String> {
563 if !Self::is_name_line(line) {
564 return None;
565 }
566
567 Self::extract_quoted_value(line)
568 }
569
570 fn extract_node_project_name(
591 &self,
592 package_json: &Path,
593 errors: &Arc<Mutex<Vec<String>>>,
594 ) -> Option<String> {
595 match fs::read_to_string(package_json) {
596 Ok(content) => match from_str::<Value>(&content) {
597 Ok(json) => json
598 .get("name")
599 .and_then(|v| v.as_str())
600 .map(std::string::ToString::to_string),
601 Err(e) => {
602 if self.scan_options.verbose
603 && let Ok(mut errs) = errors.lock()
604 {
605 errs.push(format!("Error parsing {}: {e}", package_json.display()));
606 }
607 None
608 }
609 },
610 Err(e) => {
611 if self.scan_options.verbose
612 && let Ok(mut errs) = errors.lock()
613 {
614 errs.push(format!("Error reading {}: {e}", package_json.display()));
615 }
616 None
617 }
618 }
619 }
620
621 fn is_name_line(line: &str) -> bool {
623 line.starts_with("name") && line.contains('=')
624 }
625
626 fn log_file_error(
628 &self,
629 file_path: &Path,
630 error: &std::io::Error,
631 errors: &Arc<Mutex<Vec<String>>>,
632 ) {
633 if self.scan_options.verbose
634 && let Ok(mut errs) = errors.lock()
635 {
636 errs.push(format!("Error reading {}: {error}", file_path.display()));
637 }
638 }
639
640 fn parse_toml_name_field(content: &str) -> Option<String> {
642 for line in content.lines() {
643 if let Some(name) = Self::extract_name_from_line(line.trim()) {
644 return Some(name);
645 }
646 }
647 None
648 }
649
650 fn read_file_content(
652 &self,
653 file_path: &Path,
654 errors: &Arc<Mutex<Vec<String>>>,
655 ) -> Option<String> {
656 match fs::read_to_string(file_path) {
657 Ok(content) => Some(content),
658 Err(e) => {
659 self.log_file_error(file_path, &e, errors);
660 None
661 }
662 }
663 }
664
665 fn should_scan_entry(&self, path: &Path) -> bool {
699 if self.is_path_in_skip_list(path) {
701 return false;
702 }
703
704 if path
706 .ancestors()
707 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
708 {
709 return false;
710 }
711
712 if Self::is_hidden_directory_to_skip(path) {
714 return false;
715 }
716
717 !Self::is_excluded_directory(path)
719 }
720
721 fn is_path_in_skip_list(&self, path: &Path) -> bool {
723 self.scan_options.skip.iter().any(|skip| {
724 path.components().any(|component| {
725 component
726 .as_os_str()
727 .to_str()
728 .is_some_and(|name| name == skip.to_string_lossy())
729 })
730 })
731 }
732
733 fn is_hidden_directory_to_skip(path: &Path) -> bool {
735 path.file_name()
736 .and_then(|n| n.to_str())
737 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
738 }
739
740 fn is_excluded_directory(path: &Path) -> bool {
742 let excluded_dirs = [
743 "target",
744 "build",
745 "dist",
746 "out",
747 ".git",
748 ".svn",
749 ".hg",
750 "__pycache__",
751 "venv",
752 ".venv",
753 "env",
754 ".env",
755 "temp",
756 "tmp",
757 "vendor",
758 ".pytest_cache",
759 ".tox",
760 ".eggs",
761 ".coverage",
762 "node_modules",
763 "obj",
764 "_build",
765 "zig-cache",
766 "zig-out",
767 "dist-newstyle",
768 ];
769
770 path.file_name()
771 .and_then(|n| n.to_str())
772 .is_some_and(|name| excluded_dirs.contains(&name))
773 }
774
775 fn detect_python_project(
796 &self,
797 path: &Path,
798 errors: &Arc<Mutex<Vec<String>>>,
799 ) -> Option<Project> {
800 let config_files = [
801 "requirements.txt",
802 "setup.py",
803 "pyproject.toml",
804 "setup.cfg",
805 "Pipfile",
806 "pipenv.lock",
807 "poetry.lock",
808 ];
809
810 let build_dirs = [
811 "__pycache__",
812 ".pytest_cache",
813 "venv",
814 ".venv",
815 "build",
816 "dist",
817 ".eggs",
818 ".tox",
819 ".coverage",
820 ];
821
822 let has_config = config_files.iter().any(|&file| path.join(file).exists());
824
825 if !has_config {
826 return None;
827 }
828
829 let mut build_arts: Vec<BuildArtifacts> = build_dirs
831 .iter()
832 .filter_map(|&dir_name| {
833 let dir_path = path.join(dir_name);
834 if dir_path.exists() && dir_path.is_dir() {
835 let size = crate::utils::calculate_dir_size(&dir_path);
836 Some(BuildArtifacts {
837 path: dir_path,
838 size,
839 })
840 } else {
841 None
842 }
843 })
844 .collect();
845
846 if let Ok(entries) = std::fs::read_dir(path) {
848 for entry in entries.flatten() {
849 let entry_path = entry.path();
850 if entry_path.is_dir()
851 && entry_path
852 .file_name()
853 .and_then(|n| n.to_str())
854 .is_some_and(|n| n.ends_with(".egg-info"))
855 {
856 let size = crate::utils::calculate_dir_size(&entry_path);
857 build_arts.push(BuildArtifacts {
858 path: entry_path,
859 size,
860 });
861 }
862 }
863 }
864
865 if build_arts.is_empty() {
866 return None;
867 }
868
869 let name = self.extract_python_project_name(path, errors);
870
871 Some(Project::new(
872 ProjectType::Python,
873 path.to_path_buf(),
874 build_arts,
875 name,
876 ))
877 }
878
879 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
901 let go_mod = path.join("go.mod");
902 let vendor_dir = path.join("vendor");
903
904 if go_mod.exists() && vendor_dir.exists() {
905 let name = self.extract_go_project_name(&go_mod, errors);
906
907 let build_arts = vec![BuildArtifacts {
908 path: path.join("vendor"),
909 size: 0, }];
911
912 return Some(Project::new(
913 ProjectType::Go,
914 path.to_path_buf(),
915 build_arts,
916 name,
917 ));
918 }
919
920 None
921 }
922
923 fn extract_python_project_name(
945 &self,
946 path: &Path,
947 errors: &Arc<Mutex<Vec<String>>>,
948 ) -> Option<String> {
949 self.try_extract_from_pyproject_toml(path, errors)
951 .or_else(|| self.try_extract_from_setup_py(path, errors))
952 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
953 .or_else(|| Self::fallback_to_directory_name(path))
954 }
955
956 fn try_extract_from_pyproject_toml(
958 &self,
959 path: &Path,
960 errors: &Arc<Mutex<Vec<String>>>,
961 ) -> Option<String> {
962 let pyproject_toml = path.join("pyproject.toml");
963 if !pyproject_toml.exists() {
964 return None;
965 }
966
967 let content = self.read_file_content(&pyproject_toml, errors)?;
968 Self::extract_name_from_toml_like_content(&content)
969 }
970
971 fn try_extract_from_setup_py(
973 &self,
974 path: &Path,
975 errors: &Arc<Mutex<Vec<String>>>,
976 ) -> Option<String> {
977 let setup_py = path.join("setup.py");
978 if !setup_py.exists() {
979 return None;
980 }
981
982 let content = self.read_file_content(&setup_py, errors)?;
983 Self::extract_name_from_python_content(&content)
984 }
985
986 fn try_extract_from_setup_cfg(
988 &self,
989 path: &Path,
990 errors: &Arc<Mutex<Vec<String>>>,
991 ) -> Option<String> {
992 let setup_cfg = path.join("setup.cfg");
993 if !setup_cfg.exists() {
994 return None;
995 }
996
997 let content = self.read_file_content(&setup_cfg, errors)?;
998 Self::extract_name_from_cfg_content(&content)
999 }
1000
1001 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
1003 content
1004 .lines()
1005 .map(str::trim)
1006 .find(|line| line.starts_with("name") && line.contains('='))
1007 .and_then(Self::extract_quoted_value)
1008 }
1009
1010 fn extract_name_from_python_content(content: &str) -> Option<String> {
1012 content
1013 .lines()
1014 .map(str::trim)
1015 .find(|line| line.contains("name") && line.contains('='))
1016 .and_then(Self::extract_quoted_value)
1017 }
1018
1019 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
1021 let mut in_metadata_section = false;
1022
1023 for line in content.lines() {
1024 let line = line.trim();
1025
1026 if line == "[metadata]" {
1027 in_metadata_section = true;
1028 } else if line.starts_with('[') && line.ends_with(']') {
1029 in_metadata_section = false;
1030 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
1031 return line.split('=').nth(1).map(|name| name.trim().to_string());
1032 }
1033 }
1034
1035 None
1036 }
1037
1038 fn fallback_to_directory_name(path: &Path) -> Option<String> {
1040 path.file_name()
1041 .and_then(|name| name.to_str())
1042 .map(std::string::ToString::to_string)
1043 }
1044
1045 fn extract_go_project_name(
1065 &self,
1066 go_mod: &Path,
1067 errors: &Arc<Mutex<Vec<String>>>,
1068 ) -> Option<String> {
1069 let content = self.read_file_content(go_mod, errors)?;
1070
1071 for line in content.lines() {
1072 let line = line.trim();
1073 if line.starts_with("module ") {
1074 let module_path = line.strip_prefix("module ")?.trim();
1075
1076 if let Some(name) = module_path.split('/').next_back() {
1078 return Some(name.to_string());
1079 }
1080
1081 return Some(module_path.to_string());
1082 }
1083 }
1084
1085 None
1086 }
1087
1088 fn detect_java_project(
1099 &self,
1100 path: &Path,
1101 errors: &Arc<Mutex<Vec<String>>>,
1102 ) -> Option<Project> {
1103 let pom_xml = path.join("pom.xml");
1104 let target_dir = path.join("target");
1105
1106 if pom_xml.exists() && target_dir.exists() {
1108 let name = self.extract_java_maven_project_name(&pom_xml, errors);
1109
1110 let build_arts = vec![BuildArtifacts {
1111 path: target_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 let has_gradle =
1125 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1126 let build_dir = path.join("build");
1127
1128 if has_gradle && build_dir.exists() {
1129 let name = self.extract_java_gradle_project_name(path, errors);
1130
1131 let build_arts = vec![BuildArtifacts {
1132 path: build_dir,
1133 size: 0,
1134 }];
1135
1136 return Some(Project::new(
1137 ProjectType::Java,
1138 path.to_path_buf(),
1139 build_arts,
1140 name,
1141 ));
1142 }
1143
1144 None
1145 }
1146
1147 fn extract_java_maven_project_name(
1151 &self,
1152 pom_xml: &Path,
1153 errors: &Arc<Mutex<Vec<String>>>,
1154 ) -> Option<String> {
1155 let content = self.read_file_content(pom_xml, errors)?;
1156
1157 for line in content.lines() {
1158 let trimmed = line.trim();
1159 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1160 let name = trimmed
1161 .strip_prefix("<artifactId>")?
1162 .strip_suffix("</artifactId>")?;
1163 return Some(name.to_string());
1164 }
1165 }
1166
1167 None
1168 }
1169
1170 fn extract_java_gradle_project_name(
1175 &self,
1176 path: &Path,
1177 errors: &Arc<Mutex<Vec<String>>>,
1178 ) -> Option<String> {
1179 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1180 let settings_path = path.join(settings_file);
1181 if settings_path.exists()
1182 && let Some(content) = self.read_file_content(&settings_path, errors)
1183 {
1184 for line in content.lines() {
1185 let trimmed = line.trim();
1186 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1187 return Self::extract_quoted_value(trimmed).or_else(|| {
1188 trimmed
1189 .split('=')
1190 .nth(1)
1191 .map(|s| s.trim().trim_matches('\'').to_string())
1192 });
1193 }
1194 }
1195 }
1196 }
1197
1198 Self::fallback_to_directory_name(path)
1199 }
1200
1201 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1211 let build_dir = path.join("build");
1212
1213 if !build_dir.exists() {
1214 return None;
1215 }
1216
1217 let cmake_file = path.join("CMakeLists.txt");
1218 let makefile = path.join("Makefile");
1219
1220 if cmake_file.exists() || makefile.exists() {
1221 let name = if cmake_file.exists() {
1222 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1223 } else {
1224 Self::fallback_to_directory_name(path)
1225 };
1226
1227 let build_arts = vec![BuildArtifacts {
1228 path: build_dir,
1229 size: 0,
1230 }];
1231
1232 return Some(Project::new(
1233 ProjectType::Cpp,
1234 path.to_path_buf(),
1235 build_arts,
1236 name,
1237 ));
1238 }
1239
1240 None
1241 }
1242
1243 fn extract_cpp_cmake_project_name(
1247 &self,
1248 cmake_file: &Path,
1249 errors: &Arc<Mutex<Vec<String>>>,
1250 ) -> Option<String> {
1251 let content = self.read_file_content(cmake_file, errors)?;
1252
1253 for line in content.lines() {
1254 let trimmed = line.trim();
1255 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1256 let inner = trimmed
1257 .trim_start_matches("project(")
1258 .trim_start_matches("PROJECT(")
1259 .trim_end_matches(')')
1260 .trim();
1261
1262 let name = inner.split_whitespace().next()?;
1264 let name = name.trim_matches('"').trim_matches('\'');
1266 if !name.is_empty() {
1267 return Some(name.to_string());
1268 }
1269 }
1270 }
1271
1272 Self::fallback_to_directory_name(cmake_file.parent()?)
1273 }
1274
1275 fn detect_swift_project(
1285 &self,
1286 path: &Path,
1287 errors: &Arc<Mutex<Vec<String>>>,
1288 ) -> Option<Project> {
1289 let package_swift = path.join("Package.swift");
1290 let build_dir = path.join(".build");
1291
1292 if package_swift.exists() && build_dir.exists() {
1293 let name = self.extract_swift_project_name(&package_swift, errors);
1294
1295 let build_arts = vec![BuildArtifacts {
1296 path: build_dir,
1297 size: 0,
1298 }];
1299
1300 return Some(Project::new(
1301 ProjectType::Swift,
1302 path.to_path_buf(),
1303 build_arts,
1304 name,
1305 ));
1306 }
1307
1308 None
1309 }
1310
1311 fn extract_swift_project_name(
1315 &self,
1316 package_swift: &Path,
1317 errors: &Arc<Mutex<Vec<String>>>,
1318 ) -> Option<String> {
1319 let content = self.read_file_content(package_swift, errors)?;
1320
1321 for line in content.lines() {
1322 let trimmed = line.trim();
1323 if trimmed.contains("name:") {
1324 return Self::extract_quoted_value(trimmed);
1325 }
1326 }
1327
1328 Self::fallback_to_directory_name(package_swift.parent()?)
1329 }
1330
1331 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1341 let bin_dir = path.join("bin");
1342 let obj_dir = path.join("obj");
1343
1344 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1345 if !has_build_dir {
1346 return None;
1347 }
1348
1349 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1350
1351 let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1353 (true, true) => {
1354 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1355 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1356 vec![
1357 BuildArtifacts {
1358 path: bin_dir,
1359 size: bin_size,
1360 },
1361 BuildArtifacts {
1362 path: obj_dir,
1363 size: obj_size,
1364 },
1365 ]
1366 }
1367 (true, false) => vec![BuildArtifacts {
1368 path: bin_dir,
1369 size: 0,
1370 }],
1371 (false, true) => vec![BuildArtifacts {
1372 path: obj_dir,
1373 size: 0,
1374 }],
1375 (false, false) => return None,
1376 };
1377
1378 let name = csproj_file
1379 .file_stem()
1380 .and_then(|s| s.to_str())
1381 .map(std::string::ToString::to_string);
1382
1383 Some(Project::new(
1384 ProjectType::DotNet,
1385 path.to_path_buf(),
1386 build_arts,
1387 name,
1388 ))
1389 }
1390
1391 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1393 let entries = fs::read_dir(dir).ok()?;
1394 for entry in entries.flatten() {
1395 let path = entry.path();
1396 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1397 return Some(path);
1398 }
1399 }
1400 None
1401 }
1402
1403 fn detect_deno_project(
1412 &self,
1413 path: &Path,
1414 errors: &Arc<Mutex<Vec<String>>>,
1415 ) -> Option<Project> {
1416 let deno_json = path.join("deno.json");
1417 let deno_jsonc = path.join("deno.jsonc");
1418
1419 if !deno_json.exists() && !deno_jsonc.exists() {
1420 return None;
1421 }
1422
1423 let config_path = if deno_json.exists() {
1424 deno_json
1425 } else {
1426 deno_jsonc
1427 };
1428
1429 let vendor_dir = path.join("vendor");
1431 if vendor_dir.exists() {
1432 let name = self.extract_deno_project_name(&config_path, errors);
1433 return Some(Project::new(
1434 ProjectType::Deno,
1435 path.to_path_buf(),
1436 vec![BuildArtifacts {
1437 path: vendor_dir,
1438 size: 0,
1439 }],
1440 name,
1441 ));
1442 }
1443
1444 let node_modules = path.join("node_modules");
1446 if node_modules.exists() && !path.join("package.json").exists() {
1447 let name = self.extract_deno_project_name(&config_path, errors);
1448 return Some(Project::new(
1449 ProjectType::Deno,
1450 path.to_path_buf(),
1451 vec![BuildArtifacts {
1452 path: node_modules,
1453 size: 0,
1454 }],
1455 name,
1456 ));
1457 }
1458
1459 None
1460 }
1461
1462 fn extract_deno_project_name(
1467 &self,
1468 config_path: &Path,
1469 errors: &Arc<Mutex<Vec<String>>>,
1470 ) -> Option<String> {
1471 match fs::read_to_string(config_path) {
1472 Ok(content) => {
1473 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1474 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1475 {
1476 return Some(name.to_string());
1477 }
1478 Self::fallback_to_directory_name(config_path.parent()?)
1479 }
1480 Err(e) => {
1481 self.log_file_error(config_path, &e, errors);
1482 Self::fallback_to_directory_name(config_path.parent()?)
1483 }
1484 }
1485 }
1486
1487 fn detect_ruby_project(
1497 &self,
1498 path: &Path,
1499 errors: &Arc<Mutex<Vec<String>>>,
1500 ) -> Option<Project> {
1501 let gemfile = path.join("Gemfile");
1502 if !gemfile.exists() {
1503 return None;
1504 }
1505
1506 let bundle_dir = path.join(".bundle");
1507 let vendor_bundle_dir = path.join("vendor").join("bundle");
1508
1509 let build_arts: Vec<BuildArtifacts> =
1510 match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1511 (true, true) => {
1512 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1513 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1514 vec![
1515 BuildArtifacts {
1516 path: bundle_dir,
1517 size: bundle_size,
1518 },
1519 BuildArtifacts {
1520 path: vendor_bundle_dir,
1521 size: vendor_size,
1522 },
1523 ]
1524 }
1525 (true, false) => vec![BuildArtifacts {
1526 path: bundle_dir,
1527 size: 0,
1528 }],
1529 (false, true) => vec![BuildArtifacts {
1530 path: vendor_bundle_dir,
1531 size: 0,
1532 }],
1533 (false, false) => return None,
1534 };
1535
1536 let name = self.extract_ruby_project_name(path, errors);
1537
1538 Some(Project::new(
1539 ProjectType::Ruby,
1540 path.to_path_buf(),
1541 build_arts,
1542 name,
1543 ))
1544 }
1545
1546 fn extract_ruby_project_name(
1551 &self,
1552 path: &Path,
1553 errors: &Arc<Mutex<Vec<String>>>,
1554 ) -> Option<String> {
1555 let entries = fs::read_dir(path).ok()?;
1556 for entry in entries.flatten() {
1557 let entry_path = entry.path();
1558 if entry_path.is_file()
1559 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1560 && let Some(content) = self.read_file_content(&entry_path, errors)
1561 {
1562 for line in content.lines() {
1563 let trimmed = line.trim();
1564 if trimmed.contains(".name")
1565 && trimmed.contains('=')
1566 && let Some(name) = Self::extract_quoted_value(trimmed)
1567 {
1568 return Some(name);
1569 }
1570 }
1571 }
1572 }
1573
1574 Self::fallback_to_directory_name(path)
1575 }
1576
1577 fn detect_elixir_project(
1587 &self,
1588 path: &Path,
1589 errors: &Arc<Mutex<Vec<String>>>,
1590 ) -> Option<Project> {
1591 let mix_exs = path.join("mix.exs");
1592 let build_dir = path.join("_build");
1593
1594 if mix_exs.exists() && build_dir.exists() {
1595 let name = self.extract_elixir_project_name(&mix_exs, errors);
1596
1597 return Some(Project::new(
1598 ProjectType::Elixir,
1599 path.to_path_buf(),
1600 vec![BuildArtifacts {
1601 path: build_dir,
1602 size: 0,
1603 }],
1604 name,
1605 ));
1606 }
1607
1608 None
1609 }
1610
1611 fn extract_elixir_project_name(
1616 &self,
1617 mix_exs: &Path,
1618 errors: &Arc<Mutex<Vec<String>>>,
1619 ) -> Option<String> {
1620 let content = self.read_file_content(mix_exs, errors)?;
1621
1622 for line in content.lines() {
1623 let trimmed = line.trim();
1624 if trimmed.contains("app:")
1625 && let Some(pos) = trimmed.find("app:")
1626 {
1627 let after = trimmed[pos + 4..].trim_start();
1628 if let Some(atom) = after.strip_prefix(':') {
1629 let name: String = atom
1631 .chars()
1632 .take_while(|c| c.is_alphanumeric() || *c == '_')
1633 .collect();
1634 if !name.is_empty() {
1635 return Some(name);
1636 }
1637 }
1638 }
1639 }
1640
1641 Self::fallback_to_directory_name(mix_exs.parent()?)
1642 }
1643
1644 fn detect_php_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1654 let composer_json = path.join("composer.json");
1655 let vendor_dir = path.join("vendor");
1656
1657 if composer_json.exists() && vendor_dir.exists() {
1658 let name = self.extract_php_project_name(&composer_json, errors);
1659
1660 return Some(Project::new(
1661 ProjectType::Php,
1662 path.to_path_buf(),
1663 vec![BuildArtifacts {
1664 path: vendor_dir,
1665 size: 0,
1666 }],
1667 name,
1668 ));
1669 }
1670
1671 None
1672 }
1673
1674 fn extract_php_project_name(
1680 &self,
1681 composer_json: &Path,
1682 errors: &Arc<Mutex<Vec<String>>>,
1683 ) -> Option<String> {
1684 match fs::read_to_string(composer_json) {
1685 Ok(content) => {
1686 if let Ok(json) = from_str::<Value>(&content)
1687 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1688 {
1689 let package = name.split('/').next_back().unwrap_or(name);
1691 return Some(package.to_string());
1692 }
1693 Self::fallback_to_directory_name(composer_json.parent()?)
1694 }
1695 Err(e) => {
1696 self.log_file_error(composer_json, &e, errors);
1697 Self::fallback_to_directory_name(composer_json.parent()?)
1698 }
1699 }
1700 }
1701
1702 fn detect_haskell_project(
1708 &self,
1709 path: &Path,
1710 errors: &Arc<Mutex<Vec<String>>>,
1711 ) -> Option<Project> {
1712 let stack_yaml = path.join("stack.yaml");
1714 let stack_work = path.join(".stack-work");
1715
1716 if stack_yaml.exists() && stack_work.exists() {
1717 let name = self.extract_haskell_project_name(path, errors);
1718 return Some(Project::new(
1719 ProjectType::Haskell,
1720 path.to_path_buf(),
1721 vec![BuildArtifacts {
1722 path: stack_work,
1723 size: 0,
1724 }],
1725 name,
1726 ));
1727 }
1728
1729 let dist_newstyle = path.join("dist-newstyle");
1731 if dist_newstyle.exists() {
1732 let has_cabal_project = path.join("cabal.project").exists();
1733 let has_cabal_file = Self::find_file_with_extension(path, "cabal").is_some();
1734
1735 if has_cabal_project || has_cabal_file {
1736 let name = self.extract_haskell_project_name(path, errors);
1737 return Some(Project::new(
1738 ProjectType::Haskell,
1739 path.to_path_buf(),
1740 vec![BuildArtifacts {
1741 path: dist_newstyle,
1742 size: 0,
1743 }],
1744 name,
1745 ));
1746 }
1747 }
1748
1749 None
1750 }
1751
1752 fn extract_haskell_project_name(
1757 &self,
1758 path: &Path,
1759 errors: &Arc<Mutex<Vec<String>>>,
1760 ) -> Option<String> {
1761 if let Some(cabal_file) = Self::find_file_with_extension(path, "cabal")
1763 && let Some(content) = self.read_file_content(&cabal_file, errors)
1764 {
1765 for line in content.lines() {
1766 let trimmed = line.trim();
1767 if let Some(rest) = trimmed.strip_prefix("name:") {
1768 let name = rest.trim().to_string();
1769 if !name.is_empty() {
1770 return Some(name);
1771 }
1772 }
1773 }
1774 }
1775
1776 let package_yaml = path.join("package.yaml");
1778 if package_yaml.exists()
1779 && let Some(content) = self.read_file_content(&package_yaml, errors)
1780 {
1781 for line in content.lines() {
1782 let trimmed = line.trim();
1783 if let Some(rest) = trimmed.strip_prefix("name:") {
1784 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1785 if !name.is_empty() {
1786 return Some(name);
1787 }
1788 }
1789 }
1790 }
1791
1792 Self::fallback_to_directory_name(path)
1793 }
1794
1795 fn detect_dart_project(
1805 &self,
1806 path: &Path,
1807 errors: &Arc<Mutex<Vec<String>>>,
1808 ) -> Option<Project> {
1809 let pubspec_yaml = path.join("pubspec.yaml");
1810 if !pubspec_yaml.exists() {
1811 return None;
1812 }
1813
1814 let dart_tool = path.join(".dart_tool");
1815 let build_dir = path.join("build");
1816
1817 let build_arts: Vec<BuildArtifacts> = match (dart_tool.exists(), build_dir.exists()) {
1818 (true, true) => {
1819 let dart_size = crate::utils::calculate_dir_size(&dart_tool);
1820 let build_size = crate::utils::calculate_dir_size(&build_dir);
1821 vec![
1822 BuildArtifacts {
1823 path: dart_tool,
1824 size: dart_size,
1825 },
1826 BuildArtifacts {
1827 path: build_dir,
1828 size: build_size,
1829 },
1830 ]
1831 }
1832 (true, false) => vec![BuildArtifacts {
1833 path: dart_tool,
1834 size: 0,
1835 }],
1836 (false, true) => vec![BuildArtifacts {
1837 path: build_dir,
1838 size: 0,
1839 }],
1840 (false, false) => return None,
1841 };
1842
1843 let name = self.extract_dart_project_name(&pubspec_yaml, errors);
1844
1845 Some(Project::new(
1846 ProjectType::Dart,
1847 path.to_path_buf(),
1848 build_arts,
1849 name,
1850 ))
1851 }
1852
1853 fn extract_dart_project_name(
1858 &self,
1859 pubspec_yaml: &Path,
1860 errors: &Arc<Mutex<Vec<String>>>,
1861 ) -> Option<String> {
1862 let content = self.read_file_content(pubspec_yaml, errors)?;
1863
1864 for line in content.lines() {
1865 let trimmed = line.trim();
1866 if let Some(rest) = trimmed.strip_prefix("name:") {
1867 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
1868 if !name.is_empty() {
1869 return Some(name);
1870 }
1871 }
1872 }
1873
1874 Self::fallback_to_directory_name(pubspec_yaml.parent()?)
1875 }
1876
1877 fn detect_zig_project(path: &Path) -> Option<Project> {
1887 let build_zig = path.join("build.zig");
1888 if !build_zig.exists() {
1889 return None;
1890 }
1891
1892 let zig_cache = path.join("zig-cache");
1893 let zig_out = path.join("zig-out");
1894
1895 let build_arts: Vec<BuildArtifacts> = match (zig_cache.exists(), zig_out.exists()) {
1896 (true, true) => {
1897 let cache_size = crate::utils::calculate_dir_size(&zig_cache);
1898 let out_size = crate::utils::calculate_dir_size(&zig_out);
1899 vec![
1900 BuildArtifacts {
1901 path: zig_cache,
1902 size: cache_size,
1903 },
1904 BuildArtifacts {
1905 path: zig_out,
1906 size: out_size,
1907 },
1908 ]
1909 }
1910 (true, false) => vec![BuildArtifacts {
1911 path: zig_cache,
1912 size: 0,
1913 }],
1914 (false, true) => vec![BuildArtifacts {
1915 path: zig_out,
1916 size: 0,
1917 }],
1918 (false, false) => return None,
1919 };
1920
1921 let name = Self::fallback_to_directory_name(path);
1922
1923 Some(Project::new(
1924 ProjectType::Zig,
1925 path.to_path_buf(),
1926 build_arts,
1927 name,
1928 ))
1929 }
1930
1931 fn detect_scala_project(
1941 &self,
1942 path: &Path,
1943 errors: &Arc<Mutex<Vec<String>>>,
1944 ) -> Option<Project> {
1945 let build_sbt = path.join("build.sbt");
1946 let target_dir = path.join("target");
1947
1948 if build_sbt.exists() && target_dir.exists() {
1949 let name = self.extract_scala_project_name(&build_sbt, errors);
1950
1951 return Some(Project::new(
1952 ProjectType::Scala,
1953 path.to_path_buf(),
1954 vec![BuildArtifacts {
1955 path: target_dir,
1956 size: 0,
1957 }],
1958 name,
1959 ));
1960 }
1961
1962 None
1963 }
1964
1965 fn extract_scala_project_name(
1970 &self,
1971 build_sbt: &Path,
1972 errors: &Arc<Mutex<Vec<String>>>,
1973 ) -> Option<String> {
1974 let content = self.read_file_content(build_sbt, errors)?;
1975
1976 for line in content.lines() {
1977 let trimmed = line.trim();
1978 if trimmed.starts_with("name")
1979 && trimmed.contains(":=")
1980 && let Some(name) = Self::extract_quoted_value(trimmed)
1981 {
1982 return Some(name);
1983 }
1984 }
1985
1986 Self::fallback_to_directory_name(build_sbt.parent()?)
1987 }
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993 use std::path::PathBuf;
1994 use tempfile::TempDir;
1995
1996 fn default_scanner(filter: ProjectFilter) -> Scanner {
1998 Scanner::new(
1999 ScanOptions {
2000 verbose: false,
2001 threads: 1,
2002 skip: vec![],
2003 max_depth: None,
2004 },
2005 filter,
2006 )
2007 }
2008
2009 fn create_file(path: &Path, content: &str) -> anyhow::Result<()> {
2011 if let Some(parent) = path.parent() {
2012 fs::create_dir_all(parent)?;
2013 }
2014 fs::write(path, content)?;
2015 Ok(())
2016 }
2017
2018 #[test]
2021 fn test_is_hidden_directory_to_skip() {
2022 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2024 "/some/.hidden"
2025 )));
2026 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2027 "/some/.git"
2028 )));
2029 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2030 "/some/.svn"
2031 )));
2032 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
2033
2034 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2036 "/home/user/.cargo"
2037 )));
2038 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
2039
2040 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2042 "/some/visible"
2043 )));
2044 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
2045 }
2046
2047 #[test]
2048 fn test_is_excluded_directory() {
2049 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
2051 assert!(Scanner::is_excluded_directory(Path::new(
2052 "/some/node_modules"
2053 )));
2054 assert!(Scanner::is_excluded_directory(Path::new(
2055 "/some/__pycache__"
2056 )));
2057 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
2058 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
2059 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
2060 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
2061
2062 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
2064 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
2065 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
2066
2067 assert!(Scanner::is_excluded_directory(Path::new(
2069 "/some/.pytest_cache"
2070 )));
2071 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
2072 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
2073 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
2074
2075 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
2077 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
2078 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
2079 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
2080
2081 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
2083 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
2084
2085 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
2087 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
2088 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
2089 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
2090 }
2091
2092 #[test]
2093 fn test_extract_quoted_value() {
2094 assert_eq!(
2095 Scanner::extract_quoted_value(r#"name = "my-project""#),
2096 Some("my-project".to_string())
2097 );
2098 assert_eq!(
2099 Scanner::extract_quoted_value(r#"name = "with spaces""#),
2100 Some("with spaces".to_string())
2101 );
2102 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
2103 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
2105 }
2106
2107 #[test]
2108 fn test_is_name_line() {
2109 assert!(Scanner::is_name_line("name = \"test\""));
2110 assert!(Scanner::is_name_line("name=\"test\""));
2111 assert!(!Scanner::is_name_line("version = \"1.0\""));
2112 assert!(!Scanner::is_name_line("# name = \"commented\""));
2113 assert!(!Scanner::is_name_line("name: \"yaml style\""));
2114 }
2115
2116 #[test]
2117 fn test_parse_toml_name_field() {
2118 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
2119 assert_eq!(
2120 Scanner::parse_toml_name_field(content),
2121 Some("test-project".to_string())
2122 );
2123
2124 let no_name = "[package]\nversion = \"0.1.0\"\n";
2125 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
2126
2127 let empty = "";
2128 assert_eq!(Scanner::parse_toml_name_field(empty), None);
2129 }
2130
2131 #[test]
2132 fn test_extract_name_from_cfg_content() {
2133 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
2134 assert_eq!(
2135 Scanner::extract_name_from_cfg_content(content),
2136 Some("my-package".to_string())
2137 );
2138
2139 let wrong_section = "[options]\nname = not-this\n";
2141 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
2142
2143 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
2145 assert_eq!(
2146 Scanner::extract_name_from_cfg_content(multi),
2147 Some("correct".to_string())
2148 );
2149 }
2150
2151 #[test]
2152 fn test_extract_name_from_python_content() {
2153 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
2154 assert_eq!(
2155 Scanner::extract_name_from_python_content(content),
2156 Some("my-pkg".to_string())
2157 );
2158
2159 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
2160 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
2161 }
2162
2163 #[test]
2164 fn test_fallback_to_directory_name() {
2165 assert_eq!(
2166 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
2167 Some("project-name".to_string())
2168 );
2169 assert_eq!(
2170 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
2171 Some("my_app".to_string())
2172 );
2173 }
2174
2175 #[test]
2176 fn test_is_path_in_skip_list() {
2177 let scanner = Scanner::new(
2178 ScanOptions {
2179 verbose: false,
2180 threads: 1,
2181 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
2182 max_depth: None,
2183 },
2184 ProjectFilter::All,
2185 );
2186
2187 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
2188 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
2189 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
2190 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
2191 }
2192
2193 #[test]
2194 fn test_is_path_in_empty_skip_list() {
2195 let scanner = default_scanner(ProjectFilter::All);
2196 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
2197 }
2198
2199 #[test]
2202 fn test_scan_directory_with_spaces_in_path() -> anyhow::Result<()> {
2203 let tmp = TempDir::new()?;
2204 let base = tmp.path().join("path with spaces");
2205 fs::create_dir_all(&base)?;
2206
2207 let project = base.join("my project");
2208 create_file(
2209 &project.join("Cargo.toml"),
2210 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
2211 )?;
2212 create_file(&project.join("target/dummy"), "content")?;
2213
2214 let scanner = default_scanner(ProjectFilter::Rust);
2215 let projects = scanner.scan_directory(&base);
2216 assert_eq!(projects.len(), 1);
2217 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
2218 Ok(())
2219 }
2220
2221 #[test]
2222 fn test_scan_directory_with_unicode_names() -> anyhow::Result<()> {
2223 let tmp = TempDir::new()?;
2224 let base = tmp.path();
2225
2226 let project = base.join("プロジェクト");
2227 create_file(
2228 &project.join("package.json"),
2229 r#"{"name": "unicode-project"}"#,
2230 )?;
2231 create_file(&project.join("node_modules/dep.js"), "module.exports = {};")?;
2232
2233 let scanner = default_scanner(ProjectFilter::Node);
2234 let projects = scanner.scan_directory(base);
2235 assert_eq!(projects.len(), 1);
2236 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
2237 Ok(())
2238 }
2239
2240 #[test]
2241 fn test_scan_directory_with_special_characters_in_name() -> anyhow::Result<()> {
2242 let tmp = TempDir::new()?;
2243 let base = tmp.path();
2244
2245 let project = base.join("project-with-dashes_and_underscores.v2");
2246 create_file(
2247 &project.join("Cargo.toml"),
2248 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
2249 )?;
2250 create_file(&project.join("target/dummy"), "content")?;
2251
2252 let scanner = default_scanner(ProjectFilter::Rust);
2253 let projects = scanner.scan_directory(base);
2254 assert_eq!(projects.len(), 1);
2255 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
2256 Ok(())
2257 }
2258
2259 #[test]
2262 #[cfg(unix)]
2263 fn test_hidden_directory_itself_not_detected_as_project_unix() -> anyhow::Result<()> {
2264 let tmp = TempDir::new()?;
2265 let base = tmp.path();
2266
2267 let hidden = base.join(".hidden-project");
2272 create_file(
2273 &hidden.join("Cargo.toml"),
2274 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
2275 )?;
2276 create_file(&hidden.join("target/dummy"), "content")?;
2277
2278 let visible = base.join("visible-project");
2280 create_file(
2281 &visible.join("Cargo.toml"),
2282 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
2283 )?;
2284 create_file(&visible.join("target/dummy"), "content")?;
2285
2286 let scanner = default_scanner(ProjectFilter::Rust);
2287 let projects = scanner.scan_directory(base);
2288
2289 assert_eq!(projects.len(), 1);
2292 assert_eq!(projects[0].name.as_deref(), Some("visible"));
2293 Ok(())
2294 }
2295
2296 #[test]
2297 #[cfg(unix)]
2298 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() -> anyhow::Result<()> {
2299 let tmp = TempDir::new()?;
2300 let base = tmp.path();
2301
2302 let nested = base.join(".hidden-parent/visible-child");
2305 create_file(
2306 &nested.join("Cargo.toml"),
2307 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
2308 )?;
2309 create_file(&nested.join("target/dummy"), "content")?;
2310
2311 let scanner = default_scanner(ProjectFilter::Rust);
2312 let projects = scanner.scan_directory(base);
2313
2314 assert_eq!(projects.len(), 1);
2316 assert_eq!(projects[0].name.as_deref(), Some("nested"));
2317 Ok(())
2318 }
2319
2320 #[test]
2321 #[cfg(unix)]
2322 fn test_dotcargo_directory_not_skipped_unix() {
2323 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
2326 "/home/user/.cargo"
2327 )));
2328
2329 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2331 "/home/user/.local"
2332 )));
2333 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
2334 "/home/user/.npm"
2335 )));
2336 }
2337
2338 #[test]
2341 fn test_detect_python_with_pyproject_toml() -> anyhow::Result<()> {
2342 let tmp = TempDir::new()?;
2343 let base = tmp.path();
2344
2345 let project = base.join("py-project");
2346 create_file(
2347 &project.join("pyproject.toml"),
2348 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\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 assert_eq!(projects[0].kind, ProjectType::Python);
2358 Ok(())
2359 }
2360
2361 #[test]
2362 fn test_detect_python_with_setup_py() -> anyhow::Result<()> {
2363 let tmp = TempDir::new()?;
2364 let base = tmp.path();
2365
2366 let project = base.join("setup-project");
2367 create_file(
2368 &project.join("setup.py"),
2369 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
2370 )?;
2371 let pycache = project.join("__pycache__");
2372 fs::create_dir_all(&pycache)?;
2373 create_file(&pycache.join("module.pyc"), "bytecode")?;
2374
2375 let scanner = default_scanner(ProjectFilter::Python);
2376 let projects = scanner.scan_directory(base);
2377 assert_eq!(projects.len(), 1);
2378 Ok(())
2379 }
2380
2381 #[test]
2382 fn test_detect_python_with_pipfile() -> anyhow::Result<()> {
2383 let tmp = TempDir::new()?;
2384 let base = tmp.path();
2385
2386 let project = base.join("pipenv-project");
2387 create_file(
2388 &project.join("Pipfile"),
2389 "[[source]]\nurl = \"https://pypi.org/simple\"",
2390 )?;
2391 let pycache = project.join("__pycache__");
2392 fs::create_dir_all(&pycache)?;
2393 create_file(&pycache.join("module.pyc"), "bytecode")?;
2394
2395 let scanner = default_scanner(ProjectFilter::Python);
2396 let projects = scanner.scan_directory(base);
2397 assert_eq!(projects.len(), 1);
2398 Ok(())
2399 }
2400
2401 #[test]
2404 fn test_detect_go_extracts_module_name() -> anyhow::Result<()> {
2405 let tmp = TempDir::new()?;
2406 let base = tmp.path();
2407
2408 let project = base.join("go-service");
2409 create_file(
2410 &project.join("go.mod"),
2411 "module github.com/user/my-service\n\ngo 1.21\n",
2412 )?;
2413 let vendor = project.join("vendor");
2414 fs::create_dir_all(&vendor)?;
2415 create_file(&vendor.join("modules.txt"), "vendor manifest")?;
2416
2417 let scanner = default_scanner(ProjectFilter::Go);
2418 let projects = scanner.scan_directory(base);
2419 assert_eq!(projects.len(), 1);
2420 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
2422 Ok(())
2423 }
2424
2425 #[test]
2428 fn test_detect_java_maven_project() -> anyhow::Result<()> {
2429 let tmp = TempDir::new()?;
2430 let base = tmp.path();
2431
2432 let project = base.join("java-maven");
2433 create_file(
2434 &project.join("pom.xml"),
2435 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
2436 )?;
2437 create_file(&project.join("target/classes/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-java-app"));
2444 Ok(())
2445 }
2446
2447 #[test]
2448 fn test_detect_java_gradle_project() -> anyhow::Result<()> {
2449 let tmp = TempDir::new()?;
2450 let base = tmp.path();
2451
2452 let project = base.join("java-gradle");
2453 create_file(&project.join("build.gradle"), "apply plugin: 'java'")?;
2454 create_file(
2455 &project.join("settings.gradle"),
2456 "rootProject.name = \"my-gradle-app\"",
2457 )?;
2458 create_file(&project.join("build/classes/main/Main.class"), "bytecode")?;
2459
2460 let scanner = default_scanner(ProjectFilter::Java);
2461 let projects = scanner.scan_directory(base);
2462 assert_eq!(projects.len(), 1);
2463 assert_eq!(projects[0].kind, ProjectType::Java);
2464 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2465 Ok(())
2466 }
2467
2468 #[test]
2469 fn test_detect_java_gradle_kts_project() -> anyhow::Result<()> {
2470 let tmp = TempDir::new()?;
2471 let base = tmp.path();
2472
2473 let project = base.join("kotlin-gradle");
2474 create_file(
2475 &project.join("build.gradle.kts"),
2476 "plugins { kotlin(\"jvm\") }",
2477 )?;
2478 create_file(
2479 &project.join("settings.gradle.kts"),
2480 "rootProject.name = \"my-kotlin-app\"",
2481 )?;
2482 create_file(
2483 &project.join("build/classes/kotlin/main/MainKt.class"),
2484 "bytecode",
2485 )?;
2486
2487 let scanner = default_scanner(ProjectFilter::Java);
2488 let projects = scanner.scan_directory(base);
2489 assert_eq!(projects.len(), 1);
2490 assert_eq!(projects[0].kind, ProjectType::Java);
2491 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2492 Ok(())
2493 }
2494
2495 #[test]
2498 fn test_detect_cpp_cmake_project() -> anyhow::Result<()> {
2499 let tmp = TempDir::new()?;
2500 let base = tmp.path();
2501
2502 let project = base.join("cpp-cmake");
2503 create_file(
2504 &project.join("CMakeLists.txt"),
2505 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2506 )?;
2507 create_file(&project.join("build/CMakeCache.txt"), "cache")?;
2508
2509 let scanner = default_scanner(ProjectFilter::Cpp);
2510 let projects = scanner.scan_directory(base);
2511 assert_eq!(projects.len(), 1);
2512 assert_eq!(projects[0].kind, ProjectType::Cpp);
2513 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2514 Ok(())
2515 }
2516
2517 #[test]
2518 fn test_detect_cpp_makefile_project() -> anyhow::Result<()> {
2519 let tmp = TempDir::new()?;
2520 let base = tmp.path();
2521
2522 let project = base.join("cpp-make");
2523 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp")?;
2524 create_file(&project.join("build/main.o"), "object")?;
2525
2526 let scanner = default_scanner(ProjectFilter::Cpp);
2527 let projects = scanner.scan_directory(base);
2528 assert_eq!(projects.len(), 1);
2529 assert_eq!(projects[0].kind, ProjectType::Cpp);
2530 Ok(())
2531 }
2532
2533 #[test]
2536 fn test_detect_swift_project() -> anyhow::Result<()> {
2537 let tmp = TempDir::new()?;
2538 let base = tmp.path();
2539
2540 let project = base.join("swift-pkg");
2541 create_file(
2542 &project.join("Package.swift"),
2543 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2544 )?;
2545 create_file(&project.join(".build/debug/my-swift-lib"), "binary")?;
2546
2547 let scanner = default_scanner(ProjectFilter::Swift);
2548 let projects = scanner.scan_directory(base);
2549 assert_eq!(projects.len(), 1);
2550 assert_eq!(projects[0].kind, ProjectType::Swift);
2551 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2552 Ok(())
2553 }
2554
2555 #[test]
2558 fn test_detect_dotnet_project() -> anyhow::Result<()> {
2559 let tmp = TempDir::new()?;
2560 let base = tmp.path();
2561
2562 let project = base.join("dotnet-app");
2563 create_file(
2564 &project.join("MyApp.csproj"),
2565 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2566 )?;
2567 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly")?;
2568 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate")?;
2569
2570 let scanner = default_scanner(ProjectFilter::DotNet);
2571 let projects = scanner.scan_directory(base);
2572 assert_eq!(projects.len(), 1);
2573 assert_eq!(projects[0].kind, ProjectType::DotNet);
2574 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2575 Ok(())
2576 }
2577
2578 #[test]
2579 fn test_detect_dotnet_project_obj_only() -> anyhow::Result<()> {
2580 let tmp = TempDir::new()?;
2581 let base = tmp.path();
2582
2583 let project = base.join("dotnet-obj-only");
2584 create_file(
2585 &project.join("Lib.csproj"),
2586 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2587 )?;
2588 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate")?;
2589
2590 let scanner = default_scanner(ProjectFilter::DotNet);
2591 let projects = scanner.scan_directory(base);
2592 assert_eq!(projects.len(), 1);
2593 assert_eq!(projects[0].kind, ProjectType::DotNet);
2594 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2595 Ok(())
2596 }
2597
2598 #[test]
2601 fn test_obj_directory_is_excluded() {
2602 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2603 }
2604
2605 #[test]
2608 fn test_calculate_build_dir_size_empty() -> anyhow::Result<()> {
2609 let tmp = TempDir::new()?;
2610 let empty_dir = tmp.path().join("empty");
2611 fs::create_dir_all(&empty_dir)?;
2612
2613 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2614 Ok(())
2615 }
2616
2617 #[test]
2618 fn test_calculate_build_dir_size_nonexistent() {
2619 assert_eq!(
2620 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2621 0
2622 );
2623 }
2624
2625 #[test]
2626 fn test_calculate_build_dir_size_with_nested_files() -> anyhow::Result<()> {
2627 let tmp = TempDir::new()?;
2628 let dir = tmp.path().join("nested");
2629
2630 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);
2635 assert_eq!(size, 12);
2636 Ok(())
2637 }
2638
2639 #[test]
2642 fn test_scanner_quiet_mode() -> anyhow::Result<()> {
2643 let tmp = TempDir::new()?;
2644 let base = tmp.path();
2645
2646 let project = base.join("quiet-project");
2647 create_file(
2648 &project.join("Cargo.toml"),
2649 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2650 )?;
2651 create_file(&project.join("target/dummy"), "content")?;
2652
2653 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2654 let projects = scanner.scan_directory(base);
2655 assert_eq!(projects.len(), 1);
2656 Ok(())
2657 }
2658
2659 #[test]
2662 fn test_detect_ruby_with_vendor_bundle() -> anyhow::Result<()> {
2663 let tmp = TempDir::new()?;
2664 let base = tmp.path();
2665
2666 let project = base.join("ruby-project");
2667 create_file(
2668 &project.join("Gemfile"),
2669 "source 'https://rubygems.org'\ngem 'rails'",
2670 )?;
2671 create_file(
2672 &project.join("my-app.gemspec"),
2673 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2674 )?;
2675 create_file(
2676 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2677 "# rails",
2678 )?;
2679
2680 let scanner = default_scanner(ProjectFilter::Ruby);
2681 let projects = scanner.scan_directory(base);
2682 assert_eq!(projects.len(), 1);
2683 assert_eq!(projects[0].kind, ProjectType::Ruby);
2684 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2685 Ok(())
2686 }
2687
2688 #[test]
2689 fn test_detect_ruby_with_dot_bundle() -> anyhow::Result<()> {
2690 let tmp = TempDir::new()?;
2691 let base = tmp.path();
2692
2693 let project = base.join("ruby-dot-bundle");
2694 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2695 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack")?;
2696
2697 let scanner = default_scanner(ProjectFilter::Ruby);
2698 let projects = scanner.scan_directory(base);
2699 assert_eq!(projects.len(), 1);
2700 assert_eq!(projects[0].kind, ProjectType::Ruby);
2701 Ok(())
2702 }
2703
2704 #[test]
2705 fn test_detect_ruby_no_artifact_not_detected() -> anyhow::Result<()> {
2706 let tmp = TempDir::new()?;
2707 let base = tmp.path();
2708
2709 let project = base.join("gemfile-only");
2711 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2712
2713 let scanner = default_scanner(ProjectFilter::Ruby);
2714 let projects = scanner.scan_directory(base);
2715 assert_eq!(projects.len(), 0);
2716 Ok(())
2717 }
2718
2719 #[test]
2720 fn test_detect_ruby_fallback_to_dir_name() -> anyhow::Result<()> {
2721 let tmp = TempDir::new()?;
2722 let base = tmp.path();
2723
2724 let project = base.join("my-ruby-app");
2725 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'")?;
2726 create_file(
2727 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2728 "# sinatra",
2729 )?;
2730
2731 let scanner = default_scanner(ProjectFilter::Ruby);
2732 let projects = scanner.scan_directory(base);
2733 assert_eq!(projects.len(), 1);
2734 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2735 Ok(())
2736 }
2737
2738 #[test]
2741 fn test_detect_elixir_project() -> anyhow::Result<()> {
2742 let tmp = TempDir::new()?;
2743 let base = tmp.path();
2744
2745 let project = base.join("elixir-project");
2746 create_file(
2747 &project.join("mix.exs"),
2748 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2749 )?;
2750 create_file(
2751 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2752 "# build",
2753 )?;
2754
2755 let scanner = default_scanner(ProjectFilter::Elixir);
2756 let projects = scanner.scan_directory(base);
2757 assert_eq!(projects.len(), 1);
2758 assert_eq!(projects[0].kind, ProjectType::Elixir);
2759 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2760 Ok(())
2761 }
2762
2763 #[test]
2764 fn test_detect_elixir_no_build_not_detected() -> anyhow::Result<()> {
2765 let tmp = TempDir::new()?;
2766 let base = tmp.path();
2767
2768 let project = base.join("mix-only");
2769 create_file(
2770 &project.join("mix.exs"),
2771 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2772 )?;
2773
2774 let scanner = default_scanner(ProjectFilter::Elixir);
2775 let projects = scanner.scan_directory(base);
2776 assert_eq!(projects.len(), 0);
2777 Ok(())
2778 }
2779
2780 #[test]
2781 fn test_detect_elixir_fallback_to_dir_name() -> anyhow::Result<()> {
2782 let tmp = TempDir::new()?;
2783 let base = tmp.path();
2784
2785 let project = base.join("my_elixir_project");
2786 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:")?;
2787 create_file(
2788 &project.join("_build/prod/lib/my_elixir_project.beam"),
2789 "bytecode",
2790 )?;
2791
2792 let scanner = default_scanner(ProjectFilter::Elixir);
2793 let projects = scanner.scan_directory(base);
2794 assert_eq!(projects.len(), 1);
2795 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2796 Ok(())
2797 }
2798
2799 #[test]
2802 fn test_detect_deno_with_vendor() -> anyhow::Result<()> {
2803 let tmp = TempDir::new()?;
2804 let base = tmp.path();
2805
2806 let project = base.join("deno-project");
2807 create_file(
2808 &project.join("deno.json"),
2809 r#"{"name": "my-deno-app", "imports": {}}"#,
2810 )?;
2811 create_file(&project.join("vendor/modules.json"), "{}")?;
2812
2813 let scanner = default_scanner(ProjectFilter::Deno);
2814 let projects = scanner.scan_directory(base);
2815 assert_eq!(projects.len(), 1);
2816 assert_eq!(projects[0].kind, ProjectType::Deno);
2817 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2818 Ok(())
2819 }
2820
2821 #[test]
2822 fn test_detect_deno_jsonc_config() -> anyhow::Result<()> {
2823 let tmp = TempDir::new()?;
2824 let base = tmp.path();
2825
2826 let project = base.join("deno-jsonc-project");
2827 create_file(
2828 &project.join("deno.jsonc"),
2829 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2830 )?;
2831 create_file(&project.join("vendor/modules.json"), "{}")?;
2832
2833 let scanner = default_scanner(ProjectFilter::Deno);
2834 let projects = scanner.scan_directory(base);
2835 assert_eq!(projects.len(), 1);
2836 assert_eq!(projects[0].kind, ProjectType::Deno);
2837 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2838 Ok(())
2839 }
2840
2841 #[test]
2842 fn test_detect_deno_node_modules_without_package_json() -> anyhow::Result<()> {
2843 let tmp = TempDir::new()?;
2844 let base = tmp.path();
2845
2846 let project = base.join("deno-npm-project");
2847 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#)?;
2848 create_file(
2849 &project.join("node_modules/.deno/lodash/index.js"),
2850 "// lodash",
2851 )?;
2852
2853 let scanner = default_scanner(ProjectFilter::Deno);
2854 let projects = scanner.scan_directory(base);
2855 assert_eq!(projects.len(), 1);
2856 assert_eq!(projects[0].kind, ProjectType::Deno);
2857 Ok(())
2858 }
2859
2860 #[test]
2861 fn test_detect_deno_node_modules_with_package_json_becomes_node() -> anyhow::Result<()> {
2862 let tmp = TempDir::new()?;
2863 let base = tmp.path();
2864
2865 let project = base.join("ambiguous-project");
2867 create_file(&project.join("deno.json"), r"{}")?;
2868 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#)?;
2869 create_file(&project.join("node_modules/dep/index.js"), "// dep")?;
2870
2871 let scanner = default_scanner(ProjectFilter::All);
2872 let projects = scanner.scan_directory(base);
2873 assert_eq!(projects.len(), 1);
2874 assert_eq!(projects[0].kind, ProjectType::Node);
2875 Ok(())
2876 }
2877
2878 #[test]
2879 fn test_detect_deno_no_artifact_not_detected() -> anyhow::Result<()> {
2880 let tmp = TempDir::new()?;
2881 let base = tmp.path();
2882
2883 let project = base.join("deno-no-artifact");
2884 create_file(&project.join("deno.json"), r"{}")?;
2885
2886 let scanner = default_scanner(ProjectFilter::Deno);
2887 let projects = scanner.scan_directory(base);
2888 assert_eq!(projects.len(), 0);
2889 Ok(())
2890 }
2891
2892 #[test]
2893 fn test_build_directory_is_excluded() {
2894 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2895 }
2896
2897 #[test]
2900 fn test_is_cargo_workspace_root() -> anyhow::Result<()> {
2901 let tmp = TempDir::new()?;
2902 let cargo_toml = tmp.path().join("Cargo.toml");
2903
2904 create_file(
2906 &cargo_toml,
2907 "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2908 )?;
2909 assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2910
2911 create_file(
2913 &cargo_toml,
2914 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2915 )?;
2916 assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2917
2918 assert!(!Scanner::is_cargo_workspace_root(Path::new(
2920 "/nonexistent/Cargo.toml"
2921 )));
2922 Ok(())
2923 }
2924
2925 #[test]
2926 fn test_workspace_root_detected() -> 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 scanner = default_scanner(ProjectFilter::Rust);
2939 let projects = scanner.scan_directory(base);
2940
2941 assert_eq!(projects.len(), 1);
2942 assert_eq!(projects[0].root_path, workspace);
2943 Ok(())
2944 }
2945
2946 #[test]
2947 fn test_workspace_member_with_own_target_skipped() -> anyhow::Result<()> {
2948 let tmp = TempDir::new()?;
2949 let base = tmp.path();
2950
2951 let workspace = base.join("my-workspace");
2953 create_file(
2954 &workspace.join("Cargo.toml"),
2955 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2956 )?;
2957 create_file(&workspace.join("target/dummy"), "content")?;
2958
2959 let member = workspace.join("crate-a");
2961 create_file(
2962 &member.join("Cargo.toml"),
2963 "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2964 )?;
2965 create_file(&member.join("target/dummy"), "content")?;
2966
2967 let scanner = default_scanner(ProjectFilter::Rust);
2968 let projects = scanner.scan_directory(base);
2969
2970 assert_eq!(projects.len(), 1);
2972 assert_eq!(projects[0].root_path, workspace);
2973 Ok(())
2974 }
2975
2976 #[test]
2979 fn test_detect_php_project() -> anyhow::Result<()> {
2980 let tmp = TempDir::new()?;
2981 let base = tmp.path();
2982
2983 let project = base.join("php-project");
2984 create_file(
2985 &project.join("composer.json"),
2986 r#"{"name": "acme/my-php-app", "require": {}}"#,
2987 )?;
2988 create_file(&project.join("vendor/autoload.php"), "<?php // autoloader")?;
2989
2990 let scanner = default_scanner(ProjectFilter::Php);
2991 let projects = scanner.scan_directory(base);
2992 assert_eq!(projects.len(), 1);
2993 assert_eq!(projects[0].kind, ProjectType::Php);
2994 assert_eq!(projects[0].name.as_deref(), Some("my-php-app"));
2996 Ok(())
2997 }
2998
2999 #[test]
3000 fn test_detect_php_no_vendor_not_detected() -> anyhow::Result<()> {
3001 let tmp = TempDir::new()?;
3002 let base = tmp.path();
3003
3004 let project = base.join("php-no-vendor");
3005 create_file(&project.join("composer.json"), r#"{"name": "acme/my-app"}"#)?;
3006
3007 let scanner = default_scanner(ProjectFilter::Php);
3008 let projects = scanner.scan_directory(base);
3009 assert_eq!(projects.len(), 0);
3010 Ok(())
3011 }
3012
3013 #[test]
3014 fn test_detect_php_fallback_to_dir_name() -> anyhow::Result<()> {
3015 let tmp = TempDir::new()?;
3016 let base = tmp.path();
3017
3018 let project = base.join("my-php-project");
3019 create_file(&project.join("composer.json"), r#"{"require": {}}"#)?;
3021 create_file(&project.join("vendor/autoload.php"), "<?php")?;
3022
3023 let scanner = default_scanner(ProjectFilter::Php);
3024 let projects = scanner.scan_directory(base);
3025 assert_eq!(projects.len(), 1);
3026 assert_eq!(projects[0].name.as_deref(), Some("my-php-project"));
3027 Ok(())
3028 }
3029
3030 #[test]
3033 fn test_detect_haskell_stack_project() -> anyhow::Result<()> {
3034 let tmp = TempDir::new()?;
3035 let base = tmp.path();
3036
3037 let project = base.join("haskell-stack");
3038 create_file(
3039 &project.join("stack.yaml"),
3040 "resolver: lts-21.0\npackages:\n - .",
3041 )?;
3042 create_file(
3043 &project.join("my-haskell-lib.cabal"),
3044 "name: my-haskell-lib\nversion: 0.1.0.0\n",
3045 )?;
3046 create_file(
3047 &project.join(".stack-work/dist/x86_64-linux/ghc-9.4.7/build/Main.o"),
3048 "object",
3049 )?;
3050
3051 let scanner = default_scanner(ProjectFilter::Haskell);
3052 let projects = scanner.scan_directory(base);
3053 assert_eq!(projects.len(), 1);
3054 assert_eq!(projects[0].kind, ProjectType::Haskell);
3055 assert_eq!(projects[0].name.as_deref(), Some("my-haskell-lib"));
3056 Ok(())
3057 }
3058
3059 #[test]
3060 fn test_detect_haskell_cabal_project() -> anyhow::Result<()> {
3061 let tmp = TempDir::new()?;
3062 let base = tmp.path();
3063
3064 let project = base.join("haskell-cabal");
3065 create_file(&project.join("cabal.project"), "packages: .\n")?;
3066 create_file(
3067 &project.join("my-cabal-lib.cabal"),
3068 "name: my-cabal-lib\nversion: 0.1.0.0\n",
3069 )?;
3070 create_file(
3071 &project.join(
3072 "dist-newstyle/build/x86_64-linux/ghc-9.4.7/my-cabal-lib-0.1.0.0/build/Main.o",
3073 ),
3074 "object",
3075 )?;
3076
3077 let scanner = default_scanner(ProjectFilter::Haskell);
3078 let projects = scanner.scan_directory(base);
3079 assert_eq!(projects.len(), 1);
3080 assert_eq!(projects[0].kind, ProjectType::Haskell);
3081 assert_eq!(projects[0].name.as_deref(), Some("my-cabal-lib"));
3082 Ok(())
3083 }
3084
3085 #[test]
3086 fn test_detect_haskell_no_artifact_not_detected() -> anyhow::Result<()> {
3087 let tmp = TempDir::new()?;
3088 let base = tmp.path();
3089
3090 let project = base.join("haskell-no-artifact");
3091 create_file(&project.join("stack.yaml"), "resolver: lts-21.0")?;
3092
3093 let scanner = default_scanner(ProjectFilter::Haskell);
3094 let projects = scanner.scan_directory(base);
3095 assert_eq!(projects.len(), 0);
3096 Ok(())
3097 }
3098
3099 #[test]
3102 fn test_detect_dart_project_with_dart_tool() -> anyhow::Result<()> {
3103 let tmp = TempDir::new()?;
3104 let base = tmp.path();
3105
3106 let project = base.join("dart-project");
3107 create_file(
3108 &project.join("pubspec.yaml"),
3109 "name: my_dart_app\nversion: 1.0.0\n",
3110 )?;
3111 create_file(
3112 &project.join(".dart_tool/package_config.json"),
3113 r#"{"configVersion": 2}"#,
3114 )?;
3115
3116 let scanner = default_scanner(ProjectFilter::Dart);
3117 let projects = scanner.scan_directory(base);
3118 assert_eq!(projects.len(), 1);
3119 assert_eq!(projects[0].kind, ProjectType::Dart);
3120 assert_eq!(projects[0].name.as_deref(), Some("my_dart_app"));
3121 Ok(())
3122 }
3123
3124 #[test]
3125 fn test_detect_dart_project_with_build_dir() -> anyhow::Result<()> {
3126 let tmp = TempDir::new()?;
3127 let base = tmp.path();
3128
3129 let project = base.join("flutter-project");
3130 create_file(
3131 &project.join("pubspec.yaml"),
3132 "name: my_flutter_app\nversion: 1.0.0\n",
3133 )?;
3134 create_file(
3135 &project.join("build/flutter_assets/AssetManifest.json"),
3136 "{}",
3137 )?;
3138
3139 let scanner = default_scanner(ProjectFilter::Dart);
3140 let projects = scanner.scan_directory(base);
3141 assert_eq!(projects.len(), 1);
3142 assert_eq!(projects[0].kind, ProjectType::Dart);
3143 assert_eq!(projects[0].name.as_deref(), Some("my_flutter_app"));
3144 Ok(())
3145 }
3146
3147 #[test]
3148 fn test_detect_dart_no_artifact_not_detected() -> anyhow::Result<()> {
3149 let tmp = TempDir::new()?;
3150 let base = tmp.path();
3151
3152 let project = base.join("pubspec-only");
3153 create_file(&project.join("pubspec.yaml"), "name: empty_project\n")?;
3154
3155 let scanner = default_scanner(ProjectFilter::Dart);
3156 let projects = scanner.scan_directory(base);
3157 assert_eq!(projects.len(), 0);
3158 Ok(())
3159 }
3160
3161 #[test]
3164 fn test_detect_zig_project_with_cache() -> anyhow::Result<()> {
3165 let tmp = TempDir::new()?;
3166 let base = tmp.path();
3167
3168 let project = base.join("zig-project");
3169 create_file(
3170 &project.join("build.zig"),
3171 "const std = @import(\"std\");\npub fn build(b: *std.Build) void {}\n",
3172 )?;
3173 create_file(&project.join("zig-cache/h/abc123.h"), "// generated")?;
3174
3175 let scanner = default_scanner(ProjectFilter::Zig);
3176 let projects = scanner.scan_directory(base);
3177 assert_eq!(projects.len(), 1);
3178 assert_eq!(projects[0].kind, ProjectType::Zig);
3179 assert_eq!(projects[0].name.as_deref(), Some("zig-project"));
3181 Ok(())
3182 }
3183
3184 #[test]
3185 fn test_detect_zig_project_with_out_dir() -> anyhow::Result<()> {
3186 let tmp = TempDir::new()?;
3187 let base = tmp.path();
3188
3189 let project = base.join("zig-out-project");
3190 create_file(&project.join("build.zig"), "// zig build script")?;
3191 create_file(&project.join("zig-out/bin/my-app"), "binary")?;
3192
3193 let scanner = default_scanner(ProjectFilter::Zig);
3194 let projects = scanner.scan_directory(base);
3195 assert_eq!(projects.len(), 1);
3196 assert_eq!(projects[0].kind, ProjectType::Zig);
3197 Ok(())
3198 }
3199
3200 #[test]
3201 fn test_detect_zig_no_artifact_not_detected() -> anyhow::Result<()> {
3202 let tmp = TempDir::new()?;
3203 let base = tmp.path();
3204
3205 let project = base.join("zig-no-artifact");
3206 create_file(&project.join("build.zig"), "// zig build script")?;
3207
3208 let scanner = default_scanner(ProjectFilter::Zig);
3209 let projects = scanner.scan_directory(base);
3210 assert_eq!(projects.len(), 0);
3211 Ok(())
3212 }
3213
3214 #[test]
3215 fn test_zig_cache_directory_is_excluded() {
3216 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-cache")));
3217 assert!(Scanner::is_excluded_directory(Path::new("/some/zig-out")));
3218 assert!(Scanner::is_excluded_directory(Path::new(
3219 "/some/dist-newstyle"
3220 )));
3221 }
3222
3223 #[test]
3226 fn test_detect_scala_project() -> anyhow::Result<()> {
3227 let tmp = TempDir::new()?;
3228 let base = tmp.path();
3229
3230 let project = base.join("scala-project");
3231 create_file(
3232 &project.join("build.sbt"),
3233 "name := \"my-scala-app\"\nscalaVersion := \"3.3.0\"\n",
3234 )?;
3235 create_file(
3236 &project.join("target/scala-3.3.0/classes/Main.class"),
3237 "bytecode",
3238 )?;
3239
3240 let scanner = default_scanner(ProjectFilter::Scala);
3241 let projects = scanner.scan_directory(base);
3242 assert_eq!(projects.len(), 1);
3243 assert_eq!(projects[0].kind, ProjectType::Scala);
3244 assert_eq!(projects[0].name.as_deref(), Some("my-scala-app"));
3245 Ok(())
3246 }
3247
3248 #[test]
3249 fn test_detect_scala_no_target_not_detected() -> anyhow::Result<()> {
3250 let tmp = TempDir::new()?;
3251 let base = tmp.path();
3252
3253 let project = base.join("sbt-only");
3254 create_file(&project.join("build.sbt"), "name := \"unbuilt-project\"\n")?;
3255
3256 let scanner = default_scanner(ProjectFilter::Scala);
3257 let projects = scanner.scan_directory(base);
3258 assert_eq!(projects.len(), 0);
3259 Ok(())
3260 }
3261
3262 #[test]
3263 fn test_detect_scala_fallback_to_dir_name() -> anyhow::Result<()> {
3264 let tmp = TempDir::new()?;
3265 let base = tmp.path();
3266
3267 let project = base.join("my-scala-project");
3268 create_file(&project.join("build.sbt"), "scalaVersion := \"3.3.0\"\n")?;
3270 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3271
3272 let scanner = default_scanner(ProjectFilter::Scala);
3273 let projects = scanner.scan_directory(base);
3274 assert_eq!(projects.len(), 1);
3275 assert_eq!(projects[0].name.as_deref(), Some("my-scala-project"));
3276 Ok(())
3277 }
3278
3279 #[test]
3280 fn test_scala_detected_before_java_for_build_sbt_projects() -> anyhow::Result<()> {
3281 let tmp = TempDir::new()?;
3282 let base = tmp.path();
3283
3284 let project = base.join("scala-maven-project");
3286 create_file(&project.join("build.sbt"), "name := \"scala-maven\"\n")?;
3287 create_file(
3288 &project.join("pom.xml"),
3289 "<project><artifactId>scala-maven</artifactId></project>",
3290 )?;
3291 create_file(&project.join("target/scala-3.3.0/Main.class"), "bytecode")?;
3292
3293 let scanner = default_scanner(ProjectFilter::All);
3294 let projects = scanner.scan_directory(base);
3295 assert_eq!(projects.len(), 1);
3296 assert_eq!(projects[0].kind, ProjectType::Scala);
3297 Ok(())
3298 }
3299}