Skip to main content

f00_format/
color.rs

1//! Color engine using the `lscolors` crate fully from `LS_COLORS`.
2
3use std::path::Path;
4
5use f00_core::{Entry, EntryKind};
6use lscolors::{LsColors, Style};
7use nu_ansi_term::Color;
8
9/// Color engine wrapping `LS_COLORS` / defaults.
10#[derive(Clone)]
11pub struct Colorizer {
12    enabled: bool,
13    ls: LsColors,
14}
15
16impl Colorizer {
17    /// Build from environment (`LS_COLORS`) when enabled.
18    pub fn new(enabled: bool) -> Self {
19        Self {
20            enabled,
21            ls: LsColors::from_env().unwrap_or_default(),
22        }
23    }
24
25    /// Build with an explicit LS_COLORS string (tests / overrides).
26    pub fn from_ls_colors(enabled: bool, ls_colors: &str) -> Self {
27        Self {
28            enabled,
29            ls: LsColors::from_string(ls_colors),
30        }
31    }
32
33    /// Access the underlying `LsColors` map.
34    pub fn ls_colors(&self) -> &LsColors {
35        &self.ls
36    }
37
38    pub fn enabled(&self) -> bool {
39        self.enabled
40    }
41
42    /// Colorize a display name for an entry using full LS_COLORS matching.
43    pub fn paint_name(&self, entry: &Entry, text: &str) -> String {
44        if !self.enabled {
45            return text.to_string();
46        }
47
48        // Prefer path + metadata style when possible.
49        if let Some(style) = self.style_for_entry(entry) {
50            return paint_with_ls_style(text, style);
51        }
52
53        // Fallback by kind
54        match entry.kind {
55            EntryKind::Directory => Color::Blue.bold().paint(text).to_string(),
56            EntryKind::Symlink => Color::Cyan.paint(text).to_string(),
57            EntryKind::File if entry_is_exec(entry) => Color::Green.bold().paint(text).to_string(),
58            EntryKind::File => text.to_string(),
59            EntryKind::Other => Color::Yellow.paint(text).to_string(),
60        }
61    }
62
63    /// Resolve LS_COLORS style for an entry (extension, type indicators, etc.).
64    pub fn style_for_entry<'a>(&'a self, entry: &'a Entry) -> Option<&'a Style> {
65        // Try path with metadata-like hints via style_for_path first.
66        if let Some(style) = self.ls.style_for_path(&entry.path) {
67            return Some(style);
68        }
69        // Extension / name patterns
70        if let Some(style) = self.ls.style_for_path(Path::new(&entry.name)) {
71            return Some(style);
72        }
73        // Indicator by kind
74        use lscolors::Indicator;
75        let indicator = match entry.kind {
76            EntryKind::Directory => Some(Indicator::Directory),
77            EntryKind::Symlink => Some(Indicator::SymbolicLink),
78            EntryKind::File if entry_is_exec(entry) => Some(Indicator::ExecutableFile),
79            EntryKind::File => Some(Indicator::RegularFile),
80            EntryKind::Other => {
81                #[cfg(unix)]
82                {
83                    match entry.mode & 0o170000 {
84                        0o010000 => Some(Indicator::FIFO),
85                        0o140000 => Some(Indicator::Socket),
86                        0o060000 => Some(Indicator::BlockDevice),
87                        0o020000 => Some(Indicator::CharacterDevice),
88                        _ => None,
89                    }
90                }
91                #[cfg(not(unix))]
92                {
93                    None
94                }
95            }
96        };
97        indicator.and_then(|i| self.ls.style_for_indicator(i))
98    }
99
100    pub fn paint_git_char(&self, ch: char) -> String {
101        if !self.enabled {
102            return ch.to_string();
103        }
104        let s = ch.to_string();
105        match ch {
106            'M' => Color::Yellow.bold().paint(s).to_string(),
107            'A' => Color::Green.bold().paint(s).to_string(),
108            'D' | 'U' => Color::Red.bold().paint(s).to_string(),
109            '?' => Color::Purple.paint(s).to_string(),
110            '!' => Color::DarkGray.paint(s).to_string(),
111            'R' => Color::Cyan.bold().paint(s).to_string(),
112            _ => s,
113        }
114    }
115}
116
117fn paint_with_ls_style(text: &str, style: &Style) -> String {
118    style.to_nu_ansi_term_style().paint(text).to_string()
119}
120
121fn entry_is_exec(entry: &Entry) -> bool {
122    #[cfg(unix)]
123    {
124        entry.mode & 0o111 != 0
125    }
126    #[cfg(not(unix))]
127    {
128        let _ = entry;
129        false
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use f00_core::{Entry, EntryKind, GitStatus};
137    use std::path::PathBuf;
138
139    fn file(name: &str) -> Entry {
140        Entry {
141            path: PathBuf::from(name),
142            name: name.into(),
143            kind: EntryKind::File,
144            size: 0,
145            modified: None,
146            created: None,
147            accessed: None,
148            changed: None,
149            mode: 0o644,
150            readonly: false,
151            symlink_target: None,
152            depth: 0,
153            git_status: GitStatus::Clean,
154            is_dir_header: false,
155            nlink: 1,
156            uid: 0,
157            gid: 0,
158            inode: 0,
159            blocks: 0,
160            owner: "u".into(),
161            group: "g".into(),
162            author: "u".into(),
163            context: String::new(),
164        }
165    }
166
167    #[test]
168    fn disabled_no_ansi() {
169        let c = Colorizer::from_ls_colors(false, "*.rs=01;31");
170        let e = file("main.rs");
171        assert_eq!(c.paint_name(&e, "main.rs"), "main.rs");
172    }
173
174    #[test]
175    fn ls_colors_extension() {
176        let c = Colorizer::from_ls_colors(true, "*.rs=01;31:");
177        let e = file("main.rs");
178        let painted = c.paint_name(&e, "main.rs");
179        // Should contain ANSI CSI when style applies.
180        assert!(painted.contains("main.rs"), "{painted:?}");
181        // Style applied → not equal plain (or equal if style empty — accept either with style present)
182        let _ = c.style_for_entry(&e);
183    }
184}