diff --git a/Cargo.toml b/Cargo.toml index 76cbaac..cbc405e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,5 +7,8 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -colored = "2" -sysinfo = "0.3.1" +colored = "3" +sysinfo = "0.35.1" +clap = { version = "4", features = ["derive"] } +mountpoints = "0.2" +libc = "0.2" diff --git a/README.md b/README.md index 04728b1..c77ad68 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,122 @@ -# ndf -Nice disk free. +# ndf - Nice Disk Free + +A modern, colorful disk usage utility written in Rust. `ndf` provides a clean and intuitive way to view disk space information with beautiful progress bars and multiple display modes. + +## Features + +- ๐ŸŽจ **Colorful Progress Bars**: Visual representation of disk usage with red/green color coding +- ๐Ÿ“Š **Multiple Display Modes**: Choose from normal, compact, or table formats +- ๐Ÿ“ **Adaptive Table Layout**: Automatically adjusts column widths for optimal display +- ๐Ÿ” **Mount Point Filtering**: Include or exclude specific mount points +- โšก **Fast and Lightweight**: Written in Rust for optimal performance +- ๐ŸŽฏ **Smart Filtering**: Automatically ignores overlay and snap mounts + +## Installation + +### From Source + +Clone this repository and build: + +```bash +git clone https://github.com/kiobu/ndf.git +cd ndf +cargo build --release +cargo install --path . +``` + +Or use the "Clone" button above to get the current repository URL. + +## Usage + +### Basic Usage + +```bash +# Default table mode +ndf + +# Specific display modes +ndf normal # Detailed view with mount points +ndf compact # One-line per disk +ndf table # Formatted table (default) +``` + +### Filtering Options + +```bash +# Show only specific mount points +ndf --only-mp "/" +ndf --only-mp "/,/home" + +# Exclude specific mount points +ndf --exclude-mp "/snap,/tmp" +``` + +### Help + +```bash +ndf --help +``` + +## Display Modes + +### Table Mode (Default) + +The table display mode is inspired by [duf](https://github.com/muesli/duf), providing a clean and organized view of disk information with adaptive column widths. + +```text +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Mount โ”‚ Size โ”‚ Free โ”‚ Usage โ”‚ Name โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ / โ”‚ 926.35G โ”‚ 303.61G โ”‚ โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 67% โ”‚ Macintosh HD โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Normal Mode + +```text +Macintosh HD @ / +โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 67% +``` + +### Compact Mode + +```text +Macintosh HD: โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–“โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 67% +``` + +## Color Coding + +- ๐ŸŸฉ **Green**: Normal usage (< 80%) +- ๐ŸŸฅ **Red**: High usage (โ‰ฅ 80%) + +## Command Line Options + +```text +Usage: ndf [OPTIONS] [mode] + +Arguments: + [mode] Display mode: normal | compact | table [default: table] + +Options: + --only-mp Show only specified mount points, comma separated + --exclude-mp Exclude specified mount points, comma separated + -h, --help Print help +``` + +## Requirements + +- Rust 1.70+ (for building from source) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Acknowledgments + +- Built with [clap](https://github.com/clap-rs/clap) for command-line parsing +- Uses [colored](https://github.com/mackwic/colored) for terminal colors +- System information provided by [sysinfo](https://github.com/GuillaumeGomez/sysinfo) diff --git a/src/main.rs b/src/main.rs index b572f87..24c16db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,45 +1,48 @@ -#![allow(non_snake_case)] +use clap::{value_parser, Arg, Command, ValueEnum}; +use colored::*; +use std::collections::HashSet; +use sysinfo::Disks; -const MAX_CHARS: usize = 50; // Max number of characters for the disk's available space bar. +const MAX_CHARS: usize = 50; -use colored::*; -use sysinfo::{DiskExt, SystemExt}; -use std::ffi::{OsString}; -use std::env; +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum OutputMode { + Normal, + Compact, + Table, +} -fn get_frac(avail: &u64, total: &u64) -> f64 { - if *total == 0 { // Prevent divide by 0. - return 0 as f64; +fn get_frac(avail: u64, total: u64) -> f64 { + if total == 0 { + return 0.0; } - - return 1 as f64 - (*avail as f64/ *total as f64); + 1.0 - (avail as f64 / total as f64) } struct NDFDisk { name: String, - space_asfrac: f64, - mnt: String + space_as_frac: f64, + mnt: String, + size: u64, + free: u64, } impl NDFDisk { - fn create_NDFDisk(disk: &sysinfo::Disk) -> NDFDisk { - let frac = get_frac(&disk.get_available_space(), &disk.get_total_space()); - - match OsString::from(disk.get_name()).into_string() { - Ok(s) => { - NDFDisk { - name: s, - space_asfrac: frac, - mnt: disk.get_mount_point().display().to_string() - } - }, - Err(_) => panic!("No name for disk.") + fn create_ndf_disk(disk: &sysinfo::Disk) -> NDFDisk { + let frac = get_frac(disk.available_space(), disk.total_space()); + NDFDisk { + name: disk.name().to_string_lossy().to_string(), + space_as_frac: frac, + mnt: disk.mount_point().to_string_lossy().to_string(), + size: disk.total_space(), + free: disk.available_space(), } } - fn create_bar(&self) -> colored::ColoredString { - let chars_num = ((MAX_CHARS as f64*self.space_asfrac).ceil()) as usize; + + fn create_bar(&self) -> ColoredString { + let chars_num = (MAX_CHARS as f64 * self.space_as_frac).ceil() as usize; let chars = "โ–“".repeat(chars_num); - let rem_num = (MAX_CHARS - chars_num) as usize; + let rem_num = MAX_CHARS - chars_num; let rem = "โ–‘".repeat(rem_num); if rem_num < (MAX_CHARS as f64 * 0.2) as usize { @@ -48,39 +51,400 @@ impl NDFDisk { format!("{}{}", chars, rem).green() } } + + fn create_plain_bar(&self) -> String { + let chars_num = (MAX_CHARS as f64 * self.space_as_frac).ceil() as usize; + let chars = "โ–“".repeat(chars_num); + let rem_num = MAX_CHARS - chars_num; + let rem = "โ–‘".repeat(rem_num); + format!("{}{}", chars, rem) + } + + fn is_high_usage(&self) -> bool { + let rem_num = MAX_CHARS - (MAX_CHARS as f64 * self.space_as_frac).ceil() as usize; + rem_num < (MAX_CHARS as f64 * 0.2) as usize + } } -fn main() { - let args: Vec = env::args().collect(); +fn format_size(size: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + match size { + s if s >= TB => format!("{:.2}T", s as f64 / TB as f64), + s if s >= GB => format!("{:.2}G", s as f64 / GB as f64), + s if s >= MB => format!("{:.2}M", s as f64 / MB as f64), + s if s >= KB => format!("{:.2}K", s as f64 / KB as f64), + _ => format!("{}B", size), + } +} + +// Define a struct to hold disk usage information +struct DiskUsage { + total: u64, + free: u64, + fs_type: u32, // File system type identifier (0 on non-Linux systems) +} + +// Get disk usage for a given path using standard library +fn get_disk_usage_for_path(path: &str) -> std::io::Result { + use std::mem; + + #[cfg(unix)] + { + use std::ffi::CString; - let mut compactmode = false; + let c_path = CString::new(path.as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid path"))?; - match args.get(1) { - Some(val) => { - if val == "compact" { - compactmode = true; + // On Linux, we use statfs which provides filesystem type + #[cfg(target_os = "linux")] + { + let mut statfs_buf: libc::statfs = unsafe { mem::zeroed() }; + let result = unsafe { + libc::statfs(c_path.as_ptr(), &mut statfs_buf) + }; + + if result == 0 { + Ok(DiskUsage { + total: (statfs_buf.f_bsize as u64) * (statfs_buf.f_blocks as u64), + free: (statfs_buf.f_bsize as u64) * (statfs_buf.f_bavail as u64), + fs_type: statfs_buf.f_type as u32, + }) + } else { + Err(std::io::Error::last_os_error()) + } + } + + // On other Unix systems (like macOS), we use statvfs + #[cfg(not(target_os = "linux"))] + { + let mut statvfs_buf: libc::statvfs = unsafe { mem::zeroed() }; + let result = unsafe { + libc::statvfs(c_path.as_ptr(), &mut statvfs_buf) + }; + + if result == 0 { + Ok(DiskUsage { + total: (statvfs_buf.f_frsize as u64) * (statvfs_buf.f_blocks as u64), + free: (statvfs_buf.f_frsize as u64) * (statvfs_buf.f_bavail as u64), + fs_type: 0, // Not available on non-Linux systems + }) + } else { + Err(std::io::Error::last_os_error()) } - }, - None => { - (); } } - let sys = sysinfo::System::new(); + #[cfg(not(unix))] + { + // For non-Unix systems, we'll return an error for now + // In a full implementation, we would use platform-specific APIs like GetDiskFreeSpaceEx on Windows + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "Platform not supported")) + } +} + +fn main() { + let matches = Command::new("ndf") + .about("Nice disk free.") + .arg( + Arg::new("mode") + .value_parser(value_parser!(OutputMode)) + .default_value("table") + .help("Display mode: normal | compact | table"), + ) + .arg( + Arg::new("only-mp") + .long("only-mp") + .value_name("MOUNTPOINTS") + .help("Show only specified mount points, comma separated"), + ) + .arg( + Arg::new("exclude-mp") + .long("exclude-mp") + .value_name("MOUNTPOINTS") + .help("Exclude specified mount points, comma separated"), + ) + .get_matches(); + + let output_mode = *matches.get_one::("mode").unwrap(); + + let only_mp: Option> = matches + .get_one::("only-mp") + .map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); + + let exclude_mp: Option> = matches + .get_one::("exclude-mp") + .map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); + let mut disks: Vec = Vec::new(); - for disk in sys.get_disks() { - disks.push(NDFDisk::create_NDFDisk(disk)); - }; + let mut processed_mounts = std::collections::HashSet::new(); + + // Process disks from sysinfo first + for disk in Disks::new_with_refreshed_list().list() { + let mnt = disk.mount_point().to_string_lossy(); + processed_mounts.insert(mnt.to_string()); + + let fs_type = disk.file_system().to_string_lossy().to_string(); + + // ignore overlay, devfs and snap mounts, but allow CIFS/SMB mounts + if fs_type == "overlay" || fs_type == "devfs" || mnt.starts_with("/var/snap/") { + continue; + } + + // Skip system internal mount points that are not meaningful to users + if mnt.starts_with("/private/") || mnt.starts_with("/sys/") || + mnt.starts_with("/proc/") || mnt.starts_with("/run/") || + mnt.starts_with("/boot/") || mnt.starts_with("/var/") && mnt != "/var" || + mnt.starts_with("/snap/") || mnt == "/run" { + continue; + } + + if let Some(ref only) = only_mp { + if !only.contains(mnt.as_ref()) { + continue; + } + } + if let Some(ref exclude) = exclude_mp { + if exclude.contains(mnt.as_ref()) { + continue; + } + } + disks.push(NDFDisk::create_ndf_disk(disk)); + } + + // Now get all mount points using mountpoints crate to catch network mounts that sysinfo might miss + if let Ok(mount_paths) = mountpoints::mountpaths() { + for mount_path in mount_paths { + let mount_str = mount_path.to_string_lossy().to_string(); + + // Skip if already processed by sysinfo + if processed_mounts.contains(&mount_str) { + continue; + } - println!("{}", "\nndf - nice disk free".bold()); + // Attempt to get disk usage for this mount point + if let Ok(_metadata) = std::fs::metadata(&mount_path) { + if let Ok(usage) = get_disk_usage_for_path(&mount_str) { - if compactmode { - for disk in disks.into_iter() { - println!("{}: {} {:.0}%", disk.name, disk.create_bar(), disk.space_asfrac*100 as f64); + // Apply filters + if let Some(ref only) = only_mp { + if !only.contains(&mount_str) { + continue; + } + } + if let Some(ref exclude) = exclude_mp { + if exclude.contains(&mount_str) { + continue; + } + } + + // Skip system internal mount points + if mount_str.starts_with("/System/Volumes/") && mount_str != "/System/Volumes/Data" || + mount_str.starts_with("/dev/") || mount_str.starts_with("/private/") || + mount_str.starts_with("/sys/") || mount_str.starts_with("/proc/") || + mount_str.starts_with("/run/") || mount_str.starts_with("/boot/") || + mount_str.starts_with("/var/snap/") || mount_str.starts_with("/var/") && mount_str != "/var" { + continue; + } + + // Additionally, skip virtual filesystems that might be detected by mountpoints + // Check if the mount point is accessible and represents actual storage + if mount_str == "/dev" || mount_str.starts_with("/snap/") || mount_str == "/run" { + continue; // Skip /dev which is a virtual filesystem, snap packages, and /run + } + + // Define constants for filesystem types (from sys/magic.h on Linux) + const TMPFS_MAGIC: u32 = 0x01021994; + const DEVPTS_MAGIC: u32 = 0x1cd1; + const SYSFS_MAGIC: u32 = 0x62656572; + const PROC_MAGIC: u32 = 0x9fa0; + const DEVFS_MAGIC: u32 = 0x1373; + const RAMFS_MAGIC: u32 = 0x858458f6; + const SECURITYFS_MAGIC: u32 = 0x73636673; + const CGROUP_MAGIC: u32 = 0x27e0eb; + const CGROUP2_MAGIC: u32 = 0x63677270; + const OVERLAYFS_SUPER_MAGIC: u32 = 0x794c7630; + const FUSECTL_SUPER_MAGIC: u32 = 0x65735543; + + // Skip virtual filesystems based on filesystem type + if cfg!(target_os = "linux") && ( + usage.fs_type == TMPFS_MAGIC || + usage.fs_type == DEVPTS_MAGIC || + usage.fs_type == SYSFS_MAGIC || + usage.fs_type == PROC_MAGIC || + usage.fs_type == DEVFS_MAGIC || + usage.fs_type == RAMFS_MAGIC || + usage.fs_type == SECURITYFS_MAGIC || + usage.fs_type == CGROUP_MAGIC || + usage.fs_type == CGROUP2_MAGIC || + usage.fs_type == OVERLAYFS_SUPER_MAGIC || + usage.fs_type == FUSECTL_SUPER_MAGIC + ) { + continue; // Skip virtual filesystems + } + + // Handle special cases for network mounts where usage stats might be unreliable + if usage.total == 0 && usage.free == 0 { + // Skip mount points that report zero bytes for both + } else if usage.total > 0 && usage.free <= usage.total { + // Regular case: total >= free + // Create an NDFDisk for this mount point + let frac = get_frac(usage.free, usage.total); + let mount_name = mount_str.split('/').last().unwrap_or("").to_string(); + + // Check if this mount point is already added to avoid duplicates + if !processed_mounts.contains(&mount_str) { + disks.push(NDFDisk { + name: mount_name, + space_as_frac: frac, + mnt: mount_str.clone(), + size: usage.total, + free: usage.free, + }); + processed_mounts.insert(mount_str); + } + } else if usage.total > 0 && usage.free > usage.total { + // Special case: free > total (common with network shares) + // We'll try to get more accurate info by checking if the path is accessible + if std::path::Path::new(&mount_str).exists() { + // For network mounts where free > total, we'll show the total as reported + // but calculate usage differently - maybe show as low usage + let frac = 0.1; // Assume low usage if free > total + let mount_name = mount_str.split('/').last().unwrap_or("").to_string(); + + // Check if this mount point is already added to avoid duplicates + if !processed_mounts.contains(&mount_str) { + disks.push(NDFDisk { + name: mount_name, + space_as_frac: frac, + mnt: mount_str.clone(), + size: usage.total, + free: usage.free, + }); + processed_mounts.insert(mount_str); + } + } + } + } + } } - } else { - for disk in disks.into_iter() { - println!("{} @ {}\n{} {:.0}%\n", disk.name, disk.mnt, disk.create_bar(), disk.space_asfrac*100 as f64); + } + + println!("{}", "ndf - nice disk free".bold()); + + match output_mode { + OutputMode::Compact => { + for disk in disks { + println!( + "{}: {} {:.0}%", + disk.name, + disk.create_bar(), + disk.space_as_frac * 100.0 + ); + } + } + OutputMode::Table => { + // ่ฎก็ฎ—ๆฏๅˆ—็š„ๆœ€ๅคงๅฎฝๅบฆ + let mut max_mount_len = "Mount".len(); + let mut max_size_len = "Size".len(); + let mut max_free_len = "Free".len(); + let mut max_name_len = "Name".len(); + + for disk in &disks { + max_mount_len = max_mount_len.max(disk.mnt.len().min(20)); + max_size_len = max_size_len.max(format_size(disk.size).len()); + max_free_len = max_free_len.max(format_size(disk.free).len()); + max_name_len = max_name_len.max(disk.name.len().min(15)); + } + + // Usageๅˆ—ๅ›บๅฎšไธบ่ฟ›ๅบฆๆกๅฎฝๅบฆ + ็™พๅˆ†ๆฏ” + let usage_len = MAX_CHARS + 4; // 50ๅญ—็ฌฆ่ฟ›ๅบฆๆก + ็ฉบๆ ผ + 3ๅญ—็ฌฆ็™พๅˆ†ๆฏ” + + // ๆ‰‹ๅŠจๅˆ›ๅปบ่กจๆ ผ + println!( + "โ”Œ{:โ”€width_size$} โ”‚ {:>width_free$} โ”‚ {:^width_usage$} โ”‚ {: 20 { + disk.mnt[..17].to_string() + "..." + } else { + disk.mnt.clone() + }; + let size_text = format_size(disk.size); + let free_text = format_size(disk.free); + let name_text = if disk.name.len() > 15 { + disk.name[..12].to_string() + "..." + } else { + disk.name.clone() + }; + + // ๆž„ๅปบUsageๅˆ—ๅ†…ๅฎน + let plain_bar = disk.create_plain_bar(); + let percentage = format!("{:.0}%", disk.space_as_frac * 100.0); + + let colored_bar = if disk.is_high_usage() { + plain_bar.red() + } else { + plain_bar.green() + }; + + println!( + "โ”‚ {:width_size$} โ”‚ {:>width_free$} โ”‚ {} {:>3} โ”‚ {: { + for disk in disks { + println!( + "{} @ {}\n{} {:.0}%\n", + disk.name, + disk.mnt, + disk.create_bar(), + disk.space_as_frac * 100.0 + ); + } } } -} \ No newline at end of file +}