fsize-cli 0.2.1

A command-line tool for measuring file and directory size
use clap::Parser;
use fsize_core::{
    compute_total_size, disk_usage, format_mtime, format_size, Color, FsizeError, Unit,
    WalkOptions, WalkOutcome,
};
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

#[derive(Parser)]
#[command(
    name = "fsize (file/folder size)",
    version,
    about = "Display file/directory sizes",
    arg_required_else_help = true
)]
struct Args {
    #[arg(required = true, value_name = "PATH")]
    paths: Vec<PathBuf>,

    #[arg(short = 'b', long = "binary", conflicts_with = "raw")]
    binary: bool,

    #[arg(short = 'r', long = "raw", visible_alias = "byte")]
    raw: bool,

    #[arg(short = 'i', long = "info")]
    info: bool,

    #[arg(short = 'm', long = "metadata", conflicts_with = "disk_usage")]
    metadata: bool,

    #[arg(short = 'd', long = "disk-usage")]
    disk_usage: bool,

    #[arg(
        short = 'u',
        long = "unit",
        value_name = "UNIT",
        conflicts_with = "raw"
    )]
    in_unit: Option<Unit>,

    #[arg(long = "exclude", value_name = "PATTERN")]
    excludes: Vec<String>,

    #[arg(long = "max-depth", value_name = "N")]
    max_depth: Option<usize>,

    #[arg(short = 'L', long = "follow-symlinks")]
    follow_symlinks: bool,

    #[arg(long = "json")]
    json: bool,
}

fn main() {
    let args = Args::parse();

    let excludes = match WalkOptions::compile_excludes(&args.excludes) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("{}[ERROR]{} {}", Color::red(), Color::reset(), e);
            process::exit(2);
        }
    };

    let walk_opts = WalkOptions {
        max_depth: args.max_depth,
        excludes,
        follow_links: args.follow_symlinks,
    };

    let mut exit_code = 0;
    let mut grand_total: u128 = 0;
    let mut json_entries: Vec<serde_json::Value> = Vec::new();

    for path in &args.paths {
        if args.disk_usage {
            match disk_usage(path) {
                Ok(info) => {
                    if args.json {
                        json_entries.push(serde_json::json!({
                            "path": path.display().to_string(),
                            "total": info.total.to_string(),
                            "used": info.used().to_string(),
                            "free": info.free.to_string(),
                            "available": info.available.to_string(),
                            "percent_used": info.percent_used(),
                        }));
                    } else {
                        let fmt = |b: u128| {
                            if args.raw {
                                b.to_string()
                            } else {
                                format_size(b, args.in_unit, args.binary)
                            }
                        };
                        println!(
                            "{}\ttotal {}\tused {}\tavailable {}\t({:.1}% used)",
                            path.display(),
                            fmt(info.total),
                            fmt(info.used()),
                            fmt(info.available),
                            info.percent_used(),
                        );
                    }
                }
                Err(e) => {
                    eprintln!("{}[ERROR]{} {}", Color::red(), Color::reset(), e);
                    exit_code = 1;
                }
            }
            continue;
        }

        let outcome: Result<WalkOutcome, FsizeError> = if args.metadata {
            fs::symlink_metadata(path)
                .map(|m| WalkOutcome {
                    total: m.len() as u128,
                    warnings: Vec::new(),
                    files_scanned: 1,
                })
                .map_err(|e| FsizeError::Io {
                    path: path.to_owned(),
                    source: e,
                })
        } else {
            run_with_progress(path, &walk_opts)
        };

        match outcome {
            Ok(WalkOutcome {
                total, warnings, ..
            }) => {
                for w in &warnings {
                    eprintln!("{}[WARNING]{} {}", Color::yellow(), Color::reset(), w);
                }
                if !warnings.is_empty() {
                    exit_code = 1;
                }

                grand_total += total;

                let raw = args.raw;
                let size_str = if raw {
                    total.to_string()
                } else {
                    format_size(total, args.in_unit, args.binary)
                };

                let mut extra_type = None;
                let mut extra_mtime = None;
                if args.info {
                    match fs::symlink_metadata(path) {
                        Ok(meta) => {
                            let ft = meta.file_type();
                            extra_type = Some(if ft.is_dir() {
                                'D'
                            } else if ft.is_symlink() {
                                'L'
                            } else {
                                'F'
                            });
                            if let Ok(mtime) = meta.modified() {
                                extra_mtime = Some(format_mtime(mtime));
                            }
                        }
                        Err(e) => {
                            eprintln!(
                                "{}[WARNING]{} Cannot read metadata for `{}`: {}",
                                Color::yellow(),
                                Color::reset(),
                                path.display(),
                                e
                            );
                            exit_code = 1;
                        }
                    }
                }

                if args.json {
                    json_entries.push(serde_json::json!({
                        "path": path.display().to_string(),
                        "bytes": total.to_string(),
                        "formatted": size_str,
                        "type": extra_type.map(|c| c.to_string()),
                        "modified": extra_mtime,
                        "warnings": warnings,
                    }));
                } else {
                    let mut output = size_str;
                    if let Some(t) = extra_type {
                        output.push(' ');
                        output.push(t);
                        if let Some(m) = &extra_mtime {
                            output.push(' ');
                            output.push_str(m);
                        }
                    }

                    if args.paths.len() > 1 {
                        println!("{}\t{}", output, path.display());
                    } else {
                        println!("{}", output);
                    }
                }
            }
            Err(e) => {
                eprintln!("{}[ERROR]{} {}", Color::red(), Color::reset(), e);
                exit_code = 1;
            }
        }
    }

    if args.json {
        let payload = if args.paths.len() > 1 && !args.disk_usage {
            serde_json::json!({
                "entries": json_entries,
                "total_bytes": grand_total.to_string(),
                              "total_formatted": if args.raw {
                                  grand_total.to_string()
                              } else {
                                  format_size(grand_total, args.in_unit, args.binary)
                              },
            })
        } else {
            serde_json::json!({ "entries": json_entries })
        };
        println!("{}", serde_json::to_string_pretty(&payload).unwrap());
    } else if args.paths.len() > 1 && !args.disk_usage {
        let total_str = if args.raw {
            grand_total.to_string()
        } else {
            format_size(grand_total, args.in_unit, args.binary)
        };
        println!("{}\ttotal", total_str);
    }

    process::exit(exit_code);
}

fn run_with_progress(path: &Path, opts: &WalkOptions) -> Result<WalkOutcome, FsizeError> {
    use std::io::IsTerminal;

    let counter = Arc::new(AtomicU64::new(0));
    let done = Arc::new(AtomicBool::new(false));

    let show_progress = std::io::stderr().is_terminal();

    let progress_thread = if show_progress {
        let counter = Arc::clone(&counter);
        let done = Arc::clone(&done);
        Some(std::thread::spawn(move || {
            let start = Instant::now();
            loop {
                std::thread::sleep(Duration::from_millis(150));
                if done.load(Ordering::Relaxed) {
                    break;
                }
                if start.elapsed() > Duration::from_millis(300) {
                    eprint!(
                        "\r{}[SCANNING]{} {} files",
                        Color::yellow(),
                        Color::reset(),
                        counter.load(Ordering::Relaxed)
                    );
                }
            }
        }))
    } else {
        None
    };

    let result = compute_total_size(path, opts, Some(&counter));

    done.store(true, Ordering::Relaxed);
    if let Some(t) = progress_thread {
        let _ = t.join();
        if counter.load(Ordering::Relaxed) > 0 {
            eprint!("\r{}\r", " ".repeat(40));
        }
    }

    result
}