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    /// Whether modern multi-column long-format theming should run.
117    ///
118    /// On for friendly (non-GNU) mode when colors are enabled; off under `--gnu`.
119    pub fn modern_long_theme(&self, gnu_mode: bool) -> bool {
120        self.enabled && !gnu_mode
121    }
122
123    /// Permission string (e.g. `-rwxr-xr-x`) with type letter + rwx tinting.
124    pub fn paint_perms(&self, perms: &str, gnu_mode: bool) -> String {
125        if !self.modern_long_theme(gnu_mode) || perms.is_empty() {
126            return perms.to_string();
127        }
128        let mut out = String::with_capacity(perms.len() * 8);
129        for (i, ch) in perms.chars().enumerate() {
130            let painted = if i == 0 {
131                match ch {
132                    'd' => Color::Blue.bold().paint(ch.to_string()).to_string(),
133                    'l' => Color::Cyan.bold().paint(ch.to_string()).to_string(),
134                    'c' | 'b' => Color::Yellow.bold().paint(ch.to_string()).to_string(),
135                    'p' | 's' => Color::Purple.paint(ch.to_string()).to_string(),
136                    _ => Color::DarkGray.paint(ch.to_string()).to_string(),
137                }
138            } else {
139                match ch {
140                    'r' => Color::Yellow.paint(ch.to_string()).to_string(),
141                    'w' => Color::Red.paint(ch.to_string()).to_string(),
142                    'x' | 's' | 't' | 'S' | 'T' => {
143                        Color::Green.bold().paint(ch.to_string()).to_string()
144                    }
145                    '-' => Color::DarkGray.paint(ch.to_string()).to_string(),
146                    _ => ch.to_string(),
147                }
148            };
149            out.push_str(&painted);
150        }
151        out
152    }
153
154    /// Dim metadata (nlink, inode, blocks, context).
155    pub fn paint_meta(&self, text: &str, gnu_mode: bool) -> String {
156        if !self.modern_long_theme(gnu_mode) {
157            return text.to_string();
158        }
159        Color::DarkGray.paint(text).to_string()
160    }
161
162    /// Owner / group / author column.
163    pub fn paint_user(&self, text: &str, gnu_mode: bool) -> String {
164        if !self.modern_long_theme(gnu_mode) {
165            return text.to_string();
166        }
167        Color::Yellow.paint(text).to_string()
168    }
169
170    /// Size column: green → yellow → red by magnitude (bytes).
171    pub fn paint_size(&self, text: &str, bytes: u64, gnu_mode: bool) -> String {
172        if !self.modern_long_theme(gnu_mode) {
173            return text.to_string();
174        }
175        let style = if bytes >= 1_073_741_824 {
176            // ≥ 1 GiB
177            Color::Red.bold()
178        } else if bytes >= 10_485_760 {
179            // ≥ 10 MiB
180            Color::Yellow.bold()
181        } else if bytes >= 1_048_576 {
182            // ≥ 1 MiB
183            Color::Green.bold()
184        } else if bytes > 0 {
185            Color::Green.normal()
186        } else {
187            Color::DarkGray.normal()
188        };
189        style.paint(text).to_string()
190    }
191
192    /// Timestamp column.
193    pub fn paint_time(&self, text: &str, gnu_mode: bool) -> String {
194        if !self.modern_long_theme(gnu_mode) {
195            return text.to_string();
196        }
197        Color::Blue.paint(text).to_string()
198    }
199
200    /// Symlink name + arrow + target (name via LS_COLORS / cyan; target dim cyan).
201    pub fn paint_symlink_name(
202        &self,
203        entry: &Entry,
204        icon_and_name: &str,
205        arrow_and_target: &str,
206        gnu_mode: bool,
207    ) -> String {
208        if !self.modern_long_theme(gnu_mode) {
209            let full = format!("{icon_and_name}{arrow_and_target}");
210            return self.paint_name(entry, &full);
211        }
212        let name = self.paint_name(entry, icon_and_name);
213        if arrow_and_target.is_empty() {
214            return name;
215        }
216        // Split " -> target" style
217        let target = if let Some(rest) = arrow_and_target.strip_prefix(" -> ") {
218            format!(
219                " {} {}",
220                Color::DarkGray.paint("→"),
221                Color::Cyan.dimmed().paint(rest)
222            )
223        } else {
224            Color::DarkGray.paint(arrow_and_target).to_string()
225        };
226        format!("{name}{target}")
227    }
228}
229
230fn paint_with_ls_style(text: &str, style: &Style) -> String {
231    style.to_nu_ansi_term_style().paint(text).to_string()
232}
233
234fn entry_is_exec(entry: &Entry) -> bool {
235    #[cfg(unix)]
236    {
237        entry.mode & 0o111 != 0
238    }
239    #[cfg(not(unix))]
240    {
241        let _ = entry;
242        false
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use f00_core::{Entry, EntryKind, GitStatus};
250    use std::path::PathBuf;
251
252    fn file(name: &str) -> Entry {
253        Entry {
254            path: PathBuf::from(name),
255            name: name.into(),
256            kind: EntryKind::File,
257            size: 0,
258            modified: None,
259            created: None,
260            accessed: None,
261            changed: None,
262            mode: 0o644,
263            readonly: false,
264            symlink_target: None,
265            depth: 0,
266            git_status: GitStatus::Clean,
267            is_dir_header: false,
268            nlink: 1,
269            uid: 0,
270            gid: 0,
271            inode: 0,
272            blocks: 0,
273            owner: "u".into(),
274            group: "g".into(),
275            author: "u".into(),
276            context: String::new(),
277        }
278    }
279
280    #[test]
281    fn disabled_no_ansi() {
282        let c = Colorizer::from_ls_colors(false, "*.rs=01;31");
283        let e = file("main.rs");
284        assert_eq!(c.paint_name(&e, "main.rs"), "main.rs");
285    }
286
287    #[test]
288    fn ls_colors_extension() {
289        let c = Colorizer::from_ls_colors(true, "*.rs=01;31:");
290        let e = file("main.rs");
291        let painted = c.paint_name(&e, "main.rs");
292        // Should contain ANSI CSI when style applies.
293        assert!(painted.contains("main.rs"), "{painted:?}");
294        // Style applied → not equal plain (or equal if style empty — accept either with style present)
295        let _ = c.style_for_entry(&e);
296    }
297
298    #[test]
299    fn modern_theme_off_under_gnu() {
300        let c = Colorizer::from_ls_colors(true, "");
301        assert!(!c.modern_long_theme(true));
302        assert!(c.modern_long_theme(false));
303        assert_eq!(c.paint_perms("-rwxr-xr-x", true), "-rwxr-xr-x");
304        assert_ne!(c.paint_perms("-rwxr-xr-x", false), "-rwxr-xr-x");
305    }
306
307    #[test]
308    fn modern_size_tints() {
309        let c = Colorizer::from_ls_colors(true, "");
310        let small = c.paint_size("1.0K", 1024, false);
311        let big = c.paint_size("2.0G", 2_147_483_648, false);
312        assert!(small.contains("1.0K"), "{small}");
313        assert!(big.contains("2.0G"), "{big}");
314        // Different colors → different escape sequences (or at least styled).
315        assert!(small.contains('\u{1b}') || small != "1.0K");
316        assert!(big.contains('\u{1b}') || big != "2.0G");
317    }
318}