1use std::{
9 fs,
10 path::Path,
11 sync::{Arc, Mutex},
12};
13
14use colored::Colorize;
15use indicatif::{ProgressBar, ProgressStyle};
16use rayon::prelude::*;
17use serde_json::{Value, from_str};
18use walkdir::{DirEntry, WalkDir};
19
20use crate::{
21 config::{ProjectFilter, ScanOptions},
22 project::{BuildArtifacts, Project, ProjectType},
23};
24
25pub struct Scanner {
32 scan_options: ScanOptions,
34
35 project_filter: ProjectFilter,
37
38 quiet: bool,
40}
41
42impl Scanner {
43 #[must_use]
67 pub const fn new(scan_options: ScanOptions, project_filter: ProjectFilter) -> Self {
68 Self {
69 scan_options,
70 project_filter,
71 quiet: false,
72 }
73 }
74
75 #[must_use]
80 pub const fn with_quiet(mut self, quiet: bool) -> Self {
81 self.quiet = quiet;
82 self
83 }
84
85 pub fn scan_directory(&self, root: &Path) -> Vec<Project> {
122 let errors = Arc::new(Mutex::new(Vec::<String>::new()));
123
124 let progress = if self.quiet {
125 ProgressBar::hidden()
126 } else {
127 let pb = ProgressBar::new_spinner();
128 pb.set_style(
129 ProgressStyle::default_spinner()
130 .template("{spinner:.green} {msg}")
131 .unwrap(),
132 );
133 pb.set_message("Scanning directories...");
134 pb
135 };
136
137 let potential_projects: Vec<_> = WalkDir::new(root)
139 .into_iter()
140 .filter_map(Result::ok)
141 .filter(|entry| self.should_scan_entry(entry))
142 .collect::<Vec<_>>()
143 .into_par_iter()
144 .filter_map(|entry| self.detect_project(&entry, &errors))
145 .collect();
146
147 progress.finish_with_message("✅ Directory scan complete");
148
149 let projects_with_sizes: Vec<_> = potential_projects
151 .into_par_iter()
152 .filter_map(|mut project| {
153 if project.build_arts.size == 0 {
154 project.build_arts.size =
155 Self::calculate_build_dir_size(&project.build_arts.path);
156 }
157
158 if project.build_arts.size > 0 {
159 Some(project)
160 } else {
161 None
162 }
163 })
164 .collect();
165
166 if self.scan_options.verbose {
168 let errors = errors.lock().unwrap();
169 for error in errors.iter() {
170 eprintln!("{}", error.red());
171 }
172 }
173
174 projects_with_sizes
175 }
176
177 fn calculate_build_dir_size(path: &Path) -> u64 {
198 if !path.exists() {
199 return 0;
200 }
201
202 crate::utils::calculate_dir_size(path)
203 }
204
205 fn detect_node_project(
227 &self,
228 path: &Path,
229 errors: &Arc<Mutex<Vec<String>>>,
230 ) -> Option<Project> {
231 let package_json = path.join("package.json");
232 let node_modules = path.join("node_modules");
233
234 if package_json.exists() && node_modules.exists() {
235 let name = self.extract_node_project_name(&package_json, errors);
236
237 let build_arts = BuildArtifacts {
238 path: path.join("node_modules"),
239 size: 0, };
241
242 return Some(Project::new(
243 ProjectType::Node,
244 path.to_path_buf(),
245 build_arts,
246 name,
247 ));
248 }
249
250 None
251 }
252
253 fn detect_project(
280 &self,
281 entry: &DirEntry,
282 errors: &Arc<Mutex<Vec<String>>>,
283 ) -> Option<Project> {
284 let path = entry.path();
285
286 if !entry.file_type().is_dir() {
287 return None;
288 }
289
290 self.try_detect(ProjectFilter::Rust, || {
294 self.detect_rust_project(path, errors)
295 })
296 .or_else(|| {
297 self.try_detect(ProjectFilter::Node, || {
298 self.detect_node_project(path, errors)
299 })
300 })
301 .or_else(|| {
302 self.try_detect(ProjectFilter::Java, || {
303 self.detect_java_project(path, errors)
304 })
305 })
306 .or_else(|| {
307 self.try_detect(ProjectFilter::Swift, || {
308 self.detect_swift_project(path, errors)
309 })
310 })
311 .or_else(|| self.try_detect(ProjectFilter::DotNet, || Self::detect_dotnet_project(path)))
312 .or_else(|| {
313 self.try_detect(ProjectFilter::Python, || {
314 self.detect_python_project(path, errors)
315 })
316 })
317 .or_else(|| self.try_detect(ProjectFilter::Go, || self.detect_go_project(path, errors)))
318 .or_else(|| self.try_detect(ProjectFilter::Cpp, || self.detect_cpp_project(path, errors)))
319 }
320
321 fn try_detect(
326 &self,
327 filter: ProjectFilter,
328 detect: impl FnOnce() -> Option<Project>,
329 ) -> Option<Project> {
330 if self.project_filter == ProjectFilter::All || self.project_filter == filter {
331 detect()
332 } else {
333 None
334 }
335 }
336
337 fn detect_rust_project(
359 &self,
360 path: &Path,
361 errors: &Arc<Mutex<Vec<String>>>,
362 ) -> Option<Project> {
363 let cargo_toml = path.join("Cargo.toml");
364 let target_dir = path.join("target");
365
366 if cargo_toml.exists() && target_dir.exists() {
367 let name = self.extract_rust_project_name(&cargo_toml, errors);
368
369 let build_arts = BuildArtifacts {
370 path: path.join("target"),
371 size: 0, };
373
374 return Some(Project::new(
375 ProjectType::Rust,
376 path.to_path_buf(),
377 build_arts,
378 name,
379 ));
380 }
381
382 None
383 }
384
385 fn extract_rust_project_name(
407 &self,
408 cargo_toml: &Path,
409 errors: &Arc<Mutex<Vec<String>>>,
410 ) -> Option<String> {
411 let content = self.read_file_content(cargo_toml, errors)?;
412 Self::parse_toml_name_field(&content)
413 }
414
415 fn extract_quoted_value(line: &str) -> Option<String> {
417 let start = line.find('"')?;
418 let end = line.rfind('"')?;
419
420 if start == end {
421 return None;
422 }
423
424 Some(line[start + 1..end].to_string())
425 }
426
427 fn extract_name_from_line(line: &str) -> Option<String> {
429 if !Self::is_name_line(line) {
430 return None;
431 }
432
433 Self::extract_quoted_value(line)
434 }
435
436 fn extract_node_project_name(
457 &self,
458 package_json: &Path,
459 errors: &Arc<Mutex<Vec<String>>>,
460 ) -> Option<String> {
461 match fs::read_to_string(package_json) {
462 Ok(content) => match from_str::<Value>(&content) {
463 Ok(json) => json
464 .get("name")
465 .and_then(|v| v.as_str())
466 .map(std::string::ToString::to_string),
467 Err(e) => {
468 if self.scan_options.verbose {
469 errors
470 .lock()
471 .unwrap()
472 .push(format!("Error parsing {}: {e}", package_json.display()));
473 }
474 None
475 }
476 },
477 Err(e) => {
478 if self.scan_options.verbose {
479 errors
480 .lock()
481 .unwrap()
482 .push(format!("Error reading {}: {e}", package_json.display()));
483 }
484 None
485 }
486 }
487 }
488
489 fn is_name_line(line: &str) -> bool {
491 line.starts_with("name") && line.contains('=')
492 }
493
494 fn log_file_error(
496 &self,
497 file_path: &Path,
498 error: &std::io::Error,
499 errors: &Arc<Mutex<Vec<String>>>,
500 ) {
501 if self.scan_options.verbose {
502 errors
503 .lock()
504 .unwrap()
505 .push(format!("Error reading {}: {error}", file_path.display()));
506 }
507 }
508
509 fn parse_toml_name_field(content: &str) -> Option<String> {
511 for line in content.lines() {
512 if let Some(name) = Self::extract_name_from_line(line.trim()) {
513 return Some(name);
514 }
515 }
516 None
517 }
518
519 fn read_file_content(
521 &self,
522 file_path: &Path,
523 errors: &Arc<Mutex<Vec<String>>>,
524 ) -> Option<String> {
525 match fs::read_to_string(file_path) {
526 Ok(content) => Some(content),
527 Err(e) => {
528 self.log_file_error(file_path, &e, errors);
529 None
530 }
531 }
532 }
533
534 fn should_scan_entry(&self, entry: &DirEntry) -> bool {
568 let path = entry.path();
569
570 if self.is_path_in_skip_list(path) {
572 return false;
573 }
574
575 if path
577 .ancestors()
578 .any(|ancestor| ancestor.file_name().and_then(|n| n.to_str()) == Some("node_modules"))
579 {
580 return false;
581 }
582
583 if Self::is_hidden_directory_to_skip(path) {
585 return false;
586 }
587
588 !Self::is_excluded_directory(path)
590 }
591
592 fn is_path_in_skip_list(&self, path: &Path) -> bool {
594 self.scan_options.skip.iter().any(|skip| {
595 path.components().any(|component| {
596 component
597 .as_os_str()
598 .to_str()
599 .is_some_and(|name| name == skip.to_string_lossy())
600 })
601 })
602 }
603
604 fn is_hidden_directory_to_skip(path: &Path) -> bool {
606 path.file_name()
607 .and_then(|n| n.to_str())
608 .is_some_and(|name| name.starts_with('.') && name != ".cargo")
609 }
610
611 fn is_excluded_directory(path: &Path) -> bool {
613 let excluded_dirs = [
614 "target",
615 "build",
616 "dist",
617 "out",
618 ".git",
619 ".svn",
620 ".hg",
621 "__pycache__",
622 "venv",
623 ".venv",
624 "env",
625 ".env",
626 "temp",
627 "tmp",
628 "vendor",
629 ".pytest_cache",
630 ".tox",
631 ".eggs",
632 ".coverage",
633 "node_modules",
634 "obj",
635 ];
636
637 path.file_name()
638 .and_then(|n| n.to_str())
639 .is_some_and(|name| excluded_dirs.contains(&name))
640 }
641
642 fn detect_python_project(
663 &self,
664 path: &Path,
665 errors: &Arc<Mutex<Vec<String>>>,
666 ) -> Option<Project> {
667 let config_files = [
668 "requirements.txt",
669 "setup.py",
670 "pyproject.toml",
671 "setup.cfg",
672 "Pipfile",
673 "pipenv.lock",
674 "poetry.lock",
675 ];
676
677 let build_dirs = [
678 "__pycache__",
679 ".pytest_cache",
680 "venv",
681 ".venv",
682 "build",
683 "dist",
684 ".eggs",
685 ".tox",
686 ".coverage",
687 ];
688
689 let has_config = config_files.iter().any(|&file| path.join(file).exists());
691
692 if !has_config {
693 return None;
694 }
695
696 let mut largest_build_dir = None;
698 let mut largest_size = 0;
699
700 for &dir_name in &build_dirs {
701 let dir_path = path.join(dir_name);
702
703 if dir_path.exists() && dir_path.is_dir() {
704 let size = crate::utils::calculate_dir_size(&dir_path);
705 if size > largest_size {
706 largest_size = size;
707 largest_build_dir = Some(dir_path);
708 }
709 }
710 }
711
712 if let Some(build_path) = largest_build_dir {
713 let name = self.extract_python_project_name(path, errors);
714
715 let build_arts = BuildArtifacts {
716 path: build_path,
717 size: largest_size,
718 };
719
720 return Some(Project::new(
721 ProjectType::Python,
722 path.to_path_buf(),
723 build_arts,
724 name,
725 ));
726 }
727
728 None
729 }
730
731 fn detect_go_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
753 let go_mod = path.join("go.mod");
754 let vendor_dir = path.join("vendor");
755
756 if go_mod.exists() && vendor_dir.exists() {
757 let name = self.extract_go_project_name(&go_mod, errors);
758
759 let build_arts = BuildArtifacts {
760 path: path.join("vendor"),
761 size: 0, };
763
764 return Some(Project::new(
765 ProjectType::Go,
766 path.to_path_buf(),
767 build_arts,
768 name,
769 ));
770 }
771
772 None
773 }
774
775 fn extract_python_project_name(
797 &self,
798 path: &Path,
799 errors: &Arc<Mutex<Vec<String>>>,
800 ) -> Option<String> {
801 self.try_extract_from_pyproject_toml(path, errors)
803 .or_else(|| self.try_extract_from_setup_py(path, errors))
804 .or_else(|| self.try_extract_from_setup_cfg(path, errors))
805 .or_else(|| Self::fallback_to_directory_name(path))
806 }
807
808 fn try_extract_from_pyproject_toml(
810 &self,
811 path: &Path,
812 errors: &Arc<Mutex<Vec<String>>>,
813 ) -> Option<String> {
814 let pyproject_toml = path.join("pyproject.toml");
815 if !pyproject_toml.exists() {
816 return None;
817 }
818
819 let content = self.read_file_content(&pyproject_toml, errors)?;
820 Self::extract_name_from_toml_like_content(&content)
821 }
822
823 fn try_extract_from_setup_py(
825 &self,
826 path: &Path,
827 errors: &Arc<Mutex<Vec<String>>>,
828 ) -> Option<String> {
829 let setup_py = path.join("setup.py");
830 if !setup_py.exists() {
831 return None;
832 }
833
834 let content = self.read_file_content(&setup_py, errors)?;
835 Self::extract_name_from_python_content(&content)
836 }
837
838 fn try_extract_from_setup_cfg(
840 &self,
841 path: &Path,
842 errors: &Arc<Mutex<Vec<String>>>,
843 ) -> Option<String> {
844 let setup_cfg = path.join("setup.cfg");
845 if !setup_cfg.exists() {
846 return None;
847 }
848
849 let content = self.read_file_content(&setup_cfg, errors)?;
850 Self::extract_name_from_cfg_content(&content)
851 }
852
853 fn extract_name_from_toml_like_content(content: &str) -> Option<String> {
855 content
856 .lines()
857 .map(str::trim)
858 .find(|line| line.starts_with("name") && line.contains('='))
859 .and_then(Self::extract_quoted_value)
860 }
861
862 fn extract_name_from_python_content(content: &str) -> Option<String> {
864 content
865 .lines()
866 .map(str::trim)
867 .find(|line| line.contains("name") && line.contains('='))
868 .and_then(Self::extract_quoted_value)
869 }
870
871 fn extract_name_from_cfg_content(content: &str) -> Option<String> {
873 let mut in_metadata_section = false;
874
875 for line in content.lines() {
876 let line = line.trim();
877
878 if line == "[metadata]" {
879 in_metadata_section = true;
880 } else if line.starts_with('[') && line.ends_with(']') {
881 in_metadata_section = false;
882 } else if in_metadata_section && line.starts_with("name") && line.contains('=') {
883 return line.split('=').nth(1).map(|name| name.trim().to_string());
884 }
885 }
886
887 None
888 }
889
890 fn fallback_to_directory_name(path: &Path) -> Option<String> {
892 path.file_name()
893 .and_then(|name| name.to_str())
894 .map(std::string::ToString::to_string)
895 }
896
897 fn extract_go_project_name(
917 &self,
918 go_mod: &Path,
919 errors: &Arc<Mutex<Vec<String>>>,
920 ) -> Option<String> {
921 let content = self.read_file_content(go_mod, errors)?;
922
923 for line in content.lines() {
924 let line = line.trim();
925 if line.starts_with("module ") {
926 let module_path = line.strip_prefix("module ")?.trim();
927
928 if let Some(name) = module_path.split('/').next_back() {
930 return Some(name.to_string());
931 }
932
933 return Some(module_path.to_string());
934 }
935 }
936
937 None
938 }
939
940 fn detect_java_project(
951 &self,
952 path: &Path,
953 errors: &Arc<Mutex<Vec<String>>>,
954 ) -> Option<Project> {
955 let pom_xml = path.join("pom.xml");
956 let target_dir = path.join("target");
957
958 if pom_xml.exists() && target_dir.exists() {
960 let name = self.extract_java_maven_project_name(&pom_xml, errors);
961
962 let build_arts = BuildArtifacts {
963 path: target_dir,
964 size: 0,
965 };
966
967 return Some(Project::new(
968 ProjectType::Java,
969 path.to_path_buf(),
970 build_arts,
971 name,
972 ));
973 }
974
975 let has_gradle =
977 path.join("build.gradle").exists() || path.join("build.gradle.kts").exists();
978 let build_dir = path.join("build");
979
980 if has_gradle && build_dir.exists() {
981 let name = self.extract_java_gradle_project_name(path, errors);
982
983 let build_arts = BuildArtifacts {
984 path: build_dir,
985 size: 0,
986 };
987
988 return Some(Project::new(
989 ProjectType::Java,
990 path.to_path_buf(),
991 build_arts,
992 name,
993 ));
994 }
995
996 None
997 }
998
999 fn extract_java_maven_project_name(
1003 &self,
1004 pom_xml: &Path,
1005 errors: &Arc<Mutex<Vec<String>>>,
1006 ) -> Option<String> {
1007 let content = self.read_file_content(pom_xml, errors)?;
1008
1009 for line in content.lines() {
1010 let trimmed = line.trim();
1011 if trimmed.starts_with("<artifactId>") && trimmed.ends_with("</artifactId>") {
1012 let name = trimmed
1013 .strip_prefix("<artifactId>")?
1014 .strip_suffix("</artifactId>")?;
1015 return Some(name.to_string());
1016 }
1017 }
1018
1019 None
1020 }
1021
1022 fn extract_java_gradle_project_name(
1027 &self,
1028 path: &Path,
1029 errors: &Arc<Mutex<Vec<String>>>,
1030 ) -> Option<String> {
1031 for settings_file in &["settings.gradle", "settings.gradle.kts"] {
1032 let settings_path = path.join(settings_file);
1033 if settings_path.exists()
1034 && let Some(content) = self.read_file_content(&settings_path, errors)
1035 {
1036 for line in content.lines() {
1037 let trimmed = line.trim();
1038 if trimmed.contains("rootProject.name") && trimmed.contains('=') {
1039 return Self::extract_quoted_value(trimmed).or_else(|| {
1040 trimmed
1041 .split('=')
1042 .nth(1)
1043 .map(|s| s.trim().trim_matches('\'').to_string())
1044 });
1045 }
1046 }
1047 }
1048 }
1049
1050 Self::fallback_to_directory_name(path)
1051 }
1052
1053 fn detect_cpp_project(&self, path: &Path, errors: &Arc<Mutex<Vec<String>>>) -> Option<Project> {
1063 let build_dir = path.join("build");
1064
1065 if !build_dir.exists() {
1066 return None;
1067 }
1068
1069 let cmake_file = path.join("CMakeLists.txt");
1070 let makefile = path.join("Makefile");
1071
1072 if cmake_file.exists() || makefile.exists() {
1073 let name = if cmake_file.exists() {
1074 self.extract_cpp_cmake_project_name(&cmake_file, errors)
1075 } else {
1076 Self::fallback_to_directory_name(path)
1077 };
1078
1079 let build_arts = BuildArtifacts {
1080 path: build_dir,
1081 size: 0,
1082 };
1083
1084 return Some(Project::new(
1085 ProjectType::Cpp,
1086 path.to_path_buf(),
1087 build_arts,
1088 name,
1089 ));
1090 }
1091
1092 None
1093 }
1094
1095 fn extract_cpp_cmake_project_name(
1099 &self,
1100 cmake_file: &Path,
1101 errors: &Arc<Mutex<Vec<String>>>,
1102 ) -> Option<String> {
1103 let content = self.read_file_content(cmake_file, errors)?;
1104
1105 for line in content.lines() {
1106 let trimmed = line.trim();
1107 if trimmed.starts_with("project(") || trimmed.starts_with("PROJECT(") {
1108 let inner = trimmed
1109 .trim_start_matches("project(")
1110 .trim_start_matches("PROJECT(")
1111 .trim_end_matches(')')
1112 .trim();
1113
1114 let name = inner.split_whitespace().next()?;
1116 let name = name.trim_matches('"').trim_matches('\'');
1118 if !name.is_empty() {
1119 return Some(name.to_string());
1120 }
1121 }
1122 }
1123
1124 Self::fallback_to_directory_name(cmake_file.parent()?)
1125 }
1126
1127 fn detect_swift_project(
1137 &self,
1138 path: &Path,
1139 errors: &Arc<Mutex<Vec<String>>>,
1140 ) -> Option<Project> {
1141 let package_swift = path.join("Package.swift");
1142 let build_dir = path.join(".build");
1143
1144 if package_swift.exists() && build_dir.exists() {
1145 let name = self.extract_swift_project_name(&package_swift, errors);
1146
1147 let build_arts = BuildArtifacts {
1148 path: build_dir,
1149 size: 0,
1150 };
1151
1152 return Some(Project::new(
1153 ProjectType::Swift,
1154 path.to_path_buf(),
1155 build_arts,
1156 name,
1157 ));
1158 }
1159
1160 None
1161 }
1162
1163 fn extract_swift_project_name(
1167 &self,
1168 package_swift: &Path,
1169 errors: &Arc<Mutex<Vec<String>>>,
1170 ) -> Option<String> {
1171 let content = self.read_file_content(package_swift, errors)?;
1172
1173 for line in content.lines() {
1174 let trimmed = line.trim();
1175 if trimmed.contains("name:") {
1176 return Self::extract_quoted_value(trimmed);
1177 }
1178 }
1179
1180 Self::fallback_to_directory_name(package_swift.parent()?)
1181 }
1182
1183 fn detect_dotnet_project(path: &Path) -> Option<Project> {
1193 let bin_dir = path.join("bin");
1194 let obj_dir = path.join("obj");
1195
1196 let has_build_dir = bin_dir.exists() || obj_dir.exists();
1197 if !has_build_dir {
1198 return None;
1199 }
1200
1201 let csproj_file = Self::find_file_with_extension(path, "csproj")?;
1202
1203 let (build_path, precomputed_size) = match (bin_dir.exists(), obj_dir.exists()) {
1205 (true, true) => {
1206 let bin_size = crate::utils::calculate_dir_size(&bin_dir);
1207 let obj_size = crate::utils::calculate_dir_size(&obj_dir);
1208 if obj_size >= bin_size {
1209 (obj_dir, obj_size)
1210 } else {
1211 (bin_dir, bin_size)
1212 }
1213 }
1214 (true, false) => (bin_dir, 0),
1215 (false, true) => (obj_dir, 0),
1216 (false, false) => return None,
1217 };
1218
1219 let name = csproj_file
1220 .file_stem()
1221 .and_then(|s| s.to_str())
1222 .map(std::string::ToString::to_string);
1223
1224 let build_arts = BuildArtifacts {
1225 path: build_path,
1226 size: precomputed_size,
1227 };
1228
1229 Some(Project::new(
1230 ProjectType::DotNet,
1231 path.to_path_buf(),
1232 build_arts,
1233 name,
1234 ))
1235 }
1236
1237 fn find_file_with_extension(dir: &Path, extension: &str) -> Option<std::path::PathBuf> {
1239 let entries = fs::read_dir(dir).ok()?;
1240 for entry in entries.flatten() {
1241 let path = entry.path();
1242 if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some(extension) {
1243 return Some(path);
1244 }
1245 }
1246 None
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use std::path::PathBuf;
1254 use tempfile::TempDir;
1255
1256 fn default_scanner(filter: ProjectFilter) -> Scanner {
1258 Scanner::new(
1259 ScanOptions {
1260 verbose: false,
1261 threads: 1,
1262 skip: vec![],
1263 },
1264 filter,
1265 )
1266 }
1267
1268 fn create_file(path: &Path, content: &str) {
1270 if let Some(parent) = path.parent() {
1271 fs::create_dir_all(parent).unwrap();
1272 }
1273 fs::write(path, content).unwrap();
1274 }
1275
1276 #[test]
1279 fn test_is_hidden_directory_to_skip() {
1280 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1282 "/some/.hidden"
1283 )));
1284 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1285 "/some/.git"
1286 )));
1287 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1288 "/some/.svn"
1289 )));
1290 assert!(Scanner::is_hidden_directory_to_skip(Path::new(".env")));
1291
1292 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1294 "/home/user/.cargo"
1295 )));
1296 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(".cargo")));
1297
1298 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1300 "/some/visible"
1301 )));
1302 assert!(!Scanner::is_hidden_directory_to_skip(Path::new("src")));
1303 }
1304
1305 #[test]
1306 fn test_is_excluded_directory() {
1307 assert!(Scanner::is_excluded_directory(Path::new("/some/target")));
1309 assert!(Scanner::is_excluded_directory(Path::new(
1310 "/some/node_modules"
1311 )));
1312 assert!(Scanner::is_excluded_directory(Path::new(
1313 "/some/__pycache__"
1314 )));
1315 assert!(Scanner::is_excluded_directory(Path::new("/some/vendor")));
1316 assert!(Scanner::is_excluded_directory(Path::new("/some/build")));
1317 assert!(Scanner::is_excluded_directory(Path::new("/some/dist")));
1318 assert!(Scanner::is_excluded_directory(Path::new("/some/out")));
1319
1320 assert!(Scanner::is_excluded_directory(Path::new("/some/.git")));
1322 assert!(Scanner::is_excluded_directory(Path::new("/some/.svn")));
1323 assert!(Scanner::is_excluded_directory(Path::new("/some/.hg")));
1324
1325 assert!(Scanner::is_excluded_directory(Path::new(
1327 "/some/.pytest_cache"
1328 )));
1329 assert!(Scanner::is_excluded_directory(Path::new("/some/.tox")));
1330 assert!(Scanner::is_excluded_directory(Path::new("/some/.eggs")));
1331 assert!(Scanner::is_excluded_directory(Path::new("/some/.coverage")));
1332
1333 assert!(Scanner::is_excluded_directory(Path::new("/some/venv")));
1335 assert!(Scanner::is_excluded_directory(Path::new("/some/.venv")));
1336 assert!(Scanner::is_excluded_directory(Path::new("/some/env")));
1337 assert!(Scanner::is_excluded_directory(Path::new("/some/.env")));
1338
1339 assert!(Scanner::is_excluded_directory(Path::new("/some/temp")));
1341 assert!(Scanner::is_excluded_directory(Path::new("/some/tmp")));
1342
1343 assert!(!Scanner::is_excluded_directory(Path::new("/some/src")));
1345 assert!(!Scanner::is_excluded_directory(Path::new("/some/lib")));
1346 assert!(!Scanner::is_excluded_directory(Path::new("/some/app")));
1347 assert!(!Scanner::is_excluded_directory(Path::new("/some/tests")));
1348 }
1349
1350 #[test]
1351 fn test_extract_quoted_value() {
1352 assert_eq!(
1353 Scanner::extract_quoted_value(r#"name = "my-project""#),
1354 Some("my-project".to_string())
1355 );
1356 assert_eq!(
1357 Scanner::extract_quoted_value(r#"name = "with spaces""#),
1358 Some("with spaces".to_string())
1359 );
1360 assert_eq!(Scanner::extract_quoted_value("no quotes here"), None);
1361 assert_eq!(Scanner::extract_quoted_value(r#"only "one"#), None);
1363 }
1364
1365 #[test]
1366 fn test_is_name_line() {
1367 assert!(Scanner::is_name_line("name = \"test\""));
1368 assert!(Scanner::is_name_line("name=\"test\""));
1369 assert!(!Scanner::is_name_line("version = \"1.0\""));
1370 assert!(!Scanner::is_name_line("# name = \"commented\""));
1371 assert!(!Scanner::is_name_line("name: \"yaml style\""));
1372 }
1373
1374 #[test]
1375 fn test_parse_toml_name_field() {
1376 let content = "[package]\nname = \"test-project\"\nversion = \"0.1.0\"\n";
1377 assert_eq!(
1378 Scanner::parse_toml_name_field(content),
1379 Some("test-project".to_string())
1380 );
1381
1382 let no_name = "[package]\nversion = \"0.1.0\"\n";
1383 assert_eq!(Scanner::parse_toml_name_field(no_name), None);
1384
1385 let empty = "";
1386 assert_eq!(Scanner::parse_toml_name_field(empty), None);
1387 }
1388
1389 #[test]
1390 fn test_extract_name_from_cfg_content() {
1391 let content = "[metadata]\nname = my-package\nversion = 1.0\n";
1392 assert_eq!(
1393 Scanner::extract_name_from_cfg_content(content),
1394 Some("my-package".to_string())
1395 );
1396
1397 let wrong_section = "[options]\nname = not-this\n";
1399 assert_eq!(Scanner::extract_name_from_cfg_content(wrong_section), None);
1400
1401 let multi = "[options]\nkey = val\n\n[metadata]\nname = correct\n\n[other]\nname = wrong\n";
1403 assert_eq!(
1404 Scanner::extract_name_from_cfg_content(multi),
1405 Some("correct".to_string())
1406 );
1407 }
1408
1409 #[test]
1410 fn test_extract_name_from_python_content() {
1411 let content = "from setuptools import setup\nsetup(\n name=\"my-pkg\",\n)\n";
1412 assert_eq!(
1413 Scanner::extract_name_from_python_content(content),
1414 Some("my-pkg".to_string())
1415 );
1416
1417 let no_name = "from setuptools import setup\nsetup(version=\"1.0\")\n";
1418 assert_eq!(Scanner::extract_name_from_python_content(no_name), None);
1419 }
1420
1421 #[test]
1422 fn test_fallback_to_directory_name() {
1423 assert_eq!(
1424 Scanner::fallback_to_directory_name(Path::new("/some/project-name")),
1425 Some("project-name".to_string())
1426 );
1427 assert_eq!(
1428 Scanner::fallback_to_directory_name(Path::new("/some/my_app")),
1429 Some("my_app".to_string())
1430 );
1431 }
1432
1433 #[test]
1434 fn test_is_path_in_skip_list() {
1435 let scanner = Scanner::new(
1436 ScanOptions {
1437 verbose: false,
1438 threads: 1,
1439 skip: vec![PathBuf::from("skip-me"), PathBuf::from("also-skip")],
1440 },
1441 ProjectFilter::All,
1442 );
1443
1444 assert!(scanner.is_path_in_skip_list(Path::new("/root/skip-me/project")));
1445 assert!(scanner.is_path_in_skip_list(Path::new("/root/also-skip")));
1446 assert!(!scanner.is_path_in_skip_list(Path::new("/root/keep-me")));
1447 assert!(!scanner.is_path_in_skip_list(Path::new("/root/src")));
1448 }
1449
1450 #[test]
1451 fn test_is_path_in_empty_skip_list() {
1452 let scanner = default_scanner(ProjectFilter::All);
1453 assert!(!scanner.is_path_in_skip_list(Path::new("/any/path")));
1454 }
1455
1456 #[test]
1459 fn test_scan_directory_with_spaces_in_path() {
1460 let tmp = TempDir::new().unwrap();
1461 let base = tmp.path().join("path with spaces");
1462 fs::create_dir_all(&base).unwrap();
1463
1464 let project = base.join("my project");
1465 create_file(
1466 &project.join("Cargo.toml"),
1467 "[package]\nname = \"spaced\"\nversion = \"0.1.0\"",
1468 );
1469 create_file(&project.join("target/dummy"), "content");
1470
1471 let scanner = default_scanner(ProjectFilter::Rust);
1472 let projects = scanner.scan_directory(&base);
1473 assert_eq!(projects.len(), 1);
1474 assert_eq!(projects[0].name.as_deref(), Some("spaced"));
1475 }
1476
1477 #[test]
1478 fn test_scan_directory_with_unicode_names() {
1479 let tmp = TempDir::new().unwrap();
1480 let base = tmp.path();
1481
1482 let project = base.join("プロジェクト");
1483 create_file(
1484 &project.join("package.json"),
1485 r#"{"name": "unicode-project"}"#,
1486 );
1487 create_file(&project.join("node_modules/dep.js"), "module.exports = {};");
1488
1489 let scanner = default_scanner(ProjectFilter::Node);
1490 let projects = scanner.scan_directory(base);
1491 assert_eq!(projects.len(), 1);
1492 assert_eq!(projects[0].name.as_deref(), Some("unicode-project"));
1493 }
1494
1495 #[test]
1496 fn test_scan_directory_with_special_characters_in_name() {
1497 let tmp = TempDir::new().unwrap();
1498 let base = tmp.path();
1499
1500 let project = base.join("project-with-dashes_and_underscores.v2");
1501 create_file(
1502 &project.join("Cargo.toml"),
1503 "[package]\nname = \"special-chars\"\nversion = \"0.1.0\"",
1504 );
1505 create_file(&project.join("target/dummy"), "content");
1506
1507 let scanner = default_scanner(ProjectFilter::Rust);
1508 let projects = scanner.scan_directory(base);
1509 assert_eq!(projects.len(), 1);
1510 assert_eq!(projects[0].name.as_deref(), Some("special-chars"));
1511 }
1512
1513 #[test]
1516 #[cfg(unix)]
1517 fn test_hidden_directory_itself_not_detected_as_project_unix() {
1518 let tmp = TempDir::new().unwrap();
1519 let base = tmp.path();
1520
1521 let hidden = base.join(".hidden-project");
1526 create_file(
1527 &hidden.join("Cargo.toml"),
1528 "[package]\nname = \"hidden\"\nversion = \"0.1.0\"",
1529 );
1530 create_file(&hidden.join("target/dummy"), "content");
1531
1532 let visible = base.join("visible-project");
1534 create_file(
1535 &visible.join("Cargo.toml"),
1536 "[package]\nname = \"visible\"\nversion = \"0.1.0\"",
1537 );
1538 create_file(&visible.join("target/dummy"), "content");
1539
1540 let scanner = default_scanner(ProjectFilter::Rust);
1541 let projects = scanner.scan_directory(base);
1542
1543 assert_eq!(projects.len(), 1);
1546 assert_eq!(projects[0].name.as_deref(), Some("visible"));
1547 }
1548
1549 #[test]
1550 #[cfg(unix)]
1551 fn test_projects_inside_hidden_dirs_are_still_traversed_unix() {
1552 let tmp = TempDir::new().unwrap();
1553 let base = tmp.path();
1554
1555 let nested = base.join(".hidden-parent/visible-child");
1558 create_file(
1559 &nested.join("Cargo.toml"),
1560 "[package]\nname = \"nested\"\nversion = \"0.1.0\"",
1561 );
1562 create_file(&nested.join("target/dummy"), "content");
1563
1564 let scanner = default_scanner(ProjectFilter::Rust);
1565 let projects = scanner.scan_directory(base);
1566
1567 assert_eq!(projects.len(), 1);
1569 assert_eq!(projects[0].name.as_deref(), Some("nested"));
1570 }
1571
1572 #[test]
1573 #[cfg(unix)]
1574 fn test_dotcargo_directory_not_skipped_unix() {
1575 assert!(!Scanner::is_hidden_directory_to_skip(Path::new(
1578 "/home/user/.cargo"
1579 )));
1580
1581 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1583 "/home/user/.local"
1584 )));
1585 assert!(Scanner::is_hidden_directory_to_skip(Path::new(
1586 "/home/user/.npm"
1587 )));
1588 }
1589
1590 #[test]
1593 fn test_detect_python_with_pyproject_toml() {
1594 let tmp = TempDir::new().unwrap();
1595 let base = tmp.path();
1596
1597 let project = base.join("py-project");
1598 create_file(
1599 &project.join("pyproject.toml"),
1600 "[project]\nname = \"my-py-lib\"\nversion = \"1.0.0\"\n",
1601 );
1602 let pycache = project.join("__pycache__");
1603 fs::create_dir_all(&pycache).unwrap();
1604 create_file(&pycache.join("module.pyc"), "bytecode");
1605
1606 let scanner = default_scanner(ProjectFilter::Python);
1607 let projects = scanner.scan_directory(base);
1608 assert_eq!(projects.len(), 1);
1609 assert_eq!(projects[0].kind, ProjectType::Python);
1610 }
1611
1612 #[test]
1613 fn test_detect_python_with_setup_py() {
1614 let tmp = TempDir::new().unwrap();
1615 let base = tmp.path();
1616
1617 let project = base.join("setup-project");
1618 create_file(
1619 &project.join("setup.py"),
1620 "from setuptools import setup\nsetup(name=\"setup-lib\")\n",
1621 );
1622 let pycache = project.join("__pycache__");
1623 fs::create_dir_all(&pycache).unwrap();
1624 create_file(&pycache.join("module.pyc"), "bytecode");
1625
1626 let scanner = default_scanner(ProjectFilter::Python);
1627 let projects = scanner.scan_directory(base);
1628 assert_eq!(projects.len(), 1);
1629 }
1630
1631 #[test]
1632 fn test_detect_python_with_pipfile() {
1633 let tmp = TempDir::new().unwrap();
1634 let base = tmp.path();
1635
1636 let project = base.join("pipenv-project");
1637 create_file(
1638 &project.join("Pipfile"),
1639 "[[source]]\nurl = \"https://pypi.org/simple\"",
1640 );
1641 let pycache = project.join("__pycache__");
1642 fs::create_dir_all(&pycache).unwrap();
1643 create_file(&pycache.join("module.pyc"), "bytecode");
1644
1645 let scanner = default_scanner(ProjectFilter::Python);
1646 let projects = scanner.scan_directory(base);
1647 assert_eq!(projects.len(), 1);
1648 }
1649
1650 #[test]
1653 fn test_detect_go_extracts_module_name() {
1654 let tmp = TempDir::new().unwrap();
1655 let base = tmp.path();
1656
1657 let project = base.join("go-service");
1658 create_file(
1659 &project.join("go.mod"),
1660 "module github.com/user/my-service\n\ngo 1.21\n",
1661 );
1662 let vendor = project.join("vendor");
1663 fs::create_dir_all(&vendor).unwrap();
1664 create_file(&vendor.join("modules.txt"), "vendor manifest");
1665
1666 let scanner = default_scanner(ProjectFilter::Go);
1667 let projects = scanner.scan_directory(base);
1668 assert_eq!(projects.len(), 1);
1669 assert_eq!(projects[0].name.as_deref(), Some("my-service"));
1671 }
1672
1673 #[test]
1676 fn test_detect_java_maven_project() {
1677 let tmp = TempDir::new().unwrap();
1678 let base = tmp.path();
1679
1680 let project = base.join("java-maven");
1681 create_file(
1682 &project.join("pom.xml"),
1683 "<project>\n <artifactId>my-java-app</artifactId>\n</project>",
1684 );
1685 create_file(&project.join("target/classes/Main.class"), "bytecode");
1686
1687 let scanner = default_scanner(ProjectFilter::Java);
1688 let projects = scanner.scan_directory(base);
1689 assert_eq!(projects.len(), 1);
1690 assert_eq!(projects[0].kind, ProjectType::Java);
1691 assert_eq!(projects[0].name.as_deref(), Some("my-java-app"));
1692 }
1693
1694 #[test]
1695 fn test_detect_java_gradle_project() {
1696 let tmp = TempDir::new().unwrap();
1697 let base = tmp.path();
1698
1699 let project = base.join("java-gradle");
1700 create_file(&project.join("build.gradle"), "apply plugin: 'java'");
1701 create_file(
1702 &project.join("settings.gradle"),
1703 "rootProject.name = \"my-gradle-app\"",
1704 );
1705 create_file(&project.join("build/classes/main/Main.class"), "bytecode");
1706
1707 let scanner = default_scanner(ProjectFilter::Java);
1708 let projects = scanner.scan_directory(base);
1709 assert_eq!(projects.len(), 1);
1710 assert_eq!(projects[0].kind, ProjectType::Java);
1711 assert_eq!(projects[0].name.as_deref(), Some("my-gradle-app"));
1712 }
1713
1714 #[test]
1715 fn test_detect_java_gradle_kts_project() {
1716 let tmp = TempDir::new().unwrap();
1717 let base = tmp.path();
1718
1719 let project = base.join("kotlin-gradle");
1720 create_file(
1721 &project.join("build.gradle.kts"),
1722 "plugins { kotlin(\"jvm\") }",
1723 );
1724 create_file(
1725 &project.join("settings.gradle.kts"),
1726 "rootProject.name = \"my-kotlin-app\"",
1727 );
1728 create_file(
1729 &project.join("build/classes/kotlin/main/MainKt.class"),
1730 "bytecode",
1731 );
1732
1733 let scanner = default_scanner(ProjectFilter::Java);
1734 let projects = scanner.scan_directory(base);
1735 assert_eq!(projects.len(), 1);
1736 assert_eq!(projects[0].kind, ProjectType::Java);
1737 assert_eq!(projects[0].name.as_deref(), Some("my-kotlin-app"));
1738 }
1739
1740 #[test]
1743 fn test_detect_cpp_cmake_project() {
1744 let tmp = TempDir::new().unwrap();
1745 let base = tmp.path();
1746
1747 let project = base.join("cpp-cmake");
1748 create_file(
1749 &project.join("CMakeLists.txt"),
1750 "project(my-cpp-lib)\ncmake_minimum_required(VERSION 3.10)",
1751 );
1752 create_file(&project.join("build/CMakeCache.txt"), "cache");
1753
1754 let scanner = default_scanner(ProjectFilter::Cpp);
1755 let projects = scanner.scan_directory(base);
1756 assert_eq!(projects.len(), 1);
1757 assert_eq!(projects[0].kind, ProjectType::Cpp);
1758 assert_eq!(projects[0].name.as_deref(), Some("my-cpp-lib"));
1759 }
1760
1761 #[test]
1762 fn test_detect_cpp_makefile_project() {
1763 let tmp = TempDir::new().unwrap();
1764 let base = tmp.path();
1765
1766 let project = base.join("cpp-make");
1767 create_file(&project.join("Makefile"), "all:\n\tg++ -o main main.cpp");
1768 create_file(&project.join("build/main.o"), "object");
1769
1770 let scanner = default_scanner(ProjectFilter::Cpp);
1771 let projects = scanner.scan_directory(base);
1772 assert_eq!(projects.len(), 1);
1773 assert_eq!(projects[0].kind, ProjectType::Cpp);
1774 }
1775
1776 #[test]
1779 fn test_detect_swift_project() {
1780 let tmp = TempDir::new().unwrap();
1781 let base = tmp.path();
1782
1783 let project = base.join("swift-pkg");
1784 create_file(
1785 &project.join("Package.swift"),
1786 "let package = Package(\n name: \"my-swift-lib\",\n targets: []\n)",
1787 );
1788 create_file(&project.join(".build/debug/my-swift-lib"), "binary");
1789
1790 let scanner = default_scanner(ProjectFilter::Swift);
1791 let projects = scanner.scan_directory(base);
1792 assert_eq!(projects.len(), 1);
1793 assert_eq!(projects[0].kind, ProjectType::Swift);
1794 assert_eq!(projects[0].name.as_deref(), Some("my-swift-lib"));
1795 }
1796
1797 #[test]
1800 fn test_detect_dotnet_project() {
1801 let tmp = TempDir::new().unwrap();
1802 let base = tmp.path();
1803
1804 let project = base.join("dotnet-app");
1805 create_file(
1806 &project.join("MyApp.csproj"),
1807 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
1808 );
1809 create_file(&project.join("bin/Debug/net8.0/MyApp.dll"), "assembly");
1810 create_file(&project.join("obj/Debug/net8.0/MyApp.dll"), "intermediate");
1811
1812 let scanner = default_scanner(ProjectFilter::DotNet);
1813 let projects = scanner.scan_directory(base);
1814 assert_eq!(projects.len(), 1);
1815 assert_eq!(projects[0].kind, ProjectType::DotNet);
1816 assert_eq!(projects[0].name.as_deref(), Some("MyApp"));
1817 }
1818
1819 #[test]
1820 fn test_detect_dotnet_project_obj_only() {
1821 let tmp = TempDir::new().unwrap();
1822 let base = tmp.path();
1823
1824 let project = base.join("dotnet-obj-only");
1825 create_file(
1826 &project.join("Lib.csproj"),
1827 "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>",
1828 );
1829 create_file(&project.join("obj/Debug/net8.0/Lib.dll"), "intermediate");
1830
1831 let scanner = default_scanner(ProjectFilter::DotNet);
1832 let projects = scanner.scan_directory(base);
1833 assert_eq!(projects.len(), 1);
1834 assert_eq!(projects[0].kind, ProjectType::DotNet);
1835 assert_eq!(projects[0].name.as_deref(), Some("Lib"));
1836 }
1837
1838 #[test]
1841 fn test_obj_directory_is_excluded() {
1842 assert!(Scanner::is_excluded_directory(Path::new("/some/obj")));
1843 }
1844
1845 #[test]
1848 fn test_calculate_build_dir_size_empty() {
1849 let tmp = TempDir::new().unwrap();
1850 let empty_dir = tmp.path().join("empty");
1851 fs::create_dir_all(&empty_dir).unwrap();
1852
1853 assert_eq!(Scanner::calculate_build_dir_size(&empty_dir), 0);
1854 }
1855
1856 #[test]
1857 fn test_calculate_build_dir_size_nonexistent() {
1858 assert_eq!(
1859 Scanner::calculate_build_dir_size(Path::new("/nonexistent/path")),
1860 0
1861 );
1862 }
1863
1864 #[test]
1865 fn test_calculate_build_dir_size_with_nested_files() {
1866 let tmp = TempDir::new().unwrap();
1867 let dir = tmp.path().join("nested");
1868
1869 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);
1874 assert_eq!(size, 12);
1875 }
1876
1877 #[test]
1880 fn test_scanner_quiet_mode() {
1881 let tmp = TempDir::new().unwrap();
1882 let base = tmp.path();
1883
1884 let project = base.join("quiet-project");
1885 create_file(
1886 &project.join("Cargo.toml"),
1887 "[package]\nname = \"quiet\"\nversion = \"0.1.0\"",
1888 );
1889 create_file(&project.join("target/dummy"), "content");
1890
1891 let scanner = default_scanner(ProjectFilter::Rust).with_quiet(true);
1892 let projects = scanner.scan_directory(base);
1893 assert_eq!(projects.len(), 1);
1894 }
1895}