f00-format 0.7.2

Display formatting for f00 (columns, long, tree, json, icons, color)
Documentation
//! Color engine using the `lscolors` crate fully from `LS_COLORS`.

use std::path::Path;

use f00_core::{Entry, EntryKind};
use lscolors::{LsColors, Style};
use nu_ansi_term::Color;

/// Color engine wrapping `LS_COLORS` / defaults.
#[derive(Clone)]
pub struct Colorizer {
    enabled: bool,
    ls: LsColors,
}

impl Colorizer {
    /// Build from environment (`LS_COLORS`) when enabled.
    pub fn new(enabled: bool) -> Self {
        Self {
            enabled,
            ls: LsColors::from_env().unwrap_or_default(),
        }
    }

    /// Build with an explicit LS_COLORS string (tests / overrides).
    pub fn from_ls_colors(enabled: bool, ls_colors: &str) -> Self {
        Self {
            enabled,
            ls: LsColors::from_string(ls_colors),
        }
    }

    /// Access the underlying `LsColors` map.
    pub fn ls_colors(&self) -> &LsColors {
        &self.ls
    }

    pub fn enabled(&self) -> bool {
        self.enabled
    }

    /// Colorize a display name for an entry using full LS_COLORS matching.
    pub fn paint_name(&self, entry: &Entry, text: &str) -> String {
        if !self.enabled {
            return text.to_string();
        }

        // Prefer path + metadata style when possible.
        if let Some(style) = self.style_for_entry(entry) {
            return paint_with_ls_style(text, style);
        }

        // Fallback by kind
        match entry.kind {
            EntryKind::Directory => Color::Blue.bold().paint(text).to_string(),
            EntryKind::Symlink => Color::Cyan.paint(text).to_string(),
            EntryKind::File if entry_is_exec(entry) => Color::Green.bold().paint(text).to_string(),
            EntryKind::File => text.to_string(),
            EntryKind::Other => Color::Yellow.paint(text).to_string(),
        }
    }

    /// Resolve LS_COLORS style for an entry (extension, type indicators, etc.).
    pub fn style_for_entry<'a>(&'a self, entry: &'a Entry) -> Option<&'a Style> {
        // Try path with metadata-like hints via style_for_path first.
        if let Some(style) = self.ls.style_for_path(&entry.path) {
            return Some(style);
        }
        // Extension / name patterns
        if let Some(style) = self.ls.style_for_path(Path::new(&entry.name)) {
            return Some(style);
        }
        // Indicator by kind
        use lscolors::Indicator;
        let indicator = match entry.kind {
            EntryKind::Directory => Some(Indicator::Directory),
            EntryKind::Symlink => Some(Indicator::SymbolicLink),
            EntryKind::File if entry_is_exec(entry) => Some(Indicator::ExecutableFile),
            EntryKind::File => Some(Indicator::RegularFile),
            EntryKind::Other => {
                #[cfg(unix)]
                {
                    match entry.mode & 0o170000 {
                        0o010000 => Some(Indicator::FIFO),
                        0o140000 => Some(Indicator::Socket),
                        0o060000 => Some(Indicator::BlockDevice),
                        0o020000 => Some(Indicator::CharacterDevice),
                        _ => None,
                    }
                }
                #[cfg(not(unix))]
                {
                    None
                }
            }
        };
        indicator.and_then(|i| self.ls.style_for_indicator(i))
    }

    pub fn paint_git_char(&self, ch: char) -> String {
        if !self.enabled {
            return ch.to_string();
        }
        let s = ch.to_string();
        match ch {
            'M' => Color::Yellow.bold().paint(s).to_string(),
            'A' => Color::Green.bold().paint(s).to_string(),
            'D' | 'U' => Color::Red.bold().paint(s).to_string(),
            '?' => Color::Purple.paint(s).to_string(),
            '!' => Color::DarkGray.paint(s).to_string(),
            'R' => Color::Cyan.bold().paint(s).to_string(),
            _ => s,
        }
    }
}

fn paint_with_ls_style(text: &str, style: &Style) -> String {
    style.to_nu_ansi_term_style().paint(text).to_string()
}

fn entry_is_exec(entry: &Entry) -> bool {
    #[cfg(unix)]
    {
        entry.mode & 0o111 != 0
    }
    #[cfg(not(unix))]
    {
        let _ = entry;
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use f00_core::{Entry, EntryKind, GitStatus};
    use std::path::PathBuf;

    fn file(name: &str) -> Entry {
        Entry {
            path: PathBuf::from(name),
            name: name.into(),
            kind: EntryKind::File,
            size: 0,
            modified: None,
            created: None,
            accessed: None,
            changed: None,
            mode: 0o644,
            readonly: false,
            symlink_target: None,
            depth: 0,
            git_status: GitStatus::Clean,
            is_dir_header: false,
            nlink: 1,
            uid: 0,
            gid: 0,
            inode: 0,
            blocks: 0,
            owner: "u".into(),
            group: "g".into(),
            author: "u".into(),
            context: String::new(),
        }
    }

    #[test]
    fn disabled_no_ansi() {
        let c = Colorizer::from_ls_colors(false, "*.rs=01;31");
        let e = file("main.rs");
        assert_eq!(c.paint_name(&e, "main.rs"), "main.rs");
    }

    #[test]
    fn ls_colors_extension() {
        let c = Colorizer::from_ls_colors(true, "*.rs=01;31:");
        let e = file("main.rs");
        let painted = c.paint_name(&e, "main.rs");
        // Should contain ANSI CSI when style applies.
        assert!(painted.contains("main.rs"), "{painted:?}");
        // Style applied → not equal plain (or equal if style empty — accept either with style present)
        let _ = c.style_for_entry(&e);
    }
}