Skip to main content

vtcode_core/ui/
git_config.rs

1use crate::utils::file_utils::read_file_with_context_sync;
2use anstyle::Style;
3/// Git configuration color parsing
4///
5/// Parses [color "..."] sections from .git/config and converts them to anstyle::Style objects.
6/// This allows vtcode to respect user's Git color configuration for diff and status visualization.
7///
8/// # Example
9/// ```ignore
10/// use vtcode_core::ui::git_config::GitColorConfig;
11/// use std::path::Path;
12///
13/// let config = GitColorConfig::from_git_config(Path::new(".git/config"))?;
14/// let diff_style = config.diff_new;
15/// ```
16use anyhow::Result;
17use once_cell::sync::Lazy;
18use std::path::Path;
19
20/// Parsed Git configuration colors for diff, status, and branch visualization
21#[derive(Debug, Clone)]
22pub struct GitColorConfig {
23    /// Color for added lines in diff (default: green)
24    pub diff_new: Style,
25    /// Color for removed lines in diff (default: red)
26    pub diff_old: Style,
27    /// Color for context/unchanged lines in diff (default: none)
28    pub diff_context: Style,
29    /// Color for diff headers (default: none)
30    pub diff_header: Style,
31    /// Color for file metadata lines (default: none)
32    pub diff_meta: Style,
33    /// Color for stat +++ markers (default: green)
34    pub diff_frag: Style,
35
36    /// Color for added files in status (default: green)
37    pub status_added: Style,
38    /// Color for modified files in status (default: red)
39    pub status_modified: Style,
40    /// Color for deleted files in status (default: red)
41    pub status_deleted: Style,
42    /// Color for untracked files in status (default: none)
43    pub status_untracked: Style,
44
45    /// Color for current branch (default: none)
46    pub branch_current: Style,
47    /// Color for local branches (default: none)
48    pub branch_local: Style,
49    /// Color for remote branches (default: none)
50    pub branch_remote: Style,
51}
52
53impl Default for GitColorConfig {
54    /// Returns default Git color configuration
55    fn default() -> Self {
56        Self::with_defaults()
57    }
58}
59
60impl GitColorConfig {
61    /// Create Git color config with default values
62    /// Uses dimmed colors (50% brightness) for git status and background colors
63    /// to reduce eye strain during long development sessions
64    pub fn with_defaults() -> Self {
65        Self {
66            diff_new: crate::utils::style_helpers::style_from_color_name("green"),
67            diff_old: crate::utils::style_helpers::style_from_color_name("red"),
68            diff_context: Style::new(),
69            diff_header: Style::new(),
70            diff_meta: Style::new(),
71            diff_frag: crate::utils::style_helpers::style_from_color_name("cyan"),
72            // Use dimmed green and red for git status to reduce brightness (50% ANSI)
73            status_added: crate::utils::style_helpers::style_from_color_name("green:dimmed"),
74            status_modified: crate::utils::style_helpers::style_from_color_name("red:dimmed"),
75            status_deleted: crate::utils::style_helpers::style_from_color_name("red:dimmed"),
76            status_untracked: Style::new(),
77            branch_current: Style::new(),
78            branch_local: Style::new(),
79            branch_remote: Style::new(),
80        }
81    }
82
83    /// Load Git colors from .git/config file
84    ///
85    /// Parses [color "diff"], [color "status"], and [color "branch"] sections.
86    /// Falls back to defaults for any missing colors.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the file cannot be read, but parsing errors are logged
91    /// and defaults are used for invalid color values.
92    pub fn from_git_config(config_path: &Path) -> Result<Self> {
93        let content = read_file_with_context_sync(config_path, "Git config")?;
94
95        let mut config = Self::with_defaults();
96
97        // Parse [color "diff"] section
98        if let Some(diff_new) = Self::extract_git_color(&content, "diff", "new") {
99            config.diff_new = diff_new;
100        }
101        if let Some(diff_old) = Self::extract_git_color(&content, "diff", "old") {
102            config.diff_old = diff_old;
103        }
104        if let Some(diff_context) = Self::extract_git_color(&content, "diff", "context") {
105            config.diff_context = diff_context;
106        }
107        if let Some(diff_header) = Self::extract_git_color(&content, "diff", "header") {
108            config.diff_header = diff_header;
109        }
110        if let Some(diff_meta) = Self::extract_git_color(&content, "diff", "meta") {
111            config.diff_meta = diff_meta;
112        }
113        if let Some(diff_frag) = Self::extract_git_color(&content, "diff", "frag") {
114            config.diff_frag = diff_frag;
115        }
116
117        // Parse [color "status"] section
118        if let Some(status_added) = Self::extract_git_color(&content, "status", "added") {
119            config.status_added = status_added;
120        }
121        if let Some(status_modified) = Self::extract_git_color(&content, "status", "modified") {
122            config.status_modified = status_modified;
123        }
124        if let Some(status_deleted) = Self::extract_git_color(&content, "status", "deleted") {
125            config.status_deleted = status_deleted;
126        }
127        if let Some(status_untracked) = Self::extract_git_color(&content, "status", "untracked") {
128            config.status_untracked = status_untracked;
129        }
130
131        // Parse [color "branch"] section
132        if let Some(branch_current) = Self::extract_git_color(&content, "branch", "current") {
133            config.branch_current = branch_current;
134        }
135        if let Some(branch_local) = Self::extract_git_color(&content, "branch", "local") {
136            config.branch_local = branch_local;
137        }
138        if let Some(branch_remote) = Self::extract_git_color(&content, "branch", "remote") {
139            config.branch_remote = branch_remote;
140        }
141
142        Ok(config)
143    }
144
145    /// Extract a single Git color setting from config content
146    ///
147    /// Looks for patterns like: [color "section"] key = value
148    /// Note: This parses the color name but ignores bold/dim effects to ensure
149    /// consistent diff styling without theme-dependent formatting.
150    fn extract_git_color(content: &str, section: &str, key: &str) -> Option<Style> {
151        // Pattern: [color "section"]
152        let section_pattern = format!(r#"\[color "{}"\]"#, regex::escape(section));
153
154        // Find the section
155        let section_re = regex::Regex::new(&section_pattern).ok()?;
156        let section_start = section_re.find(content)?.end();
157
158        // Find the next section or end of file
159        static OPEN_BRACKET_RE: Lazy<regex::Regex> = Lazy::new(|| match regex::Regex::new(r"\[") {
160            Ok(regex) => regex,
161            Err(error) => panic!("open bracket regex must compile: {error}"),
162        });
163        let section_end =
164            if let Some(next_section) = OPEN_BRACKET_RE.find(&content[section_start..]) {
165                section_start + next_section.start()
166            } else {
167                content.len()
168            };
169
170        let section_content = &content[section_start..section_end];
171
172        // Pattern: key = value
173        let key_pattern = format!(r"{}\s*=\s*(.+?)(?:\r?\n|$)", regex::escape(key));
174        let key_re = regex::Regex::new(&key_pattern).ok()?;
175
176        let value = key_re.captures(section_content)?.get(1)?.as_str().trim();
177
178        // Parse color name but ignore effects (bold, dim, etc.) for consistent styling
179        // Extract just the color name, ignoring any effects like "bold", "dim", etc.
180        let color_name = value.split_whitespace().find(|word| {
181            matches!(
182                word.to_lowercase().as_str(),
183                "normal"
184                    | "black"
185                    | "red"
186                    | "green"
187                    | "yellow"
188                    | "blue"
189                    | "magenta"
190                    | "purple"
191                    | "cyan"
192                    | "white"
193            )
194        })?;
195
196        Some(crate::utils::style_helpers::style_from_color_name(
197            color_name,
198        ))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::io::Write;
206
207    #[test]
208    fn test_git_color_config_defaults() {
209        let config = GitColorConfig::default();
210        assert_ne!(config.diff_new, Style::new());
211        assert_ne!(config.diff_old, Style::new());
212    }
213
214    fn create_test_git_config(content: &str) -> Result<GitColorConfig> {
215        let mut temp = tempfile::NamedTempFile::new()?;
216        temp.write_all(content.as_bytes())?;
217        temp.flush()?;
218        GitColorConfig::from_git_config(temp.path())
219    }
220
221    #[test]
222    fn test_parse_git_config_diff_section() {
223        let config_text = r#"
224[core]
225    bare = false
226
227[color "diff"]
228    new = green
229    old = red
230    context = cyan
231"#;
232        let config = create_test_git_config(config_text).expect("Failed to parse test config");
233
234        // Should have parsed colors
235        assert_ne!(config.diff_new, Style::new());
236        assert_ne!(config.diff_old, Style::new());
237    }
238
239    #[test]
240    fn test_parse_git_config_status_section() {
241        let config_text = r#"
242[color "status"]
243    added = green bold
244    modified = cyan
245    deleted = red bold
246    untracked = cyan
247"#;
248        let config = create_test_git_config(config_text).expect("Failed to parse test config");
249
250        // Should have parsed status colors
251        assert_ne!(config.status_added, Style::new());
252        assert_ne!(config.status_modified, Style::new());
253    }
254
255    #[test]
256    fn test_parse_git_config_hex_colors() {
257        let config_text = r#"
258[color "diff"]
259    new = #00ff00
260    old = #ff0000
261"#;
262        let config = create_test_git_config(config_text).expect("Failed to parse test config");
263
264        // Should have parsed hex colors
265        assert_ne!(config.diff_new, Style::new());
266        assert_ne!(config.diff_old, Style::new());
267    }
268
269    #[test]
270    fn test_parse_git_config_missing_file() {
271        let result = GitColorConfig::from_git_config(Path::new("/nonexistent/.git/config"));
272        result.unwrap_err();
273    }
274
275    #[test]
276    fn test_parse_git_config_empty_file() {
277        let config_text = "";
278        let config = create_test_git_config(config_text).expect("Failed to parse empty config");
279
280        // Should fall back to defaults
281        assert_ne!(config.diff_new, Style::new());
282        assert_ne!(config.diff_old, Style::new());
283    }
284
285    #[test]
286    fn test_parse_git_config_branch_section() {
287        let config_text = r#"
288[color "branch"]
289    current = cyan bold
290    local = cyan
291    remote = cyan
292"#;
293        let config = create_test_git_config(config_text).expect("Failed to parse test config");
294
295        // Should have parsed branch colors
296        assert_ne!(config.branch_current, Style::new());
297    }
298
299    #[test]
300    fn test_parse_git_config_all_sections() {
301        let config_text = r#"
302[color "diff"]
303    new = green
304    old = red
305
306[color "status"]
307    added = green
308    modified = cyan
309
310[color "branch"]
311    current = cyan bold
312"#;
313        let config = create_test_git_config(config_text).expect("Failed to parse test config");
314
315        // Should have parsed colors from all sections
316        assert_ne!(config.diff_new, Style::new());
317        assert_ne!(config.status_added, Style::new());
318        assert_ne!(config.branch_current, Style::new());
319    }
320}