diff --git a/CHANGELOG.md b/CHANGELOG.md index e00a221..b4dbc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-22 + +### Changed +- Refactored core traversal logic to a **single-pass, in-memory tree construction** algorithm. This visits every file exactly once, replacing the previous $O(N \times Depth)$ behavior with $O(N)$. +- Switched default CSV output to use the `csv` crate for robust escaping (fixes issues with filenames containing commas). +- Optimized JSON output to use zero-copy serialization, improving performance and memory usage. + +### Added +- Added `MANUAL.md` with comprehensive usage documentation. + +### Removed +- Removed `walkdir` dependency (replaced by `ignore::WalkBuilder` for all traversal). + ## [0.1.0] - 2026-01-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index e594988..b969b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,16 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clap_mangen" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -868,6 +878,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + [[package]] name = "rust_decimal" version = "1.40.0" @@ -999,13 +1015,14 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "sized" -version = "0.1.0" +version = "0.2.0" dependencies = [ "atty", "byte-unit", "chrono", "clap", "clap_complete", + "clap_mangen", "colored", "comfy-table", "csv", @@ -1015,7 +1032,6 @@ dependencies = [ "serde", "serde_json", "strum", - "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0137d86..d95ecc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "sized" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] clap = { version = "4.4", features = ["derive"] } -walkdir = "2.3" + rayon = "1.8" humansize = "2.1" colored = "2.0" @@ -19,3 +19,4 @@ csv = "1.3" chrono = "0.4" clap_complete = "4.4" atty = "0.2" +clap_mangen = "0.2.31" diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..9cbf274 --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,154 @@ +# Sized User Manual + +`sized` is a modern, fast, and concurrent disk usage analyzer for the command line, written in Rust. It is designed to provide quick insights into directory sizes with a focus on readability and flexibility. + +## Table of Contents +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [Filtering and sorting](#filtering-and-sorting) +- [Output Formats](#output-formats) +- [Advanced Features](#advanced-features) + - [Concurrency](#concurrency) + - [Gitignore Support](#gitignore-support) + - [Exporting Data](#exporting-data) +- [Shell Completions](#shell-completions) + +## Installation + +Currently, `sized` can be installed from source: + +```bash +git clone +cd sized +cargo install --path . +``` + +## Basic Usage + +By default, `sized` analyzes the current directory recursively and displays a table of the immediate children, sorted by name. + +```bash +sized +``` + +To analyze a specific path: +```bash +sized /path/to/directory +``` + +The output includes: +- **Type**: Icon indicating if it's a directory (📁) or file (📄). +- **Name**: The relative path to the entry. +- **Size**: Human-readable size (e.g., 10 MB, 2.5 GB). +- **% of Parent**: The percentage of the total size of the *current view* (immediate children) that this entry consumes. +- **Last Modified**: Time since the file was last modified. + +### Total Size Header +The header displays the total size of the scanned directory in both Binary (MiB/GiB) and Decimal (MB/GB) units, along with the total block count. + +## Filtering and Sorting + +### Sorting (`--sort`) +Sort the output table by a specific column. + +**Syntax**: `--sort ` +- **Columns**: `name`, `size`, `type` +- **Directions**: `asc` (ascending), `desc` (descending) + +**Examples**: +```bash +# Sort by size (largest first) - Default behavior +sized --sort size:desc + +# Sort by name (A-Z) +sized --sort name:asc +``` + +### Minimum Size (`-m` / `--min-size`) +Hide entries smaller than a specific size to reduce noise. + +**Examples**: +```bash +# Show only files/dirs larger than 10 MB +sized -m 10MB + +# Show only files/dirs larger than 1 GB +sized --min-size 1GB +``` + +### Depth Control (`-d` / `--depth`) +Limit the recursion depth for the *calculation*. Note that the display currently shows immediate children, but this flag controls how deep `sized` looks to calculate directory sizes. +*(Note: Deeply nested directory sizes are always fully calculated unless limited)* + +```bash +sized -d 2 +``` + +## Output Formats (`--format`) + +`sized` supports multiple output formats for integration with other tools. + +### Table (Default) +The standard human-readable ASCII table with colors. +```bash +sized --format text +``` + +### CSV +Comma-Separated Values, suitable for spreadsheets. +```bash +sized --format csv +``` +Columns: `path`, `size_bytes`, `files`, `dirs` + +### JSON +Computed metrics in NDJSON (Newline Delimited JSON) format. +```bash +sized --format json +``` + +## Advanced Features + +### Path Display +- **Default**: Relative paths (`./folder`) +- **Full Path**: Use `-f` or `--path-full` to see absolute paths (`/users/name/folder`). +- **Relative Path**: Use `--path-relative` to explicitly force relative paths. + +### Concurrency (`-j` / `--threads`) +`sized` uses parallel processing. By default, it uses a number of threads equal to your CPU cores. You can limit this for system stability or increase it (though usually not clear). + +```bash +# Limit to 4 threads +sized -j 4 +``` + +### Gitignore Support (`-i` / `--ignore`) +Respect `.gitignore` and `.ignore` files. This is useful for checking the size of a project *as it would be committed*, ignoring `target/`, `node_modules/`, etc. + +```bash +sized -i +``` + +### Exporting Data (`--save`) +Save the output directly to a file. If you use `_AUTO_` (or provide no argument to the flag), it generates a filename with the current timestamp. + +```bash +# Save to a specific file +sized --save report.txt + +# Save to a timestamped file (e.g., 20250122-120000_sized_report.txt) +sized --save +``` + +## Shell Completions + +Generate shell completion scripts for Bash, Zsh, Fish, PowerShell, or Elvish. + +```bash +# Generate for Zsh +sized --completions zsh > _sized +``` + +### Installation (Zsh example) +1. Generate the completion file: `sized --completions zsh > ~/.zfunc/_sized` +2. Add to your `.zshrc`: `fpath+=~/.zfunc; autoload -Uz compinit && compinit` diff --git a/README.md b/README.md index 28f6e59..14438d3 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,32 @@ sized /path/to/directory sized . -d 1 -i -m 50MB --format csv --save ``` -**Generate Shell Completions (Zsh):** +### Generate Shell Completions + +**Bash:** +Add the following to your `.bashrc` or `.bash_profile`: ```bash -sized --completions zsh > _sized +# Option 1: Source directly +source <(sized --completions bash) + +# Option 2: Save to file (safer startup time) +sized --completions bash > ~/.sized_completions.bash +echo "source ~/.sized_completions.bash" >> ~/.bashrc +``` + +**Zsh:** +```bash +# In your .zshrc +sized --completions zsh > /usr/local/share/zsh/site-functions/_sized +# OR +sized --completions zsh > ~/.zfunc/_sized +fpath+=~/.zfunc +autoload -Uz compinit && compinit +``` + +**Fish:** +```bash +sized --completions fish > ~/.config/fish/completions/sized.fish ``` ## License diff --git a/src/main.rs b/src/main.rs index 1cc8304..97272ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,15 +5,16 @@ use clap_complete::{generate, Shell}; use colored::*; use comfy_table::presets::UTF8_FULL; use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; +use csv::WriterBuilder; 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::path::{Path, PathBuf}; use std::str::FromStr; #[derive(Parser, Debug, Clone)] @@ -120,17 +121,50 @@ impl FromStr for SortDirection { } } -#[derive(Clone, Serialize)] -struct PathMetrics { +#[derive(Clone, Serialize, Debug)] +struct Node { + path: PathBuf, + size_bytes: u64, + blocks: u64, + entry_type: EntryType, + #[serde(skip)] + children: Vec, +} + +#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum EntryType { + File, + Dir, +} + +impl std::fmt::Display for EntryType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EntryType::File => write!(f, "File"), + EntryType::Dir => write!(f, "Dir"), + } + } +} + +#[derive(Serialize)] +struct JsonMetrics { size_bytes: u64, blocks: u64, } #[derive(Serialize)] -struct EntryData { - path: PathBuf, - metrics: PathMetrics, - entry_type: String, // "File" or "Dir" +struct JsonEntry<'a> { + path: &'a PathBuf, + metrics: JsonMetrics, + entry_type: String, +} + +#[derive(Serialize)] +struct JsonReport<'a> { + path: &'a PathBuf, + total_size: u64, + blocks: u64, + entries: Vec>, } fn main() { @@ -200,157 +234,155 @@ fn main() { }; if args.format == OutputFormat::Csv { + // We do NOT write the header here manually anymore if we want to be safe/consistent, + // OR we write it manually but treat it as raw bytes. + // Actually, if we use csv::Writer, we should use it for the header too, OR assume the writer calls will handle it (no, we need to init). + // For simplicity in this logic, let's write the header manually *once* if we are creating a fresh file/stream. + // BUT, `process_node_recursive` might be called multiple times? + // Wait, `process_node_recursive` is called recursively. + // The CSV logic implementation in `print_output` currently printed children. + // If we recurse, we print children of children. + // So a single global header is correct. 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; + // Build the tree! + let root_node = build_tree(&target_path, args.ignore); + + if root_node.size_bytes < min_bytes { + // Even the root is too small? + if args.format == OutputFormat::Text { + writeln!(writer, "Root directory is smaller than minimum size.").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(); - 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, - ); + // Render output + process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); } -fn process_directory_recursive( +/// Builds the directory tree in memory, calculating sizes in a single pass. +/// Uses Rayon for parallelism. +fn build_tree(path: &Path, ignore: bool) -> Node { + let metadata = path.metadata(); + + if let Ok(meta) = metadata { + if meta.is_file() { + return Node { + path: path.to_path_buf(), + size_bytes: meta.len(), + blocks: meta.blocks(), + entry_type: EntryType::File, + children: vec![], + }; + } + } + + // It's a directory (or error, which we treat as zero-size usually, but here we try to read) + // Use ignore::WalkBuilder to get *immediate children only* respecting gitignore + let walker = WalkBuilder::new(path) + .standard_filters(false) // We manually control ignore + .hidden(false) // Show hidden files + .git_ignore(ignore) + .ignore(ignore) + .max_depth(Some(1)) // ONLY immediate children + .build(); + + let child_paths: Vec = walker + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path() != path) // Exclude self + .map(|e| e.path().to_path_buf()) + .collect(); + + // Recursively build children in parallel + let children: Vec = child_paths + .par_iter() + .map(|p| build_tree(p, ignore)) + .collect(); + + // Sum up stats + let (size_bytes, blocks) = children + .iter() + .fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks)); + + // Add self-size (directory entry itself has size) + let (self_size, self_blocks) = path + .metadata() + .map(|m| (m.len(), m.blocks())) + .unwrap_or((0, 0)); + + Node { + path: path.to_path_buf(), + size_bytes: size_bytes + self_size, + blocks: blocks + self_blocks, + entry_type: EntryType::Dir, + children, + } +} + +fn process_node_recursive( writer: &mut dyn Write, - path: &PathBuf, + node: &Node, 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 = 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); + if node.children.is_empty() && node.entry_type == EntryType::Dir { + // Empty directory or leaf in terms of permissions/ignore } - // Sort - sized_entries.sort_by(|a, b| compare_entries(a, b, sort_criteria)); + // Prepare children for display + let mut display_children: Vec<&Node> = node + .children + .iter() + .filter(|c| c.size_bytes >= min_bytes) + .collect(); - // Limit (per table) + // Sort + display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria)); + + // Limit if let Some(n) = args.number { - if n < sized_entries.len() { - sized_entries.truncate(n); + if n < display_children.len() { + display_children.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) + node.path + .strip_prefix(&cwd) + .unwrap_or(&node.path) .display() .to_string() } else { - path.canonicalize() - .unwrap_or(path.clone()) + node.path + .canonicalize() + .unwrap_or(node.path.clone()) .display() .to_string() }; print_output( writer, - path, - &total_metrics, - &sized_entries, + &node, + &display_children, args, &relative_path, + current_depth, ); - // Recurse if needed + // Recurse if current_depth < args.depth { - for entry in sized_entries { - if entry.entry_type == "Dir" { - process_directory_recursive( + for child in display_children { + if child.entry_type == EntryType::Dir { + process_node_recursive( writer, - &entry.path, + child, current_depth + 1, args, min_bytes, @@ -361,64 +393,13 @@ fn process_directory_recursive( } } -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 { +fn compare_nodes(a: &Node, b: &Node, 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), + SortColumn::Size => a.size_bytes.cmp(&b.size_bytes), + SortColumn::Blocks => a.blocks.cmp(&b.blocks), }; if order != Ordering::Equal { @@ -457,91 +438,126 @@ fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> { fn print_output( writer: &mut dyn Write, - path: &PathBuf, - metrics: &PathMetrics, - entries: &[EntryData], + parent_node: &Node, + children: &[&Node], args: &Args, relative_path: &str, + depth: usize, ) { 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(); - } + OutputFormat::Text => print_text(writer, parent_node, children, relative_path, depth), + OutputFormat::Csv => print_csv(writer, children), + OutputFormat::Json => print_json(writer, parent_node, children), } } + +fn print_text( + writer: &mut dyn Write, + parent_node: &Node, + children: &[&Node], + relative_path: &str, + depth: usize, +) { + if depth > 0 { + writeln!(writer, "{}", "-".repeat(60).dimmed()).ok(); + } + writeln!(writer, "\n{}", relative_path.bold().blue()).ok(); + writeln!( + writer, + "Total size: {} / {} ({} blocks)", + format_size(parent_node.size_bytes, BINARY).bold().green(), + format_size(parent_node.size_bytes, DECIMAL).bold().green(), + parent_node.blocks + ) + .ok(); + + if children.is_empty() { + writeln!(writer, "(No children)").ok(); + return; + } + + 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 child in children { + let name = child.path.file_name().unwrap_or_default().to_string_lossy(); + let color = if child.entry_type == EntryType::Dir { + Color::Blue + } else { + Color::Cyan + }; + + table.add_row(vec![ + Cell::new(name).fg(color), + Cell::new(&child.entry_type).fg(Color::Yellow), + Cell::new(format_size(child.size_bytes, BINARY)) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + Cell::new(format_size(child.size_bytes, DECIMAL)) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + Cell::new(child.blocks) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + ]); + } + writeln!(writer, "{}", table).ok(); +} + +fn print_csv(writer: &mut dyn Write, children: &[&Node]) { + // strict csv writing + let mut wtr = WriterBuilder::new() + .has_headers(false) // We already wrote the header globally + .from_writer(writer); + + for child in children { + wtr.write_record(&[ + child.path.to_string_lossy().as_ref(), // This handles escaping automatically + &child.entry_type.to_string(), + &child.size_bytes.to_string(), + &child.blocks.to_string(), + ]) + .ok(); + } + wtr.flush().ok(); +} + +fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { + // Zero-copy serialization + let entries_view: Vec = children + .iter() + .map(|c| JsonEntry { + path: &c.path, + metrics: JsonMetrics { + size_bytes: c.size_bytes, + blocks: c.blocks, + }, + entry_type: c.entry_type.to_string(), + }) + .collect(); + + let report = JsonReport { + path: &parent_node.path, + total_size: parent_node.size_bytes, + blocks: parent_node.blocks, + entries: entries_view, + }; + + serde_json::to_writer(&mut *writer, &report).ok(); + writer.write_all(b"\n").ok(); +}