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
12pub 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 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 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 assert!(output.contains("Repository Contributors"));
219 assert!(output.contains("15") && output.contains("total commits")); 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")); assert!(output.contains("5") && output.contains("commits")); 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 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 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"); assert_eq!(alice.last_commit, "2025-01-20"); assert_eq!(alice.commit_count, 3);
303 }
304}