dirwalk 1.1.1

Platform-optimized recursive directory walker with metadata
Documentation
use crate::entry::Entry;
use std::io::Write;
use time::OffsetDateTime;
use time::macros::format_description;

const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
const TB: u64 = 1024 * GB;

/// Format a byte count as a human-readable string (B, K, M, G, T).
/// Returns `-` for directories.
pub fn format_size(bytes: u64, is_dir: bool) -> String {
    if is_dir {
        return "-".to_owned();
    }

    match bytes {
        0 => "0".to_owned(),
        b if b < KB => format!("{b}"),
        b if b < MB => format!("{:.1}K", b as f64 / KB as f64),
        b if b < GB => format!("{:.1}M", b as f64 / MB as f64),
        b if b < TB => format!("{:.1}G", b as f64 / GB as f64),
        b => format!("{:.1}T", b as f64 / TB as f64),
    }
}

/// Write a formatted size directly to the writer, avoiding a String allocation.
pub fn write_size(w: &mut impl Write, bytes: u64, is_dir: bool) -> std::io::Result<()> {
    if is_dir {
        return w.write_all(b"-");
    }
    match bytes {
        0 => w.write_all(b"0"),
        b if b < KB => write!(w, "{b}"),
        b if b < MB => write!(w, "{:.1}K", b as f64 / KB as f64),
        b if b < GB => write!(w, "{:.1}M", b as f64 / MB as f64),
        b if b < TB => write!(w, "{:.1}G", b as f64 / GB as f64),
        b => write!(w, "{:.1}T", b as f64 / TB as f64),
    }
}

/// Return the display width of `format_size()` output without allocating.
///
/// Must match `format_size().len()` exactly. Accounts for `{:.1}` rounding
/// by computing the integer part of the rounded value.
pub fn format_size_width(bytes: u64, is_dir: bool) -> usize {
    if is_dir {
        return 1; // "-"
    }
    match bytes {
        0 => 1, // "0"
        b if b < KB => digit_count(b as usize),
        b => {
            // Determine the divisor (same as format_size).
            let divisor = if b < MB {
                KB
            } else if b < GB {
                MB
            } else if b < TB {
                GB
            } else {
                TB
            };
            // Integer part after rounding to 1 decimal place.
            // Adding 0.05 before truncation mirrors `{:.1}` rounding.
            let int_part = (b as f64 / divisor as f64 + 0.05) as usize;
            // Width = digits of int part + ".X" (2 chars) + unit letter (1 char).
            digit_count(int_part.max(1)) + 3
        }
    }
}

fn digit_count(n: usize) -> usize {
    if n == 0 {
        return 1;
    }
    let mut count = 0;
    let mut v = n;
    while v > 0 {
        count += 1;
        v /= 10;
    }
    count
}

/// Approximate seconds in 6 months (Julian half-year: 365.25/2 days).
const SIX_MONTHS_SECS: i64 = 15_778_476;

/// Format a Unix timestamp in ls-style:
/// - Recent (< 6 months): `Mar 15 14:30`
/// - Old (>= 6 months or future): `Mar 15  2025`
///
/// `now_secs` is the current Unix timestamp, passed in to avoid repeated syscalls.
pub fn format_date(timestamp: i64, now_secs: i64) -> String {
    let Ok(dt) = OffsetDateTime::from_unix_timestamp(timestamp) else {
        return "-".to_owned();
    };

    let age = now_secs - timestamp;
    let recent = age >= 0 && age < SIX_MONTHS_SECS;

    if recent {
        let fmt = format_description!("[month repr:short] [day padding:space] [hour]:[minute]");
        dt.format(&fmt).unwrap_or_else(|_| "-".to_owned())
    } else {
        let fmt = format_description!("[month repr:short] [day padding:space]  [year]");
        dt.format(&fmt).unwrap_or_else(|_| "-".to_owned())
    }
}

/// Write a formatted date directly to the writer, avoiding a String allocation.
pub fn write_date(w: &mut impl Write, timestamp: i64, now_secs: i64) -> std::io::Result<()> {
    static MONTHS: [&[u8; 3]; 12] = [
        b"Jan", b"Feb", b"Mar", b"Apr", b"May", b"Jun", b"Jul", b"Aug", b"Sep", b"Oct", b"Nov",
        b"Dec",
    ];

    let Ok(dt) = OffsetDateTime::from_unix_timestamp(timestamp) else {
        return w.write_all(b"           -");
    };

    let month_idx = dt.month() as usize - 1;
    let day = dt.day();
    let age = now_secs - timestamp;
    let recent = age >= 0 && age < SIX_MONTHS_SECS;

    w.write_all(MONTHS[month_idx])?;
    if recent {
        let hour = dt.hour();
        let minute = dt.minute();
        write!(w, " {day:2} {hour:02}:{minute:02}")
    } else {
        let year = dt.year();
        write!(w, " {day:2}  {year}")
    }
}

/// Return the classify suffix for an entry: `/` for dirs, `@` for symlinks, empty otherwise.
pub fn classify_suffix(entry: &Entry) -> &'static str {
    if entry.is_dir {
        "/"
    } else if entry.is_symlink {
        "@"
    } else {
        ""
    }
}

/// Write an entry name with optional classify suffix and color.
pub fn write_name(
    w: &mut impl Write,
    entry: &Entry,
    opts: &super::DisplayOptions,
) -> std::io::Result<()> {
    let colored = if opts.color_enabled {
        super::color::write_entry_color(w, &opts.ls_colors, entry)?
    } else {
        false
    };
    w.write_all(entry.name().as_bytes())?;
    if opts.classify {
        w.write_all(classify_suffix(entry).as_bytes())?;
    }
    if colored {
        w.write_all(super::color::RESET.as_bytes())?;
    }
    Ok(())
}

/// The display width of an entry name with optional classify suffix.
///
/// Uses a fast path for ASCII-only names (the common case) to skip
/// the char-by-char iteration of `UnicodeWidthStr::width`.
pub fn name_width(entry: &Entry, classify: bool) -> usize {
    let name = entry.name();
    let name_w = if name.is_ascii() {
        name.len()
    } else {
        unicode_width::UnicodeWidthStr::width(name)
    };
    let suffix_len = if classify {
        classify_suffix(entry).len()
    } else {
        0
    };
    name_w + suffix_len
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn size_dir() {
        assert_eq!(format_size(0, true), "-");
    }

    #[test]
    fn size_zero_file() {
        assert_eq!(format_size(0, false), "0");
    }

    #[test]
    fn size_bytes() {
        assert_eq!(format_size(512, false), "512");
        assert_eq!(format_size(1, false), "1");
        assert_eq!(format_size(1023, false), "1023");
    }

    #[test]
    fn size_kilobytes() {
        assert_eq!(format_size(1024, false), "1.0K");
        assert_eq!(format_size(1536, false), "1.5K");
        assert_eq!(format_size(10240, false), "10.0K");
    }

    #[test]
    fn size_megabytes() {
        assert_eq!(format_size(1024 * 1024, false), "1.0M");
        assert_eq!(format_size(1024 * 1024 * 5 + 1024 * 512, false), "5.5M");
    }

    #[test]
    fn size_gigabytes() {
        assert_eq!(format_size(1024 * 1024 * 1024, false), "1.0G");
    }

    #[test]
    fn size_terabytes() {
        assert_eq!(format_size(1024u64 * 1024 * 1024 * 1024, false), "1.0T");
    }

    fn now() -> i64 {
        OffsetDateTime::now_utc().unix_timestamp()
    }

    #[test]
    fn date_epoch() {
        let formatted = format_date(0, now());
        assert!(
            formatted.contains("1970"),
            "expected year 1970, got: {formatted}"
        );
    }

    #[test]
    fn date_recent() {
        let n = now();
        let formatted = format_date(n - 3600, n);
        assert!(
            formatted.contains(':'),
            "expected time format, got: {formatted}"
        );
    }

    #[test]
    fn date_old() {
        let n = now();
        let formatted = format_date(n - 2 * 365 * 24 * 3600, n);
        assert!(
            !formatted.contains(':'),
            "expected year format, got: {formatted}"
        );
    }

    #[test]
    fn date_future() {
        let n = now();
        let formatted = format_date(n + 365 * 24 * 3600, n);
        assert!(
            !formatted.contains(':'),
            "expected year format for future, got: {formatted}"
        );
    }

    #[test]
    fn size_width_matches_format_size() {
        // Exhaustive check at boundary values where rounding could cause width mismatch.
        let test_values: Vec<u64> = {
            let mut vals = vec![0u64, 1, 9, 10, 99, 100, 999, 1000, 1023];
            // Around each unit boundary, check values that might round up.
            for unit in [KB, MB, GB, TB] {
                for offset in [0, 1, 2, 10, 100] {
                    if unit > offset {
                        vals.push(unit - offset);
                    }
                    vals.push(unit + offset);
                }
                for mult in [10u64, 100, 1000] {
                    let base = unit.saturating_mul(mult);
                    for offset in [0, 1, 2, 10, 100] {
                        if base > offset {
                            vals.push(base - offset);
                        }
                        vals.push(base + offset);
                    }
                }
            }
            vals.sort();
            vals.dedup();
            vals
        };

        for &b in &test_values {
            let actual_len = format_size(b, false).len();
            let predicted = format_size_width(b, false);
            assert_eq!(
                predicted,
                actual_len,
                "format_size_width({b}) = {predicted}, but format_size({b}) = {:?} (len={actual_len})",
                format_size(b, false)
            );
        }

        // Also check directory case.
        assert_eq!(format_size_width(0, true), format_size(0, true).len());
        assert_eq!(format_size_width(1000, true), format_size(1000, true).len());
    }

    #[test]
    fn write_date_matches_format_date_for_valid_timestamps() {
        let n = now();
        for ts in [0i64, n - 3600, n - 200 * 24 * 3600, n + 365 * 24 * 3600] {
            let expected = format_date(ts, n);
            let mut buf = Vec::new();
            write_date(&mut buf, ts, n).unwrap();
            let actual = String::from_utf8(buf).unwrap();
            assert_eq!(actual, expected, "write_date mismatch for ts={ts}");
        }
    }

    #[test]
    fn write_date_invalid_timestamp_emits_fixed_width_placeholder() {
        // write_date outputs a fixed-width placeholder for column alignment,
        // which intentionally differs from format_date's compact "-".
        let mut buf = Vec::new();
        write_date(&mut buf, i64::MAX, 0).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert_eq!(s, "           -");
        assert_eq!(s.len(), 12, "placeholder must match valid date width");
    }

    #[test]
    fn write_size_matches_format_size() {
        for &(b, is_dir) in &[
            (0u64, false),
            (1, false),
            (512, false),
            (1023, false),
            (KB, false),
            (KB + 512, false),
            (10 * KB, false),
            (MB, false),
            (GB, false),
            (TB, false),
            (0, true),
            (1000, true),
        ] {
            let expected = format_size(b, is_dir);
            let mut buf = Vec::new();
            write_size(&mut buf, b, is_dir).unwrap();
            let actual = String::from_utf8(buf).unwrap();
            assert_eq!(actual, expected, "write_size({b}, {is_dir}) mismatch");
        }
    }
}