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