1use std::{
9 fs,
10 path::Path,
11 sync::{
12 Arc, Mutex,
13 atomic::{AtomicUsize, Ordering},
14 },
15};
16
17use colored::Colorize;
18use indicatif::{ProgressBar, ProgressStyle};
19use rayon::prelude::*;
20use serde_json::{Value, from_str};
21use walkdir::{DirEntry, WalkDir};
22
23use crate::{
24 config::{ProjectFilter, ScanOptions},
25 project::{BuildArtifacts, Project, ProjectType},
26};
27
28pub struct Scanner {
35 scan_options: ScanOptions,
37
38 project_filter: ProjectFilter,
40
41 quiet: bool,
43}
44
45impl Scanner {
46 #[must_use]
70 pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
71 Self {
72 scan_options,
73 project_filter,
74 quiet: false,
75 }
76 }
77
78 #[must_use]
83 pub const fn with_quiet(mut self, quiet: bool) -> Self {
84 self.quiet = quiet;
85 self
86 }
87
88 pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
125 let errors = Arc::new(Mutex::new(Vec::<String>::new()));
126
127 let progress = if self.quiet {
128 ProgressBar::hidden()
129 } else {
130 let pb = ProgressBar::new_spinner();
131 pb.set_style(
132 ProgressStyle::default_spinner()
133 .template("{spinner:.green} {msg}")
134 .unwrap(),
135 );
136 pb.set_message("Scanning...");
137 pb.enable_steady_tick(std::time::Duration::from_millis(100));
138 pb
139 };
140
141 let found_count = Arc::new(AtomicUsize::new(0));
142 let progress_clone = progress.clone();
143 let count_clone = Arc::clone(&found_count);
144
145 let walker = self.scan_options.max_depth.map_or_else(
147 || WalkDir::new(root),
148 |depth| WalkDir::new(root).max_depth(depth),
149 );
150
151 let potential_projects: Vec<_> = walker
152 .into_iter()
153 .filter_map(Result::ok)
154 .filter(|entry| self.should_scan_entry(entry))
155 .collect::<Vec<_>>()
156 .into_par_iter()
157 .filter_map(|entry| {
158 let result = self.detect_project(&entry, &errors);
159 if result.is_some() {
160 let n = count_clone.fetch_add(1, Ordering::Relaxed) + 1;
161 progress_clone.set_message(format!("Scanning... {n} found"));
162 }
163 result
164 })
165 .collect();
166
167 progress.finish_with_message("✅ Directory scan complete");
168
169 let projects_with_sizes: Vec<_> = potential_projects
171 .into_par_iter()
172 .filter_map(|mut project| {
173 for artifact in &mut project.build_arts {
174 if artifact.size == 0 {
175 artifact.size = Self::calculate_build_dir_size(&artifact.path);
176 }
177 }
178
179 if project.total_size() > 0 {
180 Some(project)
181 } else {
182 None
183 }
184 })
185 .collect();
186
187 if self.scan_options.verbose {
189 let errors = errors.lock().unwrap();
190 for error in errors.iter() {
191 eprintln!("{}", error.red());
192 }
193 }
194
195 projects_with_sizes
196 }
197
198 fn calculate_build_dir_size(path: &Path) -> u64 {
219 if !path.exists() {
220 return 0;
221 }
222
223 crate::utils::calculate_dir_size(path)
224 }
225
226 fn detect_node_project(
248 &self,
249 path: &Path,
250 errors: &Arc<Mutex<Vec<String>>>,
251 ) -> Option<Project> {
252 let package_json = path.join("package.json");
253 let node_modules = path.join("node_modules");
254
255 if package_json.exists() && node_modules.exists() {
256 let name = self.extract_node_project_name(&package_json, errors);
257
258 let build_arts = vec![BuildArtifacts {
259 path: path.join("node_modules"),
260 size: 0, }];
262
263 return Some(Project::new(
264 ProjectType::Node,
265 path.to_path_buf(),
266 build_arts,
267 name,
268 ));
269 }
270
271 None
272 }
273
274 fn detect_project(
304 &self,
305 entry: &DirEntry,
306 errors: &Arc<Mutex<Vec<String>>>,
307 ) -> Option<Project> {
308 let path = entry.path();
309
310 if !entry.file_type().is_dir() {
311 return None;
312 }
313
314 self.try_detect(ProjectFilter::Rust, || {
319 self.detect_rust_project(path, errors)
320 })
321 .or_else(|| {
322 self.try_detect(ProjectFilter::Deno, || {
323 self.detect_deno_project(path, errors)
324 })
325 })
326 .or_else(|| {
327 self.try_detect(ProjectFilter::Node, || {
328 self.detect_node_project(path, errors)
329 })
330 })
331 .or_else(|| {
332 self.try_detect(ProjectFilter::Java, || {
333 self.detect_java_project(path, errors)
334 })
335 })
336 .or_else(|| {
337 self.try_detect(ProjectFilter::Swift, || {
338 self.detect_swift_project(path, errors)
339 })
340 })
341 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
342 .or_else(|| {
343 self.try_detect(ProjectFilter::Python, || {
344 self.detect_python_project(path, errors)
345 })
346 })
347 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
348 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
349 .or_else(|| {
350 self.try_detect(ProjectFilter::Ruby, || {
351 self.detect_ruby_project(path, errors)
352 })
353 })
354 .or_else(|| {
355 self.try_detect(ProjectFilter::Elixir, || {
356 self.detect_elixir_project(path, errors)
357 })
358 })
359 }
360
361 fn try_detect(
366 &self,
367 filter: ProjectFilter,
368 detect: impl FnOnce() -> Option<Project>,
369 ) -> Option<Project> {
370 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
371 detect()
372 } else {
373 None
374 }
375 }
376
377 fn detect_rust_project(
399 &self,
400 path: &Path,
401 errors: &Arc<Mutex<Vec<String>>>,
402 ) -> Option<Project> {
403 let cargo_toml = path.join("Cargo.toml");
404 let target_dir = path.join("target");
405
406 if cargo_toml.exists() && target_dir.exists() {
407 if Self::is_inside_cargo_workspace(path) {
409 return None;
410 }
411
412 let name = self.extract_rust_project_name(&cargo_toml, errors);
413
414 let build_arts = vec![BuildArtifacts {
415 path: path.join("target"),
416 size: 0, }];
418
419 return Some(Project::new(
420 ProjectType::Rust,
421 path.to_path_buf(),
422 build_arts,
423 name,
424 ));
425 }
426
427 None
428 }
429
430 fn is_cargo_workspace_root(cargo_toml: &Path) -> bool {
432 fs::read_to_string(cargo_toml)
433 .map(|content| content.lines().any(|line| line.trim() == "[workspace]"))
434 .unwrap_or(false)
435 }
436
437 fn is_inside_cargo_workspace(path: &Path) -> bool {
440 path.ancestors()
441 .skip(1) .any(|ancestor| {
443 let cargo_toml = ancestor.join("Cargo.toml");
444 cargo_toml.exists() && Self::is_cargo_workspace_root(&cargo_toml)
445 })
446 }
447
448 fn extract_rust_project_name(
470 &self,
471 cargo_toml: &Path,
472 errors: &Arc<Mutex<Vec<String>>>,
473 ) -> Option<String> {
474 let content = self.read_file_content(cargo_toml, errors)?;
475 Self::parse_toml_name_field(&content)
476 }
477
478 fn extract_quoted_value(line: &str) -> Option<String> {
480 let start = line.find('"')?;
481 let end = line.rfind('"')?;
482
483 if start == end {
484 return None;
485 }
486
487 Some(line[start + 1..end].to_string())
488 }
489
490 fn extract_name_from_line(line: &str) -> Option<String> {
492 if !Self::is_name_line(line) {
493 return None;
494 }
495
496 Self::extract_quoted_value(line)
497 }
498
499 fn extract_node_project_name(
520 &self,
521 package_json: &Path,
522 errors: &Arc<Mutex<Vec<String>>>,
523 ) -> Option<String> {
524 match fs::read_to_string(package_json) {
525 Ok(content) => match from_str::<Value>(&content) {
526 Ok(json) => json
527 .get("name")
528 .and_then(|v| v.as_str())
529 .map(std::string::ToString::to_string),
530 Err(e) => {
531 if self.scan_options.verbose {
532 errors
533 .lock()
534 .unwrap()
535 .push(format!("Error parsing {}: {e}", package_json.display()));
536 }
537 None
538 }
539 },
540 Err(e) => {
541 if self.scan_options.verbose {
542 errors
543 .lock()
544 .unwrap()
545 .push(format!("Error reading {}: {e}", package_json.display()));
546 }
547 None
548 }
549 }
550 }
551
552 fn is_name_line(line: &str) -> bool {
554 line.starts_with("name") && line.contains('=')
555 }
556
557 fn log_file_error(
559 &self,
560 file_path: &Path,
561 error: &std::io::Error,
562 errors: &Arc<Mutex<Vec<String>>>,
563 ) {
564 if self.scan_options.verbose {
565 errors
566 .lock()
567 .unwrap()
568 .push(format!("Error reading {}: {error}", file_path.display()));
569 }
570 }
571
572 fn parse_toml_name_field(content: &str) -> Option<String> {
574 for line in content.lines() {
575 if let Some(name) = Self::extract_name_from_line(line.trim()) {
576 return Some(name);
577 }
578 }
579 None
580 }
581
582 fn read_file_content(
584 &self,
585 file_path: &Path,
586 errors: &Arc<Mutex<Vec<String>>>,
587 ) -> Option<String> {
588 match fs::read_to_string(file_path) {
589 Ok(content) => Some(content),
590 Err(e) => {
591 self.log_file_error(file_path, &e, errors);
592 None
593 }
594 }
595 }
596
597 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
631 let path = entry.path();
632
633 if self.is_path_in_skip_list(path) {
635 return false;
636 }
637
638 if path
640 .ancestors()
641 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
642 {
643 return false;
644 }
645
646 if Self::is_hidden_directory_to_skip(path) {
648 return false;
649 }
650
651 !Self::is_excluded_directory(path)
653 }
654
655 fn is_path_in_skip_list(&self, path: &Path) -> bool {
657 self.scan_options.skip.iter().any(|skip| {
658 path.components().any(|component| {
659 component
660 .as_os_str()
661 .to_str()
662 .is_some_and(|name| name == skip.to_string_lossy())
663 })
664 })
665 }
666
667 fn is_hidden_directory_to_skip(path: &Path) -> bool {
669 path.file_name()
670 .and_then(|n| n.to_str())
671 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
672 }
673
674 fn is_excluded_directory(path: &Path) -> bool {
676 let excluded_dirs = [
677 "target",
678 "build",
679 "dist",
680 "out",
681 ".git",
682 ".svn",
683 ".hg",
684 "__pycache__",
685 "venv",
686 ".venv",
687 "env",
688 ".env",
689 "temp",
690 "tmp",
691 "vendor",
692 ".pytest_cache",
693 ".tox",
694 ".eggs",
695 ".coverage",
696 "node_modules",
697 "obj",
698 "_build",
699 ];
700
701 path.file_name()
702 .and_then(|n| n.to_str())
703 .is_some_and(|name| excluded_dirs.contains(&name))
704 }
705
706 fn detect_python_project(
727 &self,
728 path: &Path,
729 errors: &Arc<Mutex<Vec<String>>>,
730 ) -> Option<Project> {
731 let config_files = [
732 "requirements.txt",
733 "setup.py",
734 "pyproject.toml",
735 "setup.cfg",
736 "Pipfile",
737 "pipenv.lock",
738 "poetry.lock",
739 ];
740
741 let build_dirs = [
742 "__pycache__",
743 ".pytest_cache",
744 "venv",
745 ".venv",
746 "build",
747 "dist",
748 ".eggs",
749 ".tox",
750 ".coverage",
751 ];
752
753 let has_config = config_files.iter().any(|&file| path.join(file).exists());
755
756 if !has_config {
757 return None;
758 }
759
760 let mut build_arts: Vec<BuildArtifacts> = build_dirs
762 .iter()
763 .filter_map(|&dir_name| {
764 let dir_path = path.join(dir_name);
765 if dir_path.exists() && dir_path.is_dir() {
766 let size = crate::utils::calculate_dir_size(&dir_path);
767 Some(BuildArtifacts {
768 path: dir_path,
769 size,
770 })
771 } else {
772 None
773 }
774 })
775 .collect();
776
777 if let Ok(entries) = std::fs::read_dir(path) {
779 for entry in entries.flatten() {
780 let entry_path = entry.path();
781 if entry_path.is_dir()
782 && entry_path
783 .file_name()
784 .and_then(|n| n.to_str())
785 .is_some_and(|n| n.ends_with(".egg-info"))
786 {
787 let size = crate::utils::calculate_dir_size(&entry_path);
788 build_arts.push(BuildArtifacts {
789 path: entry_path,
790 size,
791 });
792 }
793 }
794 }
795
796 if build_arts.is_empty() {
797 return None;
798 }
799
800 let name = self.extract_python_project_name(path, errors);
801
802 Some(Project::new(
803 ProjectType::Python,
804 path.to_path_buf(),
805 build_arts,
806 name,
807 ))
808 }
809
810 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
832 let go_mod = path.join("go.mod");
833 let vendor_dir = path.join("vendor");
834
835 if go_mod.exists() && vendor_dir.exists() {
836 let name = self.extract_go_project_name(&go_mod, errors);
837
838 let build_arts = vec![BuildArtifacts {
839 path: path.join("vendor"),
840 size: 0, }];
842
843 return Some(Project::new(
844 ProjectType::Go,
845 path.to_path_buf(),
846 build_arts,
847 name,
848 ));
849 }
850
851 None
852 }
853
854 fn extract_python_project_name(
876 &self,
877 path: &Path,
878 errors: &Arc<Mutex<Vec<String>>>,
879 ) -> Option<String> {
880 self.try_extract_from_pyproject_toml(path, errors)
882 .or_else(|| self.try_extract_from_setup_py(path, errors))
883 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
884 .or_else(|| Self::fallback_to_directory_name(path))
885 }
886
887 fn try_extract_from_pyproject_toml(
889 &self,
890 path: &Path,
891 errors: &Arc<Mutex<Vec<String>>>,
892 ) -> Option<String> {
893 let pyproject_toml = path.join("pyproject.toml");
894 if !pyproject_toml.exists() {
895 return None;
896 }
897
898 let content = self.read_file_content(&pyproject_toml, errors)?;
899 Self::extract_name_from_toml_like_content(&content)
900 }
901
902 fn try_extract_from_setup_py(
904 &self,
905 path: &Path,
906 errors: &Arc<Mutex<Vec<String>>>,
907 ) -> Option<String> {
908 let setup_py = path.join("setup.py");
909 if !setup_py.exists() {
910 return None;
911 }
912
913 let content = self.read_file_content(&setup_py, errors)?;
914 Self::extract_name_from_python_content(&content)
915 }
916
917 fn try_extract_from_setup_cfg(
919 &self,
920 path: &Path,
921 errors: &Arc<Mutex<Vec<String>>>,
922 ) -> Option<String> {
923 let setup_cfg = path.join("setup.cfg");
924 if !setup_cfg.exists() {
925 return None;
926 }
927
928 let content = self.read_file_content(&setup_cfg, errors)?;
929 Self::extract_name_from_cfg_content(&content)
930 }
931
932 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
934 content
935 .lines()
936 .map(str::trim)
937 .find(|line| line.starts_with("name") && line.contains('='))
938 .and_then(Self::extract_quoted_value)
939 }
940
941 fn extract_name_from_python_content(content: &str) -> Option<String> {
943 content
944 .lines()
945 .map(str::trim)
946 .find(|line| line.contains("name") && line.contains('='))
947 .and_then(Self::extract_quoted_value)
948 }
949
950 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
952 let mut in_metadata_section = false;
953
954 for line in content.lines() {
955 let line = line.trim();
956
957 if line == "[metadata]" {
958 in_metadata_section = true;
959 } else if line.starts_with('[') && line.ends_with(']') {
960 in_metadata_section = false;
961 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
962 return line.split('=').nth(1).map(|name| name.trim().to_string());
963 }
964 }
965
966 None
967 }
968
969 fn fallback_to_directory_name(path: &Path) -> Option<String> {
971 path.file_name()
972 .and_then(|name| name.to_str())
973 .map(std::string::ToString::to_string)
974 }
975
976 fn extract_go_project_name(
996 &self,
997 go_mod: &Path,
998 errors: &Arc<Mutex<Vec<String>>>,
999 ) -> Option<String> {
1000 let content = self.read_file_content(go_mod, errors)?;
1001
1002 for line in content.lines() {
1003 let line = line.trim();
1004 if line.starts_with("module ") {
1005 let module_path = line.strip_prefix("module ")?.trim();
1006
1007 if let Some(name) = module_path.split('/').next_back() {
1009 return Some(name.to_string());
1010 }
1011
1012 return Some(module_path.to_string());
1013 }
1014 }
1015
1016 None
1017 }
1018
1019 fn detect_java_project(
1030 &self,
1031 path: &Path,
1032 errors: &Arc<Mutex<Vec<String>>>,
1033 ) -> Option<Project> {
1034 let pom_xml = path.join("pom.xml");
1035 let target_dir = path.join("target");
1036
1037 if pom_xml.exists() && target_dir.exists() {
1039 let name = self.extract_java_maven_project_name(&pom_xml, errors);
1040
1041 let build_arts = vec![BuildArtifacts {
1042 path: target_dir,
1043 size: 0,
1044 }];
1045
1046 return Some(Project::new(
1047 ProjectType::Java,
1048 path.to_path_buf(),
1049 build_arts,
1050 name,
1051 ));
1052 }
1053
1054 let has_gradle =
1056 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
1057 let build_dir = path.join("build");
1058
1059 if has_gradle && build_dir.exists() {
1060 let name = self.extract_java_gradle_project_name(path, errors);
1061
1062 let build_arts = vec![BuildArtifacts {
1063 path: build_dir,
1064 size: 0,
1065 }];
1066
1067 return Some(Project::new(
1068 ProjectType::Java,
1069 path.to_path_buf(),
1070 build_arts,
1071 name,
1072 ));
1073 }
1074
1075 None
1076 }
1077
1078 fn extract_java_maven_project_name(
1082 &self,
1083 pom_xml: &Path,
1084 errors: &Arc<Mutex<Vec<String>>>,
1085 ) -> Option<String> {
1086 let content = self.read_file_content(pom_xml, errors)?;
1087
1088 for line in content.lines() {
1089 let trimmed = line.trim();
1090 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1091 let name = trimmed
1092 .strip_prefix("<artifactId>")?
1093 .strip_suffix("</artifactId>")?;
1094 return Some(name.to_string());
1095 }
1096 }
1097
1098 None
1099 }
1100
1101 fn extract_java_gradle_project_name(
1106 &self,
1107 path: &Path,
1108 errors: &Arc<Mutex<Vec<String>>>,
1109 ) -> Option<String> {
1110 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1111 let settings_path = path.join(settings_file);
1112 if settings_path.exists()
1113 && let Some(content) = self.read_file_content(&settings_path, errors)
1114 {
1115 for line in content.lines() {
1116 let trimmed = line.trim();
1117 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1118 return Self::extract_quoted_value(trimmed).or_else(|| {
1119 trimmed
1120 .split('=')
1121 .nth(1)
1122 .map(|s| s.trim().trim_matches('\'').to_string())
1123 });
1124 }
1125 }
1126 }
1127 }
1128
1129 Self::fallback_to_directory_name(path)
1130 }
1131
1132 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1142 let build_dir = path.join("build");
1143
1144 if !build_dir.exists() {
1145 return None;
1146 }
1147
1148 let cmake_file = path.join("CMakeLists.txt");
1149 let makefile = path.join("Makefile");
1150
1151 if cmake_file.exists() || makefile.exists() {
1152 let name = if cmake_file.exists() {
1153 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1154 } else {
1155 Self::fallback_to_directory_name(path)
1156 };
1157
1158 let build_arts = vec![BuildArtifacts {
1159 path: build_dir,
1160 size: 0,
1161 }];
1162
1163 return Some(Project::new(
1164 ProjectType::Cpp,
1165 path.to_path_buf(),
1166 build_arts,
1167 name,
1168 ));
1169 }
1170
1171 None
1172 }
1173
1174 fn extract_cpp_cmake_project_name(
1178 &self,
1179 cmake_file: &Path,
1180 errors: &Arc<Mutex<Vec<String>>>,
1181 ) -> Option<String> {
1182 let content = self.read_file_content(cmake_file, errors)?;
1183
1184 for line in content.lines() {
1185 let trimmed = line.trim();
1186 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1187 let inner = trimmed
1188 .trim_start_matches("project(")
1189 .trim_start_matches("PROJECT(")
1190 .trim_end_matches(')')
1191 .trim();
1192
1193 let name = inner.split_whitespace().next()?;
1195 let name = name.trim_matches('"').trim_matches('\'');
1197 if !name.is_empty() {
1198 return Some(name.to_string());
1199 }
1200 }
1201 }
1202
1203 Self::fallback_to_directory_name(cmake_file.parent()?)
1204 }
1205
1206 fn detect_swift_project(
1216 &self,
1217 path: &Path,
1218 errors: &Arc<Mutex<Vec<String>>>,
1219 ) -> Option<Project> {
1220 let package_swift = path.join("Package.swift");
1221 let build_dir = path.join(".build");
1222
1223 if package_swift.exists() && build_dir.exists() {
1224 let name = self.extract_swift_project_name(&package_swift, errors);
1225
1226 let build_arts = vec![BuildArtifacts {
1227 path: build_dir,
1228 size: 0,
1229 }];
1230
1231 return Some(Project::new(
1232 ProjectType::Swift,
1233 path.to_path_buf(),
1234 build_arts,
1235 name,
1236 ));
1237 }
1238
1239 None
1240 }
1241
1242 fn extract_swift_project_name(
1246 &self,
1247 package_swift: &Path,
1248 errors: &Arc<Mutex<Vec<String>>>,
1249 ) -> Option<String> {
1250 let content = self.read_file_content(package_swift, errors)?;
1251
1252 for line in content.lines() {
1253 let trimmed = line.trim();
1254 if trimmed.contains("name:") {
1255 return Self::extract_quoted_value(trimmed);
1256 }
1257 }
1258
1259 Self::fallback_to_directory_name(package_swift.parent()?)
1260 }
1261
1262 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1272 let bin_dir = path.join("bin");
1273 let obj_dir = path.join("obj");
1274
1275 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1276 if !has_build_dir {
1277 return None;
1278 }
1279
1280 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1281
1282 let build_arts: Vec<BuildArtifacts> = match (bin_dir.exists(), obj_dir.exists()) {
1284 (true, true) => {
1285 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1286 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1287 vec![
1288 BuildArtifacts {
1289 path: bin_dir,
1290 size: bin_size,
1291 },
1292 BuildArtifacts {
1293 path: obj_dir,
1294 size: obj_size,
1295 },
1296 ]
1297 }
1298 (true, false) => vec![BuildArtifacts {
1299 path: bin_dir,
1300 size: 0,
1301 }],
1302 (false, true) => vec![BuildArtifacts {
1303 path: obj_dir,
1304 size: 0,
1305 }],
1306 (false, false) => return None,
1307 };
1308
1309 let name = csproj_file
1310 .file_stem()
1311 .and_then(|s| s.to_str())
1312 .map(std::string::ToString::to_string);
1313
1314 Some(Project::new(
1315 ProjectType::DotNet,
1316 path.to_path_buf(),
1317 build_arts,
1318 name,
1319 ))
1320 }
1321
1322 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1324 let entries = fs::read_dir(dir).ok()?;
1325 for entry in entries.flatten() {
1326 let path = entry.path();
1327 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1328 return Some(path);
1329 }
1330 }
1331 None
1332 }
1333
1334 fn detect_deno_project(
1343 &self,
1344 path: &Path,
1345 errors: &Arc<Mutex<Vec<String>>>,
1346 ) -> Option<Project> {
1347 let deno_json = path.join("deno.json");
1348 let deno_jsonc = path.join("deno.jsonc");
1349
1350 if !deno_json.exists() && !deno_jsonc.exists() {
1351 return None;
1352 }
1353
1354 let config_path = if deno_json.exists() {
1355 deno_json
1356 } else {
1357 deno_jsonc
1358 };
1359
1360 let vendor_dir = path.join("vendor");
1362 if vendor_dir.exists() {
1363 let name = self.extract_deno_project_name(&config_path, errors);
1364 return Some(Project::new(
1365 ProjectType::Deno,
1366 path.to_path_buf(),
1367 vec![BuildArtifacts {
1368 path: vendor_dir,
1369 size: 0,
1370 }],
1371 name,
1372 ));
1373 }
1374
1375 let node_modules = path.join("node_modules");
1377 if node_modules.exists() && !path.join("package.json").exists() {
1378 let name = self.extract_deno_project_name(&config_path, errors);
1379 return Some(Project::new(
1380 ProjectType::Deno,
1381 path.to_path_buf(),
1382 vec![BuildArtifacts {
1383 path: node_modules,
1384 size: 0,
1385 }],
1386 name,
1387 ));
1388 }
1389
1390 None
1391 }
1392
1393 fn extract_deno_project_name(
1398 &self,
1399 config_path: &Path,
1400 errors: &Arc<Mutex<Vec<String>>>,
1401 ) -> Option<String> {
1402 match fs::read_to_string(config_path) {
1403 Ok(content) => {
1404 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
1405 && let Some(name) = json.get("name").and_then(|v| v.as_str())
1406 {
1407 return Some(name.to_string());
1408 }
1409 Self::fallback_to_directory_name(config_path.parent()?)
1410 }
1411 Err(e) => {
1412 self.log_file_error(config_path, &e, errors);
1413 Self::fallback_to_directory_name(config_path.parent()?)
1414 }
1415 }
1416 }
1417
1418 fn detect_ruby_project(
1428 &self,
1429 path: &Path,
1430 errors: &Arc<Mutex<Vec<String>>>,
1431 ) -> Option<Project> {
1432 let gemfile = path.join("Gemfile");
1433 if !gemfile.exists() {
1434 return None;
1435 }
1436
1437 let bundle_dir = path.join(".bundle");
1438 let vendor_bundle_dir = path.join("vendor").join("bundle");
1439
1440 let build_arts: Vec<BuildArtifacts> =
1441 match (bundle_dir.exists(), vendor_bundle_dir.exists()) {
1442 (true, true) => {
1443 let bundle_size = crate::utils::calculate_dir_size(&bundle_dir);
1444 let vendor_size = crate::utils::calculate_dir_size(&vendor_bundle_dir);
1445 vec![
1446 BuildArtifacts {
1447 path: bundle_dir,
1448 size: bundle_size,
1449 },
1450 BuildArtifacts {
1451 path: vendor_bundle_dir,
1452 size: vendor_size,
1453 },
1454 ]
1455 }
1456 (true, false) => vec![BuildArtifacts {
1457 path: bundle_dir,
1458 size: 0,
1459 }],
1460 (false, true) => vec![BuildArtifacts {
1461 path: vendor_bundle_dir,
1462 size: 0,
1463 }],
1464 (false, false) => return None,
1465 };
1466
1467 let name = self.extract_ruby_project_name(path, errors);
1468
1469 Some(Project::new(
1470 ProjectType::Ruby,
1471 path.to_path_buf(),
1472 build_arts,
1473 name,
1474 ))
1475 }
1476
1477 fn extract_ruby_project_name(
1482 &self,
1483 path: &Path,
1484 errors: &Arc<Mutex<Vec<String>>>,
1485 ) -> Option<String> {
1486 let entries = fs::read_dir(path).ok()?;
1487 for entry in entries.flatten() {
1488 let entry_path = entry.path();
1489 if entry_path.is_file()
1490 && entry_path.extension().and_then(|e| e.to_str()) == Some("gemspec")
1491 && let Some(content) = self.read_file_content(&entry_path, errors)
1492 {
1493 for line in content.lines() {
1494 let trimmed = line.trim();
1495 if trimmed.contains(".name")
1496 && trimmed.contains('=')
1497 && let Some(name) = Self::extract_quoted_value(trimmed)
1498 {
1499 return Some(name);
1500 }
1501 }
1502 }
1503 }
1504
1505 Self::fallback_to_directory_name(path)
1506 }
1507
1508 fn detect_elixir_project(
1518 &self,
1519 path: &Path,
1520 errors: &Arc<Mutex<Vec<String>>>,
1521 ) -> Option<Project> {
1522 let mix_exs = path.join("mix.exs");
1523 let build_dir = path.join("_build");
1524
1525 if mix_exs.exists() && build_dir.exists() {
1526 let name = self.extract_elixir_project_name(&mix_exs, errors);
1527
1528 return Some(Project::new(
1529 ProjectType::Elixir,
1530 path.to_path_buf(),
1531 vec![BuildArtifacts {
1532 path: build_dir,
1533 size: 0,
1534 }],
1535 name,
1536 ));
1537 }
1538
1539 None
1540 }
1541
1542 fn extract_elixir_project_name(
1547 &self,
1548 mix_exs: &Path,
1549 errors: &Arc<Mutex<Vec<String>>>,
1550 ) -> Option<String> {
1551 let content = self.read_file_content(mix_exs, errors)?;
1552
1553 for line in content.lines() {
1554 let trimmed = line.trim();
1555 if trimmed.contains("app:")
1556 && let Some(pos) = trimmed.find("app:")
1557 {
1558 let after = trimmed[pos + 4..].trim_start();
1559 if let Some(atom) = after.strip_prefix(':') {
1560 let name: String = atom
1562 .chars()
1563 .take_while(|c| c.is_alphanumeric() || *c == '_')
1564 .collect();
1565 if !name.is_empty() {
1566 return Some(name);
1567 }
1568 }
1569 }
1570 }
1571
1572 Self::fallback_to_directory_name(mix_exs.parent()?)
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use std::path::PathBuf;
1580 use tempfile::TempDir;
1581
1582 fn default_scanner(filter: ProjectFilter) -> Scanner {
1584 Scanner::new(
1585 ScanOptions {
1586 verbose: false,
1587 threads: 1,
1588 skip: vec![],
1589 max_depth: None,
1590 },
1591 filter,
1592 )
1593 }
1594
1595 fn create_file(path: &Path, content: &str) {
1597 if let Some(parent) = path.parent() {
1598 fs::create_dir_all(parent).unwrap();
1599 }
1600 fs::write(path, content).unwrap();
1601 }
1602
1603 #[test]
1606 fn test_is_hidden_directory_to_skip() {
1607 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1609 "/some/.hidden"
1610 )));
1611 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1612 "/some/.git"
1613 )));
1614 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1615 "/some/.svn"
1616 )));
1617 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1618
1619 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1621 "/home/user/.cargo"
1622 )));
1623 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1624
1625 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1627 "/some/visible"
1628 )));
1629 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1630 }
1631
1632 #[test]
1633 fn test_is_excluded_directory() {
1634 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1636 assert!(Scanner::is_excluded_directory(Path::new(
1637 "/some/node_modules"
1638 )));
1639 assert!(Scanner::is_excluded_directory(Path::new(
1640 "/some/__pycache__"
1641 )));
1642 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1643 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1644 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1645 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1646
1647 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1649 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1650 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1651
1652 assert!(Scanner::is_excluded_directory(Path::new(
1654 "/some/.pytest_cache"
1655 )));
1656 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1657 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1658 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1659
1660 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1662 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1663 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1664 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1665
1666 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1668 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1669
1670 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1672 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1673 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1674 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1675 }
1676
1677 #[test]
1678 fn test_extract_quoted_value() {
1679 assert_eq!(
1680 Scanner::extract_quoted_value(r#"name = "my-project""#),
1681 Some("my-project".to_string())
1682 );
1683 assert_eq!(
1684 Scanner::extract_quoted_value(r#"name = "with spaces""#),
1685 Some("with spaces".to_string())
1686 );
1687 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1688 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1690 }
1691
1692 #[test]
1693 fn test_is_name_line() {
1694 assert!(Scanner::is_name_line("name = \"test\""));
1695 assert!(Scanner::is_name_line("name=\"test\""));
1696 assert!(!Scanner::is_name_line("version = \"1.0\""));
1697 assert!(!Scanner::is_name_line("# name = \"commented\""));
1698 assert!(!Scanner::is_name_line("name: \"yaml style\""));
1699 }
1700
1701 #[test]
1702 fn test_parse_toml_name_field() {
1703 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1704 assert_eq!(
1705 Scanner::parse_toml_name_field(content),
1706 Some("test-project".to_string())
1707 );
1708
1709 let no_name = "[package]\nversion = \"0.1.0\"\n";
1710 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1711
1712 let empty = "";
1713 assert_eq!(Scanner::parse_toml_name_field(empty), None);
1714 }
1715
1716 #[test]
1717 fn test_extract_name_from_cfg_content() {
1718 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1719 assert_eq!(
1720 Scanner::extract_name_from_cfg_content(content),
1721 Some("my-package".to_string())
1722 );
1723
1724 let wrong_section = "[options]\nname = not-this\n";
1726 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1727
1728 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1730 assert_eq!(
1731 Scanner::extract_name_from_cfg_content(multi),
1732 Some("correct".to_string())
1733 );
1734 }
1735
1736 #[test]
1737 fn test_extract_name_from_python_content() {
1738 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
1739 assert_eq!(
1740 Scanner::extract_name_from_python_content(content),
1741 Some("my-pkg".to_string())
1742 );
1743
1744 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1745 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1746 }
1747
1748 #[test]
1749 fn test_fallback_to_directory_name() {
1750 assert_eq!(
1751 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1752 Some("project-name".to_string())
1753 );
1754 assert_eq!(
1755 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1756 Some("my_app".to_string())
1757 );
1758 }
1759
1760 #[test]
1761 fn test_is_path_in_skip_list() {
1762 let scanner = Scanner::new(
1763 ScanOptions {
1764 verbose: false,
1765 threads: 1,
1766 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1767 max_depth: None,
1768 },
1769 ProjectFilter::All,
1770 );
1771
1772 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1773 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1774 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1775 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1776 }
1777
1778 #[test]
1779 fn test_is_path_in_empty_skip_list() {
1780 let scanner = default_scanner(ProjectFilter::All);
1781 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1782 }
1783
1784 #[test]
1787 fn test_scan_directory_with_spaces_in_path() {
1788 let tmp = TempDir::new().unwrap();
1789 let base = tmp.path().join("path with spaces");
1790 fs::create_dir_all(&base).unwrap();
1791
1792 let project = base.join("my project");
1793 create_file(
1794 &project.join("Cargo.toml"),
1795 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1796 );
1797 create_file(&project.join("target/dummy"), "content");
1798
1799 let scanner = default_scanner(ProjectFilter::Rust);
1800 let projects = scanner.scan_directory(&base);
1801 assert_eq!(projects.len(), 1);
1802 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1803 }
1804
1805 #[test]
1806 fn test_scan_directory_with_unicode_names() {
1807 let tmp = TempDir::new().unwrap();
1808 let base = tmp.path();
1809
1810 let project = base.join("プロジェクト");
1811 create_file(
1812 &project.join("package.json"),
1813 r#"{"name": "unicode-project"}"#,
1814 );
1815 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1816
1817 let scanner = default_scanner(ProjectFilter::Node);
1818 let projects = scanner.scan_directory(base);
1819 assert_eq!(projects.len(), 1);
1820 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1821 }
1822
1823 #[test]
1824 fn test_scan_directory_with_special_characters_in_name() {
1825 let tmp = TempDir::new().unwrap();
1826 let base = tmp.path();
1827
1828 let project = base.join("project-with-dashes_and_underscores.v2");
1829 create_file(
1830 &project.join("Cargo.toml"),
1831 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1832 );
1833 create_file(&project.join("target/dummy"), "content");
1834
1835 let scanner = default_scanner(ProjectFilter::Rust);
1836 let projects = scanner.scan_directory(base);
1837 assert_eq!(projects.len(), 1);
1838 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1839 }
1840
1841 #[test]
1844 #[cfg(unix)]
1845 fn test_hidden_directory_itself_not_detected_as_project_unix() {
1846 let tmp = TempDir::new().unwrap();
1847 let base = tmp.path();
1848
1849 let hidden = base.join(".hidden-project");
1854 create_file(
1855 &hidden.join("Cargo.toml"),
1856 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1857 );
1858 create_file(&hidden.join("target/dummy"), "content");
1859
1860 let visible = base.join("visible-project");
1862 create_file(
1863 &visible.join("Cargo.toml"),
1864 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1865 );
1866 create_file(&visible.join("target/dummy"), "content");
1867
1868 let scanner = default_scanner(ProjectFilter::Rust);
1869 let projects = scanner.scan_directory(base);
1870
1871 assert_eq!(projects.len(), 1);
1874 assert_eq!(projects[0].name.as_deref(), Some("visible"));
1875 }
1876
1877 #[test]
1878 #[cfg(unix)]
1879 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1880 let tmp = TempDir::new().unwrap();
1881 let base = tmp.path();
1882
1883 let nested = base.join(".hidden-parent/visible-child");
1886 create_file(
1887 &nested.join("Cargo.toml"),
1888 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1889 );
1890 create_file(&nested.join("target/dummy"), "content");
1891
1892 let scanner = default_scanner(ProjectFilter::Rust);
1893 let projects = scanner.scan_directory(base);
1894
1895 assert_eq!(projects.len(), 1);
1897 assert_eq!(projects[0].name.as_deref(), Some("nested"));
1898 }
1899
1900 #[test]
1901 #[cfg(unix)]
1902 fn test_dotcargo_directory_not_skipped_unix() {
1903 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1906 "/home/user/.cargo"
1907 )));
1908
1909 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1911 "/home/user/.local"
1912 )));
1913 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1914 "/home/user/.npm"
1915 )));
1916 }
1917
1918 #[test]
1921 fn test_detect_python_with_pyproject_toml() {
1922 let tmp = TempDir::new().unwrap();
1923 let base = tmp.path();
1924
1925 let project = base.join("py-project");
1926 create_file(
1927 &project.join("pyproject.toml"),
1928 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1929 );
1930 let pycache = project.join("__pycache__");
1931 fs::create_dir_all(&pycache).unwrap();
1932 create_file(&pycache.join("module.pyc"), "bytecode");
1933
1934 let scanner = default_scanner(ProjectFilter::Python);
1935 let projects = scanner.scan_directory(base);
1936 assert_eq!(projects.len(), 1);
1937 assert_eq!(projects[0].kind, ProjectType::Python);
1938 }
1939
1940 #[test]
1941 fn test_detect_python_with_setup_py() {
1942 let tmp = TempDir::new().unwrap();
1943 let base = tmp.path();
1944
1945 let project = base.join("setup-project");
1946 create_file(
1947 &project.join("setup.py"),
1948 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1949 );
1950 let pycache = project.join("__pycache__");
1951 fs::create_dir_all(&pycache).unwrap();
1952 create_file(&pycache.join("module.pyc"), "bytecode");
1953
1954 let scanner = default_scanner(ProjectFilter::Python);
1955 let projects = scanner.scan_directory(base);
1956 assert_eq!(projects.len(), 1);
1957 }
1958
1959 #[test]
1960 fn test_detect_python_with_pipfile() {
1961 let tmp = TempDir::new().unwrap();
1962 let base = tmp.path();
1963
1964 let project = base.join("pipenv-project");
1965 create_file(
1966 &project.join("Pipfile"),
1967 "[[source]]\nurl = \"https://pypi.org/simple\"",
1968 );
1969 let pycache = project.join("__pycache__");
1970 fs::create_dir_all(&pycache).unwrap();
1971 create_file(&pycache.join("module.pyc"), "bytecode");
1972
1973 let scanner = default_scanner(ProjectFilter::Python);
1974 let projects = scanner.scan_directory(base);
1975 assert_eq!(projects.len(), 1);
1976 }
1977
1978 #[test]
1981 fn test_detect_go_extracts_module_name() {
1982 let tmp = TempDir::new().unwrap();
1983 let base = tmp.path();
1984
1985 let project = base.join("go-service");
1986 create_file(
1987 &project.join("go.mod"),
1988 "module github.com/user/my-service\n\ngo 1.21\n",
1989 );
1990 let vendor = project.join("vendor");
1991 fs::create_dir_all(&vendor).unwrap();
1992 create_file(&vendor.join("modules.txt"), "vendor manifest");
1993
1994 let scanner = default_scanner(ProjectFilter::Go);
1995 let projects = scanner.scan_directory(base);
1996 assert_eq!(projects.len(), 1);
1997 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1999 }
2000
2001 #[test]
2004 fn test_detect_java_maven_project() {
2005 let tmp = TempDir::new().unwrap();
2006 let base = tmp.path();
2007
2008 let project = base.join("java-maven");
2009 create_file(
2010 &project.join("pom.xml"),
2011 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
2012 );
2013 create_file(&project.join("target/classes/Main.class"), "bytecode");
2014
2015 let scanner = default_scanner(ProjectFilter::Java);
2016 let projects = scanner.scan_directory(base);
2017 assert_eq!(projects.len(), 1);
2018 assert_eq!(projects[0].kind, ProjectType::Java);
2019 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
2020 }
2021
2022 #[test]
2023 fn test_detect_java_gradle_project() {
2024 let tmp = TempDir::new().unwrap();
2025 let base = tmp.path();
2026
2027 let project = base.join("java-gradle");
2028 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
2029 create_file(
2030 &project.join("settings.gradle"),
2031 "rootProject.name = \"my-gradle-app\"",
2032 );
2033 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
2034
2035 let scanner = default_scanner(ProjectFilter::Java);
2036 let projects = scanner.scan_directory(base);
2037 assert_eq!(projects.len(), 1);
2038 assert_eq!(projects[0].kind, ProjectType::Java);
2039 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
2040 }
2041
2042 #[test]
2043 fn test_detect_java_gradle_kts_project() {
2044 let tmp = TempDir::new().unwrap();
2045 let base = tmp.path();
2046
2047 let project = base.join("kotlin-gradle");
2048 create_file(
2049 &project.join("build.gradle.kts"),
2050 "plugins { kotlin(\"jvm\") }",
2051 );
2052 create_file(
2053 &project.join("settings.gradle.kts"),
2054 "rootProject.name = \"my-kotlin-app\"",
2055 );
2056 create_file(
2057 &project.join("build/classes/kotlin/main/MainKt.class"),
2058 "bytecode",
2059 );
2060
2061 let scanner = default_scanner(ProjectFilter::Java);
2062 let projects = scanner.scan_directory(base);
2063 assert_eq!(projects.len(), 1);
2064 assert_eq!(projects[0].kind, ProjectType::Java);
2065 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
2066 }
2067
2068 #[test]
2071 fn test_detect_cpp_cmake_project() {
2072 let tmp = TempDir::new().unwrap();
2073 let base = tmp.path();
2074
2075 let project = base.join("cpp-cmake");
2076 create_file(
2077 &project.join("CMakeLists.txt"),
2078 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
2079 );
2080 create_file(&project.join("build/CMakeCache.txt"), "cache");
2081
2082 let scanner = default_scanner(ProjectFilter::Cpp);
2083 let projects = scanner.scan_directory(base);
2084 assert_eq!(projects.len(), 1);
2085 assert_eq!(projects[0].kind, ProjectType::Cpp);
2086 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
2087 }
2088
2089 #[test]
2090 fn test_detect_cpp_makefile_project() {
2091 let tmp = TempDir::new().unwrap();
2092 let base = tmp.path();
2093
2094 let project = base.join("cpp-make");
2095 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
2096 create_file(&project.join("build/main.o"), "object");
2097
2098 let scanner = default_scanner(ProjectFilter::Cpp);
2099 let projects = scanner.scan_directory(base);
2100 assert_eq!(projects.len(), 1);
2101 assert_eq!(projects[0].kind, ProjectType::Cpp);
2102 }
2103
2104 #[test]
2107 fn test_detect_swift_project() {
2108 let tmp = TempDir::new().unwrap();
2109 let base = tmp.path();
2110
2111 let project = base.join("swift-pkg");
2112 create_file(
2113 &project.join("Package.swift"),
2114 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
2115 );
2116 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
2117
2118 let scanner = default_scanner(ProjectFilter::Swift);
2119 let projects = scanner.scan_directory(base);
2120 assert_eq!(projects.len(), 1);
2121 assert_eq!(projects[0].kind, ProjectType::Swift);
2122 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
2123 }
2124
2125 #[test]
2128 fn test_detect_dotnet_project() {
2129 let tmp = TempDir::new().unwrap();
2130 let base = tmp.path();
2131
2132 let project = base.join("dotnet-app");
2133 create_file(
2134 &project.join("MyApp.csproj"),
2135 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2136 );
2137 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
2138 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
2139
2140 let scanner = default_scanner(ProjectFilter::DotNet);
2141 let projects = scanner.scan_directory(base);
2142 assert_eq!(projects.len(), 1);
2143 assert_eq!(projects[0].kind, ProjectType::DotNet);
2144 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
2145 }
2146
2147 #[test]
2148 fn test_detect_dotnet_project_obj_only() {
2149 let tmp = TempDir::new().unwrap();
2150 let base = tmp.path();
2151
2152 let project = base.join("dotnet-obj-only");
2153 create_file(
2154 &project.join("Lib.csproj"),
2155 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
2156 );
2157 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
2158
2159 let scanner = default_scanner(ProjectFilter::DotNet);
2160 let projects = scanner.scan_directory(base);
2161 assert_eq!(projects.len(), 1);
2162 assert_eq!(projects[0].kind, ProjectType::DotNet);
2163 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
2164 }
2165
2166 #[test]
2169 fn test_obj_directory_is_excluded() {
2170 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
2171 }
2172
2173 #[test]
2176 fn test_calculate_build_dir_size_empty() {
2177 let tmp = TempDir::new().unwrap();
2178 let empty_dir = tmp.path().join("empty");
2179 fs::create_dir_all(&empty_dir).unwrap();
2180
2181 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
2182 }
2183
2184 #[test]
2185 fn test_calculate_build_dir_size_nonexistent() {
2186 assert_eq!(
2187 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
2188 0
2189 );
2190 }
2191
2192 #[test]
2193 fn test_calculate_build_dir_size_with_nested_files() {
2194 let tmp = TempDir::new().unwrap();
2195 let dir = tmp.path().join("nested");
2196
2197 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);
2202 assert_eq!(size, 12);
2203 }
2204
2205 #[test]
2208 fn test_scanner_quiet_mode() {
2209 let tmp = TempDir::new().unwrap();
2210 let base = tmp.path();
2211
2212 let project = base.join("quiet-project");
2213 create_file(
2214 &project.join("Cargo.toml"),
2215 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
2216 );
2217 create_file(&project.join("target/dummy"), "content");
2218
2219 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
2220 let projects = scanner.scan_directory(base);
2221 assert_eq!(projects.len(), 1);
2222 }
2223
2224 #[test]
2227 fn test_detect_ruby_with_vendor_bundle() {
2228 let tmp = TempDir::new().unwrap();
2229 let base = tmp.path();
2230
2231 let project = base.join("ruby-project");
2232 create_file(
2233 &project.join("Gemfile"),
2234 "source 'https://rubygems.org'\ngem 'rails'",
2235 );
2236 create_file(
2237 &project.join("my-app.gemspec"),
2238 "Gem::Specification.new do |spec|\n spec.name = \"my-ruby-gem\"\nend",
2239 );
2240 create_file(
2241 &project.join("vendor/bundle/ruby/3.2.0/gems/rails/init.rb"),
2242 "# rails",
2243 );
2244
2245 let scanner = default_scanner(ProjectFilter::Ruby);
2246 let projects = scanner.scan_directory(base);
2247 assert_eq!(projects.len(), 1);
2248 assert_eq!(projects[0].kind, ProjectType::Ruby);
2249 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-gem"));
2250 }
2251
2252 #[test]
2253 fn test_detect_ruby_with_dot_bundle() {
2254 let tmp = TempDir::new().unwrap();
2255 let base = tmp.path();
2256
2257 let project = base.join("ruby-dot-bundle");
2258 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2259 create_file(&project.join(".bundle/gems/rack-2.0/lib/rack.rb"), "# rack");
2260
2261 let scanner = default_scanner(ProjectFilter::Ruby);
2262 let projects = scanner.scan_directory(base);
2263 assert_eq!(projects.len(), 1);
2264 assert_eq!(projects[0].kind, ProjectType::Ruby);
2265 }
2266
2267 #[test]
2268 fn test_detect_ruby_no_artifact_not_detected() {
2269 let tmp = TempDir::new().unwrap();
2270 let base = tmp.path();
2271
2272 let project = base.join("gemfile-only");
2274 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2275
2276 let scanner = default_scanner(ProjectFilter::Ruby);
2277 let projects = scanner.scan_directory(base);
2278 assert_eq!(projects.len(), 0);
2279 }
2280
2281 #[test]
2282 fn test_detect_ruby_fallback_to_dir_name() {
2283 let tmp = TempDir::new().unwrap();
2284 let base = tmp.path();
2285
2286 let project = base.join("my-ruby-app");
2287 create_file(&project.join("Gemfile"), "source 'https://rubygems.org'");
2288 create_file(
2289 &project.join("vendor/bundle/gems/sinatra/lib/sinatra.rb"),
2290 "# sinatra",
2291 );
2292
2293 let scanner = default_scanner(ProjectFilter::Ruby);
2294 let projects = scanner.scan_directory(base);
2295 assert_eq!(projects.len(), 1);
2296 assert_eq!(projects[0].name.as_deref(), Some("my-ruby-app"));
2297 }
2298
2299 #[test]
2302 fn test_detect_elixir_project() {
2303 let tmp = TempDir::new().unwrap();
2304 let base = tmp.path();
2305
2306 let project = base.join("elixir-project");
2307 create_file(
2308 &project.join("mix.exs"),
2309 "defmodule MyApp.MixProject do\n def project do\n [app: :my_app,\n version: \"0.1.0\"]\n end\nend",
2310 );
2311 create_file(
2312 &project.join("_build/dev/lib/my_app/.mix/compile.elixir"),
2313 "# build",
2314 );
2315
2316 let scanner = default_scanner(ProjectFilter::Elixir);
2317 let projects = scanner.scan_directory(base);
2318 assert_eq!(projects.len(), 1);
2319 assert_eq!(projects[0].kind, ProjectType::Elixir);
2320 assert_eq!(projects[0].name.as_deref(), Some("my_app"));
2321 }
2322
2323 #[test]
2324 fn test_detect_elixir_no_build_not_detected() {
2325 let tmp = TempDir::new().unwrap();
2326 let base = tmp.path();
2327
2328 let project = base.join("mix-only");
2329 create_file(
2330 &project.join("mix.exs"),
2331 "defmodule MixOnly.MixProject do\n def project do\n [app: :mix_only]\n end\nend",
2332 );
2333
2334 let scanner = default_scanner(ProjectFilter::Elixir);
2335 let projects = scanner.scan_directory(base);
2336 assert_eq!(projects.len(), 0);
2337 }
2338
2339 #[test]
2340 fn test_detect_elixir_fallback_to_dir_name() {
2341 let tmp = TempDir::new().unwrap();
2342 let base = tmp.path();
2343
2344 let project = base.join("my_elixir_project");
2345 create_file(&project.join("mix.exs"), "# minimal mix.exs without app:");
2346 create_file(
2347 &project.join("_build/prod/lib/my_elixir_project.beam"),
2348 "bytecode",
2349 );
2350
2351 let scanner = default_scanner(ProjectFilter::Elixir);
2352 let projects = scanner.scan_directory(base);
2353 assert_eq!(projects.len(), 1);
2354 assert_eq!(projects[0].name.as_deref(), Some("my_elixir_project"));
2355 }
2356
2357 #[test]
2360 fn test_detect_deno_with_vendor() {
2361 let tmp = TempDir::new().unwrap();
2362 let base = tmp.path();
2363
2364 let project = base.join("deno-project");
2365 create_file(
2366 &project.join("deno.json"),
2367 r#"{"name": "my-deno-app", "imports": {}}"#,
2368 );
2369 create_file(&project.join("vendor/modules.json"), "{}");
2370
2371 let scanner = default_scanner(ProjectFilter::Deno);
2372 let projects = scanner.scan_directory(base);
2373 assert_eq!(projects.len(), 1);
2374 assert_eq!(projects[0].kind, ProjectType::Deno);
2375 assert_eq!(projects[0].name.as_deref(), Some("my-deno-app"));
2376 }
2377
2378 #[test]
2379 fn test_detect_deno_jsonc_config() {
2380 let tmp = TempDir::new().unwrap();
2381 let base = tmp.path();
2382
2383 let project = base.join("deno-jsonc-project");
2384 create_file(
2385 &project.join("deno.jsonc"),
2386 r#"{"name": "my-deno-jsonc-app", "tasks": {}}"#,
2387 );
2388 create_file(&project.join("vendor/modules.json"), "{}");
2389
2390 let scanner = default_scanner(ProjectFilter::Deno);
2391 let projects = scanner.scan_directory(base);
2392 assert_eq!(projects.len(), 1);
2393 assert_eq!(projects[0].kind, ProjectType::Deno);
2394 assert_eq!(projects[0].name.as_deref(), Some("my-deno-jsonc-app"));
2395 }
2396
2397 #[test]
2398 fn test_detect_deno_node_modules_without_package_json() {
2399 let tmp = TempDir::new().unwrap();
2400 let base = tmp.path();
2401
2402 let project = base.join("deno-npm-project");
2403 create_file(&project.join("deno.json"), r#"{"nodeModulesDir": "auto"}"#);
2404 create_file(
2405 &project.join("node_modules/.deno/lodash/index.js"),
2406 "// lodash",
2407 );
2408
2409 let scanner = default_scanner(ProjectFilter::Deno);
2410 let projects = scanner.scan_directory(base);
2411 assert_eq!(projects.len(), 1);
2412 assert_eq!(projects[0].kind, ProjectType::Deno);
2413 }
2414
2415 #[test]
2416 fn test_detect_deno_node_modules_with_package_json_becomes_node() {
2417 let tmp = TempDir::new().unwrap();
2418 let base = tmp.path();
2419
2420 let project = base.join("ambiguous-project");
2422 create_file(&project.join("deno.json"), r"{}");
2423 create_file(&project.join("package.json"), r#"{"name": "my-node-app"}"#);
2424 create_file(&project.join("node_modules/dep/index.js"), "// dep");
2425
2426 let scanner = default_scanner(ProjectFilter::All);
2427 let projects = scanner.scan_directory(base);
2428 assert_eq!(projects.len(), 1);
2429 assert_eq!(projects[0].kind, ProjectType::Node);
2430 }
2431
2432 #[test]
2433 fn test_detect_deno_no_artifact_not_detected() {
2434 let tmp = TempDir::new().unwrap();
2435 let base = tmp.path();
2436
2437 let project = base.join("deno-no-artifact");
2438 create_file(&project.join("deno.json"), r"{}");
2439
2440 let scanner = default_scanner(ProjectFilter::Deno);
2441 let projects = scanner.scan_directory(base);
2442 assert_eq!(projects.len(), 0);
2443 }
2444
2445 #[test]
2446 fn test_build_directory_is_excluded() {
2447 assert!(Scanner::is_excluded_directory(Path::new("/some/_build")));
2448 }
2449
2450 #[test]
2453 fn test_is_cargo_workspace_root() {
2454 let tmp = TempDir::new().unwrap();
2455 let cargo_toml = tmp.path().join("Cargo.toml");
2456
2457 create_file(
2459 &cargo_toml,
2460 "[workspace]\nmembers = [\"crate-a\", \"crate-b\"]\n",
2461 );
2462 assert!(Scanner::is_cargo_workspace_root(&cargo_toml));
2463
2464 create_file(
2466 &cargo_toml,
2467 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
2468 );
2469 assert!(!Scanner::is_cargo_workspace_root(&cargo_toml));
2470
2471 assert!(!Scanner::is_cargo_workspace_root(Path::new(
2473 "/nonexistent/Cargo.toml"
2474 )));
2475 }
2476
2477 #[test]
2478 fn test_workspace_root_detected() {
2479 let tmp = TempDir::new().unwrap();
2480 let base = tmp.path();
2481
2482 let workspace = base.join("my-workspace");
2484 create_file(
2485 &workspace.join("Cargo.toml"),
2486 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2487 );
2488 create_file(&workspace.join("target/dummy"), "content");
2489
2490 let scanner = default_scanner(ProjectFilter::Rust);
2491 let projects = scanner.scan_directory(base);
2492
2493 assert_eq!(projects.len(), 1);
2494 assert_eq!(projects[0].root_path, workspace);
2495 }
2496
2497 #[test]
2498 fn test_workspace_member_with_own_target_skipped() {
2499 let tmp = TempDir::new().unwrap();
2500 let base = tmp.path();
2501
2502 let workspace = base.join("my-workspace");
2504 create_file(
2505 &workspace.join("Cargo.toml"),
2506 "[workspace]\nmembers = [\"crate-a\"]\n\n[package]\nname = \"my-workspace\"\nversion = \"0.1.0\"\n",
2507 );
2508 create_file(&workspace.join("target/dummy"), "content");
2509
2510 let member = workspace.join("crate-a");
2512 create_file(
2513 &member.join("Cargo.toml"),
2514 "[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
2515 );
2516 create_file(&member.join("target/dummy"), "content");
2517
2518 let scanner = default_scanner(ProjectFilter::Rust);
2519 let projects = scanner.scan_directory(base);
2520
2521 assert_eq!(projects.len(), 1);
2523 assert_eq!(projects[0].root_path, workspace);
2524 }
2525}