feat: Initial launch v0.1.0
Initialize `sized` CLI tool with comprehensive features for directory visualization. Features: - Fast parallel directory traversal using `rayon`. - Rich table output with colors and dual units (Binary/Decimal). - Recursive sizing with depth control (`-d`). - Filtering by minimum size (`-m`). - Sorting by name, type, size, or blocks (`--sort`). - Gitignore integration (`--ignore`). - Multiple output formats: Text, CSV, JSON (`--format`). - Export to file functionality (`--save`). - Shell completion generation (`--completions`).
This commit is contained in:
+547
@@ -0,0 +1,547 @@
|
||||
use byte_unit::Byte;
|
||||
use chrono::Local;
|
||||
use clap::{CommandFactory, Parser, ValueEnum};
|
||||
use clap_complete::{generate, Shell};
|
||||
use colored::*;
|
||||
use comfy_table::presets::UTF8_FULL;
|
||||
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
|
||||
use humansize::{format_size, BINARY, DECIMAL};
|
||||
use ignore::WalkBuilder;
|
||||
use rayon::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::cmp::Ordering;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(disable_version_flag = true)]
|
||||
struct Args {
|
||||
/// Print version
|
||||
#[arg(short = 'v', long, action = clap::ArgAction::Version)]
|
||||
version: Option<bool>,
|
||||
|
||||
/// Directory to analyze
|
||||
#[arg(default_value = ".")]
|
||||
path: PathBuf,
|
||||
|
||||
/// Minimum size filter (e.g. "10MB", "1gb")
|
||||
#[arg(short = 'm', long)]
|
||||
min_size: Option<String>,
|
||||
|
||||
/// Limit the number of results per table
|
||||
#[arg(short = 'n', long)]
|
||||
number: Option<usize>,
|
||||
|
||||
/// Sort by columns (e.g. "type:asc,size:dsc").
|
||||
/// defaults to size:dsc.
|
||||
/// Columns: name, type, size, blocks.
|
||||
#[arg(long)]
|
||||
sort: Option<String>,
|
||||
|
||||
/// Depth to traverse (0 = only immediate children)
|
||||
#[arg(short, long, default_value = "0")]
|
||||
depth: usize,
|
||||
|
||||
/// Display full absolute paths
|
||||
#[arg(short = 'f', long, conflicts_with = "path_relative")]
|
||||
path_full: bool,
|
||||
|
||||
/// Display paths relative to current directory (default)
|
||||
#[arg(long)]
|
||||
path_relative: bool,
|
||||
|
||||
/// Number of threads to use (defaults to available logical CPUs)
|
||||
#[arg(short = 'j', long = "threads")]
|
||||
threads: Option<usize>,
|
||||
|
||||
/// Generate shell completions
|
||||
#[arg(long, value_enum)]
|
||||
completions: Option<Shell>,
|
||||
|
||||
/// Respect .gitignore and .ignore files
|
||||
#[arg(short = 'i', long)]
|
||||
ignore: bool,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
|
||||
format: OutputFormat,
|
||||
|
||||
/// Save output to file (optional filename, defaults to timestamped name)
|
||||
#[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")]
|
||||
save: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
|
||||
enum OutputFormat {
|
||||
Text,
|
||||
Csv,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SortColumn {
|
||||
Name,
|
||||
Type,
|
||||
Size,
|
||||
Blocks,
|
||||
}
|
||||
|
||||
impl FromStr for SortColumn {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"name" | "n" => Ok(SortColumn::Name),
|
||||
"type" | "t" => Ok(SortColumn::Type),
|
||||
"size" | "s" => Ok(SortColumn::Size),
|
||||
"blocks" | "b" => Ok(SortColumn::Blocks),
|
||||
_ => Err(format!("Unknown column: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SortDirection {
|
||||
Asc,
|
||||
Dsc,
|
||||
}
|
||||
|
||||
impl FromStr for SortDirection {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"asc" | "a" => Ok(SortDirection::Asc),
|
||||
"dsc" | "d" | "desc" => Ok(SortDirection::Dsc),
|
||||
_ => Err(format!("Unknown direction: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct PathMetrics {
|
||||
size_bytes: u64,
|
||||
blocks: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EntryData {
|
||||
path: PathBuf,
|
||||
metrics: PathMetrics,
|
||||
entry_type: String, // "File" or "Dir"
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
// Handle completions
|
||||
if let Some(shell) = args.completions {
|
||||
let mut cmd = Args::command();
|
||||
let name = cmd.get_name().to_string();
|
||||
generate(shell, &mut cmd, name, &mut std::io::stdout());
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure Rayon
|
||||
if let Some(threads) = args.threads {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(threads)
|
||||
.build_global()
|
||||
.expect("Failed to build thread pool");
|
||||
}
|
||||
|
||||
let target_path = args.path.clone();
|
||||
|
||||
if !target_path.exists() {
|
||||
eprintln!("Error: Path '{}' does not exist.", target_path.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Parse min_size
|
||||
let min_bytes = if let Some(size_str) = &args.min_size {
|
||||
match Byte::parse_str(size_str, true) {
|
||||
Ok(byte) => byte.as_u64(),
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing size '{}': {}", size_str, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse sort
|
||||
let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc"));
|
||||
|
||||
// Determine output writer
|
||||
let mut writer: Box<dyn Write> = if let Some(path_arg) = &args.save {
|
||||
let output_path = if path_arg.to_string_lossy() == "_AUTO_" {
|
||||
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
||||
let dir_name = target_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy();
|
||||
let ext = match args.format {
|
||||
OutputFormat::Csv => "csv",
|
||||
OutputFormat::Json => "json",
|
||||
_ => "txt",
|
||||
};
|
||||
PathBuf::from(format!("{}_{}.{}", timestamp, dir_name, ext))
|
||||
} else {
|
||||
path_arg.clone()
|
||||
};
|
||||
|
||||
let f = std::fs::File::create(&output_path).expect("Failed to create output file");
|
||||
Box::new(f)
|
||||
} else {
|
||||
Box::new(std::io::stdout())
|
||||
};
|
||||
|
||||
if args.format == OutputFormat::Csv {
|
||||
writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header");
|
||||
}
|
||||
|
||||
if target_path.is_file() {
|
||||
// Single file case
|
||||
let metadata = target_path.metadata().expect("Failed to get metadata");
|
||||
let metrics = PathMetrics {
|
||||
size_bytes: metadata.len(),
|
||||
blocks: metadata.blocks(),
|
||||
};
|
||||
if metrics.size_bytes < min_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to relative paths unless --path-full is specified
|
||||
let show_relative = !args.path_full;
|
||||
let relative_path = if show_relative {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
target_path
|
||||
.strip_prefix(&cwd)
|
||||
.unwrap_or(&target_path)
|
||||
.display()
|
||||
.to_string()
|
||||
} else {
|
||||
target_path
|
||||
.canonicalize()
|
||||
.unwrap_or(target_path.clone())
|
||||
.display()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
print_output(
|
||||
&mut writer,
|
||||
&target_path,
|
||||
&metrics,
|
||||
&[EntryData {
|
||||
path: target_path.clone(),
|
||||
metrics: metrics.clone(),
|
||||
entry_type: "File".to_string(),
|
||||
}],
|
||||
&args,
|
||||
&relative_path,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursive tables
|
||||
process_directory_recursive(
|
||||
&mut writer,
|
||||
&target_path,
|
||||
0,
|
||||
&args,
|
||||
min_bytes,
|
||||
&sort_criteria,
|
||||
);
|
||||
}
|
||||
|
||||
fn process_directory_recursive(
|
||||
writer: &mut dyn Write,
|
||||
path: &PathBuf,
|
||||
current_depth: usize,
|
||||
args: &Args,
|
||||
min_bytes: u64,
|
||||
sort_criteria: &[(SortColumn, SortDirection)],
|
||||
) {
|
||||
// 1. Get immediate children
|
||||
let walker = WalkBuilder::new(path)
|
||||
.standard_filters(false)
|
||||
.hidden(false)
|
||||
.git_ignore(args.ignore)
|
||||
.ignore(args.ignore)
|
||||
.max_depth(Some(1))
|
||||
.build();
|
||||
|
||||
let entries: Vec<_> = walker
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path() != path) // Exclude root itself
|
||||
.collect();
|
||||
|
||||
// 2. Process metrics for them
|
||||
let mut sized_entries: Vec<EntryData> = entries
|
||||
.par_iter()
|
||||
.map(|entry| {
|
||||
let p = entry.path().to_path_buf();
|
||||
let metrics = get_path_metrics(&p, args.ignore);
|
||||
let entry_type = if p.is_file() {
|
||||
"File".to_string()
|
||||
} else {
|
||||
"Dir".to_string()
|
||||
};
|
||||
EntryData {
|
||||
path: p,
|
||||
metrics,
|
||||
entry_type,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_metrics = calc_total(&sized_entries);
|
||||
|
||||
// Filter
|
||||
if min_bytes > 0 {
|
||||
sized_entries.retain(|e| e.metrics.size_bytes >= min_bytes);
|
||||
}
|
||||
|
||||
// Sort
|
||||
sized_entries.sort_by(|a, b| compare_entries(a, b, sort_criteria));
|
||||
|
||||
// Limit (per table)
|
||||
if let Some(n) = args.number {
|
||||
if n < sized_entries.len() {
|
||||
sized_entries.truncate(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Print this level
|
||||
if current_depth > 0 && args.format == OutputFormat::Text {
|
||||
writeln!(writer, "{}", "-".repeat(60).dimmed()).ok();
|
||||
}
|
||||
// Default to relative paths unless --path-full is specified
|
||||
let show_relative = !args.path_full;
|
||||
let relative_path = if show_relative {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
path.strip_prefix(&cwd)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string()
|
||||
} else {
|
||||
path.canonicalize()
|
||||
.unwrap_or(path.clone())
|
||||
.display()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
print_output(
|
||||
writer,
|
||||
path,
|
||||
&total_metrics,
|
||||
&sized_entries,
|
||||
args,
|
||||
&relative_path,
|
||||
);
|
||||
|
||||
// Recurse if needed
|
||||
if current_depth < args.depth {
|
||||
for entry in sized_entries {
|
||||
if entry.entry_type == "Dir" {
|
||||
process_directory_recursive(
|
||||
writer,
|
||||
&entry.path,
|
||||
current_depth + 1,
|
||||
args,
|
||||
min_bytes,
|
||||
sort_criteria,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_path_metrics(path: &PathBuf, ignore: bool) -> PathMetrics {
|
||||
if path.is_file() {
|
||||
let (size, blocks) = path
|
||||
.metadata()
|
||||
.map(|m| (m.len(), m.blocks()))
|
||||
.unwrap_or((0, 0));
|
||||
return PathMetrics {
|
||||
size_bytes: size,
|
||||
blocks,
|
||||
};
|
||||
}
|
||||
|
||||
let walker = WalkBuilder::new(path)
|
||||
.standard_filters(false)
|
||||
.hidden(false)
|
||||
.git_ignore(ignore)
|
||||
.ignore(ignore)
|
||||
.build();
|
||||
|
||||
let (size_bytes, blocks) = walker
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.par_bridge()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.metadata()
|
||||
.map(|m| (m.len(), m.blocks()))
|
||||
.unwrap_or((0, 0))
|
||||
})
|
||||
.reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
|
||||
|
||||
PathMetrics { size_bytes, blocks }
|
||||
}
|
||||
|
||||
fn calc_total(entries: &[EntryData]) -> PathMetrics {
|
||||
entries.iter().fold(
|
||||
PathMetrics {
|
||||
size_bytes: 0,
|
||||
blocks: 0,
|
||||
},
|
||||
|acc, e| PathMetrics {
|
||||
size_bytes: acc.size_bytes + e.metrics.size_bytes,
|
||||
blocks: acc.blocks + e.metrics.blocks,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn compare_entries(
|
||||
a: &EntryData,
|
||||
b: &EntryData,
|
||||
criteria: &[(SortColumn, SortDirection)],
|
||||
) -> Ordering {
|
||||
for (col, dir) in criteria {
|
||||
let order = match col {
|
||||
SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()),
|
||||
SortColumn::Type => a.entry_type.cmp(&b.entry_type),
|
||||
SortColumn::Size => a.metrics.size_bytes.cmp(&b.metrics.size_bytes),
|
||||
SortColumn::Blocks => a.metrics.blocks.cmp(&b.metrics.blocks),
|
||||
};
|
||||
|
||||
if order != Ordering::Equal {
|
||||
return match dir {
|
||||
SortDirection::Asc => order,
|
||||
SortDirection::Dsc => order.reverse(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> {
|
||||
s.split(',')
|
||||
.filter_map(|part| {
|
||||
let parts: Vec<&str> = part.split(':').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let col_str = parts[0];
|
||||
let col = SortColumn::from_str(col_str).ok()?;
|
||||
|
||||
let dir = if parts.len() > 1 {
|
||||
SortDirection::from_str(parts[1]).unwrap_or(SortDirection::Dsc)
|
||||
} else {
|
||||
match col {
|
||||
SortColumn::Name | SortColumn::Type => SortDirection::Asc,
|
||||
_ => SortDirection::Dsc,
|
||||
}
|
||||
};
|
||||
Some((col, dir))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_output(
|
||||
writer: &mut dyn Write,
|
||||
path: &PathBuf,
|
||||
metrics: &PathMetrics,
|
||||
entries: &[EntryData],
|
||||
args: &Args,
|
||||
relative_path: &str,
|
||||
) {
|
||||
match args.format {
|
||||
OutputFormat::Text => {
|
||||
writeln!(writer, "\n{}", relative_path.bold().blue()).ok();
|
||||
writeln!(
|
||||
writer,
|
||||
"Total size: {} / {} ({} blocks)",
|
||||
format_size(metrics.size_bytes, BINARY).bold().green(),
|
||||
format_size(metrics.size_bytes, DECIMAL).bold().green(),
|
||||
metrics.blocks
|
||||
)
|
||||
.ok();
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
Cell::new("Name").add_attribute(Attribute::Bold),
|
||||
Cell::new("Type").add_attribute(Attribute::Bold),
|
||||
Cell::new("Size (Bin)")
|
||||
.add_attribute(Attribute::Bold)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
Cell::new("Size (Bytes)")
|
||||
.add_attribute(Attribute::Bold)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
Cell::new("Blocks")
|
||||
.add_attribute(Attribute::Bold)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
]);
|
||||
|
||||
for entry in entries {
|
||||
let name = entry.path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let color = if entry.entry_type == "Dir" {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::Cyan
|
||||
};
|
||||
|
||||
table.add_row(vec![
|
||||
Cell::new(name).fg(color),
|
||||
Cell::new(&entry.entry_type).fg(Color::Yellow),
|
||||
Cell::new(format_size(entry.metrics.size_bytes, BINARY))
|
||||
.fg(Color::Green)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
Cell::new(format_size(entry.metrics.size_bytes, DECIMAL))
|
||||
.fg(Color::Green)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
Cell::new(entry.metrics.blocks)
|
||||
.fg(Color::Green)
|
||||
.set_alignment(CellAlignment::Right),
|
||||
]);
|
||||
}
|
||||
writeln!(writer, "{}", table).ok();
|
||||
}
|
||||
OutputFormat::Csv => {
|
||||
// Check if we need to print header? Csv writer handles it if we tell it?
|
||||
// Simple flatten: Path,Type,Size,Blocks
|
||||
// But we have entries list.
|
||||
for entry in entries {
|
||||
writeln!(
|
||||
writer,
|
||||
"{},{},{},{}",
|
||||
entry.path.display(),
|
||||
entry.entry_type,
|
||||
entry.metrics.size_bytes,
|
||||
entry.metrics.blocks
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
// NDJSON report per directory
|
||||
let report = json!({
|
||||
"path": path,
|
||||
"total_size": metrics.size_bytes,
|
||||
"blocks": metrics.blocks,
|
||||
"entries": entries
|
||||
});
|
||||
writeln!(writer, "{}", serde_json::to_string(&report).unwrap()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user