dirwalk 1.1.1

Platform-optimized recursive directory walker with metadata
Documentation
use crate::entry::Entry;
use lscolors::{Indicator, LsColors, Style};

pub(super) const RESET: &str = "\x1b[0m";

#[derive(Clone, Copy, Default, clap::ValueEnum)]
pub enum ColorMode {
    Never,
    #[default]
    Auto,
    Always,
}

impl ColorMode {
    /// Resolve whether color should actually be enabled, given TTY state and env.
    pub fn resolve(self, is_tty: bool) -> bool {
        match self {
            ColorMode::Always => true,
            ColorMode::Never => false,
            ColorMode::Auto => is_tty && std::env::var_os("NO_COLOR").is_none(),
        }
    }
}

/// Render an `lscolors::Style` to an ANSI SGR escape sequence.
fn style_to_ansi(style: &Style) -> String {
    let mut buf = String::with_capacity(20);
    let mut first = true;

    let mut sep = |buf: &mut String| {
        if first {
            buf.push_str("\x1b[");
            first = false;
        } else {
            buf.push(';');
        }
    };

    let fs = &style.font_style;
    if fs.bold {
        sep(&mut buf);
        buf.push('1');
    }
    if fs.dimmed {
        sep(&mut buf);
        buf.push('2');
    }
    if fs.italic {
        sep(&mut buf);
        buf.push('3');
    }
    if fs.underline {
        sep(&mut buf);
        buf.push('4');
    }
    if fs.slow_blink {
        sep(&mut buf);
        buf.push('5');
    }
    if fs.rapid_blink {
        sep(&mut buf);
        buf.push('6');
    }
    if fs.reverse {
        sep(&mut buf);
        buf.push('7');
    }
    if fs.hidden {
        sep(&mut buf);
        buf.push('8');
    }
    if fs.strikethrough {
        sep(&mut buf);
        buf.push('9');
    }

    if let Some(ref c) = style.foreground {
        sep(&mut buf);
        push_color_code(&mut buf, c, true);
    }
    if let Some(ref c) = style.background {
        sep(&mut buf);
        push_color_code(&mut buf, c, false);
    }

    if !buf.is_empty() {
        buf.push('m');
    }
    buf
}

/// Write a color code directly into `buf`, avoiding a separate String allocation.
fn push_color_code(buf: &mut String, c: &lscolors::Color, fg: bool) {
    use lscolors::Color::*;
    use std::fmt::Write as _;

    let base: u8 = if fg { 30 } else { 40 };
    match c {
        Black => write!(buf, "{}", base),
        Red => write!(buf, "{}", base + 1),
        Green => write!(buf, "{}", base + 2),
        Yellow => write!(buf, "{}", base + 3),
        Blue => write!(buf, "{}", base + 4),
        Magenta => write!(buf, "{}", base + 5),
        Cyan => write!(buf, "{}", base + 6),
        White => write!(buf, "{}", base + 7),
        BrightBlack => write!(buf, "{}", base + 60),
        BrightRed => write!(buf, "{}", base + 61),
        BrightGreen => write!(buf, "{}", base + 62),
        BrightYellow => write!(buf, "{}", base + 63),
        BrightBlue => write!(buf, "{}", base + 64),
        BrightMagenta => write!(buf, "{}", base + 65),
        BrightCyan => write!(buf, "{}", base + 66),
        BrightWhite => write!(buf, "{}", base + 67),
        Fixed(n) => {
            let s = if fg { 38 } else { 48 };
            write!(buf, "{s};5;{n}")
        }
        RGB(r, g, b) => {
            let s = if fg { 38 } else { 48 };
            write!(buf, "{s};2;{r};{g};{b}")
        }
    }
    .unwrap();
}

/// Write the ANSI color escape for an entry directly to `w`.
///
/// Returns `true` if a color sequence was written (caller must write RESET after the name).
/// Priority: indicator (dir, symlink) > extension match > hidden (dim fallback).
pub(super) fn write_entry_color(
    w: &mut impl std::io::Write,
    lsc: &LsColors,
    entry: &Entry,
) -> std::io::Result<bool> {
    if entry.is_dir {
        if let Some(style) = lsc.style_for_indicator(Indicator::Directory) {
            let s = style_to_ansi(style);
            if !s.is_empty() {
                w.write_all(s.as_bytes())?;
                return Ok(true);
            }
        }
    }
    if entry.is_symlink {
        if let Some(style) = lsc.style_for_indicator(Indicator::SymbolicLink) {
            let s = style_to_ansi(style);
            if !s.is_empty() {
                w.write_all(s.as_bytes())?;
                return Ok(true);
            }
        }
    }

    if let Some(style) = lsc.style_for_str(entry.name()) {
        let s = style_to_ansi(style);
        if !s.is_empty() {
            w.write_all(s.as_bytes())?;
            return Ok(true);
        }
    }

    if entry.is_hidden {
        w.write_all(b"\x1b[2m")?;
        return Ok(true);
    }

    Ok(false)
}

/// On Windows, enable ANSI escape processing on the console.
/// No-op on other platforms.
#[cfg(target_os = "windows")]
pub fn enable_ansi() {
    use windows::Win32::System::Console::{
        CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, GetStdHandle,
        STD_OUTPUT_HANDLE, SetConsoleMode,
    };
    unsafe {
        let Ok(handle) = GetStdHandle(STD_OUTPUT_HANDLE) else {
            return;
        };
        let mut mode = CONSOLE_MODE::default();
        if GetConsoleMode(handle, &mut mode).is_ok() {
            let _ = SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
        }
    }
}

#[cfg(not(target_os = "windows"))]
pub fn enable_ansi() {}

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

    #[test]
    fn resolve_always() {
        assert!(ColorMode::Always.resolve(false));
        assert!(ColorMode::Always.resolve(true));
    }

    #[test]
    fn resolve_never() {
        assert!(!ColorMode::Never.resolve(true));
        assert!(!ColorMode::Never.resolve(false));
    }

    #[test]
    fn resolve_auto_not_tty() {
        assert!(!ColorMode::Auto.resolve(false));
    }

    #[test]
    fn style_to_ansi_bold_blue() {
        let style = Style {
            font_style: lscolors::FontStyle::bold(),
            foreground: Some(lscolors::Color::Blue),
            ..Default::default()
        };
        assert_eq!(style_to_ansi(&style), "\x1b[1;34m");
    }

    #[test]
    fn style_to_ansi_empty() {
        let style = Style::default();
        assert_eq!(style_to_ansi(&style), "");
    }

    #[test]
    fn write_entry_color_uses_ls_colors() {
        let lsc = LsColors::default();
        let dir = Entry {
            relative_path: "src".to_owned(),
            depth: 0,
            size: 0,
            is_dir: true,
            is_symlink: false,
            is_hidden: false,
            modified: 0,
        };
        let mut buf = Vec::new();
        let wrote = write_entry_color(&mut buf, &lsc, &dir).unwrap();
        assert!(wrote);
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("34"), "expected blue, got: {s}");
    }

    #[test]
    fn push_color_code_fg_bg() {
        let mut buf = String::new();

        push_color_code(&mut buf, &lscolors::Color::Red, true);
        assert_eq!(buf, "31");

        buf.clear();
        push_color_code(&mut buf, &lscolors::Color::Red, false);
        assert_eq!(buf, "41");

        buf.clear();
        push_color_code(&mut buf, &lscolors::Color::BrightCyan, true);
        assert_eq!(buf, "96");

        buf.clear();
        push_color_code(&mut buf, &lscolors::Color::BrightCyan, false);
        assert_eq!(buf, "106");
    }
}