1use super::{Formatter, PathDisplayMode};
2use crate::emoji_mapper;
3use crate::scanner::{FileCategory, FileNode, TreeStats};
4use anyhow::Result;
5use colored::*;
6use humansize::{format_size, BINARY};
7use std::collections::{HashMap, HashSet};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11pub struct ClassicFormatter {
12 pub no_emoji: bool,
13 pub use_color: bool,
14 pub path_mode: PathDisplayMode,
15 pub sort_field: Option<String>,
16}
17
18impl ClassicFormatter {
19 pub fn new(no_emoji: bool, use_color: bool, path_mode: PathDisplayMode) -> Self {
20 Self {
21 no_emoji,
22 use_color,
23 path_mode,
24 sort_field: None,
25 }
26 }
27
28 pub fn with_sort(mut self, sort_field: Option<String>) -> Self {
29 self.sort_field = sort_field;
30 self
31 }
32
33 #[allow(dead_code)]
36 fn calculate_visual_weight(&self, node: &FileNode) -> u8 {
37 if !node.is_dir {
38 return 1; }
40
41 let mut weight = 2;
43
44 if node.size > 0 {
47 let size_factor = (node.size as f64).log10().max(1.0) as u8;
48 weight += (size_factor / 2).min(2); }
50
51 let depth_bonus = if node.depth == 0 {
54 3 } else if node.depth == 1 {
56 2 } else if node.depth <= 3 {
58 1 } else {
60 0 };
62
63 weight += depth_bonus;
64
65 weight.min(5)
67 }
68
69 fn get_terminal_chars(&self, file_size: u64, is_last: bool) -> String {
72 let base_char = if is_last { "└── " } else { "├── " };
73
74 if self.no_emoji || !self.use_color {
75 return base_char.to_string();
77 }
78
79 let intensity = self.calculate_gradient_intensity(file_size);
81
82 self.apply_gradient_background(base_char, intensity)
84 }
85
86 fn get_continuation_chars(&self, file_size: u64, is_vertical: bool) -> String {
89 let base_char = if is_vertical { "│ " } else { " " };
90
91 if self.no_emoji || !self.use_color {
92 return base_char.to_string();
94 }
95
96 let intensity = self.calculate_gradient_intensity(file_size);
98
99 self.apply_gradient_background(base_char, intensity)
101 }
102
103 fn calculate_gradient_intensity(&self, file_size: u64) -> u8 {
106 const MB_100: u64 = 100 * 1024 * 1024; const MB_200: u64 = 200 * 1024 * 1024; const MB_500: u64 = 500 * 1024 * 1024; const GB_1: u64 = 1024 * 1024 * 1024; const GB_4: u64 = 4 * 1024 * 1024 * 1024; match file_size {
114 0..=1024 => 50, 1025..=10240 => 55, 10241..=102400 => 60, 102401..=1048576 => 65, 1048577..=10485760 => 70, 10485761..MB_100 => 75, MB_100..MB_200 => 80, MB_200..MB_500 => 85, MB_500..GB_1 => 90, GB_1..GB_4 => 95, _ => 100, }
126 }
127
128 fn apply_gradient_background(&self, text: &str, intensity: u8) -> String {
131 let chars: Vec<char> = text.chars().collect();
132 let mut result = String::new();
133
134 let bg_color = match intensity {
136 50..=60 => "\x1b[48;5;17m", 61..=70 => "\x1b[48;5;22m", 71..=75 => "\x1b[48;5;58m", 76..=80 => "\x1b[48;5;94m", 81..=85 => "\x1b[48;5;130m", 86..=90 => "\x1b[48;5;124m", 91..=95 => "\x1b[48;5;88m", 96..=100 => "\x1b[48;5;52m", _ => "\x1b[48;5;236m", };
146
147 for &ch in chars.iter() {
149 result.push_str(&format!("{}{}", bg_color, ch));
150 }
151
152 result.push_str("\x1b[0m");
154 result
155 }
156
157 fn get_file_emoji(&self, node: &FileNode) -> &'static str {
160 emoji_mapper::get_file_emoji(node, self.no_emoji)
161 }
162
163 fn build_tree_structure(
164 &self,
165 nodes: &[FileNode],
166 root_path: &Path,
167 ) -> Vec<(FileNode, Vec<bool>)> {
168 let mut result = Vec::new();
169
170 if nodes.is_empty() {
171 return result;
172 }
173
174 let mut sorted_nodes = nodes.to_vec();
176 sorted_nodes.sort_by(|a, b| a.path.cmp(&b.path));
177
178 let mut seen = HashSet::new();
180 sorted_nodes.retain(|node| seen.insert(node.path.clone()));
181
182 let mut children_map: HashMap<PathBuf, Vec<usize>> = HashMap::new();
184 let mut parent_indices: Vec<Option<usize>> = vec![None; sorted_nodes.len()];
185
186 let path_to_index: HashMap<PathBuf, usize> = sorted_nodes
188 .iter()
189 .enumerate()
190 .map(|(i, node)| (node.path.clone(), i))
191 .collect();
192
193 for (i, node) in sorted_nodes.iter().enumerate() {
194 if let Some(parent_path) = node.path.parent() {
195 if let Some(&parent_idx) = path_to_index.get(parent_path) {
197 parent_indices[i] = Some(parent_idx);
198 children_map
199 .entry(parent_path.to_path_buf())
200 .or_default()
201 .push(i);
202 }
203 }
204 }
205
206 for children in children_map.values_mut() {
208 let sort_field = self.sort_field.clone();
209 children.sort_by(|&a, &b| {
210 let node_a = &sorted_nodes[a];
211 let node_b = &sorted_nodes[b];
212
213 match sort_field.as_deref() {
215 Some("size") | Some("largest") => node_b.size.cmp(&node_a.size),
216 Some("smallest") => node_a.size.cmp(&node_b.size),
217 Some("date") | Some("newest") => node_b.modified.cmp(&node_a.modified),
218 Some("oldest") => node_a.modified.cmp(&node_b.modified),
219 Some("name") | Some("a-to-z") => {
220 let name_a = node_a
221 .path
222 .file_name()
223 .unwrap_or_default()
224 .to_string_lossy();
225 let name_b = node_b
226 .path
227 .file_name()
228 .unwrap_or_default()
229 .to_string_lossy();
230 name_a.cmp(&name_b)
231 }
232 Some("z-to-a") => {
233 let name_a = node_a
234 .path
235 .file_name()
236 .unwrap_or_default()
237 .to_string_lossy();
238 let name_b = node_b
239 .path
240 .file_name()
241 .unwrap_or_default()
242 .to_string_lossy();
243 name_b.cmp(&name_a)
244 }
245 Some("type") => {
246 let ext_a = node_a
248 .path
249 .extension()
250 .unwrap_or_default()
251 .to_string_lossy();
252 let ext_b = node_b
253 .path
254 .extension()
255 .unwrap_or_default()
256 .to_string_lossy();
257 match ext_a.cmp(&ext_b) {
258 std::cmp::Ordering::Equal => {
259 let name_a = node_a
260 .path
261 .file_name()
262 .unwrap_or_default()
263 .to_string_lossy();
264 let name_b = node_b
265 .path
266 .file_name()
267 .unwrap_or_default()
268 .to_string_lossy();
269 name_a.cmp(&name_b)
270 }
271 other => other,
272 }
273 }
274 _ => {
275 match (node_a.is_dir, node_b.is_dir) {
277 (true, false) => std::cmp::Ordering::Less,
278 (false, true) => std::cmp::Ordering::Greater,
279 _ => node_a.path.file_name().cmp(&node_b.path.file_name()),
280 }
281 }
282 }
283 });
284 }
285
286 fn add_node_to_result(
288 node_idx: usize,
289 nodes: &[FileNode],
290 children_map: &HashMap<PathBuf, Vec<usize>>,
291 result: &mut Vec<(FileNode, Vec<bool>)>,
292 is_last_stack: Vec<bool>,
293 ) {
294 let node = &nodes[node_idx];
295 result.push((node.clone(), is_last_stack.clone()));
296
297 if let Some(children) = children_map.get(&node.path) {
298 for (i, &child_idx) in children.iter().enumerate() {
299 let is_last = i == children.len() - 1;
300 let mut new_stack = is_last_stack.clone();
301 new_stack.push(is_last);
302 add_node_to_result(child_idx, nodes, children_map, result, new_stack);
303 }
304 }
305 }
306
307 for (i, node) in sorted_nodes.iter().enumerate() {
309 if node.path == root_path {
310 add_node_to_result(i, &sorted_nodes, &children_map, &mut result, vec![]);
311 break;
312 }
313 }
314
315 result
316 }
317
318 fn get_color_for_category(&self, category: FileCategory) -> Option<Color> {
319 if !self.use_color {
320 return None;
321 }
322
323 match category {
324 FileCategory::Rust => Some(Color::TrueColor {
326 r: 255,
327 g: 65,
328 b: 54,
329 }), FileCategory::Python => Some(Color::TrueColor {
331 r: 55,
332 g: 118,
333 b: 171,
334 }), FileCategory::JavaScript => Some(Color::TrueColor {
336 r: 240,
337 g: 219,
338 b: 79,
339 }), FileCategory::TypeScript => Some(Color::TrueColor {
341 r: 0,
342 g: 122,
343 b: 204,
344 }), FileCategory::Java => Some(Color::TrueColor {
346 r: 244,
347 g: 67,
348 b: 54,
349 }), FileCategory::C => Some(Color::TrueColor {
351 r: 0,
352 g: 89,
353 b: 157,
354 }), FileCategory::Cpp => Some(Color::TrueColor {
356 r: 0,
357 g: 89,
358 b: 157,
359 }), FileCategory::Go => Some(Color::TrueColor {
361 r: 0,
362 g: 173,
363 b: 216,
364 }), FileCategory::Ruby => Some(Color::TrueColor {
366 r: 204,
367 g: 52,
368 b: 45,
369 }), FileCategory::PHP => Some(Color::TrueColor {
371 r: 119,
372 g: 123,
373 b: 180,
374 }), FileCategory::Shell => Some(Color::Green),
376
377 FileCategory::Markdown => Some(Color::TrueColor {
379 r: 76,
380 g: 202,
381 b: 240,
382 }), FileCategory::Html => Some(Color::TrueColor {
384 r: 228,
385 g: 77,
386 b: 38,
387 }), FileCategory::Css => Some(Color::TrueColor {
389 r: 33,
390 g: 150,
391 b: 243,
392 }), FileCategory::Json => Some(Color::TrueColor {
394 r: 0,
395 g: 150,
396 b: 136,
397 }), FileCategory::Yaml => Some(Color::TrueColor {
399 r: 203,
400 g: 71,
401 b: 119,
402 }), FileCategory::Xml => Some(Color::TrueColor {
404 r: 255,
405 g: 111,
406 b: 0,
407 }), FileCategory::Toml => Some(Color::TrueColor {
409 r: 150,
410 g: 111,
411 b: 214,
412 }), FileCategory::Makefile => Some(Color::TrueColor {
416 r: 66,
417 g: 165,
418 b: 245,
419 }), FileCategory::Dockerfile => Some(Color::TrueColor {
421 r: 33,
422 g: 150,
423 b: 243,
424 }), FileCategory::GitConfig => Some(Color::TrueColor {
426 r: 241,
427 g: 80,
428 b: 47,
429 }), FileCategory::Archive => Some(Color::TrueColor {
433 r: 121,
434 g: 134,
435 b: 203,
436 }), FileCategory::Image => Some(Color::Magenta),
440 FileCategory::Video => Some(Color::TrueColor {
441 r: 255,
442 g: 87,
443 b: 34,
444 }), FileCategory::Audio => Some(Color::TrueColor {
446 r: 0,
447 g: 188,
448 b: 212,
449 }), FileCategory::SystemFile => Some(Color::TrueColor {
453 r: 96,
454 g: 96,
455 b: 96,
456 }), FileCategory::Binary => Some(Color::TrueColor {
458 r: 158,
459 g: 158,
460 b: 158,
461 }), FileCategory::Database => Some(Color::TrueColor {
465 r: 139,
466 g: 90,
467 b: 43,
468 }), FileCategory::Office => Some(Color::TrueColor {
472 r: 21,
473 g: 101,
474 b: 192,
475 }), FileCategory::Spreadsheet => Some(Color::TrueColor {
477 r: 30,
478 g: 123,
479 b: 64,
480 }), FileCategory::PowerPoint => Some(Color::TrueColor {
482 r: 209,
483 g: 69,
484 b: 36,
485 }), FileCategory::Pdf => Some(Color::TrueColor {
487 r: 215,
488 g: 41,
489 b: 42,
490 }), FileCategory::Ebook => Some(Color::TrueColor {
492 r: 65,
493 g: 105,
494 b: 225,
495 }), FileCategory::Log => Some(Color::TrueColor {
499 r: 128,
500 g: 128,
501 b: 128,
502 }), FileCategory::Config => Some(Color::TrueColor {
504 r: 100,
505 g: 149,
506 b: 237,
507 }), FileCategory::License => Some(Color::TrueColor {
509 r: 255,
510 g: 193,
511 b: 7,
512 }), FileCategory::Readme => Some(Color::TrueColor {
514 r: 0,
515 g: 176,
516 b: 255,
517 }), FileCategory::Txt => Some(Color::TrueColor {
519 r: 160,
520 g: 160,
521 b: 160,
522 }), FileCategory::Rtf => Some(Color::TrueColor {
524 r: 0,
525 g: 128,
526 b: 128,
527 }), FileCategory::Csv => Some(Color::TrueColor {
529 r: 46,
530 g: 125,
531 b: 50,
532 }), FileCategory::Certificate => Some(Color::TrueColor {
536 r: 255,
537 g: 87,
538 b: 34,
539 }), FileCategory::Encrypted => Some(Color::TrueColor {
541 r: 156,
542 g: 39,
543 b: 176,
544 }), FileCategory::Font => Some(Color::TrueColor {
548 r: 103,
549 g: 58,
550 b: 183,
551 }), FileCategory::DiskImage => Some(Color::TrueColor {
555 r: 33,
556 g: 33,
557 b: 33,
558 }), FileCategory::Model3D => Some(Color::TrueColor {
562 r: 255,
563 g: 152,
564 b: 0,
565 }), FileCategory::Jupyter => Some(Color::TrueColor {
569 r: 255,
570 g: 109,
571 b: 0,
572 }), FileCategory::RData => Some(Color::TrueColor {
574 r: 39,
575 g: 99,
576 b: 165,
577 }), FileCategory::Matlab => Some(Color::TrueColor {
579 r: 237,
580 g: 119,
581 b: 3,
582 }), FileCategory::WebAsset => Some(Color::TrueColor {
586 r: 96,
587 g: 125,
588 b: 139,
589 }), FileCategory::Package => Some(Color::TrueColor {
593 r: 203,
594 g: 31,
595 b: 26,
596 }), FileCategory::Lock => Some(Color::TrueColor {
598 r: 171,
599 g: 71,
600 b: 188,
601 }), FileCategory::Test => Some(Color::TrueColor {
605 r: 67,
606 g: 160,
607 b: 71,
608 }), FileCategory::Memory => Some(Color::TrueColor {
612 r: 147,
613 g: 112,
614 b: 219,
615 }), FileCategory::Backup => Some(Color::TrueColor {
619 r: 189,
620 g: 189,
621 b: 189,
622 }), FileCategory::Temp => Some(Color::TrueColor {
624 r: 117,
625 g: 117,
626 b: 117,
627 }), FileCategory::Unknown => None,
631 }
632 }
633
634 fn format_node(&self, node: &FileNode, is_last: &[bool], root_path: &Path) -> String {
635 let mut prefix = String::new();
636
637 for (i, &last) in is_last.iter().enumerate() {
641 if i == is_last.len() - 1 {
642 let terminal_chars = self.get_terminal_chars(node.size, last);
644 prefix.push_str(&terminal_chars);
645 } else {
646 let continuation_chars = self.get_continuation_chars(node.size, !last);
648 prefix.push_str(&continuation_chars);
649 }
650 }
651
652 let emoji = self.get_file_emoji(node);
653
654 let name = match self.path_mode {
656 PathDisplayMode::Off => node
657 .path
658 .file_name()
659 .unwrap_or(node.path.as_os_str())
660 .to_string_lossy()
661 .to_string(),
662 PathDisplayMode::Relative => {
663 if node.path == root_path {
664 node.path
665 .file_name()
666 .unwrap_or(node.path.as_os_str())
667 .to_string_lossy()
668 .to_string()
669 } else {
670 node.path
671 .strip_prefix(root_path)
672 .unwrap_or(&node.path)
673 .to_string_lossy()
674 .to_string()
675 }
676 }
677 PathDisplayMode::Full => node.path.display().to_string(),
678 };
679
680 let size_str = if node.is_dir {
681 String::new()
682 } else {
683 format!(" ({})", format_size(node.size, BINARY))
684 };
685
686 let indicator = if node.permission_denied {
687 " [*]"
688 } else if node.is_ignored {
689 " [ignored]"
690 } else {
691 ""
692 };
693
694 let search_indicator = if let Some(matches) = &node.search_matches {
696 if matches.total_count > 0 {
697 let (line, col) = matches.first_match;
698 let truncated = if matches.truncated { ",TRUNCATED" } else { "" };
699 if matches.total_count > 1 {
700 format!(
701 " [FOUND:L{}:C{},{}x{}]",
702 line, col, matches.total_count, truncated
703 )
704 } else {
705 format!(" [FOUND:L{}:C{}]", line, col)
706 }
707 } else {
708 String::new()
709 }
710 } else {
711 String::new()
712 };
713
714 let colored_name = if node.is_dir {
716 let dir_name = if self.use_color {
718 name.bright_yellow().bold().to_string()
719 } else {
720 name
721 };
722 if let Some(ref branch) = node.git_branch {
724 let branch_display = if self.use_color {
725 format!(" [{}]", branch.cyan())
726 } else {
727 format!(" [{}]", branch)
728 };
729 format!("{}{}", dir_name, branch_display)
730 } else {
731 dir_name
732 }
733 } else if let Some(color) = self.get_color_for_category(node.category) {
734 name.color(color).to_string()
735 } else {
736 name
737 };
738
739 if is_last.is_empty() {
740 format!(
742 "{} {}{}{}{}",
743 emoji, colored_name, size_str, indicator, search_indicator
744 )
745 } else {
746 format!(
747 "{}{} {}{}{}{}",
748 prefix, emoji, colored_name, size_str, indicator, search_indicator
749 )
750 }
751 }
752}
753
754impl Formatter for ClassicFormatter {
755 fn format(
756 &self,
757 writer: &mut dyn Write,
758 nodes: &[FileNode],
759 stats: &TreeStats,
760 root_path: &Path,
761 ) -> Result<()> {
762 let tree_structure = self.build_tree_structure(nodes, root_path);
763
764 for (node, is_last) in tree_structure {
765 writeln!(writer, "{}", self.format_node(&node, &is_last, root_path))?;
766 }
767
768 writeln!(writer)?;
770 writeln!(
771 writer,
772 "{} directories, {} files, {} total",
773 stats.total_dirs,
774 stats.total_files,
775 format_size(stats.total_size, BINARY)
776 )?;
777
778 if nodes.iter().any(|n| n.permission_denied) {
780 writeln!(writer)?;
781 writeln!(writer, "[*] Permission denied")?;
782 }
783
784 Ok(())
785 }
786}