1use super::Formatter;
5use crate::content_detector::{ContentDetector, DirectoryType, Language};
6use crate::scanner::{FileNode, TreeStats};
7use anyhow::Result;
8use colored::Colorize;
9use std::collections::HashMap;
10use std::io::Write;
11use std::path::Path;
12
13pub struct SummaryFormatter {
14 use_color: bool,
15 max_examples: usize,
16}
17
18impl SummaryFormatter {
19 pub fn new(use_color: bool) -> Self {
20 Self {
21 use_color,
22 max_examples: 5,
23 }
24 }
25
26 fn colorize(&self, text: &str, color: &str) -> String {
27 if self.use_color {
28 match color {
29 "blue" => text.blue().to_string(),
30 "green" => text.green().to_string(),
31 "yellow" => text.yellow().to_string(),
32 "red" => text.red().to_string(),
33 "cyan" => text.cyan().to_string(),
34 "magenta" => text.magenta().to_string(),
35 "bold" => text.bold().to_string(),
36 _ => text.to_string(),
37 }
38 } else {
39 text.to_string()
40 }
41 }
42
43 fn is_high_level_directory(&self, nodes: &[FileNode], _stats: &TreeStats) -> bool {
44 let mut root_dir_count = 0;
51 let mut seen_paths = std::collections::HashSet::new();
52
53 for node in nodes {
54 if node.is_dir {
55 if let Some(first) = nodes.first() {
57 if let Some(base) = first.path.parent() {
58 if let Ok(relative) = node.path.strip_prefix(base) {
59 if relative.components().count() == 1
60 && seen_paths.insert(node.path.clone())
61 {
62 root_dir_count += 1;
63 }
64 }
65 }
66 }
67 }
68 }
69
70 if root_dir_count > 20 {
71 return true;
72 }
73
74 let home_folders = [
76 "Documents",
77 "Downloads",
78 "Desktop",
79 "Pictures",
80 "Music",
81 "Videos",
82 ];
83 let mut home_folder_count = 0;
84
85 for node in nodes {
86 if node.is_dir {
87 if let Some(name) = node.path.file_name().and_then(|f| f.to_str()) {
88 if home_folders.contains(&name) {
89 home_folder_count += 1;
90 }
91 }
92 }
93 }
94
95 if home_folder_count >= 3 {
96 return true;
97 }
98
99 let project_indicators = [
101 "Cargo.toml",
102 "package.json",
103 "pom.xml",
104 ".git",
105 "requirements.txt",
106 ];
107 let mut project_dirs = std::collections::HashSet::new();
108
109 for node in nodes {
110 if let Some(name) = node.path.file_name().and_then(|f| f.to_str()) {
111 if project_indicators.contains(&name) {
112 if let Some(parent) = node.path.parent() {
113 project_dirs.insert(parent);
114 }
115 }
116 }
117 }
118
119 project_dirs.len() > 5
120 }
121
122 fn format_high_level_summary(
123 &self,
124 writer: &mut dyn Write,
125 nodes: &[FileNode],
126 stats: &TreeStats,
127 root_path: &Path,
128 ) -> Result<()> {
129 writeln!(writer, "{}", self.colorize("📊 Directory Overview", "bold"))?;
131 writeln!(writer, "{}", self.colorize("─".repeat(50).as_str(), "blue"))?;
132 writeln!(writer)?;
133
134 writeln!(
136 writer,
137 "📁 {}: {}",
138 self.colorize("Path", "cyan"),
139 root_path.display()
140 )?;
141 writeln!(
142 writer,
143 "📈 {}: {} files, {} directories, {}",
144 self.colorize("Total", "cyan"),
145 self.colorize(&stats.total_files.to_string(), "green"),
146 self.colorize(&stats.total_dirs.to_string(), "green"),
147 self.colorize(&format_size(stats.total_size), "green")
148 )?;
149 writeln!(writer)?;
150
151 let mut subdirs: HashMap<String, (usize, usize, u64)> = HashMap::new();
153 let mut actual_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
154
155 for node in nodes {
156 if let Ok(relative) = node.path.strip_prefix(root_path) {
157 let components: Vec<_> = relative.components().collect();
158 if let Some(first) = components.first() {
159 if let Some(name) = first.as_os_str().to_str() {
160 if node.is_dir && components.len() == 1 {
164 actual_dirs.insert(name.to_string());
165 }
166 if components.len() > 1 {
167 actual_dirs.insert(name.to_string());
168 }
169
170 let entry = subdirs.entry(name.to_string()).or_insert((0, 0, 0));
171 if node.is_dir {
172 entry.1 += 1;
173 } else {
174 entry.0 += 1;
175 entry.2 += node.size;
176 }
177 }
178 }
179 }
180 }
181
182 let mut sorted_dirs: Vec<_> = subdirs
184 .into_iter()
185 .filter(|(name, _)| actual_dirs.contains(name))
186 .collect();
187 sorted_dirs.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
188
189 writeln!(
191 writer,
192 "{}",
193 self.colorize("Top Directories by Size:", "yellow")
194 )?;
195 writeln!(writer)?;
196
197 for (name, (files, dirs, size)) in sorted_dirs.iter().take(10) {
198 let size_str = format_size(*size);
199 let size_bar = self.make_size_bar(*size, stats.total_size);
200
201 writeln!(
202 writer,
203 " {} {} {}",
204 self.colorize(&format!("{:20}", name), "cyan"),
205 self.colorize(&format!("{:>10}", size_str), "green"),
206 size_bar
207 )?;
208 writeln!(
209 writer,
210 " {:20} {} files, {} dirs",
211 "",
212 self.colorize(&files.to_string(), "blue"),
213 self.colorize(&dirs.to_string(), "blue")
214 )?;
215 writeln!(writer)?;
216 }
217
218 let projects = self.detect_projects(nodes, root_path);
220 if !projects.is_empty() {
221 writeln!(writer, "{}", self.colorize("Detected Projects:", "yellow"))?;
222 writeln!(writer)?;
223
224 for (path, project_type) in projects.iter().take(10) {
225 writeln!(
226 writer,
227 " • {} {}",
228 self.colorize(path, "cyan"),
229 self.colorize(&format!("({})", project_type), "magenta")
230 )?;
231 }
232 writeln!(writer)?;
233 }
234
235 writeln!(writer, "{}", self.colorize("─".repeat(50).as_str(), "blue"))?;
237 writeln!(
238 writer,
239 "💡 {}: Use {} to analyze a specific directory",
240 self.colorize("Tip", "yellow"),
241 self.colorize("st <directory>", "cyan")
242 )?;
243
244 Ok(())
245 }
246
247 fn make_size_bar(&self, size: u64, total: u64) -> String {
248 if total == 0 {
249 return String::new();
250 }
251
252 let percentage = (size as f64 / total as f64) * 100.0;
253 let bar_width = 20;
254 let filled = ((percentage / 100.0) * bar_width as f64) as usize;
255
256 let bar = "█".repeat(filled) + &"░".repeat(bar_width - filled);
257
258 format!("{} {:5.1}%", self.colorize(&bar, "blue"), percentage)
259 }
260
261 fn detect_projects(&self, nodes: &[FileNode], root_path: &Path) -> Vec<(String, String)> {
262 let mut projects = Vec::new();
263 let mut checked_dirs = std::collections::HashSet::new();
264
265 for node in nodes {
266 if let Some(parent) = node.path.parent() {
267 if checked_dirs.contains(parent) {
268 continue;
269 }
270 checked_dirs.insert(parent);
271
272 let name = node.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
273
274 let project_type = match name {
275 "Cargo.toml" => Some("Rust"),
276 "package.json" => Some("Node.js"),
277 "requirements.txt" | "setup.py" | "pyproject.toml" => Some("Python"),
278 "go.mod" => Some("Go"),
279 "pom.xml" => Some("Java/Maven"),
280 "build.gradle" | "build.gradle.kts" => Some("Java/Gradle"),
281 "Gemfile" => Some("Ruby"),
282 ".git" if node.is_dir => Some("Git Repository"),
283 _ => None,
284 };
285
286 if let Some(ptype) = project_type {
287 if let Ok(relative) = parent.strip_prefix(root_path) {
288 projects.push((relative.display().to_string(), ptype.to_string()));
289 }
290 }
291 }
292 }
293
294 projects.sort();
295 projects.dedup();
296 projects
297 }
298}
299
300impl Formatter for SummaryFormatter {
301 fn format(
302 &self,
303 writer: &mut dyn Write,
304 nodes: &[FileNode],
305 stats: &TreeStats,
306 root_path: &Path,
307 ) -> Result<()> {
308 let is_high_level = self.is_high_level_directory(nodes, stats);
310
311 if is_high_level {
312 return self.format_high_level_summary(writer, nodes, stats, root_path);
313 }
314
315 let dir_type = ContentDetector::detect(nodes, root_path);
317
318 writeln!(writer, "{}", self.colorize("📊 Directory Summary", "bold"))?;
320 writeln!(writer, "{}", self.colorize("─".repeat(50).as_str(), "blue"))?;
321 writeln!(writer)?;
322
323 writeln!(
325 writer,
326 "📁 {}: {}",
327 self.colorize("Path", "cyan"),
328 root_path.display()
329 )?;
330 writeln!(
331 writer,
332 "📈 {}: {} files, {} directories, {}",
333 self.colorize("Stats", "cyan"),
334 self.colorize(&stats.total_files.to_string(), "green"),
335 self.colorize(&stats.total_dirs.to_string(), "green"),
336 self.colorize(&format_size(stats.total_size), "green")
337 )?;
338 writeln!(writer)?;
339
340 match &dir_type {
342 DirectoryType::CodeProject {
343 language,
344 framework,
345 has_tests,
346 has_docs,
347 } => {
348 writeln!(
349 writer,
350 "🔧 {}: {} Project",
351 self.colorize("Type", "yellow"),
352 self.colorize(&format!("{:?}", language), "magenta")
353 )?;
354
355 if let Some(fw) = framework {
356 writeln!(
357 writer,
358 "🚀 {}: {:?}",
359 self.colorize("Framework", "yellow"),
360 fw
361 )?;
362 }
363
364 writeln!(
365 writer,
366 "✅ Tests: {} | 📚 Docs: {}",
367 if *has_tests {
368 self.colorize("Yes", "green")
369 } else {
370 self.colorize("No", "red")
371 },
372 if *has_docs {
373 self.colorize("Yes", "green")
374 } else {
375 self.colorize("No", "red")
376 }
377 )?;
378
379 writeln!(writer)?;
381 writeln!(writer, "{}", self.colorize("Key Files:", "cyan"))?;
382
383 let important_files = find_important_code_files(nodes, language);
385 for file in important_files.iter().take(self.max_examples) {
386 writeln!(writer, " • {}", file)?;
387 }
388
389 writeln!(writer)?;
391 writeln!(writer, "{}", self.colorize("Quick Commands:", "cyan"))?;
392 match language {
393 Language::Rust => {
394 writeln!(writer, " • cargo build --release")?;
395 writeln!(writer, " • cargo test")?;
396 writeln!(writer, " • cargo run")?;
397 }
398 Language::Python => {
399 writeln!(writer, " • python -m venv venv")?;
400 writeln!(writer, " • pip install -r requirements.txt")?;
401 writeln!(writer, " • python main.py")?;
402 }
403 Language::JavaScript | Language::TypeScript => {
404 writeln!(writer, " • npm install")?;
405 writeln!(writer, " • npm test")?;
406 writeln!(writer, " • npm start")?;
407 }
408 _ => {
409 writeln!(writer, " • Check README for build instructions")?;
410 }
411 }
412 }
413
414 DirectoryType::PhotoCollection {
415 image_count,
416 date_range,
417 cameras,
418 } => {
419 writeln!(
420 writer,
421 "📷 {}: Photo Collection",
422 self.colorize("Type", "yellow")
423 )?;
424 writeln!(
425 writer,
426 "🖼️ {}: {} images",
427 self.colorize("Count", "cyan"),
428 self.colorize(&image_count.to_string(), "green")
429 )?;
430
431 if let Some((start, end)) = date_range {
432 writeln!(
433 writer,
434 "📅 {}: {} to {}",
435 self.colorize("Date Range", "cyan"),
436 start,
437 end
438 )?;
439 }
440
441 if !cameras.is_empty() {
442 writeln!(
443 writer,
444 "📸 {}: {}",
445 self.colorize("Cameras", "cyan"),
446 cameras.join(", ")
447 )?;
448 }
449
450 let mut type_counts: HashMap<&str, usize> = HashMap::new();
452 for node in nodes {
453 if !node.is_dir {
454 if let Some(ext) = node.path.extension().and_then(|e| e.to_str()) {
455 *type_counts.entry(ext).or_insert(0) += 1;
456 }
457 }
458 }
459
460 writeln!(writer)?;
461 writeln!(writer, "{}", self.colorize("File Types:", "cyan"))?;
462 for (ext, count) in type_counts.iter() {
463 writeln!(writer, " • .{}: {}", ext, count)?;
464 }
465 }
466
467 DirectoryType::DocumentArchive {
468 categories,
469 total_docs,
470 } => {
471 writeln!(
472 writer,
473 "📚 {}: Document Archive",
474 self.colorize("Type", "yellow")
475 )?;
476 writeln!(
477 writer,
478 "📄 {}: {} documents",
479 self.colorize("Count", "cyan"),
480 self.colorize(&total_docs.to_string(), "green")
481 )?;
482
483 if !categories.is_empty() {
484 writeln!(writer)?;
485 writeln!(writer, "{}", self.colorize("Categories:", "cyan"))?;
486 for (category, count) in categories.iter() {
487 writeln!(writer, " • {}: {}", category, count)?;
488 }
489 }
490 }
491
492 DirectoryType::MediaLibrary {
493 video_count,
494 audio_count,
495 total_duration,
496 quality,
497 } => {
498 writeln!(
499 writer,
500 "🎬 {}: Media Library",
501 self.colorize("Type", "yellow")
502 )?;
503 writeln!(
504 writer,
505 "🎥 Videos: {} | 🎵 Audio: {}",
506 self.colorize(&video_count.to_string(), "green"),
507 self.colorize(&audio_count.to_string(), "green")
508 )?;
509
510 if let Some(duration) = total_duration {
511 writeln!(
512 writer,
513 "⏱️ {}: {}",
514 self.colorize("Total Duration", "cyan"),
515 duration
516 )?;
517 }
518
519 if !quality.is_empty() {
520 writeln!(
521 writer,
522 "📺 {}: {}",
523 self.colorize("Quality", "cyan"),
524 quality.join(", ")
525 )?;
526 }
527 }
528
529 DirectoryType::DataScience {
530 notebooks,
531 datasets,
532 languages,
533 } => {
534 writeln!(
535 writer,
536 "🔬 {}: Data Science Workspace",
537 self.colorize("Type", "yellow")
538 )?;
539 writeln!(
540 writer,
541 "📓 Notebooks: {} | 📊 Datasets: {}",
542 self.colorize(¬ebooks.to_string(), "green"),
543 self.colorize(&datasets.to_string(), "green")
544 )?;
545
546 if !languages.is_empty() {
547 writeln!(
548 writer,
549 "🐍 {}: {}",
550 self.colorize("Languages", "cyan"),
551 languages.join(", ")
552 )?;
553 }
554
555 writeln!(writer)?;
556 writeln!(writer, "{}", self.colorize("Quick Commands:", "cyan"))?;
557 writeln!(writer, " • jupyter notebook")?;
558 writeln!(writer, " • jupyter lab")?;
559 writeln!(writer, " • python -m notebook")?;
560 }
561
562 DirectoryType::MixedContent {
563 dominant_type,
564 file_types,
565 total_files,
566 } => {
567 writeln!(
568 writer,
569 "📦 {}: Mixed Content",
570 self.colorize("Type", "yellow")
571 )?;
572
573 if let Some(dominant) = dominant_type {
574 writeln!(
575 writer,
576 "🎯 {}: {}",
577 self.colorize("Dominant Type", "cyan"),
578 dominant
579 )?;
580 }
581
582 writeln!(
583 writer,
584 "📊 {}: {}",
585 self.colorize("Total Files", "cyan"),
586 self.colorize(&total_files.to_string(), "green")
587 )?;
588
589 let mut types: Vec<_> = file_types.iter().collect();
591 types.sort_by(|a, b| b.1.cmp(a.1));
592
593 writeln!(writer)?;
594 writeln!(writer, "{}", self.colorize("Top File Types:", "cyan"))?;
595 for (ext, count) in types.iter().take(self.max_examples) {
596 writeln!(writer, " • .{}: {}", ext, count)?;
597 }
598 }
599 }
600
601 writeln!(writer)?;
603 writeln!(writer, "{}", self.colorize("─".repeat(50).as_str(), "blue"))?;
604 writeln!(
605 writer,
606 "💡 {}: Use {} for detailed analysis",
607 self.colorize("Tip", "yellow"),
608 self.colorize("st --mode relations", "cyan")
609 )?;
610
611 Ok(())
612 }
613}
614
615fn format_size(bytes: u64) -> String {
616 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
617 let mut size = bytes as f64;
618 let mut unit_index = 0;
619
620 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
621 size /= 1024.0;
622 unit_index += 1;
623 }
624
625 if unit_index == 0 {
626 format!("{} {}", size as u64, UNITS[unit_index])
627 } else {
628 format!("{:.2} {}", size, UNITS[unit_index])
629 }
630}
631
632fn find_important_code_files(nodes: &[FileNode], language: &Language) -> Vec<String> {
633 let mut important = Vec::new();
634
635 for node in nodes {
636 if node.is_dir {
637 continue;
638 }
639
640 let name = node.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
641
642 let is_important = match language {
643 Language::Rust => {
644 matches!(name, "main.rs" | "lib.rs" | "Cargo.toml" | "build.rs")
645 }
646 Language::Python => {
647 matches!(
648 name,
649 "main.py" | "__init__.py" | "setup.py" | "requirements.txt" | "pyproject.toml"
650 )
651 }
652 Language::JavaScript | Language::TypeScript => {
653 matches!(
654 name,
655 "index.js"
656 | "index.ts"
657 | "package.json"
658 | "tsconfig.json"
659 | "webpack.config.js"
660 )
661 }
662 Language::Go => {
663 matches!(name, "main.go" | "go.mod" | "go.sum")
664 }
665 Language::Java => {
666 matches!(name, "Main.java" | "pom.xml" | "build.gradle")
667 }
668 _ => false,
669 };
670
671 if is_important {
672 important.push(node.path.display().to_string());
673 }
674 }
675
676 important
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::scanner::FileNode;
683 use std::path::PathBuf;
684
685 fn create_test_nodes() -> Vec<FileNode> {
686 use crate::scanner::{FileCategory, FileType, FilesystemType};
687 vec![
688 FileNode {
689 path: PathBuf::from("/test/src/main.rs"),
690 is_dir: false,
691 size: 1000,
692 permissions: 0o644,
693 uid: 1000,
694 gid: 1000,
695 modified: std::time::SystemTime::now(),
696 is_symlink: false,
697 is_hidden: false,
698 permission_denied: false,
699 is_ignored: false,
700 depth: 2,
701 file_type: FileType::RegularFile,
702 category: FileCategory::Rust,
703 search_matches: None,
704 filesystem_type: FilesystemType::Ext4,
705 git_branch: None,
706 traversal_context: None,
707 interest: None,
708 security_findings: Vec::new(),
709 change_status: None,
710 content_hash: None,
711 },
712 FileNode {
713 path: PathBuf::from("/test/Cargo.toml"),
714 is_dir: false,
715 size: 500,
716 permissions: 0o644,
717 uid: 1000,
718 gid: 1000,
719 modified: std::time::SystemTime::now(),
720 is_symlink: false,
721 is_hidden: false,
722 permission_denied: false,
723 is_ignored: false,
724 depth: 1,
725 file_type: FileType::RegularFile,
726 category: FileCategory::Toml,
727 search_matches: None,
728 filesystem_type: FilesystemType::Ext4,
729 git_branch: None,
730 traversal_context: None,
731 interest: None,
732 security_findings: Vec::new(),
733 change_status: None,
734 content_hash: None,
735 },
736 FileNode {
737 path: PathBuf::from("/test/src"),
738 is_dir: true,
739 size: 0,
740 permissions: 0o755,
741 uid: 1000,
742 gid: 1000,
743 modified: std::time::SystemTime::now(),
744 is_symlink: false,
745 is_hidden: false,
746 permission_denied: false,
747 is_ignored: false,
748 depth: 1,
749 file_type: FileType::Directory,
750 category: FileCategory::Unknown,
751 search_matches: None,
752 filesystem_type: FilesystemType::Ext4,
753 git_branch: None,
754 traversal_context: None,
755 interest: None,
756 security_findings: Vec::new(),
757 change_status: None,
758 content_hash: None,
759 },
760 ]
761 }
762
763 #[test]
764 fn test_summary_formatter_rust_project() {
765 let formatter = SummaryFormatter::new(false);
766 let nodes = create_test_nodes();
767 let stats = TreeStats {
768 total_files: 2,
769 total_dirs: 1,
770 total_size: 1500,
771 file_types: HashMap::new(),
772 largest_files: vec![],
773 newest_files: vec![],
774 oldest_files: vec![],
775 };
776
777 let mut output = Vec::new();
778 let result = formatter.format(&mut output, &nodes, &stats, &PathBuf::from("/test"));
779
780 assert!(result.is_ok());
781 let output_str = String::from_utf8(output).unwrap();
782 assert!(output_str.contains("Rust Project"));
783 assert!(output_str.contains("cargo build"));
784 }
785
786 #[test]
787 fn test_high_level_directory_detection() {
788 let formatter = SummaryFormatter::new(false);
789
790 use crate::scanner::{FileCategory, FileType, FilesystemType};
792 let mut nodes = vec![];
793 for i in 0..25 {
794 nodes.push(FileNode {
795 path: PathBuf::from(format!("/home/user/dir{}", i)),
796 is_dir: true,
797 size: 0,
798 permissions: 0o755,
799 uid: 1000,
800 gid: 1000,
801 modified: std::time::SystemTime::now(),
802 is_symlink: false,
803 is_hidden: false,
804 permission_denied: false,
805 is_ignored: false,
806 depth: 1,
807 file_type: FileType::Directory,
808 category: FileCategory::Unknown,
809 search_matches: None,
810 filesystem_type: FilesystemType::Ext4,
811 git_branch: None,
812 traversal_context: None,
813 interest: None,
814 security_findings: Vec::new(),
815 change_status: None,
816 content_hash: None,
817 });
818 }
819
820 let stats = TreeStats {
821 total_files: 0,
822 total_dirs: 25,
823 total_size: 0,
824 file_types: HashMap::new(),
825 largest_files: vec![],
826 newest_files: vec![],
827 oldest_files: vec![],
828 };
829
830 let is_high_level = formatter.is_high_level_directory(&nodes, &stats);
831 assert!(is_high_level);
832 }
833}