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 =
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 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 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 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 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 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 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 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 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}