1use anyhow::Result;
5use rayon::prelude::*;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use walkdir::{DirEntry, WalkDir};
10
11use crate::formatters::Formatter;
12use crate::scanner::{FileNode, TreeStats};
13
14#[derive(Debug, Clone)]
15pub struct ProjectInfo {
16 pub path: PathBuf,
17 pub name: String,
18 pub project_type: ProjectType,
19 pub summary: String, pub size: u64, pub file_count: usize,
22 pub created: u64, pub last_modified: u64,
24 pub last_accessed: u64, pub dependencies: Vec<String>, pub hex_signature: String, pub git_info: Option<GitInfo>, }
29
30#[derive(Debug, Clone)]
31pub struct GitInfo {
32 pub branch: String,
33 pub commit: String, pub commit_message: String, pub is_dirty: bool, pub ahead: usize, pub behind: usize, pub last_commit_date: u64, }
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum ProjectType {
43 Rust, NodeJs, Python, Go, Java, DotNet, Ruby, Docker, Kubernetes, Monorepo, Unknown,
54}
55
56pub struct ProjectsFormatter {
57 max_depth: Option<usize>,
58 min_project_size: u64, show_dependencies: bool,
60 condensed_mode: bool, }
62
63impl Default for ProjectsFormatter {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl ProjectsFormatter {
70 pub fn new() -> Self {
71 Self {
72 max_depth: Some(8), min_project_size: 1024, show_dependencies: true,
75 condensed_mode: true,
76 }
77 }
78
79 pub fn scan_projects(&self, root: &Path) -> Result<Vec<ProjectInfo>> {
81 let projects = Arc::new(Mutex::new(Vec::new()));
82 let seen_paths = Arc::new(Mutex::new(std::collections::HashSet::new()));
83 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
84
85 let walker = WalkDir::new(&root)
87 .max_depth(self.max_depth.unwrap_or(10))
88 .follow_links(false)
89 .into_iter()
90 .filter_map(|e| e.ok())
91 .collect::<Vec<_>>();
92
93 walker.par_iter().for_each(|entry| {
94 if self.is_readme(entry) {
96 let project_path = entry.path().parent().unwrap();
97 if seen_paths
98 .lock()
99 .unwrap()
100 .insert(project_path.to_path_buf())
101 {
102 if let Ok(project) = self.analyze_project(project_path) {
103 if project.size >= self.min_project_size {
104 projects.lock().unwrap().push(project);
105 }
106 }
107 }
108 }
109
110 if entry.file_type().is_file() {
112 let filename = entry.file_name().to_str().unwrap_or("");
113 let is_project_marker = matches!(
114 filename,
115 "Cargo.toml"
116 | "package.json"
117 | "go.mod"
118 | "pom.xml"
119 | "build.gradle"
120 | "Gemfile"
121 | "requirements.txt"
122 | "pyproject.toml"
123 | "Dockerfile"
124 | ".gitmodules"
125 | "setup.py"
126 | "Makefile"
127 | "CMakeLists.txt"
128 | "configure.ac"
129 | "Rakefile"
130 | "build.xml"
131 | "build.gradle.kts"
132 | "build.sbt"
133 | "build.sh"
134 | "build.ps1"
135 | "build.bat"
136 | "CMakeCache.txt"
137 | "CMakeLists.txt.user"
138 | "CMakeLists.txt.in"
139 | "CMakeLists.txt.cmake"
140 | ".gitignore"
141 | ".dockerignore"
142 | "docker-compose.yml"
143 | "kustomization.yaml"
144 | "config.yaml"
145 | "CLAUDE.md"
146
147
148
149 );
150
151 if is_project_marker {
152 let project_path = entry.path().parent().unwrap();
153
154 if seen_paths
156 .lock()
157 .unwrap()
158 .insert(project_path.to_path_buf())
159 {
160 let readme_path = if !project_path.join("README.md").exists() {
162 project_path
164 .parent()
165 .and_then(|p| {
166 if p.join("README.md").exists() {
167 Some(p)
168 } else {
169 None
170 }
171 })
172 .unwrap_or(project_path)
173 } else {
174 project_path
175 };
176
177 if let Ok(project) =
178 self.analyze_project_with_readme_path(project_path, readme_path)
179 {
180 if project.size >= self.min_project_size {
181 projects.lock().unwrap().push(project);
182 }
183 }
184 }
185 }
186 }
187 });
188
189 let mut result = projects.lock().unwrap().clone();
190 result.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); Ok(result)
192 }
193
194 fn is_readme(&self, entry: &DirEntry) -> bool {
195 entry.file_type().is_file()
196 && entry
197 .file_name()
198 .to_str()
199 .map(|s| s.eq_ignore_ascii_case("README.md"))
200 .unwrap_or(false)
201 }
202
203 fn analyze_project(&self, project_path: &Path) -> Result<ProjectInfo> {
205 self.analyze_project_with_readme_path(project_path, project_path)
206 }
207
208 fn analyze_project_with_readme_path(
210 &self,
211 project_path: &Path,
212 readme_path: &Path,
213 ) -> Result<ProjectInfo> {
214 let mut name = project_path
215 .file_name()
216 .and_then(|n| n.to_str())
217 .unwrap_or("unknown")
218 .to_string();
219
220 let is_submodule = project_path.join(".git").exists()
222 && project_path
223 .parent()
224 .map(|p| p.join(".gitmodules").exists())
225 .unwrap_or(false);
226
227 if is_submodule {
228 name = format!("π{}", name); }
230
231 let mut project_type = self.detect_project_type(project_path);
232
233 if project_type == ProjectType::Unknown && project_path != readme_path {
235 project_type = self.detect_project_type(readme_path);
236 }
237
238 let summary = self.extract_summary(readme_path)?;
239 let (size, file_count, created, last_modified, last_accessed) =
240 self.get_project_stats(project_path)?;
241 let mut dependencies = self.detect_dependencies(project_path, &project_type);
242
243 if let Ok(submodules) = self.detect_git_submodules(project_path) {
245 for submodule in submodules {
246 dependencies.push(format!("π{}", submodule));
247 }
248 }
249
250 let git_info = self.get_git_info(project_path);
252
253 let hex_signature = self.generate_hex_signature(&name, &project_type, size);
255
256 Ok(ProjectInfo {
257 path: project_path.to_path_buf(),
258 name,
259 project_type,
260 summary,
261 size,
262 file_count,
263 created,
264 last_modified,
265 last_accessed,
266 dependencies,
267 hex_signature,
268 git_info,
269 })
270 }
271
272 fn detect_git_submodules(&self, path: &Path) -> Result<Vec<String>> {
274 let gitmodules_path = path.join(".gitmodules");
275 let mut submodules = Vec::new();
276
277 if gitmodules_path.exists() {
278 let content = fs::read_to_string(&gitmodules_path)?;
279 for line in content.lines() {
280 if line.trim().starts_with("path = ") {
281 if let Some(path) = line.split('=').nth(1) {
282 submodules.push(path.trim().to_string());
283 }
284 }
285 }
286 }
287
288 Ok(submodules)
289 }
290
291 fn detect_project_type(&self, path: &Path) -> ProjectType {
293 let markers = vec![
294 ("Cargo.toml", ProjectType::Rust),
295 ("package.json", ProjectType::NodeJs),
296 ("requirements.txt", ProjectType::Python),
297 ("pyproject.toml", ProjectType::Python),
298 ("setup.py", ProjectType::Python),
299 ("go.mod", ProjectType::Go),
300 ("pom.xml", ProjectType::Java),
301 ("build.gradle", ProjectType::Java),
302 ("Gemfile", ProjectType::Ruby),
303 ("Dockerfile", ProjectType::Docker),
304 ];
305
306 let mut detected_types = Vec::new();
307
308 for (marker, proj_type) in markers {
309 if path.join(marker).exists() {
310 detected_types.push(proj_type);
311 }
312 }
313
314 if let Ok(entries) = fs::read_dir(path) {
316 for entry in entries.flatten() {
317 if let Some(ext) = entry.path().extension() {
318 if ext == "csproj" || ext == "sln" {
319 detected_types.push(ProjectType::DotNet);
320 break;
321 }
322 }
323 }
324 }
325
326 match detected_types.len() {
327 0 => ProjectType::Unknown,
328 1 => detected_types[0].clone(),
329 _ => ProjectType::Monorepo,
330 }
331 }
332
333 fn extract_summary(&self, project_path: &Path) -> Result<String> {
335 let readme_path = project_path.join("README.md");
336 if !readme_path.exists() {
337 return Ok(String::new());
338 }
339
340 let content = fs::read_to_string(&readme_path)?;
341
342 let summary = self.extract_description(&content);
344
345 if self.condensed_mode {
346 Ok(self.condense_text(&summary))
347 } else {
348 Ok(summary)
349 }
350 }
351
352 fn extract_description(&self, content: &str) -> String {
354 let lines: Vec<&str> = content.lines().collect();
355 let mut description = String::new();
356 let mut found_header = false;
357 let mut in_code_block = false;
358 let mut consecutive_content_lines = 0;
359
360 for line in lines.iter().take(50) {
361 let trimmed = line.trim();
363
364 if trimmed.starts_with("```") {
366 in_code_block = !in_code_block;
367 continue;
368 }
369
370 if in_code_block {
371 continue;
372 }
373
374 if trimmed.is_empty() {
376 if consecutive_content_lines >= 2 {
378 break; }
380 continue;
381 }
382
383 if trimmed.starts_with('#') {
385 found_header = true;
386 consecutive_content_lines = 0;
387 continue;
388 }
389
390 if trimmed.starts_with(".count();
411 let word_count = trimmed.split_whitespace().count();
412 if word_count > 0 && link_count > 0 {
413 let link_ratio = link_count as f32 / word_count as f32;
414 if link_ratio > 0.4 {
415 continue;
417 }
418 }
419
420 if trimmed.len() < 15 && !found_header {
422 continue;
423 }
424
425 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
427 continue;
428 }
429
430 if !description.is_empty() {
432 description.push(' ');
433 }
434
435 let cleaned = trimmed
437 .replace("**", "") .replace("__", "") .replace("~~", "") .replace("`", ""); description.push_str(&cleaned);
443 consecutive_content_lines += 1;
444
445 if description.len() > 200 || consecutive_content_lines >= 3 {
447 break;
448 }
449 }
450
451 let mut final_desc = description.trim().to_string();
453
454 if final_desc.contains(':') {
456 final_desc = final_desc
457 .split_whitespace()
458 .filter(|word| !word.starts_with(':') || !word.ends_with(':'))
459 .collect::<Vec<_>>()
460 .join(" ");
461 }
462
463 if final_desc.len() > 250 {
465 let mut truncated = String::new();
466 for (char_count, ch) in final_desc.chars().enumerate() {
467 if char_count >= 247 {
468 break;
469 }
470 truncated.push(ch);
471 }
472
473 format!("{}...", truncated)
474 } else {
475 final_desc
476 }
477 }
478
479 fn condense_text(&self, text: &str) -> String {
481 let stopwords = vec![
483 "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with",
484 "by", "from", "up", "about", "into", "through", "during", "is", "are", "was", "were",
485 "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would",
486 "should", "could", "may", "might", "this", "that", "these", "those", "it", "its",
487 "which", "what",
488 ];
489
490 let words: Vec<&str> = text.split_whitespace().collect();
491 let mut condensed = Vec::new();
492
493 for word in words {
494 let lower = word.to_lowercase();
495
496 if stopwords.contains(&lower.as_str()) {
498 continue;
499 }
500
501 let condensed_word = if word.len() > 3 {
503 word.chars()
504 .filter(|c| !"aeiouAEIOU".contains(*c))
505 .collect::<String>()
506 } else {
507 word.to_string()
508 };
509
510 if !condensed_word.is_empty() {
511 condensed.push(condensed_word);
512 }
513 }
514
515 condensed.join(".")
517 }
518
519 fn get_project_stats(&self, path: &Path) -> Result<(u64, usize, u64, u64, u64)> {
521 let mut total_size = 0u64;
522 let mut file_count = 0usize;
523 let mut last_modified = 0u64;
524 let mut created = u64::MAX;
525 let mut last_accessed = 0u64;
526
527 let ignored_dirs = [
529 "node_modules",
530 "target",
531 ".git",
532 "dist",
533 "build",
534 "__pycache__",
535 ];
536
537 if let Ok(dir_metadata) = fs::metadata(path) {
539 #[cfg(unix)]
540 {
541 use std::os::unix::fs::MetadataExt;
542 created = dir_metadata.ctime() as u64;
543 last_accessed = dir_metadata.atime() as u64;
544 }
545 #[cfg(not(unix))]
546 {
547 if let Ok(created_time) = dir_metadata.created() {
548 if let Ok(duration) = created_time.duration_since(std::time::UNIX_EPOCH) {
549 created = duration.as_secs();
550 }
551 }
552 if let Ok(accessed_time) = dir_metadata.accessed() {
553 if let Ok(duration) = accessed_time.duration_since(std::time::UNIX_EPOCH) {
554 last_accessed = duration.as_secs();
555 }
556 }
557 }
558 }
559
560 for entry in WalkDir::new(path)
561 .max_depth(3) .into_iter()
563 .filter_entry(|e| {
564 !e.file_name()
565 .to_str()
566 .map(|s| ignored_dirs.contains(&s))
567 .unwrap_or(false)
568 })
569 .filter_map(|e| e.ok())
570 {
571 if entry.file_type().is_file() {
572 file_count += 1;
573 if let Ok(metadata) = entry.metadata() {
574 total_size += metadata.len();
575 if let Ok(modified) = metadata.modified() {
576 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
577 last_modified = last_modified.max(duration.as_secs());
578 }
579 }
580 }
581 }
582 }
583
584 Ok((
585 total_size,
586 file_count,
587 created,
588 last_modified,
589 last_accessed,
590 ))
591 }
592
593 fn get_git_info(&self, path: &Path) -> Option<GitInfo> {
595 use std::process::Command;
596
597 if !path.join(".git").exists() {
599 return None;
600 }
601
602 let branch = Command::new("git")
604 .arg("branch")
605 .arg("--show-current")
606 .current_dir(path)
607 .output()
608 .ok()
609 .and_then(|output| String::from_utf8(output.stdout).ok())
610 .map(|s| s.trim().to_string())
611 .unwrap_or_else(|| "unknown".to_string());
612
613 let commit = Command::new("git")
615 .arg("rev-parse")
616 .arg("--short")
617 .arg("HEAD")
618 .current_dir(path)
619 .output()
620 .ok()
621 .and_then(|output| String::from_utf8(output.stdout).ok())
622 .map(|s| s.trim().to_string())
623 .unwrap_or_else(|| "unknown".to_string());
624
625 let commit_message = Command::new("git")
627 .arg("log")
628 .arg("-1")
629 .arg("--pretty=%s")
630 .current_dir(path)
631 .output()
632 .ok()
633 .and_then(|output| String::from_utf8(output.stdout).ok())
634 .map(|s| s.trim().to_string())
635 .unwrap_or_default();
636
637 let is_dirty = Command::new("git")
639 .arg("status")
640 .arg("--porcelain")
641 .current_dir(path)
642 .output()
643 .ok()
644 .map(|output| !output.stdout.is_empty())
645 .unwrap_or(false);
646
647 let (ahead, behind) = Command::new("git")
649 .arg("rev-list")
650 .arg("--left-right")
651 .arg("--count")
652 .arg("HEAD...@{upstream}")
653 .current_dir(path)
654 .output()
655 .ok()
656 .and_then(|output| String::from_utf8(output.stdout).ok())
657 .and_then(|s| {
658 let parts: Vec<&str> = s.trim().split('\t').collect();
659 if parts.len() == 2 {
660 Some((parts[0].parse().unwrap_or(0), parts[1].parse().unwrap_or(0)))
661 } else {
662 None
663 }
664 })
665 .unwrap_or((0, 0));
666
667 let last_commit_date = Command::new("git")
669 .arg("log")
670 .arg("-1")
671 .arg("--pretty=%ct")
672 .current_dir(path)
673 .output()
674 .ok()
675 .and_then(|output| String::from_utf8(output.stdout).ok())
676 .and_then(|s| s.trim().parse::<u64>().ok())
677 .unwrap_or(0);
678
679 Some(GitInfo {
680 branch,
681 commit,
682 commit_message,
683 is_dirty,
684 ahead,
685 behind,
686 last_commit_date,
687 })
688 }
689
690 fn detect_dependencies(&self, path: &Path, project_type: &ProjectType) -> Vec<String> {
692 let mut deps = Vec::new();
693
694 match project_type {
695 ProjectType::Rust => {
696 if let Ok(content) = fs::read_to_string(path.join("Cargo.toml")) {
697 for line in content.lines() {
699 if line.contains("=") && !line.starts_with('[') {
700 if let Some(dep) = line.split('=').next() {
701 let dep = dep.trim().replace('"', "");
702 if !dep.is_empty() && deps.len() < 5 {
703 deps.push(dep);
704 }
705 }
706 }
707 }
708 }
709 }
710 ProjectType::NodeJs => {
711 if let Ok(content) = fs::read_to_string(path.join("package.json")) {
712 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
713 if let Some(dependencies) = json["dependencies"].as_object() {
714 for (key, _) in dependencies.iter().take(5) {
715 deps.push(key.to_string());
716 }
717 }
718 }
719 }
720 }
721 _ => {}
722 }
723
724 deps
725 }
726
727 fn generate_hex_signature(&self, name: &str, project_type: &ProjectType, size: u64) -> String {
729 let type_byte = match project_type {
730 ProjectType::Rust => 0x52, ProjectType::NodeJs => 0x4E, ProjectType::Python => 0x50, ProjectType::Go => 0x47, ProjectType::Java => 0x4A, ProjectType::DotNet => 0x44, ProjectType::Ruby => 0x52, ProjectType::Docker => 0x43, ProjectType::Kubernetes => 0x4B, ProjectType::Monorepo => 0x4D, ProjectType::Unknown => 0x55, };
743
744 let name_hash = name.bytes().fold(0u16, |acc, b| acc.wrapping_add(b as u16));
746
747 let size_cat = match size {
749 0..=1024 => 0x1,
750 1025..=10240 => 0x2,
751 10241..=102400 => 0x4,
752 102401..=1048576 => 0x8,
753 _ => 0xF,
754 };
755
756 format!("{:02X}{:04X}{:X}", type_byte, name_hash, size_cat)
757 }
758}
759
760impl Formatter for ProjectsFormatter {
761 fn format(
762 &self,
763 writer: &mut dyn std::io::Write,
764 _nodes: &[FileNode],
765 _stats: &TreeStats,
766 root_path: &Path,
767 ) -> Result<()> {
768 let projects = self.scan_projects(root_path)?;
769
770 let mut output = String::new();
771
772 output.push_str(&format!(
774 "π Project Discovery: {} projects found\n",
775 projects.len()
776 ));
777 output.push_str("β".repeat(60).as_str());
778 output.push('\n');
779
780 for project in projects {
781 let type_icon = match project.project_type {
783 ProjectType::Rust => "π¦",
784 ProjectType::NodeJs => "π¦",
785 ProjectType::Python => "π",
786 ProjectType::Go => "πΉ",
787 ProjectType::Java => "β",
788 ProjectType::DotNet => "π·",
789 ProjectType::Ruby => "π",
790 ProjectType::Docker => "π³",
791 ProjectType::Kubernetes => "βΈοΈ",
792 ProjectType::Monorepo => "π",
793 ProjectType::Unknown => "π",
794 };
795
796 let project_name = if let Some(ref git) = project.git_info {
798 if git.is_dirty {
799 format!("{} *", project.name) } else {
801 project.name.clone()
802 }
803 } else {
804 project.name.clone()
805 };
806
807 output.push_str(&format!(
808 "[{}] {} {}\n",
809 project.hex_signature, type_icon, project_name
810 ));
811
812 if !project.summary.is_empty() {
814 output.push_str(&format!(" ββ {}\n", project.summary));
815 }
816
817 let display_path = if let Ok(cwd) = std::env::current_dir() {
819 project
820 .path
821 .strip_prefix(&cwd)
822 .unwrap_or(&project.path)
823 .display()
824 .to_string()
825 } else {
826 project.path.display().to_string()
827 };
828 output.push_str(&format!(" π {}\n", display_path));
829
830 if let Some(ref git) = project.git_info {
832 let git_status = if git.ahead > 0 && git.behind > 0 {
833 format!("β{}β{}", git.ahead, git.behind)
834 } else if git.ahead > 0 {
835 format!("β{}", git.ahead)
836 } else if git.behind > 0 {
837 format!("β{}", git.behind)
838 } else {
839 String::new()
840 };
841
842 output.push_str(&format!(
843 " π {} @ {} {}\n",
844 git.branch, git.commit, git_status
845 ));
846
847 if !git.commit_message.is_empty() {
848 let msg = if git.commit_message.chars().count() > 50 {
849 let mut truncated = String::new();
851 for (char_count, ch) in git.commit_message.chars().enumerate() {
852 if char_count >= 47 {
853 break;
854 }
855 truncated.push(ch);
856 }
857 format!("{}...", truncated)
858 } else {
859 git.commit_message.clone()
860 };
861 output.push_str(&format!(" \"{}\"\n", msg));
862 }
863 }
864
865 let created_str = format_timestamp(project.created);
867 let modified_str = format_timestamp(project.last_modified);
868 output.push_str(&format!(
869 " π
Created: {}, Modified: {}\n",
870 created_str, modified_str
871 ));
872
873 output.push_str(&format!(
875 " π {} files, {}\n",
876 project.file_count,
877 format_size(project.size)
878 ));
879
880 if self.show_dependencies && !project.dependencies.is_empty() {
882 let deps_str = if project.dependencies.len() > 3 {
883 format!(
884 "{}, +{} more",
885 project.dependencies[..3].join(", "),
886 project.dependencies.len() - 3
887 )
888 } else {
889 project.dependencies.join(", ")
890 };
891 output.push_str(&format!(" π¦ {}\n", deps_str));
892 }
893
894 output.push('\n');
895 }
896
897 writer.write_all(output.as_bytes())?;
898 Ok(())
899 }
900}
901
902fn format_size(size: u64) -> String {
903 const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
904 let mut size = size as f64;
905 let mut unit_index = 0;
906
907 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
908 size /= 1024.0;
909 unit_index += 1;
910 }
911
912 if unit_index == 0 {
913 format!("{} {}", size as u64, UNITS[unit_index])
914 } else {
915 format!("{:.1} {}", size, UNITS[unit_index])
916 }
917}
918
919fn format_timestamp(timestamp: u64) -> String {
920 use chrono::{Local, TimeZone};
921
922 if timestamp == 0 || timestamp == u64::MAX {
923 return "unknown".to_string();
924 }
925
926 let dt = Local.timestamp_opt(timestamp as i64, 0).single();
927 if let Some(dt) = dt {
928 let now = Local::now();
929 let duration = now.signed_duration_since(dt);
930
931 if duration.num_days() == 0 {
932 return "today".to_string();
933 } else if duration.num_days() == 1 {
934 return "yesterday".to_string();
935 } else if duration.num_days() < 7 {
936 return format!("{} days ago", duration.num_days());
937 } else if duration.num_days() < 30 {
938 return format!("{} weeks ago", duration.num_weeks());
939 } else if duration.num_days() < 365 {
940 return format!("{} months ago", duration.num_days() / 30);
941 } else {
942 return format!("{} years ago", duration.num_days() / 365);
943 }
944 }
945
946 "unknown".to_string()
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn test_condense_text() {
955 let formatter = ProjectsFormatter::new();
956
957 let text = "This is a test project for machine learning and data analysis";
958 let condensed = formatter.condense_text(text);
959
960 assert!(!condensed.contains("This"));
962 assert!(!condensed.contains("is"));
963 assert!(!condensed.contains("a"));
964 assert!(condensed.contains("tst")); assert!(condensed.contains("prjct")); }
967
968 #[test]
969 fn test_project_type_detection() {
970 let formatter = ProjectsFormatter::new();
971 let temp_dir = tempfile::tempdir().unwrap();
972
973 std::fs::write(temp_dir.path().join("Cargo.toml"), "").unwrap();
975 assert_eq!(
976 formatter.detect_project_type(temp_dir.path()),
977 ProjectType::Rust
978 );
979
980 std::fs::write(temp_dir.path().join("package.json"), "{}").unwrap();
982 assert_eq!(
983 formatter.detect_project_type(temp_dir.path()),
984 ProjectType::Monorepo
985 );
986 }
987
988 }