1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
16pub enum OutputFormat {
17 Tree,
19 Json,
21 Csv,
23 Plain,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
29pub enum DistributionType {
30 Type,
32 Size,
34 Ext,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40pub enum DistributionFormat {
41 Table,
43 Chart,
45}
46
47#[derive(Debug, Clone)]
49pub struct FormatOptions {
50 pub unicode: bool,
52 pub color: bool,
54 pub full_path: bool,
56 pub show_size: bool,
58 pub show_lines: bool,
60 pub dir_sizes: bool,
62}
63
64impl FormatOptions {
65 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 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
87struct 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
112pub 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 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
134fn 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 for &cont in &prefix {
145 write!(out, "{}", if cont { chars.down } else { " " })?;
146 }
147
148 write!(out, "{}", if is_last { chars.last } else { chars.down_right })?;
150
151 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 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 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 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
209pub fn print_json(entries: &[TreeEntry]) -> Result<()> {
211 let json = serde_json::to_string_pretty(entries)?;
212 println!("{}", json);
213 Ok(())
214}
215
216pub 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
248pub 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
267pub 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
287pub 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 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 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
311fn 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 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 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 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
379fn 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
410fn 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 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 let label = format!("{:>12}", category);
431 let percent_str = format!("{:>5.1}%", percent);
432 let size_str = format_size(*size);
433
434 let bar = bar_char.repeat(bar_width);
436 let empty = empty_char.repeat(empty_width);
437
438 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
470pub 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
488fn terminal_width() -> usize {
490 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}