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 = if let Some(next_section) = OPEN_BRACKET_RE.find(&content[section_start..]) {
164            section_start + next_section.start()
165        } else {
166            content.len()
167        };
168
169        let section_content = &content[section_start..section_end];
170
171        // Pattern: key = value
172        let key_pattern = format!(r"{}\s*=\s*(.+?)(?:\r?\n|$)", regex::escape(key));
173        let key_re = regex::Regex::new(&key_pattern).ok()?;
174
175        let value = key_re.captures(section_content)?.get(1)?.as_str().trim();
176
177        // Parse color name but ignore effects (bold, dim, etc.) for consistent styling
178        // Extract just the color name, ignoring any effects like "bold", "dim", etc.
179        let color_name = value.split_whitespace().find(|word| {
180            matches!(
181                word.to_lowercase().as_str(),
182                "normal" | "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "purple" | "cyan" | "white"
183            )
184        })?;
185
186        Some(crate::utils::style_helpers::style_from_color_name(color_name))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::io::Write;
194
195    #[test]
196    fn test_git_color_config_defaults() {
197        let config = GitColorConfig::default();
198        assert_ne!(config.diff_new, Style::new());
199        assert_ne!(config.diff_old, Style::new());
200    }
201
202    fn create_test_git_config(content: &str) -> Result<GitColorConfig> {
203        let mut temp = tempfile::NamedTempFile::new()?;
204        temp.write_all(content.as_bytes())?;
205        temp.flush()?;
206        GitColorConfig::from_git_config(temp.path())
207    }
208
209    #[test]
210    fn test_parse_git_config_diff_section() {
211        let config_text = r#"
212[core]
213    bare = false
214
215[color "diff"]
216    new = green
217    old = red
218    context = cyan
219"#;
220        let config = create_test_git_config(config_text).expect("Failed to parse test config");
221
222        // Should have parsed colors
223        assert_ne!(config.diff_new, Style::new());
224        assert_ne!(config.diff_old, Style::new());
225    }
226
227    #[test]
228    fn test_parse_git_config_status_section() {
229        let config_text = r#"
230[color "status"]
231    added = green bold
232    modified = cyan
233    deleted = red bold
234    untracked = cyan
235"#;
236        let config = create_test_git_config(config_text).expect("Failed to parse test config");
237
238        // Should have parsed status colors
239        assert_ne!(config.status_added, Style::new());
240        assert_ne!(config.status_modified, Style::new());
241    }
242
243    #[test]
244    fn test_parse_git_config_hex_colors() {
245        let config_text = r#"
246[color "diff"]
247    new = #00ff00
248    old = #ff0000
249"#;
250        let config = create_test_git_config(config_text).expect("Failed to parse test config");
251
252        // Should have parsed hex colors
253        assert_ne!(config.diff_new, Style::new());
254        assert_ne!(config.diff_old, Style::new());
255    }
256
257    #[test]
258    fn test_parse_git_config_missing_file() {
259        let result = GitColorConfig::from_git_config(Path::new("/nonexistent/.git/config"));
260        result.unwrap_err();
261    }
262
263    #[test]
264    fn test_parse_git_config_empty_file() {
265        let config_text = "";
266        let config = create_test_git_config(config_text).expect("Failed to parse empty config");
267
268        // Should fall back to defaults
269        assert_ne!(config.diff_new, Style::new());
270        assert_ne!(config.diff_old, Style::new());
271    }
272
273    #[test]
274    fn test_parse_git_config_branch_section() {
275        let config_text = r#"
276[color "branch"]
277    current = cyan bold
278    local = cyan
279    remote = cyan
280"#;
281        let config = create_test_git_config(config_text).expect("Failed to parse test config");
282
283        // Should have parsed branch colors
284        assert_ne!(config.branch_current, Style::new());
285    }
286
287    #[test]
288    fn test_parse_git_config_all_sections() {
289        let config_text = r#"
290[color "diff"]
291    new = green
292    old = red
293
294[color "status"]
295    added = green
296    modified = cyan
297
298[color "branch"]
299    current = cyan bold
300"#;
301        let config = create_test_git_config(config_text).expect("Failed to parse test config");
302
303        // Should have parsed colors from all sections
304        assert_ne!(config.diff_new, Style::new());
305        assert_ne!(config.status_added, Style::new());
306        assert_ne!(config.branch_current, Style::new());
307    }
308}