vtcode_core/ui/
git_config.rs1use crate::utils::file_utils::read_file_with_context_sync;
2use anstyle::Style;
3use anyhow::Result;
17use once_cell::sync::Lazy;
18use std::path::Path;
19
20#[derive(Debug, Clone)]
22pub struct GitColorConfig {
23 pub diff_new: Style,
25 pub diff_old: Style,
27 pub diff_context: Style,
29 pub diff_header: Style,
31 pub diff_meta: Style,
33 pub diff_frag: Style,
35
36 pub status_added: Style,
38 pub status_modified: Style,
40 pub status_deleted: Style,
42 pub status_untracked: Style,
44
45 pub branch_current: Style,
47 pub branch_local: Style,
49 pub branch_remote: Style,
51}
52
53impl Default for GitColorConfig {
54 fn default() -> Self {
56 Self::with_defaults()
57 }
58}
59
60impl GitColorConfig {
61 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 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 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 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 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 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 fn extract_git_color(content: &str, section: &str, key: &str) -> Option<Style> {
151 let section_pattern = format!(r#"\[color "{}"\]"#, regex::escape(section));
153
154 let section_re = regex::Regex::new(§ion_pattern).ok()?;
156 let section_start = section_re.find(content)?.end();
157
158 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 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 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 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 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 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 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 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 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}