1#[derive(Debug, Clone)]
2pub enum HelpTopic {
3 Top,
4 Stats,
5 Json,
6 User,
7}
8
9#[derive(Debug)]
10pub enum Commands {
11 Stats { by_name: bool },
13 Json,
14 User {
16 username: String,
17 ownership: bool,
18 by_email: bool,
19 top: Option<usize>,
20 sort: Option<String>,
21 },
22 Help { topic: HelpTopic },
23 Version,
24}
25
26#[derive(Debug)]
27pub struct Cli {
28 pub command: Commands,
29}
30
31impl Cli {
32 pub fn parse() -> Result<Cli, String> {
33 let args: Vec<String> = std::env::args().collect();
34 Cli::parse_from_args(args)
35 }
36
37 pub fn parse_from_args(args: Vec<String>) -> Result<Cli, String> {
38 if args.len() < 2 {
39 return Ok(Cli {
40 command: Commands::Help {
41 topic: HelpTopic::Top,
42 },
43 });
44 }
45
46 let command_str = &args[1];
47
48 if command_str == "-h" || command_str == "--help" {
49 return Ok(Cli {
50 command: Commands::Help {
51 topic: HelpTopic::Top,
52 },
53 });
54 }
55 if command_str == "-v" || command_str == "--version" {
56 return Ok(Cli {
57 command: Commands::Version,
58 });
59 }
60
61 let command = match command_str.as_str() {
62 "stats" => {
63 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
64 Commands::Help {
65 topic: HelpTopic::Stats,
66 }
67 } else {
68 let by_email =
69 has_flag(&args[2..], "--by-email") || has_flag(&args[2..], "-e");
70 let by_name = !by_email;
71 Commands::Stats { by_name }
72 }
73 }
74 "json" => {
75 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
76 Commands::Help {
77 topic: HelpTopic::Json,
78 }
79 } else {
80 Commands::Json
81 }
82 }
83 "user" => {
84 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
85 Commands::Help {
86 topic: HelpTopic::User,
87 }
88 } else {
89 if args.len() < 3 {
90 return Err("Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]".to_string());
91 }
92 let username = args[2].clone();
93 let mut ownership = false;
94 let mut by_email = false;
95 let mut top: Option<usize> = None;
96 let mut sort: Option<String> = None;
97
98 let rest = &args[3..];
99 let mut i = 0;
100 while i < rest.len() {
101 let a = &rest[i];
102 if a == "--ownership" {
103 ownership = true;
104 } else if a == "--by-email" || a == "-e" {
105 by_email = true;
106 } else if a == "--top" {
107 if i + 1 < rest.len() {
108 if let Ok(v) = rest[i + 1].parse::<usize>() {
109 top = Some(v);
110 }
111 i += 1;
112 }
113 } else if let Some(eq) = a.strip_prefix("--top=") {
114 if let Ok(v) = eq.parse::<usize>() {
115 top = Some(v);
116 }
117 } else if a == "--sort" {
118 if i + 1 < rest.len() {
119 sort = Some(rest[i + 1].to_lowercase());
120 i += 1;
121 }
122 } else if let Some(eq) = a.strip_prefix("--sort=") {
123 sort = Some(eq.to_lowercase());
124 }
125 i += 1;
126 }
127
128 Commands::User {
129 username,
130 ownership,
131 by_email,
132 top,
133 sort,
134 }
135 }
136 }
137 _ => {
138 return Err(format!(
139 "Unknown command: {}\n{}",
140 command_str,
141 render_help(HelpTopic::Top)
142 ));
143 }
144 };
145
146 Ok(Cli { command })
147 }
148}
149
150fn has_flag(args: &[String], needle: &str) -> bool {
151 args.iter().any(|a| a == needle)
152}
153
154pub fn render_help(topic: HelpTopic) -> String {
155 match topic {
156 HelpTopic::Top => {
157 let ver = version_string();
158 format!(
159 "\
160git-insights v{ver}
161
162A CLI tool to generate Git repo stats and insights (no dependencies).
163
164USAGE:
165 git-insights <COMMAND> [OPTIONS]
166
167COMMANDS:
168 stats Show repository stats (surviving LOC, commits, files)
169 json Export stats to git-insights.json
170 user <name> Show insights for a specific user
171 help Show this help
172 version Show version information
173
174GLOBAL OPTIONS:
175 -h, --help Show help
176 -v, --version Show version
177
178EXAMPLES:
179 git-insights stats
180 git-insights stats --by-email
181 git-insights json
182 git-insights user alice
183
184See 'git-insights <COMMAND> --help' for command-specific options."
185 )
186 }
187 HelpTopic::Stats => {
188 "\
189git-insights stats
190
191Compute repository stats using a gitfame-like method:
192- Surviving LOC via git blame --line-porcelain HEAD
193- Commits via git shortlog -s -e HEAD
194- Only text files considered (git grep -I --name-only . HEAD AND ls-files)
195- Clean git commands (no pager), no dependencies
196
197USAGE:
198 git-insights stats [OPTIONS]
199
200OPTIONS:
201 -e, --by-email Group by \"Name <email>\" (default groups by name only)
202 -h, --help Show this help
203
204EXAMPLES:
205 git-insights stats
206 git-insights stats --by-email"
207 .to_string()
208 }
209 HelpTopic::Json => {
210 "\
211git-insights json
212
213Export stats to a JSON file (git-insights.json) mapping:
214 author -> { loc, commits, files[] }
215
216USAGE:
217 git-insights json
218
219EXAMPLES:
220 git-insights json"
221 .to_string()
222 }
223 HelpTopic::User => {
224 "\
225git-insights user
226
227Show insights for a specific user.
228
229Default behavior:
230- Merged pull request count (via commit message heuristics)
231- Tags where the user authored commits
232
233Ownership mode (per-file \"ownership\" list):
234- Computes surviving LOC per file attributed to this user at HEAD via blame
235- Shows file path, user LOC, file LOC, and ownership percentage
236
237USAGE:
238 git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]
239
240OPTIONS:
241 --ownership Show per-file ownership table for this user
242 -e, --by-email Match by email (author-mail) instead of author name
243 --top N Limit to top N rows (default: 10)
244 --sort loc|pct Sort by user LOC (loc, default) or percentage (pct)
245 -h, --help Show this help
246
247EXAMPLES:
248 git-insights user alice
249 git-insights user alice --ownership
250 git-insights user \"alice@example.com\" --ownership --by-email --top 5 --sort pct"
251 .to_string()
252 }
253 }
254}
255
256pub fn version_string() -> &'static str {
258 env!("CARGO_PKG_VERSION")
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_cli_stats_default_by_name() {
267 let cli = Cli::parse_from_args(vec![
268 "git-insights".to_string(),
269 "stats".to_string(),
270 ])
271 .expect("Failed to parse args");
272 match cli.command {
273 Commands::Stats { by_name } => assert!(by_name),
274 _ => panic!("Expected Stats command"),
275 }
276 }
277
278 #[test]
279 fn test_cli_stats_by_email_flag() {
280 let cli = Cli::parse_from_args(vec![
281 "git-insights".to_string(),
282 "stats".to_string(),
283 "--by-email".to_string(),
284 ])
285 .expect("Failed to parse args");
286 match cli.command {
287 Commands::Stats { by_name } => assert!(!by_name),
288 _ => panic!("Expected Stats command"),
289 }
290 }
291
292 #[test]
293 fn test_cli_stats_short_e_flag() {
294 let cli = Cli::parse_from_args(vec![
295 "git-insights".to_string(),
296 "stats".to_string(),
297 "-e".to_string(),
298 ])
299 .expect("Failed to parse args");
300 match cli.command {
301 Commands::Stats { by_name } => assert!(!by_name),
302 _ => panic!("Expected Stats command"),
303 }
304 }
305
306 #[test]
307 fn test_cli_json() {
308 let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "json".to_string()])
309 .expect("Failed to parse args");
310 assert!(matches!(cli.command, Commands::Json));
311 }
312
313 #[test]
314 fn test_cli_user() {
315 let cli = Cli::parse_from_args(vec![
316 "git-insights".to_string(),
317 "user".to_string(),
318 "testuser".to_string(),
319 ])
320 .expect("Failed to parse args");
321 match cli.command {
322 Commands::User { username, ownership, by_email, top, sort } => {
323 assert_eq!(username, "testuser");
324 assert!(!ownership);
325 assert!(!by_email);
326 assert!(top.is_none());
327 assert!(sort.is_none());
328 }
329 _ => panic!("Expected User command"),
330 }
331 }
332
333 #[test]
334 fn test_cli_user_ownership_flags() {
335 let cli = Cli::parse_from_args(vec![
336 "git-insights".to_string(),
337 "user".to_string(),
338 "palash".to_string(),
339 "--ownership".to_string(),
340 "--by-email".to_string(),
341 "--top".to_string(),
342 "5".to_string(),
343 "--sort".to_string(),
344 "pct".to_string(),
345 ])
346 .expect("Failed to parse args");
347 match cli.command {
348 Commands::User { username, ownership, by_email, top, sort } => {
349 assert_eq!(username, "palash");
350 assert!(ownership);
351 assert!(by_email);
352 assert_eq!(top, Some(5));
353 assert_eq!(sort.as_deref(), Some("pct"));
354 }
355 _ => panic!("Expected User command with ownership flags"),
356 }
357
358 let cli2 = Cli::parse_from_args(vec![
360 "git-insights".to_string(),
361 "user".to_string(),
362 "palash".to_string(),
363 "--ownership".to_string(),
364 "-e".to_string(),
365 "--top=3".to_string(),
366 "--sort=loc".to_string(),
367 ])
368 .expect("Failed to parse args");
369 match cli2.command {
370 Commands::User { username, ownership, by_email, top, sort } => {
371 assert_eq!(username, "palash");
372 assert!(ownership);
373 assert!(by_email);
374 assert_eq!(top, Some(3));
375 assert_eq!(sort.as_deref(), Some("loc"));
376 }
377 _ => panic!("Expected User command with equals-style flags"),
378 }
379 }
380
381 #[test]
382 fn test_cli_no_args_yields_help() {
383 let cli = Cli::parse_from_args(vec!["git-insights".to_string()]).expect("parse");
384 match cli.command {
385 Commands::Help { topic } => match topic {
386 HelpTopic::Top => {}
387 _ => panic!("Expected top-level help"),
388 },
389 _ => panic!("Expected Help command"),
390 }
391 }
392
393 #[test]
394 fn test_cli_top_help_flag() {
395 let cli = Cli::parse_from_args(vec![
396 "git-insights".to_string(),
397 "--help".to_string(),
398 ])
399 .expect("parse");
400 match cli.command {
401 Commands::Help { topic } => match topic {
402 HelpTopic::Top => {}
403 _ => panic!("Expected top-level help"),
404 },
405 _ => panic!("Expected Help command"),
406 }
407 }
408
409 #[test]
410 fn test_cli_stats_help_flag() {
411 let cli = Cli::parse_from_args(vec![
412 "git-insights".to_string(),
413 "stats".to_string(),
414 "--help".to_string(),
415 ])
416 .expect("parse");
417 match cli.command {
418 Commands::Help { topic } => match topic {
419 HelpTopic::Stats => {}
420 _ => panic!("Expected stats help"),
421 },
422 _ => panic!("Expected Help command"),
423 }
424 }
425
426 #[test]
427 fn test_cli_version_flag() {
428 let cli = Cli::parse_from_args(vec![
429 "git-insights".to_string(),
430 "--version".to_string(),
431 ])
432 .expect("parse");
433 assert!(matches!(cli.command, Commands::Version));
434 }
435
436 #[test]
437 fn test_cli_unknown_command() {
438 let err =
439 Cli::parse_from_args(vec!["git-insights".to_string(), "invalid".to_string()])
440 .expect_err("Expected an error for unknown command");
441 assert!(err.contains("Unknown command: invalid"));
442 }
443
444 #[test]
445 fn test_cli_user_no_username() {
446 let err = Cli::parse_from_args(vec!["git-insights".to_string(), "user".to_string()])
447 .expect_err("Expected an error for user command without username");
448 assert_eq!(err, "Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]");
449 }
450}