git_x/
summary.rs

1use crate::command::Command;
2use crate::{GitXError, Result};
3use std::collections::BTreeMap;
4use std::process::Command as StdCommand;
5
6use chrono::{NaiveDate, Utc};
7
8pub fn run(since: String) -> Result<()> {
9    let cmd = SummaryCommand;
10    cmd.execute(since)
11}
12
13/// Command implementation for git summary
14pub struct SummaryCommand;
15
16impl Command for SummaryCommand {
17    type Input = String;
18    type Output = ();
19
20    fn execute(&self, since: String) -> Result<()> {
21        run_summary(&since)
22    }
23
24    fn name(&self) -> &'static str {
25        "summary"
26    }
27
28    fn description(&self) -> &'static str {
29        "Show a summary of commits since a given date"
30    }
31}
32
33fn run_summary(since: &str) -> Result<()> {
34    let git_log = StdCommand::new("git")
35        .args(get_git_log_summary_args(since))
36        .output()
37        .map_err(GitXError::Io)?;
38
39    if !git_log.status.success() {
40        let stderr = String::from_utf8_lossy(&git_log.stderr);
41        return Err(GitXError::GitCommand(format!(
42            "git log failed: {}",
43            stderr.trim()
44        )));
45    }
46
47    let stdout = String::from_utf8_lossy(&git_log.stdout);
48    if is_stdout_empty(&stdout) {
49        println!("{since}");
50        return Ok(());
51    }
52
53    let grouped = parse_git_log_output(&stdout);
54    print!("{}", format_commit_summary(since, &grouped));
55    Ok(())
56}
57
58fn get_git_log_summary_args(since: &str) -> Vec<&str> {
59    vec![
60        "log",
61        "--since",
62        since,
63        "--pretty=format:%h|%ad|%s|%an|%cr",
64        "--date=short",
65    ]
66}
67
68fn is_stdout_empty(stdout: &str) -> bool {
69    stdout.trim().is_empty()
70}
71
72pub fn parse_git_log_output(stdout: &str) -> BTreeMap<NaiveDate, Vec<String>> {
73    let mut grouped: BTreeMap<NaiveDate, Vec<String>> = BTreeMap::new();
74
75    for line in stdout.lines() {
76        if let Some((date, formatted_commit)) = parse_commit_line(line) {
77            grouped.entry(date).or_default().push(formatted_commit);
78        }
79    }
80
81    grouped
82}
83
84pub fn parse_commit_line(line: &str) -> Option<(NaiveDate, String)> {
85    let parts: Vec<&str> = line.splitn(5, '|').collect();
86    if parts.len() != 5 {
87        return None;
88    }
89
90    let date = parse_commit_date(parts[1])?;
91    let message = parts[2];
92    let entry = format!(" - {} {}", get_commit_emoji_public(message), message.trim());
93    let author = parts[3];
94    let time = parts[4];
95    let meta = format!("(by {author}, {time})");
96    Some((date, format!("{entry} {meta}")))
97}
98
99pub fn parse_commit_date(date_str: &str) -> Option<NaiveDate> {
100    NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
101        .ok()
102        .or_else(|| Some(Utc::now().date_naive()))
103}
104
105pub fn format_commit_summary(since: &str, grouped: &BTreeMap<NaiveDate, Vec<String>>) -> String {
106    let mut result = since.to_string();
107
108    for (date, commits) in grouped.iter().rev() {
109        result.push_str(&date.to_string());
110        result.push('\n');
111        for commit in commits {
112            result.push_str(commit);
113            result.push('\n');
114        }
115        result.push('\n');
116    }
117
118    result
119}
120
121pub fn get_commit_emoji_public(message: &str) -> &'static str {
122    // Use case-insensitive matching without allocation
123    let msg_bytes = message.as_bytes();
124    if msg_bytes.windows(3).any(|w| w.eq_ignore_ascii_case(b"fix"))
125        || msg_bytes.windows(3).any(|w| w.eq_ignore_ascii_case(b"bug"))
126    {
127        "🐛"
128    } else if msg_bytes
129        .windows(4)
130        .any(|w| w.eq_ignore_ascii_case(b"feat"))
131        || msg_bytes.windows(3).any(|w| w.eq_ignore_ascii_case(b"add"))
132    {
133        "✨"
134    } else if msg_bytes
135        .windows(6)
136        .any(|w| w.eq_ignore_ascii_case(b"remove"))
137        || msg_bytes
138            .windows(6)
139            .any(|w| w.eq_ignore_ascii_case(b"delete"))
140    {
141        "🔥"
142    } else if msg_bytes
143        .windows(8)
144        .any(|w| w.eq_ignore_ascii_case(b"refactor"))
145    {
146        "🛠"
147    } else {
148        "🔹"
149    }
150}