dirwalk 1.1.1

Platform-optimized recursive directory walker with metadata
Documentation
use crate::entry::Entry;
use crate::output::DisplayOptions;
use crate::output::format::{name_width, write_name};
use std::borrow::Borrow;
use std::io::{self, Write};

pub fn write<E: Borrow<Entry>>(
    w: &mut impl Write,
    entries: &[E],
    opts: &DisplayOptions,
) -> io::Result<()> {
    if entries.is_empty() {
        return Ok(());
    }

    let term_w = opts.terminal_width as usize;

    // Precompute widths once — avoids recomputing UnicodeWidthStr per entry in the inner loop.
    let widths: Vec<usize> = entries
        .iter()
        .map(|e| name_width(e.borrow(), opts.classify))
        .collect();
    let max_name = widths.iter().copied().max().unwrap_or(0);

    // n columns need (n-1) * (max_name + 2) + max_name chars.
    let col_width = max_name + 2;
    let num_cols = if max_name >= term_w {
        1
    } else {
        (term_w - max_name) / col_width + 1
    };
    let num_rows = entries.len().div_ceil(num_cols);

    // Column-major order: fill top-to-bottom, then left-to-right.
    for row in 0..num_rows {
        for col in 0..num_cols {
            let idx = col * num_rows + row;
            if idx >= entries.len() {
                break;
            }
            let entry: &Entry = entries[idx].borrow();
            let is_last_col = col == num_cols - 1 || (col + 1) * num_rows + row >= entries.len();

            write_name(w, entry, opts)?;

            if !is_last_col {
                let padding = col_width.saturating_sub(widths[idx]);
                write!(w, "{:width$}", "", width = padding)?;
            }
        }
        writeln!(w)?;
    }

    Ok(())
}

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

    fn make_opts(terminal_width: u16, classify: bool) -> DisplayOptions {
        DisplayOptions {
            short: true,
            classify,
            color_enabled: false,
            terminal_width,
            ls_colors: lscolors::LsColors::empty(),
        }
    }

    fn file(name: &str) -> Entry {
        Entry {
            relative_path: name.to_owned(),
            depth: 0,
            size: 1,
            is_dir: false,
            is_symlink: false,
            is_hidden: false,
            modified: 0,
        }
    }

    fn dir(name: &str) -> Entry {
        Entry {
            relative_path: name.to_owned(),
            depth: 0,
            size: 0,
            is_dir: true,
            is_symlink: false,
            is_hidden: false,
            modified: 0,
        }
    }

    #[test]
    fn single_column_narrow_terminal() {
        let entries = vec![file("aaaa"), file("bbbb"), file("cccc")];
        let refs: Vec<&Entry> = entries.iter().collect();
        let mut buf = Vec::new();
        write(&mut buf, &refs, &make_opts(8, false)).unwrap();
        let output = String::from_utf8(buf).unwrap();
        let lines: Vec<&str> = output.lines().collect();
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[0], "aaaa");
        assert_eq!(lines[1], "bbbb");
        assert_eq!(lines[2], "cccc");
    }

    #[test]
    fn multi_column() {
        // 4-char names, col_width = 6, terminal = 20 → 3 columns.
        let entries: Vec<Entry> = (0..7).map(|i| file(&format!("f{i:03}"))).collect();
        let refs: Vec<&Entry> = entries.iter().collect();
        let mut buf = Vec::new();
        write(&mut buf, &refs, &make_opts(20, false)).unwrap();
        let output = String::from_utf8(buf).unwrap();
        let lines: Vec<&str> = output.lines().collect();
        // 7 entries / 3 cols = 3 rows (ceil).
        assert_eq!(lines.len(), 3);
        // Column-major: row 0 should have entries 0, 3, 6.
        assert!(lines[0].contains("f000"), "got: {:?}", lines[0]);
        assert!(lines[0].contains("f003"), "got: {:?}", lines[0]);
        assert!(lines[0].contains("f006"), "got: {:?}", lines[0]);
    }

    #[test]
    fn last_column_no_padding() {
        // 4-char names, col_width = 6, terminal = 10.
        // Old formula: 10/6 = 1 column. Correct: 4+2+4 = 10, so 2 columns fit.
        let entries: Vec<Entry> = (0..5).map(|i| file(&format!("f{i:03}"))).collect();
        let refs: Vec<&Entry> = entries.iter().collect();
        let mut buf = Vec::new();
        write(&mut buf, &refs, &make_opts(10, false)).unwrap();
        let output = String::from_utf8(buf).unwrap();
        let lines: Vec<&str> = output.lines().collect();
        // 5 entries / 2 cols = 3 rows.
        assert_eq!(lines.len(), 3, "expected 3 rows, got: {output}");
        // Row 0 should have entries 0 and 3.
        assert!(lines[0].contains("f000"), "got: {:?}", lines[0]);
        assert!(lines[0].contains("f003"), "got: {:?}", lines[0]);
    }

    #[test]
    fn classify_adds_suffix() {
        let entries = vec![dir("src"), file("main.rs")];
        let refs: Vec<&Entry> = entries.iter().collect();
        let mut buf = Vec::new();
        write(&mut buf, &refs, &make_opts(80, true)).unwrap();
        let output = String::from_utf8(buf).unwrap();
        assert!(output.contains("src/"), "got: {output}");
        assert!(output.contains("main.rs"), "got: {output}");
        assert!(!output.contains("main.rs/"), "got: {output}");
    }

    #[test]
    fn empty() {
        let mut buf = Vec::new();
        write::<&Entry>(&mut buf, &[], &make_opts(80, false)).unwrap();
        assert!(buf.is_empty());
    }
}