git_x/
contributors.rs

1use crate::command::Command;
2use crate::{GitXError, Result};
3use console::style;
4use std::collections::HashMap;
5use std::process::Command as StdCommand;
6
7pub fn run() -> Result<String> {
8    let cmd = ContributorsCommand;
9    cmd.execute(())
10}
11
12/// Command implementation for git contributors
13pub struct ContributorsCommand;
14
15impl Command for ContributorsCommand {
16    type Input = ();
17    type Output = String;
18
19    fn execute(&self, _input: ()) -> Result<String> {
20        run_contributors()
21    }
22
23    fn name(&self) -> &'static str {
24        "contributors"
25    }
26
27    fn description(&self) -> &'static str {
28        "Show repository contributors and their commit statistics"
29    }
30}
31
32fn run_contributors() -> Result<String> {
33    let output = StdCommand::new("git")
34        .args(["log", "--all", "--format=%ae|%an|%ad", "--date=short"])
35        .output()?;
36
37    if !output.status.success() {
38        return Err(GitXError::GitCommand(
39            "Failed to retrieve commit history".to_string(),
40        ));
41    }
42
43    let stdout = String::from_utf8_lossy(&output.stdout);
44    if stdout.trim().is_empty() {
45        return Ok("📊 No contributors found in this repository".to_string());
46    }
47
48    let contributors = parse_contributors(&stdout)?;
49    Ok(format_contributors_output(&contributors))
50}
51
52#[derive(Clone)]
53struct ContributorStats {
54    name: String,
55    email: String,
56    commit_count: usize,
57    first_commit: String,
58    last_commit: String,
59}
60
61fn parse_contributors(output: &str) -> Result<Vec<ContributorStats>> {
62    let mut contributors: HashMap<String, ContributorStats> = HashMap::new();
63
64    for line in output.lines() {
65        let parts: Vec<&str> = line.splitn(3, '|').collect();
66        if parts.len() != 3 {
67            continue;
68        }
69
70        let email = parts[0].trim().to_string();
71        let name = parts[1].trim().to_string();
72        let date = parts[2].trim().to_string();
73
74        contributors
75            .entry(email.clone())
76            .and_modify(|stats| {
77                stats.commit_count += 1;
78                if date < stats.first_commit {
79                    stats.first_commit = date.clone();
80                }
81                if date > stats.last_commit {
82                    stats.last_commit = date.clone();
83                }
84            })
85            .or_insert(ContributorStats {
86                name: name.clone(),
87                email: email.clone(),
88                commit_count: 1,
89                first_commit: date.clone(),
90                last_commit: date,
91            });
92    }
93
94    let mut sorted_contributors: Vec<ContributorStats> = contributors.into_values().collect();
95    sorted_contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
96
97    Ok(sorted_contributors)
98}
99
100fn format_contributors_output(contributors: &[ContributorStats]) -> String {
101    let mut output = Vec::new();
102    let total_commits: usize = contributors.iter().map(|c| c.commit_count).sum();
103
104    output.push(format!(
105        "{} Repository Contributors ({} total commits):\n",
106        style("📊").bold(),
107        style(total_commits).bold()
108    ));
109
110    for (index, contributor) in contributors.iter().enumerate() {
111        let rank_icon = match index {
112            0 => "🥇",
113            1 => "🥈",
114            2 => "🥉",
115            _ => "👤",
116        };
117
118        let percentage = (contributor.commit_count as f64 / total_commits as f64) * 100.0;
119
120        output.push(format!(
121            "{} {} {} commits ({:.1}%)",
122            rank_icon,
123            style(&contributor.name).bold(),
124            style(contributor.commit_count).cyan(),
125            percentage
126        ));
127
128        output.push(format!(
129            "   📧 {} | 📅 {} to {}",
130            style(&contributor.email).dim(),
131            style(&contributor.first_commit).dim(),
132            style(&contributor.last_commit).dim()
133        ));
134
135        if index < contributors.len() - 1 {
136            output.push(String::new());
137        }
138    }
139
140    output.join("\n")
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_parse_contributors_success() {
149        let sample_output = r#"alice@example.com|Alice Smith|2025-01-15
150bob@example.com|Bob Jones|2025-01-10
151alice@example.com|Alice Smith|2025-01-20
152charlie@example.com|Charlie Brown|2025-01-12"#;
153
154        let result = parse_contributors(sample_output);
155        assert!(result.is_ok());
156
157        let contributors = result.unwrap();
158        assert_eq!(contributors.len(), 3);
159
160        // Alice should be first with 2 commits
161        assert_eq!(contributors[0].name, "Alice Smith");
162        assert_eq!(contributors[0].commit_count, 2);
163        assert_eq!(contributors[0].first_commit, "2025-01-15");
164        assert_eq!(contributors[0].last_commit, "2025-01-20");
165
166        // Bob and Charlie should have 1 commit each
167        assert!(
168            contributors
169                .iter()
170                .any(|c| c.name == "Bob Jones" && c.commit_count == 1)
171        );
172        assert!(
173            contributors
174                .iter()
175                .any(|c| c.name == "Charlie Brown" && c.commit_count == 1)
176        );
177    }
178
179    #[test]
180    fn test_parse_contributors_empty_input() {
181        let result = parse_contributors("");
182        assert!(result.is_ok());
183        let contributors = result.unwrap();
184        assert!(contributors.is_empty());
185    }
186
187    #[test]
188    fn test_parse_contributors_malformed_input() {
189        let malformed_output = "invalid|line\nincomplete";
190        let result = parse_contributors(malformed_output);
191        assert!(result.is_ok());
192        let contributors = result.unwrap();
193        assert!(contributors.is_empty());
194    }
195
196    #[test]
197    fn test_format_contributors_output() {
198        let contributors = vec![
199            ContributorStats {
200                name: "Alice Smith".to_string(),
201                email: "alice@example.com".to_string(),
202                commit_count: 10,
203                first_commit: "2025-01-01".to_string(),
204                last_commit: "2025-01-15".to_string(),
205            },
206            ContributorStats {
207                name: "Bob Jones".to_string(),
208                email: "bob@example.com".to_string(),
209                commit_count: 5,
210                first_commit: "2025-01-05".to_string(),
211                last_commit: "2025-01-10".to_string(),
212            },
213        ];
214
215        let output = format_contributors_output(&contributors);
216
217        // Check that output contains expected elements (accounting for styling)
218        assert!(output.contains("Repository Contributors"));
219        assert!(output.contains("15") && output.contains("total commits")); // Account for styling
220        assert!(output.contains("🥇"));
221        assert!(output.contains("🥈"));
222        assert!(output.contains("Alice Smith"));
223        assert!(output.contains("Bob Jones"));
224        assert!(output.contains("66.7%"));
225        assert!(output.contains("33.3%"));
226        assert!(output.contains("10") && output.contains("commits")); // Alice's commit count
227        assert!(output.contains("5") && output.contains("commits")); // Bob's commit count
228        assert!(output.contains("alice@example.com"));
229        assert!(output.contains("bob@example.com"));
230    }
231
232    #[test]
233    fn test_run_no_git_repo() {
234        // This test runs in the context where git might fail
235        // We test by calling parse_contributors with valid input
236        let result = parse_contributors("test@example.com|Test User|2025-01-01");
237        match result {
238            Ok(contributors) => {
239                assert_eq!(contributors.len(), 1);
240                assert_eq!(contributors[0].name, "Test User");
241            }
242            Err(_) => panic!("Should parse valid input successfully"),
243        }
244    }
245
246    #[test]
247    fn test_contributor_stats_fields() {
248        let stats = ContributorStats {
249            name: "Test User".to_string(),
250            email: "test@example.com".to_string(),
251            commit_count: 5,
252            first_commit: "2025-01-01".to_string(),
253            last_commit: "2025-01-15".to_string(),
254        };
255
256        assert_eq!(stats.name, "Test User");
257        assert_eq!(stats.email, "test@example.com");
258        assert_eq!(stats.commit_count, 5);
259        assert_eq!(stats.first_commit, "2025-01-01");
260        assert_eq!(stats.last_commit, "2025-01-15");
261    }
262
263    #[test]
264    fn test_sorting_by_commit_count() {
265        let sample_output = r#"alice@example.com|Alice Smith|2025-01-15
266bob@example.com|Bob Jones|2025-01-10
267alice@example.com|Alice Smith|2025-01-20
268alice@example.com|Alice Smith|2025-01-25
269bob@example.com|Bob Jones|2025-01-12
270charlie@example.com|Charlie Brown|2025-01-12"#;
271
272        let result = parse_contributors(sample_output);
273        assert!(result.is_ok());
274
275        let contributors = result.unwrap();
276        assert_eq!(contributors.len(), 3);
277
278        // Should be sorted by commit count (descending)
279        assert_eq!(contributors[0].name, "Alice Smith");
280        assert_eq!(contributors[0].commit_count, 3);
281        assert_eq!(contributors[1].name, "Bob Jones");
282        assert_eq!(contributors[1].commit_count, 2);
283        assert_eq!(contributors[2].name, "Charlie Brown");
284        assert_eq!(contributors[2].commit_count, 1);
285    }
286
287    #[test]
288    fn test_date_range_tracking() {
289        let sample_output = r#"alice@example.com|Alice Smith|2025-01-20
290alice@example.com|Alice Smith|2025-01-10
291alice@example.com|Alice Smith|2025-01-15"#;
292
293        let result = parse_contributors(sample_output);
294        assert!(result.is_ok());
295
296        let contributors = result.unwrap();
297        assert_eq!(contributors.len(), 1);
298
299        let alice = &contributors[0];
300        assert_eq!(alice.first_commit, "2025-01-10"); // Earliest date
301        assert_eq!(alice.last_commit, "2025-01-20"); // Latest date
302        assert_eq!(alice.commit_count, 3);
303    }
304}