Skip to main content

zeph_tools/filter/
git.rs

1use std::fmt::Write;
2use std::sync::LazyLock;
3
4use super::{
5    CommandMatcher, FilterConfidence, FilterResult, GitFilterConfig, OutputFilter, make_result,
6};
7
8static GIT_MATCHER: LazyLock<CommandMatcher> =
9    LazyLock::new(|| CommandMatcher::Custom(Box::new(|cmd| cmd.trim_start().starts_with("git "))));
10
11pub struct GitFilter {
12    config: GitFilterConfig,
13}
14
15impl GitFilter {
16    #[must_use]
17    pub fn new(config: GitFilterConfig) -> Self {
18        Self { config }
19    }
20}
21
22impl OutputFilter for GitFilter {
23    fn name(&self) -> &'static str {
24        "git"
25    }
26
27    fn matcher(&self) -> &CommandMatcher {
28        &GIT_MATCHER
29    }
30
31    fn filter(&self, command: &str, raw_output: &str, _exit_code: i32) -> FilterResult {
32        let subcmd = command
33            .trim_start()
34            .strip_prefix("git ")
35            .unwrap_or("")
36            .split_whitespace()
37            .next()
38            .unwrap_or("");
39
40        match subcmd {
41            "status" => filter_status(raw_output),
42            "diff" => filter_diff(raw_output, self.config.max_diff_lines),
43            "log" => filter_log(raw_output, self.config.max_log_entries),
44            "push" => filter_push(raw_output),
45            _ => make_result(
46                raw_output,
47                raw_output.to_owned(),
48                FilterConfidence::Fallback,
49            ),
50        }
51    }
52}
53
54fn filter_status(raw: &str) -> FilterResult {
55    let mut modified = 0u32;
56    let mut added = 0u32;
57    let mut deleted = 0u32;
58    let mut untracked = 0u32;
59
60    for line in raw.lines() {
61        let trimmed = line.trim();
62        if trimmed.starts_with("M ") || trimmed.starts_with("MM") || trimmed.starts_with(" M") {
63            modified += 1;
64        } else if trimmed.starts_with("A ") || trimmed.starts_with("AM") {
65            added += 1;
66        } else if trimmed.starts_with("D ") || trimmed.starts_with(" D") {
67            deleted += 1;
68        } else if trimmed.starts_with("??") {
69            untracked += 1;
70        } else if trimmed.starts_with("modified:") {
71            modified += 1;
72        } else if trimmed.starts_with("new file:") {
73            added += 1;
74        } else if trimmed.starts_with("deleted:") {
75            deleted += 1;
76        }
77    }
78
79    let total = modified + added + deleted + untracked;
80    if total == 0 {
81        return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
82    }
83
84    let mut output = String::new();
85    let _ = write!(
86        output,
87        "M  {modified} files | A  {added} files | D  {deleted} files | ??  {untracked} files"
88    );
89    make_result(raw, output, FilterConfidence::Full)
90}
91
92fn filter_diff(raw: &str, max_diff_lines: usize) -> FilterResult {
93    let mut files: Vec<(String, i32, i32)> = Vec::new();
94    let mut current_file = String::new();
95    let mut additions = 0i32;
96    let mut deletions = 0i32;
97
98    for line in raw.lines() {
99        if line.starts_with("diff --git ") {
100            if !current_file.is_empty() {
101                files.push((current_file.clone(), additions, deletions));
102            }
103            line.strip_prefix("diff --git a/")
104                .and_then(|s| s.split(" b/").next())
105                .unwrap_or("unknown")
106                .clone_into(&mut current_file);
107            additions = 0;
108            deletions = 0;
109        } else if line.starts_with('+') && !line.starts_with("+++") {
110            additions += 1;
111        } else if line.starts_with('-') && !line.starts_with("---") {
112            deletions += 1;
113        }
114    }
115    if !current_file.is_empty() {
116        files.push((current_file, additions, deletions));
117    }
118
119    if files.is_empty() {
120        return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
121    }
122
123    let total_lines: usize = raw.lines().count();
124    let total_add: i32 = files.iter().map(|(_, a, _)| a).sum();
125    let total_del: i32 = files.iter().map(|(_, _, d)| d).sum();
126    let mut output = String::new();
127    for (file, add, del) in &files {
128        let _ = writeln!(output, "{file}    | +{add} -{del}");
129    }
130    let _ = write!(
131        output,
132        "{} files changed, {} insertions(+), {} deletions(-)",
133        files.len(),
134        total_add,
135        total_del
136    );
137    if total_lines > max_diff_lines {
138        let _ = write!(output, " (truncated from {total_lines} lines)");
139    }
140    make_result(raw, output, FilterConfidence::Full)
141}
142
143fn filter_log(raw: &str, max_entries: usize) -> FilterResult {
144    let lines: Vec<&str> = raw.lines().collect();
145    if lines.len() <= max_entries {
146        return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
147    }
148
149    let mut output: String = lines[..max_entries].join("\n");
150    let remaining = lines.len() - max_entries;
151    let _ = write!(output, "\n... and {remaining} more commits");
152    make_result(raw, output, FilterConfidence::Full)
153}
154
155fn filter_push(raw: &str) -> FilterResult {
156    let mut output = String::new();
157    for line in raw.lines() {
158        let trimmed = line.trim();
159        if trimmed.contains("->") || trimmed.starts_with("To ") || trimmed.starts_with("Branch") {
160            if !output.is_empty() {
161                output.push('\n');
162            }
163            output.push_str(trimmed);
164        }
165    }
166    if output.is_empty() {
167        return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
168    }
169    make_result(raw, output, FilterConfidence::Full)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn make_filter() -> GitFilter {
177        GitFilter::new(GitFilterConfig::default())
178    }
179
180    #[test]
181    fn matches_git_commands() {
182        let f = make_filter();
183        assert!(f.matcher().matches("git status"));
184        assert!(f.matcher().matches("git diff --stat"));
185        assert!(f.matcher().matches("git log --oneline"));
186        assert!(f.matcher().matches("git push origin main"));
187        assert!(!f.matcher().matches("cargo build"));
188        assert!(!f.matcher().matches("github-cli"));
189    }
190
191    #[test]
192    fn filter_status_summarizes() {
193        let f = make_filter();
194        let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA  added.rs\n";
195        let result = f.filter("git status --short", raw, 0);
196        assert!(result.output.contains("M  2 files"));
197        assert!(result.output.contains("??  1 files"));
198        assert!(result.output.contains("A  1 files"));
199        assert_eq!(result.confidence, FilterConfidence::Full);
200    }
201
202    #[test]
203    fn filter_diff_compresses() {
204        let f = make_filter();
205        let raw = "\
206diff --git a/src/main.rs b/src/main.rs
207index abc..def 100644
208--- a/src/main.rs
209+++ b/src/main.rs
210+new line 1
211+new line 2
212-old line 1
213diff --git a/src/lib.rs b/src/lib.rs
214index ghi..jkl 100644
215--- a/src/lib.rs
216+++ b/src/lib.rs
217+added
218";
219        let result = f.filter("git diff", raw, 0);
220        assert!(result.output.contains("src/main.rs"));
221        assert!(result.output.contains("src/lib.rs"));
222        assert!(result.output.contains("2 files changed"));
223        assert!(result.output.contains("3 insertions(+)"));
224        assert!(result.output.contains("1 deletions(-)"));
225    }
226
227    #[test]
228    fn filter_log_truncates() {
229        let f = make_filter();
230        let lines: Vec<String> = (0..50)
231            .map(|i| format!("abc{i:04} feat: commit {i}"))
232            .collect();
233        let raw = lines.join("\n");
234        let result = f.filter("git log --oneline", &raw, 0);
235        assert!(result.output.contains("abc0000"));
236        assert!(result.output.contains("abc0019"));
237        assert!(!result.output.contains("abc0020"));
238        assert!(result.output.contains("and 30 more commits"));
239        assert_eq!(result.confidence, FilterConfidence::Full);
240    }
241
242    #[test]
243    fn filter_log_short_passthrough() {
244        let f = make_filter();
245        let raw = "abc1234 feat: something\ndef5678 fix: other";
246        let result = f.filter("git log --oneline", raw, 0);
247        assert_eq!(result.output, raw);
248        assert_eq!(result.confidence, FilterConfidence::Fallback);
249    }
250
251    #[test]
252    fn filter_push_extracts_summary() {
253        let f = make_filter();
254        let raw = "\
255Enumerating objects: 5, done.
256Counting objects: 100% (5/5), done.
257Delta compression using up to 10 threads
258Compressing objects: 100% (3/3), done.
259Writing objects: 100% (3/3), 1.20 KiB | 1.20 MiB/s, done.
260Total 3 (delta 2), reused 0 (delta 0)
261To github.com:user/repo.git
262   abc1234..def5678  main -> main
263";
264        let result = f.filter("git push origin main", raw, 0);
265        assert!(result.output.contains("main -> main"));
266        assert!(result.output.contains("To github.com"));
267        assert!(!result.output.contains("Enumerating"));
268    }
269
270    #[test]
271    fn filter_status_long_form() {
272        let f = make_filter();
273        let raw = "\
274On branch main
275Changes not staged for commit:
276        modified:   src/main.rs
277        modified:   src/lib.rs
278        deleted:    old_file.rs
279
280Untracked files:
281        new_file.txt
282";
283        let result = f.filter("git status", raw, 0);
284        assert!(result.output.contains("M  2 files"));
285        assert!(result.output.contains("D  1 files"));
286    }
287
288    #[test]
289    fn filter_diff_empty_passthrough() {
290        let f = make_filter();
291        let raw = "";
292        let result = f.filter("git diff", raw, 0);
293        assert_eq!(result.output, raw);
294    }
295
296    #[test]
297    fn filter_unknown_subcommand_passthrough() {
298        let f = make_filter();
299        let raw = "some output";
300        let result = f.filter("git stash list", raw, 0);
301        assert_eq!(result.output, raw);
302        assert_eq!(result.confidence, FilterConfidence::Fallback);
303    }
304
305    #[test]
306    fn filter_diff_snapshot() {
307        let f = make_filter();
308        let raw = "\
309diff --git a/src/main.rs b/src/main.rs
310index abc..def 100644
311--- a/src/main.rs
312+++ b/src/main.rs
313+new line 1
314-old line 1
315diff --git a/src/lib.rs b/src/lib.rs
316index ghi..jkl 100644
317--- a/src/lib.rs
318+++ b/src/lib.rs
319+added line
320";
321        let result = f.filter("git diff", raw, 0);
322        insta::assert_snapshot!(result.output);
323    }
324
325    #[test]
326    fn filter_status_snapshot() {
327        let f = make_filter();
328        let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA  added.rs\n";
329        let result = f.filter("git status --short", raw, 0);
330        insta::assert_snapshot!(result.output);
331    }
332}