Skip to main content

vtcode_tui/ui/
file_colorizer.rs

1//! LS_COLORS-based file colorization system
2//!
3//! Parses LS_COLORS environment variable and applies system file type colors.
4//! This allows vtcode to respect user's file listing color preferences.
5
6use anstyle::Style as AnsiStyle;
7use std::collections::HashMap;
8use std::env;
9use std::path::Path;
10
11/// Manages LS_COLORS parsing and file type styling
12#[derive(Debug, Clone)]
13pub struct FileColorizer {
14    /// Parsed LS_COLORS key-value pairs
15    ls_colors_map: HashMap<String, AnsiStyle>,
16    /// Whether system has valid LS_COLORS
17    has_ls_colors: bool,
18}
19
20impl FileColorizer {
21    /// Create a new FileColorizer by parsing LS_COLORS environment variable
22    pub fn new() -> Self {
23        let ls_colors = env::var("LS_COLORS").unwrap_or_default();
24        let ls_colors_map = if ls_colors.is_empty() {
25            HashMap::new()
26        } else {
27            Self::parse_ls_colors(&ls_colors)
28        };
29
30        Self {
31            has_ls_colors: !ls_colors_map.is_empty(),
32            ls_colors_map,
33        }
34    }
35
36    /// Parse LS_COLORS environment variable into style mappings
37    ///
38    /// LS_COLORS format is: `key1=value1:key2=value2:...`
39    /// Example: `di=01;34:ln=01;36:ex=01;32:*rs=00;35`
40    fn parse_ls_colors(ls_colors: &str) -> HashMap<String, AnsiStyle> {
41        let mut map = HashMap::new();
42
43        for pair in ls_colors.split(':') {
44            if pair.is_empty() {
45                continue;
46            }
47
48            if let Some((key, value)) = pair.split_once('=') {
49                let parser = crate::utils::CachedStyleParser::default();
50                if let Ok(style) = parser.parse_ls_colors(value) {
51                    map.insert(key.to_owned(), style);
52                }
53            }
54        }
55
56        map
57    }
58
59    /// Get the appropriate style for a file path based on its type and extension
60    pub fn style_for_path(&self, path: &Path) -> Option<AnsiStyle> {
61        if !self.has_ls_colors {
62            return None;
63        }
64
65        // First try to match by extension (e.g., "*.rs", "*.toml")
66        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
67            let ext_key = format!("*.{}", ext);
68            if let Some(style) = self.get_style(&ext_key) {
69                return Some(style);
70            }
71        }
72
73        // Then try to determine file type and match
74        let file_type_key = self.determine_file_type_key(path);
75        if let Some(style) = self.get_style(&file_type_key) {
76            return Some(style);
77        }
78
79        // Finally try the fallback file type
80        self.get_style("fi") // fi = regular file
81    }
82
83    /// Get style from the map with fallbacks
84    fn get_style(&self, key: &str) -> Option<AnsiStyle> {
85        if let Some(style) = self.ls_colors_map.get(key) {
86            return Some(*style);
87        }
88
89        // For extension patterns, also try general file type
90        if key.starts_with("*.")
91            && let Some(style) = self.ls_colors_map.get("fi")
92        {
93            // regular file
94            return Some(*style);
95        }
96
97        None
98    }
99
100    /// Determine the appropriate LS_COLORS key for a file path
101    ///
102    /// This uses path-based heuristics to determine file type without I/O.
103    pub fn determine_file_type_key(&self, path: &Path) -> String {
104        // Check if path ends with a directory separator (indicates directory)
105        let path_str = path.to_string_lossy();
106        if path_str.ends_with('/') || path_str.ends_with('\\') {
107            return "di".to_string(); // directory
108        }
109
110        // Check for common executable patterns
111        if let Some(name) = path.file_name().and_then(|n| n.to_str())
112            && (name.ends_with(".sh")
113                || name.ends_with(".py")
114                || name.ends_with(".rb")
115                || name.ends_with(".pl")
116                || name.ends_with(".php"))
117        {
118            return "ex".to_string(); // executable
119        }
120
121        // Check for special file types based on name
122        match path.file_name().and_then(|n| n.to_str()) {
123            Some(name) if name.starts_with('.') => "so".to_string(), // socket/file (special)
124            _ => "fi".to_string(),                                   // regular file
125        }
126    }
127}
128
129impl Default for FileColorizer {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_new_without_ls_colors() {
141        // Create a FileColorizer when LS_COLORS is not set
142        // We'll test the parsing function directly rather than modifying global env
143        let ls_colors_val = env::var("LS_COLORS").unwrap_or_default();
144        let colorizer = FileColorizer::new();
145
146        // If LS_COLORS was not set originally, map should be empty
147        if ls_colors_val.is_empty() {
148            assert!(!colorizer.has_ls_colors);
149            assert!(colorizer.ls_colors_map.is_empty());
150        }
151    }
152
153    #[test]
154    fn test_parse_ls_colors() {
155        let ls_colors = "di=01;34:ln=01;36:ex=01;32:*rs=00;35";
156        let map = FileColorizer::parse_ls_colors(ls_colors);
157
158        assert_eq!(map.len(), 4);
159        assert!(map.contains_key("di"));
160        assert!(map.contains_key("ln"));
161        assert!(map.contains_key("ex"));
162        assert!(map.contains_key("*rs"));
163    }
164
165    #[test]
166    fn test_style_for_path_no_ls_colors() {
167        // Test with empty LS_COLORS map
168        let colorizer = FileColorizer {
169            ls_colors_map: HashMap::new(),
170            has_ls_colors: false,
171        };
172
173        let path = Path::new("/tmp/test.rs");
174        let style = colorizer.style_for_path(path);
175
176        assert!(style.is_none());
177    }
178
179    #[test]
180    fn test_determine_file_type_key_directory() {
181        // Test with a pre-populated colorizer
182        let colorizer = FileColorizer {
183            ls_colors_map: {
184                let mut map = HashMap::new();
185                map.insert("di".to_string(), anstyle::Style::new().bold());
186                map
187            },
188            has_ls_colors: true,
189        };
190
191        // This test checks the logic in style_for_path which calls determine_file_type_key internally
192        // For directory paths, it should try to match with "di" key
193        assert_eq!(
194            colorizer.determine_file_type_key(Path::new("/tmp/dir/")),
195            "di"
196        );
197    }
198
199    #[test]
200    fn test_determine_file_type_key_extension() {
201        let colorizer = FileColorizer {
202            ls_colors_map: {
203                let mut map = HashMap::new();
204                map.insert("*.rs".to_string(), anstyle::Style::new().bold());
205                map.insert("fi".to_string(), anstyle::Style::new().underline());
206                map
207            },
208            has_ls_colors: true,
209        };
210
211        let rs_path = Path::new("/tmp/test.rs");
212        let rs_style = colorizer.style_for_path(rs_path);
213        assert!(rs_style.is_some());
214
215        let txt_path = Path::new("/tmp/test.txt");
216        let txt_style = colorizer.style_for_path(txt_path);
217        assert!(txt_style.is_some()); // Should fall back to 'fi' style
218    }
219
220    #[test]
221    fn test_determine_file_type_key_executables() {
222        let colorizer = FileColorizer {
223            ls_colors_map: {
224                let mut map = HashMap::new();
225                map.insert("ex".to_string(), anstyle::Style::new().bold());
226                map
227            },
228            has_ls_colors: true,
229        };
230
231        assert_eq!(
232            colorizer.determine_file_type_key(Path::new("/tmp/script.sh")),
233            "ex"
234        );
235        assert_eq!(
236            colorizer.determine_file_type_key(Path::new("/tmp/main.py")),
237            "ex"
238        );
239        assert_eq!(
240            colorizer.determine_file_type_key(Path::new("/tmp/main.rb")),
241            "ex"
242        );
243    }
244}