maram/
formatter.rs

1//! Output formatting and visualization
2//!
3//! This module handles all output formatting including tree visualization,
4//! JSON/CSV export, and beautiful size distribution charts.
5
6use crate::{Args, Config, Result, TreeEntry, TreeStats};
7use clap::ValueEnum;
8use colored::*;
9use serde_json;
10use std::collections::HashMap;
11use std::io::{self, Write};
12use std::path::Path;
13
14/// Output format options
15#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
16pub enum OutputFormat {
17    /// Tree visualization (default)
18    Tree,
19    /// JSON output
20    Json,
21    /// CSV output
22    Csv,
23    /// Plain text list
24    Plain,
25}
26
27/// Size distribution types
28#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
29pub enum DistributionType {
30    /// Distribution by file type
31    Type,
32    /// Distribution by size buckets
33    Size,
34    /// Distribution by file extension
35    Ext,
36}
37
38/// Distribution output format
39#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40pub enum DistributionFormat {
41    /// Table format
42    Table,
43    /// Bar chart format
44    Chart,
45}
46
47/// Options for formatting output
48#[derive(Debug, Clone)]
49pub struct FormatOptions {
50    /// Use Unicode characters for tree
51    pub unicode: bool,
52    /// Use colored output
53    pub color: bool,
54    /// Show full paths
55    pub full_path: bool,
56    /// Show file sizes
57    pub show_size: bool,
58    /// Show line counts
59    pub show_lines: bool,
60    /// Show directory sizes
61    pub dir_sizes: bool,
62}
63
64impl FormatOptions {
65    /// Create format options from args and config
66    pub fn from_args_and_config(args: &Args, config: &Config) -> Self {
67        let color = if args.no_color {
68            false
69        } else if args.color {
70            true
71        } else {
72            // Auto-detect based on terminal
73            atty::is(atty::Stream::Stdout) && std::env::var("NO_COLOR").is_err()
74        };
75        
76        Self {
77            unicode: args.unicode || config.display.unicode,
78            color,
79            full_path: args.full_path,
80            show_size: args.show_size,
81            show_lines: args.show_lines,
82            dir_sizes: args.dir_sizes,
83        }
84    }
85}
86
87/// Tree drawing characters
88struct TreeChars {
89    down: &'static str,
90    down_right: &'static str,
91    last: &'static str,
92}
93
94impl TreeChars {
95    fn new(unicode: bool) -> Self {
96        if unicode {
97            Self {
98                down: "│   ",
99                down_right: "├── ",
100                last: "└── ",
101            }
102        } else {
103            Self {
104                down: "│   ",
105                down_right: "├── ",
106                last: "└── ",
107            }
108        }
109    }
110}
111
112/// Print tree visualization
113pub fn print_tree(entries: &[TreeEntry], opts: &FormatOptions) -> Result<()> {
114    let chars = TreeChars::new(opts.unicode);
115    let mut stdout = io::stdout();
116    
117    for (i, entry) in entries.iter().enumerate() {
118        print_tree_entry(&mut stdout, entry, &chars, opts, Vec::new(), i == entries.len() - 1)?;
119    }
120    
121    // Print summary line like tree command
122    let stats = TreeStats::from_entries(entries);
123    println!();
124    println!("{} {}, {} {}",
125        stats.dir_count,
126        if stats.dir_count == 1 { "directory" } else { "directories" },
127        stats.file_count,
128        if stats.file_count == 1 { "file" } else { "files" }
129    );
130    
131    Ok(())
132}
133
134/// Print a single tree entry recursively
135fn print_tree_entry(
136    out: &mut dyn Write,
137    entry: &TreeEntry,
138    chars: &TreeChars,
139    opts: &FormatOptions,
140    prefix: Vec<bool>,
141    is_last: bool,
142) -> Result<()> {
143    // Print prefix
144    for &cont in &prefix {
145        write!(out, "{}", if cont { chars.down } else { "    " })?;
146    }
147    
148    // Print connector
149    write!(out, "{}", if is_last { chars.last } else { chars.down_right })?;
150    
151    // Format name with color
152    let name = if opts.color {
153        if entry.is_dir {
154            entry.name.blue().bold().to_string()
155        } else if entry.is_symlink {
156            entry.name.cyan().to_string()
157        } else if entry.is_executable {
158            entry.name.green().to_string()
159        } else {
160            entry.name.clone()
161        }
162    } else {
163        entry.name.clone()
164    };
165    
166    // Add details
167    let mut details = Vec::new();
168    
169    if opts.show_size && (!entry.is_dir || opts.dir_sizes) {
170        details.push(format_size(entry.size));
171    }
172    
173    if opts.show_lines && entry.line_count > 0 {
174        details.push(format!("{} lines", entry.line_count));
175    }
176    
177    // Print entry
178    if details.is_empty() {
179        writeln!(out, "{}", name)?;
180    } else {
181        let detail_str = if opts.color {
182            format!(" ({})", details.join(", ")).dimmed().to_string()
183        } else {
184            format!(" ({})", details.join(", "))
185        };
186        writeln!(out, "{}{}", name, detail_str)?;
187    }
188    
189    // Print children
190    if !entry.children.is_empty() {
191        let mut new_prefix = prefix;
192        new_prefix.push(!is_last);
193        
194        for (i, child) in entry.children.iter().enumerate() {
195            print_tree_entry(
196                out,
197                child,
198                chars,
199                opts,
200                new_prefix.clone(),
201                i == entry.children.len() - 1,
202            )?;
203        }
204    }
205    
206    Ok(())
207}
208
209/// Print JSON output
210pub fn print_json(entries: &[TreeEntry]) -> Result<()> {
211    let json = serde_json::to_string_pretty(entries)?;
212    println!("{}", json);
213    Ok(())
214}
215
216/// Print CSV output
217pub fn print_csv(entries: &[TreeEntry]) -> Result<()> {
218    println!("path,type,size,lines,modified");
219    
220    fn print_csv_entry(entry: &TreeEntry, parent_path: &str) -> Result<()> {
221        let path = if parent_path.is_empty() {
222            entry.name.clone()
223        } else {
224            format!("{}/{}", parent_path, entry.name)
225        };
226        
227        let entry_type = if entry.is_dir { "directory" } else { "file" };
228        let modified = entry.modified.duration_since(std::time::UNIX_EPOCH)
229            .unwrap_or_default()
230            .as_secs();
231        
232        println!("{},{},{},{},{}", path, entry_type, entry.size, entry.line_count, modified);
233        
234        for child in &entry.children {
235            print_csv_entry(child, &path)?;
236        }
237        
238        Ok(())
239    }
240    
241    for entry in entries {
242        print_csv_entry(entry, "")?;
243    }
244    
245    Ok(())
246}
247
248/// Print plain text output
249pub fn print_plain(entries: &[TreeEntry]) -> Result<()> {
250    fn print_plain_entry(entry: &TreeEntry, depth: usize) -> Result<()> {
251        println!("{}{}", "  ".repeat(depth), entry.name);
252        
253        for child in &entry.children {
254            print_plain_entry(child, depth + 1)?;
255        }
256        
257        Ok(())
258    }
259    
260    for entry in entries {
261        print_plain_entry(entry, 0)?;
262    }
263    
264    Ok(())
265}
266
267/// Print total size summary
268pub fn print_total_size(stats: &TreeStats, opts: &FormatOptions) -> Result<()> {
269    let total_str = format!(
270        "\nTotal: {} ({} files: {}, {} directories: {})",
271        format_size(stats.total_size),
272        stats.file_count,
273        format_size(stats.file_size),
274        stats.dir_count,
275        format_size(stats.dir_size),
276    );
277    
278    if opts.color {
279        println!("{}", total_str.bright_yellow().bold());
280    } else {
281        println!("{}", total_str);
282    }
283    
284    Ok(())
285}
286
287/// Print size distribution
288pub fn print_distribution(
289    entries: &[TreeEntry],
290    dist_type: &DistributionType,
291    top: usize,
292    format: &DistributionFormat,
293    opts: &FormatOptions,
294) -> Result<()> {
295    let distribution = calculate_distribution(entries, dist_type);
296    
297    // Sort by size descending and take top N
298    let mut sorted: Vec<_> = distribution.into_iter().collect();
299    sorted.sort_by(|a, b| b.1.cmp(&a.1));
300    sorted.truncate(top);
301    
302    // Calculate total for percentages
303    let total: u64 = sorted.iter().map(|(_, size)| size).sum();
304    
305    match format {
306        DistributionFormat::Table => print_distribution_table(&sorted, total, opts),
307        DistributionFormat::Chart => print_distribution_chart(&sorted, total, opts),
308    }
309}
310
311/// Calculate size distribution
312fn calculate_distribution(
313    entries: &[TreeEntry],
314    dist_type: &DistributionType,
315) -> HashMap<String, u64> {
316    let mut dist = HashMap::new();
317    
318    fn process_entry(
319        entry: &TreeEntry,
320        dist: &mut HashMap<String, u64>,
321        dist_type: &DistributionType,
322    ) {
323        if !entry.is_dir {
324            let key = match dist_type {
325                DistributionType::Type => {
326                    // Determine file type by extension
327                    let ext = Path::new(&entry.name)
328                        .extension()
329                        .and_then(|s| s.to_str())
330                        .unwrap_or("no extension");
331                    
332                    match ext.to_lowercase().as_str() {
333                        "jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" => "Images",
334                        "mp4" | "avi" | "mkv" | "mov" | "wmv" => "Videos",
335                        "mp3" | "wav" | "flac" | "aac" | "ogg" => "Audio",
336                        "zip" | "tar" | "gz" | "7z" | "rar" => "Archives",
337                        "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" => "Documents",
338                        "rs" | "js" | "ts" | "py" | "go" | "c" | "cpp" | "java" => "Code",
339                        "txt" | "md" | "log" => "Text",
340                        _ => "Other",
341                    }.to_string()
342                }
343                DistributionType::Size => {
344                    // Size buckets
345                    match entry.size {
346                        0..=1024 => "< 1KB",
347                        1025..=1_048_576 => "1KB - 1MB",
348                        1_048_577..=10_485_760 => "1MB - 10MB",
349                        10_485_761..=104_857_600 => "10MB - 100MB",
350                        104_857_601..=1_073_741_824 => "100MB - 1GB",
351                        _ => "> 1GB",
352                    }.to_string()
353                }
354                DistributionType::Ext => {
355                    // By extension
356                    Path::new(&entry.name)
357                        .extension()
358                        .and_then(|s| s.to_str())
359                        .unwrap_or("no extension")
360                        .to_string()
361                }
362            };
363            
364            *dist.entry(key).or_insert(0) += entry.size;
365        }
366        
367        for child in &entry.children {
368            process_entry(child, dist, dist_type);
369        }
370    }
371    
372    for entry in entries {
373        process_entry(entry, &mut dist, dist_type);
374    }
375    
376    dist
377}
378
379/// Print distribution as a table
380fn print_distribution_table(
381    data: &[(String, u64)],
382    total: u64,
383    opts: &FormatOptions,
384) -> Result<()> {
385    println!("\n{:>15} {:>12} {:>8}", "Category", "Size", "Percent");
386    println!("{}", "-".repeat(40));
387    
388    for (category, size) in data {
389        let percent = (*size as f64 / total as f64) * 100.0;
390        let line = format!(
391            "{:>15} {:>12} {:>7.1}%",
392            category,
393            format_size(*size),
394            percent
395        );
396        
397        if opts.color {
398            println!("{}", line.bright_white());
399        } else {
400            println!("{}", line);
401        }
402    }
403    
404    println!("{}", "-".repeat(40));
405    println!("{:>15} {:>12} {:>7.1}%", "Total", format_size(total), 100.0);
406    
407    Ok(())
408}
409
410/// Print distribution as a beautiful bar chart
411fn print_distribution_chart(
412    data: &[(String, u64)],
413    total: u64,
414    opts: &FormatOptions,
415) -> Result<()> {
416    println!("\n{}", "Size Distribution".bold());
417    println!();
418    
419    // Calculate max width for bars (terminal width - space for labels)
420    let term_width = terminal_width().saturating_sub(35);
421    let bar_char = if opts.unicode { "█" } else { "#" };
422    let empty_char = if opts.unicode { "░" } else { "-" };
423    
424    for (category, size) in data {
425        let percent = (*size as f64 / total as f64) * 100.0;
426        let bar_width = ((percent / 100.0) * term_width as f64) as usize;
427        let empty_width = term_width.saturating_sub(bar_width);
428        
429        // Format label
430        let label = format!("{:>12}", category);
431        let percent_str = format!("{:>5.1}%", percent);
432        let size_str = format_size(*size);
433        
434        // Create bar
435        let bar = bar_char.repeat(bar_width);
436        let empty = empty_char.repeat(empty_width);
437        
438        // Color based on size
439        let (label_color, bar_color) = if opts.color {
440            match percent as u32 {
441                0..=10 => (label.green(), bar.green()),
442                11..=25 => (label.yellow(), bar.yellow()),
443                26..=50 => (label.bright_yellow(), bar.bright_yellow()),
444                _ => (label.red(), bar.red()),
445            }
446        } else {
447            (label.normal(), bar.normal())
448        };
449        
450        println!(
451            "{} {} [{}{}] {}",
452            label_color,
453            percent_str.dimmed(),
454            bar_color,
455            empty.dimmed(),
456            size_str.bright_white()
457        );
458    }
459    
460    println!("\n{:>12} {:>6} {} {}", 
461        "Total".bold(), 
462        "100.0%".dimmed(),
463        " ".repeat(term_width + 2),
464        format_size(total).bright_white().bold()
465    );
466    
467    Ok(())
468}
469
470/// Format size in human-readable format
471pub fn format_size(size: u64) -> String {
472    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
473    let mut size = size as f64;
474    let mut unit_idx = 0;
475    
476    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
477        size /= 1024.0;
478        unit_idx += 1;
479    }
480    
481    if unit_idx == 0 {
482        format!("{} {}", size as u64, UNITS[unit_idx])
483    } else {
484        format!("{:.1} {}", size, UNITS[unit_idx])
485    }
486}
487
488/// Get terminal width
489fn terminal_width() -> usize {
490    // Try to get terminal width, default to 80 if unavailable
491    term_size::dimensions().map(|(w, _)| w).unwrap_or(80)
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    
498    #[test]
499    fn test_format_size() {
500        assert_eq!(format_size(0), "0 B");
501        assert_eq!(format_size(1023), "1023 B");
502        assert_eq!(format_size(1024), "1.0 KB");
503        assert_eq!(format_size(1536), "1.5 KB");
504        assert_eq!(format_size(1_048_576), "1.0 MB");
505        assert_eq!(format_size(1_073_741_824), "1.0 GB");
506    }
507}