1use std::{
9 fs,
10 path::Path,
11 sync::{Arc, Mutex},
12};
13
14use colored::Colorize;
15use indicatif::{ProgressBar, ProgressStyle};
16use rayon::prelude::*;
17use serde_json::{Value, from_str};
18use walkdir::{DirEntry, WalkDir};
19
20use crate::{
21 config::{ProjectFilter, ScanOptions},
22 project::{BuildArtifacts, Project, ProjectType},
23};
24
25pub struct Scanner {
32 scan_options: ScanOptions,
34
35 project_filter: ProjectFilter,
37
38 quiet: bool,
40}
41
42impl Scanner {
43 #[must_use]
67 pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
68 Self {
69 scan_options,
70 project_filter,
71 quiet: false,
72 }
73 }
74
75 #[must_use]
80 pub const fn with_quiet(mut self, quiet: bool) -> Self {
81 self.quiet = quiet;
82 self
83 }
84
85 pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
122 let errors = Arc::new(Mutex::new(Vec::<String>::new()));
123
124 let progress = if self.quiet {
125 ProgressBar::hidden()
126 } else {
127 let pb = ProgressBar::new_spinner();
128 pb.set_style(
129 ProgressStyle::default_spinner()
130 .template("{spinner:.green} {msg}")
131 .unwrap(),
132 );
133 pb.set_message("Scanning directories...");
134 pb
135 };
136
137 let potential_projects: Vec<_> = WalkDir::new(root)
139 .into_iter()
140 .filter_map(Result::ok)
141 .filter(|entry| self.should_scan_entry(entry))
142 .collect::<Vec<_>>()
143 .into_par_iter()
144 .filter_map(|entry| self.detect_project(&entry, &errors))
145 .collect();
146
147 progress.finish_with_message("✅ Directory scan complete");
148
149 let projects_with_sizes: Vec<_> = potential_projects
151 .into_par_iter()
152 .filter_map(|mut project| {
153 if project.build_arts.size == 0 {
154 project.build_arts.size =
155 Self::calculate_build_dir_size(&project.build_arts.path);
156 }
157
158 if project.build_arts.size > 0 {
159 Some(project)
160 } else {
161 None
162 }
163 })
164 .collect();
165
166 if self.scan_options.verbose {
168 let errors = errors.lock().unwrap();
169 for error in errors.iter() {
170 eprintln!("{}", error.red());
171 }
172 }
173
174 projects_with_sizes
175 }
176
177 fn calculate_build_dir_size(path: &Path) -> u64 {
198 if !path.exists() {
199 return 0;
200 }
201
202 crate::utils::calculate_dir_size(path)
203 }
204
205 fn detect_node_project(
227 &self,
228 path: &Path,
229 errors: &Arc<Mutex<Vec<String>>>,
230 ) -> Option<Project> {
231 let package_json = path.join("package.json");
232 let node_modules = path.join("node_modules");
233
234 if package_json.exists() && node_modules.exists() {
235 let name = self.extract_node_project_name(&package_json, errors);
236
237 let build_arts = BuildArtifacts {
238 path: path.join("node_modules"),
239 size: 0, };
241
242 return Some(Project::new(
243 ProjectType::Node,
244 path.to_path_buf(),
245 build_arts,
246 name,
247 ));
248 }
249
250 None
251 }
252
253 fn detect_project(
283 &self,
284 entry: &DirEntry,
285 errors: &Arc<Mutex<Vec<String>>>,
286 ) -> Option<Project> {
287 let path = entry.path();
288
289 if !entry.file_type().is_dir() {
290 return None;
291 }
292
293 self.try_detect(ProjectFilter::Rust, || {
298 self.detect_rust_project(path, errors)
299 })
300 .or_else(|| {
301 self.try_detect(ProjectFilter::Deno, || {
302 self.detect_deno_project(path, errors)
303 })
304 })
305 .or_else(|| {
306 self.try_detect(ProjectFilter::Node, || {
307 self.detect_node_project(path, errors)
308 })
309 })
310 .or_else(|| {
311 self.try_detect(ProjectFilter::Java, || {
312 self.detect_java_project(path, errors)
313 })
314 })
315 .or_else(|| {
316 self.try_detect(ProjectFilter::Swift, || {
317 self.detect_swift_project(path, errors)
318 })
319 })
320 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
321 .or_else(|| {
322 self.try_detect(ProjectFilter::Python, || {
323 self.detect_python_project(path, errors)
324 })
325 })
326 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
327 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
328 .or_else(|| {
329 self.try_detect(ProjectFilter::Ruby, || {
330 self.detect_ruby_project(path, errors)
331 })
332 })
333 .or_else(|| {
334 self.try_detect(ProjectFilter::Elixir, || {
335 self.detect_elixir_project(path, errors)
336 })
337 })
338 }
339
340 fn try_detect(
345 &self,
346 filter: ProjectFilter,
347 detect: impl FnOnce() -> Option<Project>,
348 ) -> Option<Project> {
349 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
350 detect()
351 } else {
352 None
353 }
354 }
355
356 fn detect_rust_project(
378 &self,
379 path: &Path,
380 errors: &Arc<Mutex<Vec<String>>>,
381 ) -> Option<Project> {
382 let cargo_toml = path.join("Cargo.toml");
383 let target_dir = path.join("target");
384
385 if cargo_toml.exists() && target_dir.exists() {
386 let name = self.extract_rust_project_name(&cargo_toml, errors);
387
388 let build_arts = BuildArtifacts {
389 path: path.join("target"),
390 size: 0, };
392
393 return Some(Project::new(
394 ProjectType::Rust,
395 path.to_path_buf(),
396 build_arts,
397 name,
398 ));
399 }
400
401 None
402 }
403
404 fn extract_rust_project_name(
426 &self,
427 cargo_toml: &Path,
428 errors: &Arc<Mutex<Vec<String>>>,
429 ) -> Option<String> {
430 let content = self.read_file_content(cargo_toml, errors)?;
431 Self::parse_toml_name_field(&content)
432 }
433
434 fn extract_quoted_value(line: &str) -> Option<String> {
436 let start = line.find('"')?;
437 let end = line.rfind('"')?;
438
439 if start == end {
440 return None;
441 }
442
443 Some(line[start + 1..end].to_string())
444 }
445
446 fn extract_name_from_line(line: &str) -> Option<String> {
448 if !Self::is_name_line(line) {
449 return None;
450 }
451
452 Self::extract_quoted_value(line)
453 }
454
455 fn extract_node_project_name(
476 &self,
477 package_json: &Path,
478 errors: &Arc<Mutex<Vec<String>>>,
479 ) -> Option<String> {
480 match fs::read_to_string(package_json) {
481 Ok(content) => match from_str::<Value>(&content) {
482 Ok(json) => json
483 .get("name")
484 .and_then(|v| v.as_str())
485 .map(std::string::ToString::to_string),
486 Err(e) => {
487 if self.scan_options.verbose {
488 errors
489 .lock()
490 .unwrap()
491 .push(format!("Error parsing {}: {e}", package_json.display()));
492 }
493 None
494 }
495 },
496 Err(e) => {
497 if self.scan_options.verbose {
498 errors
499 .lock()
500 .unwrap()
501 .push(format!("Error reading {}: {e}", package_json.display()));
502 }
503 None
504 }
505 }
506 }
507
508 fn is_name_line(line: &str) -> bool {
510 line.starts_with("name") && line.contains('=')
511 }
512
513 fn log_file_error(
515 &self,
516 file_path: &Path,
517 error: &std::io::Error,
518 errors: &Arc<Mutex<Vec<String>>>,
519 ) {
520 if self.scan_options.verbose {
521 errors
522 .lock()
523 .unwrap()
524 .push(format!("Error reading {}: {error}", file_path.display()));
525 }
526 }
527
528 fn parse_toml_name_field(content: &str) -> Option<String> {
530 for line in content.lines() {
531 if let Some(name) = Self::extract_name_from_line(line.trim()) {
532 return Some(name);
533 }
534 }
535 None
536 }
537
538 fn read_file_content(
540 &self,
541 file_path: &Path,
542 errors: &Arc<Mutex<Vec<String>>>,
543 ) -> Option<String> {
544 match fs::read_to_string(file_path) {
545 Ok(content) => Some(content),
546 Err(e) => {
547 self.log_file_error(file_path, &e, errors);
548 None
549 }
550 }
551 }
552
553 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
587 let path = entry.path();
588
589 if self.is_path_in_skip_list(path) {
591 return false;
592 }
593
594 if path
596 .ancestors()
597 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
598 {
599 return false;
600 }
601
602 if Self::is_hidden_directory_to_skip(path) {
604 return false;
605 }
606
607 !Self::is_excluded_directory(path)
609 }
610
611 fn is_path_in_skip_list(&self, path: &Path) -> bool {
613 self.scan_options.skip.iter().any(|skip| {
614 path.components().any(|component| {
615 component
616 .as_os_str()
617 .to_str()
618 .is_some_and(|name| name == skip.to_string_lossy())
619 })
620 })
621 }
622
623 fn is_hidden_directory_to_skip(path: &Path) -> bool {
625 path.file_name()
626 .and_then(|n| n.to_str())
627 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
628 }
629
630 fn is_excluded_directory(path: &Path) -> bool {
632 let excluded_dirs = [
633 "target",
634 "build",
635 "dist",
636 "out",
637 ".git",
638 ".svn",
639 ".hg",
640 "__pycache__",
641 "venv",
642 ".venv",
643 "env",
644 ".env",
645 "temp",
646 "tmp",
647 "vendor",
648 ".pytest_cache",
649 ".tox",
650 ".eggs",
651 ".coverage",
652 "node_modules",
653 "obj",
654 "_build",
655 ];
656
657 path.file_name()
658 .and_then(|n| n.to_str())
659 .is_some_and(|name| excluded_dirs.contains(&name))
660 }
661
662 fn detect_python_project(
683 &self,
684 path: &Path,
685 errors: &Arc<Mutex<Vec<String>>>,
686 ) -> Option<Project> {
687 let config_files = [
688 "requirements.txt",
689 "setup.py",
690 "pyproject.toml",
691 "setup.cfg",
692 "Pipfile",
693 "pipenv.lock",
694 "poetry.lock",
695 ];
696
697 let build_dirs = [
698 "__pycache__",
699 ".pytest_cache",
700 "venv",
701 ".venv",
702 "build",
703 "dist",
704 ".eggs",
705 ".tox",
706 ".coverage",
707 ];
708
709 let has_config = config_files.iter().any(|&file| path.join(file).exists());
711
712 if !has_config {
713 return None;
714 }
715
716 let mut largest_build_dir = None;
718 let mut largest_size = 0;
719
720 for &dir_name in &build_dirs {
721 let dir_path = path.join(dir_name);
722
723 if dir_path.exists() && dir_path.is_dir() {
724 let size = crate::utils::calculate_dir_size(&dir_path);
725 if size > largest_size {
726 largest_size = size;
727 largest_build_dir = Some(dir_path);
728 }
729 }
730 }
731
732 if let Some(build_path) = largest_build_dir {
733 let name = self.extract_python_project_name(path, errors);
734
735 let build_arts = BuildArtifacts {
736 path: build_path,
737 size: largest_size,
738 };
739
740 return Some(Project::new(
741 ProjectType::Python,
742 path.to_path_buf(),
743 build_arts,
744 name,
745 ));
746 }
747
748 None
749 }
750
751 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
773 let go_mod = path.join("go.mod");
774 let vendor_dir = path.join("vendor");
775
776 if go_mod.exists() && vendor_dir.exists() {
777 let name = self.extract_go_project_name(&go_mod, errors);
778
779 let build_arts = BuildArtifacts {
780 path: path.join("vendor"),
781 size: 0, };
783
784 return Some(Project::new(
785 ProjectType::Go,
786 path.to_path_buf(),
787 build_arts,
788 name,
789 ));
790 }
791
792 None
793 }
794
795 fn extract_python_project_name(
817 &self,
818 path: &Path,
819 errors: &Arc<Mutex<Vec<String>>>,
820 ) -> Option<String> {
821 self.try_extract_from_pyproject_toml(path, errors)
823 .or_else(|| self.try_extract_from_setup_py(path, errors))
824 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
825 .or_else(|| Self::fallback_to_directory_name(path))
826 }
827
828 fn try_extract_from_pyproject_toml(
830 &self,
831 path: &Path,
832 errors: &Arc<Mutex<Vec<String>>>,
833 ) -> Option<String> {
834 let pyproject_toml = path.join("pyproject.toml");
835 if !pyproject_toml.exists() {
836 return None;
837 }
838
839 let content = self.read_file_content(&pyproject_toml, errors)?;
840 Self::extract_name_from_toml_like_content(&content)
841 }
842
843 fn try_extract_from_setup_py(
845 &self,
846 path: &Path,
847 errors: &Arc<Mutex<Vec<String>>>,
848 ) -> Option<String> {
849 let setup_py = path.join("setup.py");
850 if !setup_py.exists() {
851 return None;
852 }
853
854 let content = self.read_file_content(&setup_py, errors)?;
855 Self::extract_name_from_python_content(&content)
856 }
857
858 fn try_extract_from_setup_cfg(
860 &self,
861 path: &Path,
862 errors: &Arc<Mutex<Vec<String>>>,
863 ) -> Option<String> {
864 let setup_cfg = path.join("setup.cfg");
865 if !setup_cfg.exists() {
866 return None;
867 }
868
869 let content = self.read_file_content(&setup_cfg, errors)?;
870 Self::extract_name_from_cfg_content(&content)
871 }
872
873 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
875 content
876 .lines()
877 .map(str::trim)
878 .find(|line| line.starts_with("name") && line.contains('='))
879 .and_then(Self::extract_quoted_value)
880 }
881
882 fn extract_name_from_python_content(content: &str) -> Option<String> {
884 content
885 .lines()
886 .map(str::trim)
887 .find(|line| line.contains("name") && line.contains('='))
888 .and_then(Self::extract_quoted_value)
889 }
890
891 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
893 let mut in_metadata_section = false;
894
895 for line in content.lines() {
896 let line = line.trim();
897
898 if line == "[metadata]" {
899 in_metadata_section = true;
900 } else if line.starts_with('[') && line.ends_with(']') {
901 in_metadata_section = false;
902 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
903 return line.split('=').nth(1).map(|name| name.trim().to_string());
904 }
905 }
906
907 None
908 }
909
910 fn fallback_to_directory_name(path: &Path) -> Option<String> {
912 path.file_name()
913 .and_then(|name| name.to_str())
914 .map(std::string::ToString::to_string)
915 }
916
917 fn extract_go_project_name(
937 &self,
938 go_mod: &Path,
939 errors: &Arc<Mutex<Vec<String>>>,
940 ) -> Option<String> {
941 let content = self.read_file_content(go_mod, errors)?;
942
943 for line in content.lines() {
944 let line = line.trim();
945 if line.starts_with("module ") {
946 let module_path = line.strip_prefix("module ")?.trim();
947
948 if let Some(name) = module_path.split('/').next_back() {
950 return Some(name.to_string());
951 }
952
953 return Some(module_path.to_string());
954 }
955 }
956
957 None
958 }
959
960 fn detect_java_project(
971 &self,
972 path: &Path,
973 errors: &Arc<Mutex<Vec<String>>>,
974 ) -> Option<Project> {
975 let pom_xml = path.join("pom.xml");
976 let target_dir = path.join("target");
977
978 if pom_xml.exists() && target_dir.exists() {
980 let name = self.extract_java_maven_project_name(&pom_xml, errors);
981
982 let build_arts = BuildArtifacts {
983 path: target_dir,
984 size: 0,
985 };
986
987 return Some(Project::new(
988 ProjectType::Java,
989 path.to_path_buf(),
990 build_arts,
991 name,
992 ));
993 }
994
995 let has_gradle =
997 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
998 let build_dir = path.join("build");
999
1000 if has_gradle && build_dir.exists() {
1001 let name = self.extract_java_gradle_project_name(path, errors);
1002
1003 let build_arts = BuildArtifacts {
1004 path: build_dir,
1005 size: 0,
1006 };
1007
1008 return Some(Project::new(
1009 ProjectType::Java,
1010 path.to_path_buf(),
1011 build_arts,
1012 name,
1013 ));
1014 }
1015
1016 None
1017 }
1018
1019 fn extract_java_maven_project_name(
1023 &self,
1024 pom_xml: &Path,
1025 errors: &Arc<Mutex<Vec<String>>>,
1026 ) -> Option<String> {
1027 let content = self.read_file_content(pom_xml, errors)?;
1028
1029 for line in content.lines() {
1030 let trimmed = line.trim();
1031 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1032 let name = trimmed
1033 .strip_prefix("<artifactId>")?
1034 .strip_suffix("</artifactId>")?;
1035 return Some(name.to_string());
1036 }
1037 }
1038
1039 None
1040 }
1041
1042 fn extract_java_gradle_project_name(
1047 &self,
1048 path: &Path,
1049 errors: &Arc<Mutex<Vec<String>>>,
1050 ) -> Option<String> {
1051 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1052 let settings_path = path.join(settings_file);
1053 if settings_path.exists()
1054 && let Some(content) = self.read_file_content(&settings_path, errors)
1055 {
1056 for line in content.lines() {
1057 let trimmed = line.trim();
1058 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1059 return Self::extract_quoted_value(trimmed).or_else(|| {
1060 trimmed
1061 .split('=')
1062 .nth(1)
1063 .map(|s| s.trim().trim_matches('\'').to_string())
1064 });
1065 }
1066 }
1067 }
1068 }
1069
1070 Self::fallback_to_directory_name(path)
1071 }
1072
1073 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1083 let build_dir = path.join("build");
1084
1085 if !build_dir.exists() {
1086 return None;
1087 }
1088
1089 let cmake_file = path.join("CMakeLists.txt");
1090 let makefile = path.join("Makefile");
1091
1092 if cmake_file.exists() || makefile.exists() {
1093 let name = if cmake_file.exists() {
1094 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1095 } else {
1096 Self::fallback_to_directory_name(path)
1097 };
1098
1099 let build_arts = BuildArtifacts {
1100 path: build_dir,
1101 size: 0,
1102 };
1103
1104 return Some(Project::new(
1105 ProjectType::Cpp,
1106 path.to_path_buf(),
1107 build_arts,
1108 name,
1109 ));
1110 }
1111
1112 None
1113 }
1114
1115 fn extract_cpp_cmake_project_name(
1119 &self,
1120 cmake_file: &Path,
1121 errors: &Arc<Mutex<Vec<String>>>,
1122 ) -> Option<String> {
1123 let content = self.read_file_content(cmake_file, errors)?;
1124
1125 for line in content.lines() {
1126 let trimmed = line.trim();
1127 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1128 let inner = trimmed
1129 .trim_start_matches("project(")
1130 .trim_start_matches("PROJECT(")
1131 .trim_end_matches(')')
1132 .trim();
1133
1134 let name = inner.split_whitespace().next()?;
1136 let name = name.trim_matches('"').trim_matches('\'');
1138 if !name.is_empty() {
1139 return Some(name.to_string());
1140 }
1141 }
1142 }
1143
1144 Self::fallback_to_directory_name(cmake_file.parent()?)
1145 }
1146
1147 fn detect_swift_project(
1157 &self,
1158 path: &Path,
1159 errors: &Arc<Mutex<Vec<String>>>,
1160 ) -> Option<Project> {
1161 let package_swift = path.join("Package.swift");
1162 let build_dir = path.join(".build");
1163
1164 if package_swift.exists() && build_dir.exists() {
1165 let name = self.extract_swift_project_name(&package_swift, errors);
1166
1167 let build_arts = BuildArtifacts {
1168 path: build_dir,
1169 size: 0,
1170 };
1171
1172 return Some(Project::new(
1173 ProjectType::Swift,
1174 path.to_path_buf(),
1175 build_arts,
1176 name,
1177 ));
1178 }
1179
1180 None
1181 }
1182
1183 fn extract_swift_project_name(
1187 &self,
1188 package_swift: &Path,
1189 errors: &Arc<Mutex<Vec<String>>>,
1190 ) -> Option<String> {
1191 let content = self.read_file_content(package_swift, errors)?;
1192
1193 for line in content.lines() {
1194 let trimmed = line.trim();
1195 if trimmed.contains("name:") {
1196 return Self::extract_quoted_value(trimmed);
1197 }
1198 }
1199
1200 Self::fallback_to_directory_name(package_swift.parent()?)
1201 }
1202
1203 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1213 let bin_dir = path.join("bin");
1214 let obj_dir = path.join("obj");
1215
1216 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1217 if !has_build_dir {
1218 return None;
1219 }
1220
1221 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1222
1223 let (build_path, precomputed_size) = match (bin_dir.exists(), obj_dir.exists()) {
1225 (true, true) => {
1226 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1227 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1228 if obj_size >= bin_size {
1229 (obj_dir, obj_size)
1230 } else {
1231 (bin_dir, bin_size)
1232 }
1233 }
1234 (true, false) => (bin_dir, 0),
1235 (false, true) => (obj_dir, 0),
1236 (false, false) => return None,
1237 };
1238
1239 let name = csproj_file
1240 .file_stem()
1241 .and_then(|s| s.to_str())
1242 .map(std::string::ToString::to_string);
1243
1244 let build_arts = BuildArtifacts {
1245 path: build_path,
1246 size: precomputed_size,
1247 };
1248
1249 Some(Project::new(
1250 ProjectType::DotNet,
1251 path.to_path_buf(),
1252 build_arts,
1253 name,
1254 ))
1255 }
1256
1257 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1259 let entries = fs::read_dir(dir).ok()?;
1260 for entry in entries.flatten() {
1261 let path = entry.path();
1262 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1263 return Some(path);
1264 }
1265 }
1266 None
1267 }
1268
1269 fn detect_deno_project(
1278 &self,
1279 path: &Path,
1280 errors: &Arc<Mutex<Vec<String>>>,
1281 ) -> Option<Project> {
1282 let deno_json = path.join("deno.json");
1283 let deno_jsonc = path.join("deno.jsonc");
1284
1285 if !deno_json.exists() && !deno_jsonc.exists() {
1286 return None;
1287 }
1288
1289 let config_path = if deno_json.exists() {
1290 deno_json
1291 } else {
1292 deno_jsonc
1293 };
1294
1295 let vendor_dir = path.join("vendor");
1297 if vendor_dir.exists() {
1298 let name = self.extract_deno_project_name(&config_path, errors);
1299 return Some(Project::new(
1300 ProjectType::Deno,
1301 path.to_path_buf(),
1302 BuildArtifacts {
1303 path: vendor_dir,
1304 size: 0,
1305 },
1306 name,
1307 ));
1308 }
1309
1310 let node_modules = path.join("node_modules");
1312 if node_modules.exists() && !path.join("package.json").exists() {
1313 let name = self.extract_deno_project_name(&config_path, errors);
1314 return Some(Project::new(
1315 ProjectType::Deno,
1316 path.to_path_buf(),
1317 BuildArtifacts {
1318 path: node_modules,
1319 size: 0,
1320 },
1321 name,
1322 ));
1323 }
1324
1325 None
1326 }
1327
1328 fn extract_deno_project_name(
1333 &self,
1334 config_path: &Path,
1335 errors: &Arc<Mutex<Vec<String>>>,
1336 ) -> Option<String> {
1337 match fs::read_to_string(config_path) {
1338 Ok(content) => {
1339 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1340 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1341 {
1342 return Some(name.to_string());
1343 }
1344 Self::fallback_to_directory_name(config_path.parent()?)
1345 }
1346 Err(e) => {
1347 self.log_file_error(config_path, &e, errors);
1348 Self::fallback_to_directory_name(config_path.parent()?)
1349 }
1350 }
1351 }
1352
1353 fn detect_ruby_project(
1363 &self,
1364 path: &Path,
1365 errors: &Arc<Mutex<Vec<String>>>,
1366 ) -> Option<Project> {
1367 let gemfile = path.join("Gemfile");
1368 if !gemfile.exists() {
1369 return None;
1370 }
1371
1372 let bundle_dir = path.join(".bundle");
1373 let vendor_bundle_dir = path.join("vendor").join("bundle");
1374
1375 let (build_path, precomputed_size) = match (bundle_dir.exists(), vendor_bundle_dir.exists())
1376 {
1377 (true, true) => {
1378 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1379 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1380 if vendor_size >= bundle_size {
1381 (vendor_bundle_dir, vendor_size)
1382 } else {
1383 (bundle_dir, bundle_size)
1384 }
1385 }
1386 (true, false) => (bundle_dir, 0),
1387 (false, true) => (vendor_bundle_dir, 0),
1388 (false, false) => return None,
1389 };
1390
1391 let name = self.extract_ruby_project_name(path, errors);
1392
1393 Some(Project::new(
1394 ProjectType::Ruby,
1395 path.to_path_buf(),
1396 BuildArtifacts {
1397 path: build_path,
1398 size: precomputed_size,
1399 },
1400 name,
1401 ))
1402 }
1403
1404 fn extract_ruby_project_name(
1409 &self,
1410 path: &Path,
1411 errors: &Arc<Mutex<Vec<String>>>,
1412 ) -> Option<String> {
1413 let entries = fs::read_dir(path).ok()?;
1414 for entry in entries.flatten() {
1415 let entry_path = entry.path();
1416 if entry_path.is_file()
1417 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1418 && let Some(content) = self.read_file_content(&entry_path, errors)
1419 {
1420 for line in content.lines() {
1421 let trimmed = line.trim();
1422 if trimmed.contains(".name")
1423 && trimmed.contains('=')
1424 && let Some(name) = Self::extract_quoted_value(trimmed)
1425 {
1426 return Some(name);
1427 }
1428 }
1429 }
1430 }
1431
1432 Self::fallback_to_directory_name(path)
1433 }
1434
1435 fn detect_elixir_project(
1445 &self,
1446 path: &Path,
1447 errors: &Arc<Mutex<Vec<String>>>,
1448 ) -> Option<Project> {
1449 let mix_exs = path.join("mix.exs");
1450 let build_dir = path.join("_build");
1451
1452 if mix_exs.exists() && build_dir.exists() {
1453 let name = self.extract_elixir_project_name(&mix_exs, errors);
1454
1455 return Some(Project::new(
1456 ProjectType::Elixir,
1457 path.to_path_buf(),
1458 BuildArtifacts {
1459 path: build_dir,
1460 size: 0,
1461 },
1462 name,
1463 ));
1464 }
1465
1466 None
1467 }
1468
1469 fn extract_elixir_project_name(
1474 &self,
1475 mix_exs: &Path,
1476 errors: &Arc<Mutex<Vec<String>>>,
1477 ) -> Option<String> {
1478 let content = self.read_file_content(mix_exs, errors)?;
1479
1480 for line in content.lines() {
1481 let trimmed = line.trim();
1482 if trimmed.contains("app:")
1483 && let Some(pos) = trimmed.find("app:")
1484 {
1485 let after = trimmed[pos + 4..].trim_start();
1486 if let Some(atom) = after.strip_prefix(':') {
1487 let name: String = atom
1489 .chars()
1490 .take_while(|c| c.is_alphanumeric() || *c == '_')
1491 .collect();
1492 if !name.is_empty() {
1493 return Some(name);
1494 }
1495 }
1496 }
1497 }
1498
1499 Self::fallback_to_directory_name(mix_exs.parent()?)
1500 }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506 use std::path::PathBuf;
1507 use tempfile::TempDir;
1508
1509 fn default_scanner(filter: ProjectFilter) -> Scanner {
1511 Scanner::new(
1512 ScanOptions {
1513 verbose: false,
1514 threads: 1,
1515 skip: vec![],
1516 },
1517 filter,
1518 )
1519 }
1520
1521 fn create_file(path: &Path, content: &str) {
1523 if let Some(parent) = path.parent() {
1524 fs::create_dir_all(parent).unwrap();
1525 }
1526 fs::write(path, content).unwrap();
1527 }
1528
1529 #[test]
1532 fn test_is_hidden_directory_to_skip() {
1533 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1535 "/some/.hidden"
1536 )));
1537 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1538 "/some/.git"
1539 )));
1540 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1541 "/some/.svn"
1542 )));
1543 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1544
1545 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1547 "/home/user/.cargo"
1548 )));
1549 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1550
1551 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1553 "/some/visible"
1554 )));
1555 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1556 }
1557
1558 #[test]
1559 fn test_is_excluded_directory() {
1560 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1562 assert!(Scanner::is_excluded_directory(Path::new(
1563 "/some/node_modules"
1564 )));
1565 assert!(Scanner::is_excluded_directory(Path::new(
1566 "/some/__pycache__"
1567 )));
1568 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1569 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1570 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1571 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1572
1573 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1575 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1576 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1577
1578 assert!(Scanner::is_excluded_directory(Path::new(
1580 "/some/.pytest_cache"
1581 )));
1582 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1583 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1584 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1585
1586 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1588 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1589 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1590 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1591
1592 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1594 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1595
1596 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1598 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1599 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1600 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1601 }
1602
1603 #[test]
1604 fn test_extract_quoted_value() {
1605 assert_eq!(
1606 Scanner::extract_quoted_value(r#"name = "my-project""#),
1607 Some("my-project".to_string())
1608 );
1609 assert_eq!(
1610 Scanner::extract_quoted_value(r#"name = "with spaces""#),
1611 Some("with spaces".to_string())
1612 );
1613 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1614 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1616 }
1617
1618 #[test]
1619 fn test_is_name_line() {
1620 assert!(Scanner::is_name_line("name = \"test\""));
1621 assert!(Scanner::is_name_line("name=\"test\""));
1622 assert!(!Scanner::is_name_line("version = \"1.0\""));
1623 assert!(!Scanner::is_name_line("# name = \"commented\""));
1624 assert!(!Scanner::is_name_line("name: \"yaml style\""));
1625 }
1626
1627 #[test]
1628 fn test_parse_toml_name_field() {
1629 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1630 assert_eq!(
1631 Scanner::parse_toml_name_field(content),
1632 Some("test-project".to_string())
1633 );
1634
1635 let no_name = "[package]\nversion = \"0.1.0\"\n";
1636 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1637
1638 let empty = "";
1639 assert_eq!(Scanner::parse_toml_name_field(empty), None);
1640 }
1641
1642 #[test]
1643 fn test_extract_name_from_cfg_content() {
1644 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1645 assert_eq!(
1646 Scanner::extract_name_from_cfg_content(content),
1647 Some("my-package".to_string())
1648 );
1649
1650 let wrong_section = "[options]\nname = not-this\n";
1652 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1653
1654 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1656 assert_eq!(
1657 Scanner::extract_name_from_cfg_content(multi),
1658 Some("correct".to_string())
1659 );
1660 }
1661
1662 #[test]
1663 fn test_extract_name_from_python_content() {
1664 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
1665 assert_eq!(
1666 Scanner::extract_name_from_python_content(content),
1667 Some("my-pkg".to_string())
1668 );
1669
1670 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1671 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1672 }
1673
1674 #[test]
1675 fn test_fallback_to_directory_name() {
1676 assert_eq!(
1677 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1678 Some("project-name".to_string())
1679 );
1680 assert_eq!(
1681 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1682 Some("my_app".to_string())
1683 );
1684 }
1685
1686 #[test]
1687 fn test_is_path_in_skip_list() {
1688 let scanner = Scanner::new(
1689 ScanOptions {
1690 verbose: false,
1691 threads: 1,
1692 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1693 },
1694 ProjectFilter::All,
1695 );
1696
1697 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1698 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1699 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1700 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1701 }
1702
1703 #[test]
1704 fn test_is_path_in_empty_skip_list() {
1705 let scanner = default_scanner(ProjectFilter::All);
1706 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1707 }
1708
1709 #[test]
1712 fn test_scan_directory_with_spaces_in_path() {
1713 let tmp = TempDir::new().unwrap();
1714 let base = tmp.path().join("path with spaces");
1715 fs::create_dir_all(&base).unwrap();
1716
1717 let project = base.join("my project");
1718 create_file(
1719 &project.join("Cargo.toml"),
1720 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1721 );
1722 create_file(&project.join("target/dummy"), "content");
1723
1724 let scanner = default_scanner(ProjectFilter::Rust);
1725 let projects = scanner.scan_directory(&base);
1726 assert_eq!(projects.len(), 1);
1727 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1728 }
1729
1730 #[test]
1731 fn test_scan_directory_with_unicode_names() {
1732 let tmp = TempDir::new().unwrap();
1733 let base = tmp.path();
1734
1735 let project = base.join("プロジェクト");
1736 create_file(
1737 &project.join("package.json"),
1738 r#"{"name": "unicode-project"}"#,
1739 );
1740 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1741
1742 let scanner = default_scanner(ProjectFilter::Node);
1743 let projects = scanner.scan_directory(base);
1744 assert_eq!(projects.len(), 1);
1745 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1746 }
1747
1748 #[test]
1749 fn test_scan_directory_with_special_characters_in_name() {
1750 let tmp = TempDir::new().unwrap();
1751 let base = tmp.path();
1752
1753 let project = base.join("project-with-dashes_and_underscores.v2");
1754 create_file(
1755 &project.join("Cargo.toml"),
1756 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1757 );
1758 create_file(&project.join("target/dummy"), "content");
1759
1760 let scanner = default_scanner(ProjectFilter::Rust);
1761 let projects = scanner.scan_directory(base);
1762 assert_eq!(projects.len(), 1);
1763 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1764 }
1765
1766 #[test]
1769 #[cfg(unix)]
1770 fn test_hidden_directory_itself_not_detected_as_project_unix() {
1771 let tmp = TempDir::new().unwrap();
1772 let base = tmp.path();
1773
1774 let hidden = base.join(".hidden-project");
1779 create_file(
1780 &hidden.join("Cargo.toml"),
1781 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1782 );
1783 create_file(&hidden.join("target/dummy"), "content");
1784
1785 let visible = base.join("visible-project");
1787 create_file(
1788 &visible.join("Cargo.toml"),
1789 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1790 );
1791 create_file(&visible.join("target/dummy"), "content");
1792
1793 let scanner = default_scanner(ProjectFilter::Rust);
1794 let projects = scanner.scan_directory(base);
1795
1796 assert_eq!(projects.len(), 1);
1799 assert_eq!(projects[0].name.as_deref(), Some("visible"));
1800 }
1801
1802 #[test]
1803 #[cfg(unix)]
1804 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1805 let tmp = TempDir::new().unwrap();
1806 let base = tmp.path();
1807
1808 let nested = base.join(".hidden-parent/visible-child");
1811 create_file(
1812 &nested.join("Cargo.toml"),
1813 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1814 );
1815 create_file(&nested.join("target/dummy"), "content");
1816
1817 let scanner = default_scanner(ProjectFilter::Rust);
1818 let projects = scanner.scan_directory(base);
1819
1820 assert_eq!(projects.len(), 1);
1822 assert_eq!(projects[0].name.as_deref(), Some("nested"));
1823 }
1824
1825 #[test]
1826 #[cfg(unix)]
1827 fn test_dotcargo_directory_not_skipped_unix() {
1828 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1831 "/home/user/.cargo"
1832 )));
1833
1834 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1836 "/home/user/.local"
1837 )));
1838 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1839 "/home/user/.npm"
1840 )));
1841 }
1842
1843 #[test]
1846 fn test_detect_python_with_pyproject_toml() {
1847 let tmp = TempDir::new().unwrap();
1848 let base = tmp.path();
1849
1850 let project = base.join("py-project");
1851 create_file(
1852 &project.join("pyproject.toml"),
1853 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1854 );
1855 let pycache = project.join("__pycache__");
1856 fs::create_dir_all(&pycache).unwrap();
1857 create_file(&pycache.join("module.pyc"), "bytecode");
1858
1859 let scanner = default_scanner(ProjectFilter::Python);
1860 let projects = scanner.scan_directory(base);
1861 assert_eq!(projects.len(), 1);
1862 assert_eq!(projects[0].kind, ProjectType::Python);
1863 }
1864
1865 #[test]
1866 fn test_detect_python_with_setup_py() {
1867 let tmp = TempDir::new().unwrap();
1868 let base = tmp.path();
1869
1870 let project = base.join("setup-project");
1871 create_file(
1872 &project.join("setup.py"),
1873 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1874 );
1875 let pycache = project.join("__pycache__");
1876 fs::create_dir_all(&pycache).unwrap();
1877 create_file(&pycache.join("module.pyc"), "bytecode");
1878
1879 let scanner = default_scanner(ProjectFilter::Python);
1880 let projects = scanner.scan_directory(base);
1881 assert_eq!(projects.len(), 1);
1882 }
1883
1884 #[test]
1885 fn test_detect_python_with_pipfile() {
1886 let tmp = TempDir::new().unwrap();
1887 let base = tmp.path();
1888
1889 let project = base.join("pipenv-project");
1890 create_file(
1891 &project.join("Pipfile"),
1892 "[[source]]\nurl = \"https://pypi.org/simple\"",
1893 );
1894 let pycache = project.join("__pycache__");
1895 fs::create_dir_all(&pycache).unwrap();
1896 create_file(&pycache.join("module.pyc"), "bytecode");
1897
1898 let scanner = default_scanner(ProjectFilter::Python);
1899 let projects = scanner.scan_directory(base);
1900 assert_eq!(projects.len(), 1);
1901 }
1902
1903 #[test]
1906 fn test_detect_go_extracts_module_name() {
1907 let tmp = TempDir::new().unwrap();
1908 let base = tmp.path();
1909
1910 let project = base.join("go-service");
1911 create_file(
1912 &project.join("go.mod"),
1913 "module github.com/user/my-service\n\ngo 1.21\n",
1914 );
1915 let vendor = project.join("vendor");
1916 fs::create_dir_all(&vendor).unwrap();
1917 create_file(&vendor.join("modules.txt"), "vendor manifest");
1918
1919 let scanner = default_scanner(ProjectFilter::Go);
1920 let projects = scanner.scan_directory(base);
1921 assert_eq!(projects.len(), 1);
1922 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1924 }
1925
1926 #[test]
1929 fn test_detect_java_maven_project() {
1930 let tmp = TempDir::new().unwrap();
1931 let base = tmp.path();
1932
1933 let project = base.join("java-maven");
1934 create_file(
1935 &project.join("pom.xml"),
1936 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
1937 );
1938 create_file(&project.join("target/classes/Main.class"), "bytecode");
1939
1940 let scanner = default_scanner(ProjectFilter::Java);
1941 let projects = scanner.scan_directory(base);
1942 assert_eq!(projects.len(), 1);
1943 assert_eq!(projects[0].kind, ProjectType::Java);
1944 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
1945 }
1946
1947 #[test]
1948 fn test_detect_java_gradle_project() {
1949 let tmp = TempDir::new().unwrap();
1950 let base = tmp.path();
1951
1952 let project = base.join("java-gradle");
1953 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
1954 create_file(
1955 &project.join("settings.gradle"),
1956 "rootProject.name = \"my-gradle-app\"",
1957 );
1958 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
1959
1960 let scanner = default_scanner(ProjectFilter::Java);
1961 let projects = scanner.scan_directory(base);
1962 assert_eq!(projects.len(), 1);
1963 assert_eq!(projects[0].kind, ProjectType::Java);
1964 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
1965 }
1966
1967 #[test]
1968 fn test_detect_java_gradle_kts_project() {
1969 let tmp = TempDir::new().unwrap();
1970 let base = tmp.path();
1971
1972 let project = base.join("kotlin-gradle");
1973 create_file(
1974 &project.join("build.gradle.kts"),
1975 "plugins { kotlin(\"jvm\") }",
1976 );
1977 create_file(
1978 &project.join("settings.gradle.kts"),
1979 "rootProject.name = \"my-kotlin-app\"",
1980 );
1981 create_file(
1982 &project.join("build/classes/kotlin/main/MainKt.class"),
1983 "bytecode",
1984 );
1985
1986 let scanner = default_scanner(ProjectFilter::Java);
1987 let projects = scanner.scan_directory(base);
1988 assert_eq!(projects.len(), 1);
1989 assert_eq!(projects[0].kind, ProjectType::Java);
1990 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
1991 }
1992
1993 #[test]
1996 fn test_detect_cpp_cmake_project() {
1997 let tmp = TempDir::new().unwrap();
1998 let base = tmp.path();
1999
2000 let project = base.join("cpp-cmake");
2001 create_file(
2002 &project.join("CMakeLists.txt"),
2003 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2004 );
2005 create_file(&project.join("build/CMakeCache.txt"), "cache");
2006
2007 let scanner = default_scanner(ProjectFilter::Cpp);
2008 let projects = scanner.scan_directory(base);
2009 assert_eq!(projects.len(), 1);
2010 assert_eq!(projects[0].kind, ProjectType::Cpp);
2011 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2012 }
2013
2014 #[test]
2015 fn test_detect_cpp_makefile_project() {
2016 let tmp = TempDir::new().unwrap();
2017 let base = tmp.path();
2018
2019 let project = base.join("cpp-make");
2020 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2021 create_file(&project.join("build/main.o"), "object");
2022
2023 let scanner = default_scanner(ProjectFilter::Cpp);
2024 let projects = scanner.scan_directory(base);
2025 assert_eq!(projects.len(), 1);
2026 assert_eq!(projects[0].kind, ProjectType::Cpp);
2027 }
2028
2029 #[test]
2032 fn test_detect_swift_project() {
2033 let tmp = TempDir::new().unwrap();
2034 let base = tmp.path();
2035
2036 let project = base.join("swift-pkg");
2037 create_file(
2038 &project.join("Package.swift"),
2039 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2040 );
2041 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2042
2043 let scanner = default_scanner(ProjectFilter::Swift);
2044 let projects = scanner.scan_directory(base);
2045 assert_eq!(projects.len(), 1);
2046 assert_eq!(projects[0].kind, ProjectType::Swift);
2047 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2048 }
2049
2050 #[test]
2053 fn test_detect_dotnet_project() {
2054 let tmp = TempDir::new().unwrap();
2055 let base = tmp.path();
2056
2057 let project = base.join("dotnet-app");
2058 create_file(
2059 &project.join("MyApp.csproj"),
2060 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2061 );
2062 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2063 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2064
2065 let scanner = default_scanner(ProjectFilter::DotNet);
2066 let projects = scanner.scan_directory(base);
2067 assert_eq!(projects.len(), 1);
2068 assert_eq!(projects[0].kind, ProjectType::DotNet);
2069 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2070 }
2071
2072 #[test]
2073 fn test_detect_dotnet_project_obj_only() {
2074 let tmp = TempDir::new().unwrap();
2075 let base = tmp.path();
2076
2077 let project = base.join("dotnet-obj-only");
2078 create_file(
2079 &project.join("Lib.csproj"),
2080 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2081 );
2082 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2083
2084 let scanner = default_scanner(ProjectFilter::DotNet);
2085 let projects = scanner.scan_directory(base);
2086 assert_eq!(projects.len(), 1);
2087 assert_eq!(projects[0].kind, ProjectType::DotNet);
2088 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2089 }
2090
2091 #[test]
2094 fn test_obj_directory_is_excluded() {
2095 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2096 }
2097
2098 #[test]
2101 fn test_calculate_build_dir_size_empty() {
2102 let tmp = TempDir::new().unwrap();
2103 let empty_dir = tmp.path().join("empty");
2104 fs::create_dir_all(&empty_dir).unwrap();
2105
2106 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2107 }
2108
2109 #[test]
2110 fn test_calculate_build_dir_size_nonexistent() {
2111 assert_eq!(
2112 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2113 0
2114 );
2115 }
2116
2117 #[test]
2118 fn test_calculate_build_dir_size_with_nested_files() {
2119 let tmp = TempDir::new().unwrap();
2120 let dir = tmp.path().join("nested");
2121
2122 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);
2127 assert_eq!(size, 12);
2128 }
2129
2130 #[test]
2133 fn test_scanner_quiet_mode() {
2134 let tmp = TempDir::new().unwrap();
2135 let base = tmp.path();
2136
2137 let project = base.join("quiet-project");
2138 create_file(
2139 &project.join("Cargo.toml"),
2140 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2141 );
2142 create_file(&project.join("target/dummy"), "content");
2143
2144 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2145 let projects = scanner.scan_directory(base);
2146 assert_eq!(projects.len(), 1);
2147 }
2148
2149 #[test]
2152 fn test_detect_ruby_with_vendor_bundle() {
2153 let tmp = TempDir::new().unwrap();
2154 let base = tmp.path();
2155
2156 let project = base.join("ruby-project");
2157 create_file(
2158 &project.join("Gemfile"),
2159 "source 'https://rubygems.org'\ngem 'rails'",
2160 );
2161 create_file(
2162 &project.join("my-app.gemspec"),
2163 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2164 );
2165 create_file(
2166 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2167 "# rails",
2168 );
2169
2170 let scanner = default_scanner(ProjectFilter::Ruby);
2171 let projects = scanner.scan_directory(base);
2172 assert_eq!(projects.len(), 1);
2173 assert_eq!(projects[0].kind, ProjectType::Ruby);
2174 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2175 }
2176
2177 #[test]
2178 fn test_detect_ruby_with_dot_bundle() {
2179 let tmp = TempDir::new().unwrap();
2180 let base = tmp.path();
2181
2182 let project = base.join("ruby-dot-bundle");
2183 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2184 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2185
2186 let scanner = default_scanner(ProjectFilter::Ruby);
2187 let projects = scanner.scan_directory(base);
2188 assert_eq!(projects.len(), 1);
2189 assert_eq!(projects[0].kind, ProjectType::Ruby);
2190 }
2191
2192 #[test]
2193 fn test_detect_ruby_no_artifact_not_detected() {
2194 let tmp = TempDir::new().unwrap();
2195 let base = tmp.path();
2196
2197 let project = base.join("gemfile-only");
2199 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2200
2201 let scanner = default_scanner(ProjectFilter::Ruby);
2202 let projects = scanner.scan_directory(base);
2203 assert_eq!(projects.len(), 0);
2204 }
2205
2206 #[test]
2207 fn test_detect_ruby_fallback_to_dir_name() {
2208 let tmp = TempDir::new().unwrap();
2209 let base = tmp.path();
2210
2211 let project = base.join("my-ruby-app");
2212 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2213 create_file(
2214 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2215 "# sinatra",
2216 );
2217
2218 let scanner = default_scanner(ProjectFilter::Ruby);
2219 let projects = scanner.scan_directory(base);
2220 assert_eq!(projects.len(), 1);
2221 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2222 }
2223
2224 #[test]
2227 fn test_detect_elixir_project() {
2228 let tmp = TempDir::new().unwrap();
2229 let base = tmp.path();
2230
2231 let project = base.join("elixir-project");
2232 create_file(
2233 &project.join("mix.exs"),
2234 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2235 );
2236 create_file(
2237 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2238 "# build",
2239 );
2240
2241 let scanner = default_scanner(ProjectFilter::Elixir);
2242 let projects = scanner.scan_directory(base);
2243 assert_eq!(projects.len(), 1);
2244 assert_eq!(projects[0].kind, ProjectType::Elixir);
2245 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2246 }
2247
2248 #[test]
2249 fn test_detect_elixir_no_build_not_detected() {
2250 let tmp = TempDir::new().unwrap();
2251 let base = tmp.path();
2252
2253 let project = base.join("mix-only");
2254 create_file(
2255 &project.join("mix.exs"),
2256 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2257 );
2258
2259 let scanner = default_scanner(ProjectFilter::Elixir);
2260 let projects = scanner.scan_directory(base);
2261 assert_eq!(projects.len(), 0);
2262 }
2263
2264 #[test]
2265 fn test_detect_elixir_fallback_to_dir_name() {
2266 let tmp = TempDir::new().unwrap();
2267 let base = tmp.path();
2268
2269 let project = base.join("my_elixir_project");
2270 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2271 create_file(
2272 &project.join("_build/prod/lib/my_elixir_project.beam"),
2273 "bytecode",
2274 );
2275
2276 let scanner = default_scanner(ProjectFilter::Elixir);
2277 let projects = scanner.scan_directory(base);
2278 assert_eq!(projects.len(), 1);
2279 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2280 }
2281
2282 #[test]
2285 fn test_detect_deno_with_vendor() {
2286 let tmp = TempDir::new().unwrap();
2287 let base = tmp.path();
2288
2289 let project = base.join("deno-project");
2290 create_file(
2291 &project.join("deno.json"),
2292 r#"{"name": "my-deno-app", "imports": {}}"#,
2293 );
2294 create_file(&project.join("vendor/modules.json"), "{}");
2295
2296 let scanner = default_scanner(ProjectFilter::Deno);
2297 let projects = scanner.scan_directory(base);
2298 assert_eq!(projects.len(), 1);
2299 assert_eq!(projects[0].kind, ProjectType::Deno);
2300 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2301 }
2302
2303 #[test]
2304 fn test_detect_deno_jsonc_config() {
2305 let tmp = TempDir::new().unwrap();
2306 let base = tmp.path();
2307
2308 let project = base.join("deno-jsonc-project");
2309 create_file(
2310 &project.join("deno.jsonc"),
2311 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2312 );
2313 create_file(&project.join("vendor/modules.json"), "{}");
2314
2315 let scanner = default_scanner(ProjectFilter::Deno);
2316 let projects = scanner.scan_directory(base);
2317 assert_eq!(projects.len(), 1);
2318 assert_eq!(projects[0].kind, ProjectType::Deno);
2319 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2320 }
2321
2322 #[test]
2323 fn test_detect_deno_node_modules_without_package_json() {
2324 let tmp = TempDir::new().unwrap();
2325 let base = tmp.path();
2326
2327 let project = base.join("deno-npm-project");
2328 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2329 create_file(
2330 &project.join("node_modules/.deno/lodash/index.js"),
2331 "// lodash",
2332 );
2333
2334 let scanner = default_scanner(ProjectFilter::Deno);
2335 let projects = scanner.scan_directory(base);
2336 assert_eq!(projects.len(), 1);
2337 assert_eq!(projects[0].kind, ProjectType::Deno);
2338 }
2339
2340 #[test]
2341 fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2342 let tmp = TempDir::new().unwrap();
2343 let base = tmp.path();
2344
2345 let project = base.join("ambiguous-project");
2347 create_file(&project.join("deno.json"), r"{}");
2348 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2349 create_file(&project.join("node_modules/dep/index.js"), "// dep");
2350
2351 let scanner = default_scanner(ProjectFilter::All);
2352 let projects = scanner.scan_directory(base);
2353 assert_eq!(projects.len(), 1);
2354 assert_eq!(projects[0].kind, ProjectType::Node);
2355 }
2356
2357 #[test]
2358 fn test_detect_deno_no_artifact_not_detected() {
2359 let tmp = TempDir::new().unwrap();
2360 let base = tmp.path();
2361
2362 let project = base.join("deno-no-artifact");
2363 create_file(&project.join("deno.json"), r"{}");
2364
2365 let scanner = default_scanner(ProjectFilter::Deno);
2366 let projects = scanner.scan_directory(base);
2367 assert_eq!(projects.len(), 0);
2368 }
2369
2370 #[test]
2371 fn test_build_directory_is_excluded() {
2372 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2373 }
2374}