1#[derive(Debug, Clone)]
2pub enum HelpTopic {
3 Top,
4 Stats,
5 Json,
6 User,
7 Timeline,
8 Heatmap,
9}
10
11#[derive(Debug)]
12pub enum Commands {
13 Stats { by_name: bool },
15 Json,
16 Timeline { weeks: Option<usize>, color: bool },
17 Heatmap { weeks: Option<usize>, color: bool },
18 User {
20 username: String,
21 ownership: bool,
22 by_email: bool,
23 top: Option<usize>,
24 sort: Option<String>,
25 },
26 Help { topic: HelpTopic },
27 Version,
28}
29
30#[derive(Debug)]
31pub struct Cli {
32 pub command: Commands,
33}
34
35impl Cli {
36 pub fn parse() -> Result<Cli, String> {
37 let args: Vec<String> = std::env::args().collect();
38 Cli::parse_from_args(args)
39 }
40
41 pub fn parse_from_args(args: Vec<String>) -> Result<Cli, String> {
42 if args.len() < 2 {
43 return Ok(Cli {
44 command: Commands::Help {
45 topic: HelpTopic::Top,
46 },
47 });
48 }
49
50 let command_str = &args[1];
51
52 if command_str == "-h" || command_str == "--help" {
53 return Ok(Cli {
54 command: Commands::Help {
55 topic: HelpTopic::Top,
56 },
57 });
58 }
59 if command_str == "-v" || command_str == "--version" {
60 return Ok(Cli {
61 command: Commands::Version,
62 });
63 }
64
65 let command = match command_str.as_str() {
66 "stats" => {
67 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
68 Commands::Help {
69 topic: HelpTopic::Stats,
70 }
71 } else {
72 let by_email =
73 has_flag(&args[2..], "--by-email") || has_flag(&args[2..], "-e");
74 let by_name = !by_email;
75 Commands::Stats { by_name }
76 }
77 }
78 "json" => {
79 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
80 Commands::Help {
81 topic: HelpTopic::Json,
82 }
83 } else {
84 Commands::Json
85 }
86 }
87 "user" => {
88 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
89 Commands::Help {
90 topic: HelpTopic::User,
91 }
92 } else {
93 if args.len() < 3 {
94 return Err("Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]".to_string());
95 }
96 let username = args[2].clone();
97 let mut ownership = false;
98 let mut by_email = false;
99 let mut top: Option<usize> = None;
100 let mut sort: Option<String> = None;
101
102 let rest = &args[3..];
103 let mut i = 0;
104 while i < rest.len() {
105 let a = &rest[i];
106 if a == "--ownership" {
107 ownership = true;
108 } else if a == "--by-email" || a == "-e" {
109 by_email = true;
110 } else if a == "--top" {
111 if i + 1 < rest.len() {
112 if let Ok(v) = rest[i + 1].parse::<usize>() {
113 top = Some(v);
114 }
115 i += 1;
116 }
117 } else if let Some(eq) = a.strip_prefix("--top=") {
118 if let Ok(v) = eq.parse::<usize>() {
119 top = Some(v);
120 }
121 } else if a == "--sort" {
122 if i + 1 < rest.len() {
123 sort = Some(rest[i + 1].to_lowercase());
124 i += 1;
125 }
126 } else if let Some(eq) = a.strip_prefix("--sort=") {
127 sort = Some(eq.to_lowercase());
128 }
129 i += 1;
130 }
131
132 Commands::User {
133 username,
134 ownership,
135 by_email,
136 top,
137 sort,
138 }
139 }
140 }
141 "timeline" => {
142 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
143 Commands::Help { topic: HelpTopic::Timeline }
144 } else {
145 let mut weeks: Option<usize> = None;
146 let mut color = true;
148
149 let rest = &args[2..];
150 let mut i = 0;
151 while i < rest.len() {
152 let a = &rest[i];
153 if a == "--weeks" {
154 if i + 1 < rest.len() {
155 if let Ok(v) = rest[i + 1].parse::<usize>() {
156 weeks = Some(v);
157 }
158 i += 1;
159 }
160 } else if let Some(eq) = a.strip_prefix("--weeks=") {
161 if let Ok(v) = eq.parse::<usize>() {
162 weeks = Some(v);
163 }
164 } else if a == "--color" || a == "-c" {
165 color = true;
166 } else if a == "--no-color" {
167 color = false;
168 } else if let Some(num) = a.strip_prefix("--") {
169 if num.chars().all(|c| c.is_ascii_digit()) {
171 if let Ok(v) = num.parse::<usize>() {
172 weeks = Some(v);
173 }
174 }
175 } else if let Some(num) = a.strip_prefix('-') {
176 if num.chars().all(|c| c.is_ascii_digit()) {
178 if let Ok(v) = num.parse::<usize>() {
179 weeks = Some(v);
180 }
181 }
182 }
183 i += 1;
184 }
185 Commands::Timeline { weeks, color }
186 }
187 }
188 "heatmap" => {
189 if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
190 Commands::Help { topic: HelpTopic::Heatmap }
191 } else {
192 let mut weeks: Option<usize> = None;
193 let mut color = true;
195
196 let rest = &args[2..];
197 let mut i = 0;
198 while i < rest.len() {
199 let a = &rest[i];
200 if a == "--weeks" {
201 if i + 1 < rest.len() {
202 if let Ok(v) = rest[i + 1].parse::<usize>() {
203 weeks = Some(v);
204 }
205 i += 1;
206 }
207 } else if let Some(eq) = a.strip_prefix("--weeks=") {
208 if let Ok(v) = eq.parse::<usize>() {
209 weeks = Some(v);
210 }
211 } else if a == "--color" || a == "-c" {
212 color = true;
213 } else if a == "--no-color" {
214 color = false;
215 } else if let Some(num) = a.strip_prefix("--") {
216 if num.chars().all(|c| c.is_ascii_digit()) {
218 if let Ok(v) = num.parse::<usize>() {
219 weeks = Some(v);
220 }
221 }
222 } else if let Some(num) = a.strip_prefix('-') {
223 if num.chars().all(|c| c.is_ascii_digit()) {
225 if let Ok(v) = num.parse::<usize>() {
226 weeks = Some(v);
227 }
228 }
229 }
230 i += 1;
231 }
232 Commands::Heatmap { weeks, color }
233 }
234 }
235 _ => {
236 return Err(format!(
237 "Unknown command: {}\n{}",
238 command_str,
239 render_help(HelpTopic::Top)
240 ));
241 }
242 };
243
244 Ok(Cli { command })
245 }
246}
247
248fn has_flag(args: &[String], needle: &str) -> bool {
249 args.iter().any(|a| a == needle)
250}
251
252pub fn render_help(topic: HelpTopic) -> String {
253 match topic {
254 HelpTopic::Top => {
255 let ver = version_string();
256 format!(
257 "\
258git-insights v{ver}
259
260A CLI tool to generate Git repo stats and insights (no dependencies).
261
262USAGE:
263 git-insights <COMMAND> [OPTIONS]
264
265COMMANDS:
266 stats Show repository stats (surviving LOC, commits, files)
267 json Export stats to git-insights.json
268 timeline Show weekly commit activity as ASCII/Unicode sparkline
269 heatmap Show UTC commit heatmap (weekday x hour)
270 user <name> Show insights for a specific user
271 help Show this help
272 version Show version information
273
274GLOBAL OPTIONS:
275 -h, --help Show help
276 -v, --version Show version
277
278EXAMPLES:
279 git-insights stats
280 git-insights stats --by-email
281 git-insights json
282 git-insights user alice
283
284See 'git-insights <COMMAND> --help' for command-specific options."
285 )
286 }
287 HelpTopic::Stats => {
288 "\
289git-insights stats
290
291Compute repository stats using a gitfame-like method:
292- Surviving LOC via git blame --line-porcelain HEAD
293- Commits via git shortlog -s -e HEAD
294- Only text files considered (git grep -I --name-only . HEAD AND ls-files)
295- Clean git commands (no pager), no dependencies
296
297USAGE:
298 git-insights stats [OPTIONS]
299
300OPTIONS:
301 -e, --by-email Group by \"Name <email>\" (default groups by name only)
302 -h, --help Show this help
303
304EXAMPLES:
305 git-insights stats
306 git-insights stats --by-email"
307 .to_string()
308 }
309 HelpTopic::Json => {
310 "\
311git-insights json
312
313Export stats to a JSON file (git-insights.json) mapping:
314 author -> { loc, commits, files[] }
315
316USAGE:
317 git-insights json
318
319EXAMPLES:
320 git-insights json"
321 .to_string()
322 }
323 HelpTopic::User => {
324 "\
325git-insights user
326
327Show insights for a specific user.
328
329Default behavior:
330- Merged pull request count (via commit message heuristics)
331- Tags where the user authored commits
332
333Ownership mode (per-file \"ownership\" list):
334- Computes surviving LOC per file attributed to this user at HEAD via blame
335- Shows file path, user LOC, file LOC, and ownership percentage
336
337USAGE:
338 git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]
339
340OPTIONS:
341 --ownership Show per-file ownership table for this user
342 -e, --by-email Match by email (author-mail) instead of author name
343 --top N Limit to top N rows (default: 10)
344 --sort loc|pct Sort by user LOC (loc, default) or percentage (pct)
345 -h, --help Show this help
346
347EXAMPLES:
348 git-insights user alice
349 git-insights user alice --ownership
350 git-insights user \"alice@example.com\" --ownership --by-email --top 5 --sort pct"
351 .to_string()
352 }
353 HelpTopic::Timeline => {
354 "\
355git-insights timeline
356
357Show weekly commit activity as a multi-row sparkline (ASCII/Unicode).
358Color output is ON by default; use --no-color to disable.
359
360USAGE:
361 git-insights timeline [--weeks N|--NN|-NN] [--no-color] [-c|--color]
362
363OPTIONS:
364 --weeks N Number of weeks to display (default: 26). Shorthand: --52 or -52
365 -c, --color Force ANSI colors (default: ON)
366 --no-color Disable ANSI colors
367 -h, --help Show this help
368
369EXAMPLES:
370 git-insights timeline
371 git-insights timeline --weeks 12
372 git-insights timeline --52
373 git-insights timeline -52 --no-color"
374 .to_string()
375 }
376 HelpTopic::Heatmap => {
377 "\
378git-insights heatmap
379
380Show a UTC commit heatmap (weekday x hour).
381Color output is ON by default; use --no-color to disable.
382
383USAGE:
384 git-insights heatmap [--weeks N|--NN|-NN] [--no-color] [-c|--color]
385
386OPTIONS:
387 --weeks N Limit to the last N weeks (default: all history). Shorthand: --60 or -60
388 -c, --color Force ANSI colors (default: ON)
389 --no-color Disable ANSI colors
390 -h, --help Show this help
391
392EXAMPLES:
393 git-insights heatmap
394 git-insights heatmap --60
395 git-insights heatmap -60 --no-color"
396 .to_string()
397 }
398 }
399}
400
401pub fn version_string() -> &'static str {
403 env!("CARGO_PKG_VERSION")
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_cli_stats_default_by_name() {
412 let cli = Cli::parse_from_args(vec![
413 "git-insights".to_string(),
414 "stats".to_string(),
415 ])
416 .expect("Failed to parse args");
417 match cli.command {
418 Commands::Stats { by_name } => assert!(by_name),
419 _ => panic!("Expected Stats command"),
420 }
421 }
422
423 #[test]
424 fn test_cli_stats_by_email_flag() {
425 let cli = Cli::parse_from_args(vec![
426 "git-insights".to_string(),
427 "stats".to_string(),
428 "--by-email".to_string(),
429 ])
430 .expect("Failed to parse args");
431 match cli.command {
432 Commands::Stats { by_name } => assert!(!by_name),
433 _ => panic!("Expected Stats command"),
434 }
435 }
436
437 #[test]
438 fn test_cli_stats_short_e_flag() {
439 let cli = Cli::parse_from_args(vec![
440 "git-insights".to_string(),
441 "stats".to_string(),
442 "-e".to_string(),
443 ])
444 .expect("Failed to parse args");
445 match cli.command {
446 Commands::Stats { by_name } => assert!(!by_name),
447 _ => panic!("Expected Stats command"),
448 }
449 }
450
451 #[test]
452 fn test_cli_json() {
453 let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "json".to_string()])
454 .expect("Failed to parse args");
455 assert!(matches!(cli.command, Commands::Json));
456 }
457
458 #[test]
459 fn test_cli_user() {
460 let cli = Cli::parse_from_args(vec![
461 "git-insights".to_string(),
462 "user".to_string(),
463 "testuser".to_string(),
464 ])
465 .expect("Failed to parse args");
466 match cli.command {
467 Commands::User { username, ownership, by_email, top, sort } => {
468 assert_eq!(username, "testuser");
469 assert!(!ownership);
470 assert!(!by_email);
471 assert!(top.is_none());
472 assert!(sort.is_none());
473 }
474 _ => panic!("Expected User command"),
475 }
476 }
477
478 #[test]
479 fn test_cli_user_ownership_flags() {
480 let cli = Cli::parse_from_args(vec![
481 "git-insights".to_string(),
482 "user".to_string(),
483 "palash".to_string(),
484 "--ownership".to_string(),
485 "--by-email".to_string(),
486 "--top".to_string(),
487 "5".to_string(),
488 "--sort".to_string(),
489 "pct".to_string(),
490 ])
491 .expect("Failed to parse args");
492 match cli.command {
493 Commands::User { username, ownership, by_email, top, sort } => {
494 assert_eq!(username, "palash");
495 assert!(ownership);
496 assert!(by_email);
497 assert_eq!(top, Some(5));
498 assert_eq!(sort.as_deref(), Some("pct"));
499 }
500 _ => panic!("Expected User command with ownership flags"),
501 }
502
503 let cli2 = Cli::parse_from_args(vec![
505 "git-insights".to_string(),
506 "user".to_string(),
507 "palash".to_string(),
508 "--ownership".to_string(),
509 "-e".to_string(),
510 "--top=3".to_string(),
511 "--sort=loc".to_string(),
512 ])
513 .expect("Failed to parse args");
514 match cli2.command {
515 Commands::User { username, ownership, by_email, top, sort } => {
516 assert_eq!(username, "palash");
517 assert!(ownership);
518 assert!(by_email);
519 assert_eq!(top, Some(3));
520 assert_eq!(sort.as_deref(), Some("loc"));
521 }
522 _ => panic!("Expected User command with equals-style flags"),
523 }
524 }
525
526 #[test]
527 fn test_cli_no_args_yields_help() {
528 let cli = Cli::parse_from_args(vec!["git-insights".to_string()]).expect("parse");
529 match cli.command {
530 Commands::Help { topic } => match topic {
531 HelpTopic::Top => {}
532 _ => panic!("Expected top-level help"),
533 },
534 _ => panic!("Expected Help command"),
535 }
536 }
537
538 #[test]
539 fn test_cli_top_help_flag() {
540 let cli = Cli::parse_from_args(vec![
541 "git-insights".to_string(),
542 "--help".to_string(),
543 ])
544 .expect("parse");
545 match cli.command {
546 Commands::Help { topic } => match topic {
547 HelpTopic::Top => {}
548 _ => panic!("Expected top-level help"),
549 },
550 _ => panic!("Expected Help command"),
551 }
552 }
553
554 #[test]
555 fn test_cli_stats_help_flag() {
556 let cli = Cli::parse_from_args(vec![
557 "git-insights".to_string(),
558 "stats".to_string(),
559 "--help".to_string(),
560 ])
561 .expect("parse");
562 match cli.command {
563 Commands::Help { topic } => match topic {
564 HelpTopic::Stats => {}
565 _ => panic!("Expected stats help"),
566 },
567 _ => panic!("Expected Help command"),
568 }
569 }
570
571 #[test]
572 fn test_cli_version_flag() {
573 let cli = Cli::parse_from_args(vec![
574 "git-insights".to_string(),
575 "--version".to_string(),
576 ])
577 .expect("parse");
578 assert!(matches!(cli.command, Commands::Version));
579 }
580
581 #[test]
582 fn test_cli_unknown_command() {
583 let err =
584 Cli::parse_from_args(vec!["git-insights".to_string(), "invalid".to_string()])
585 .expect_err("Expected an error for unknown command");
586 assert!(err.contains("Unknown command: invalid"));
587 }
588
589 #[test]
590 fn test_cli_user_no_username() {
591 let err = Cli::parse_from_args(vec!["git-insights".to_string(), "user".to_string()])
592 .expect_err("Expected an error for user command without username");
593 assert_eq!(err, "Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]");
594 }
595
596 #[test]
597 fn test_cli_timeline_default() {
598 let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "timeline".to_string()])
599 .expect("parse");
600 match cli.command {
601 Commands::Timeline { weeks, color } => {
602 assert!(weeks.is_none());
603 assert!(color); }
605 _ => panic!("Expected Timeline command"),
606 }
607 }
608
609 #[test]
610 fn test_cli_timeline_weeks_flags() {
611 let cli = Cli::parse_from_args(vec![
612 "git-insights".to_string(),
613 "timeline".to_string(),
614 "--weeks".to_string(),
615 "12".to_string(),
616 ]).expect("parse");
617 match cli.command {
618 Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(12)); assert!(color); }
619 _ => panic!("Expected Timeline command"),
620 }
621 let cli2 = Cli::parse_from_args(vec![
622 "git-insights".to_string(),
623 "timeline".to_string(),
624 "--weeks=8".to_string(),
625 ]).expect("parse");
626 match cli2.command {
627 Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(8)); assert!(color); }
628 _ => panic!("Expected Timeline command"),
629 }
630 }
631
632 #[test]
633 fn test_cli_heatmap() {
634 let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "heatmap".to_string()])
635 .expect("parse");
636 match cli.command {
637 Commands::Heatmap { weeks, color } => { assert!(weeks.is_none()); assert!(color); }
638 _ => panic!("Expected Heatmap"),
639 }
640 }
641
642 #[test]
643 fn test_cli_timeline_numeric_shorthand() {
644 let cli = Cli::parse_from_args(vec![
645 "git-insights".to_string(),
646 "timeline".to_string(),
647 "--52".to_string(),
648 ]).expect("parse");
649 match cli.command {
650 Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(52)); assert!(color); }
651 _ => panic!("Expected Timeline command with numeric shorthand"),
652 }
653
654 let cli_hyphen = Cli::parse_from_args(vec![
655 "git-insights".to_string(),
656 "timeline".to_string(),
657 "-52".to_string(),
658 ]).expect("parse");
659 match cli_hyphen.command {
660 Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(52)); assert!(color); }
661 _ => panic!("Expected Timeline command with -NN shorthand"),
662 }
663 }
664
665 #[test]
666 fn test_cli_heatmap_weeks_and_color() {
667 let cli = Cli::parse_from_args(vec![
668 "git-insights".to_string(),
669 "heatmap".to_string(),
670 "--60".to_string(),
671 "--color".to_string(),
672 ]).expect("parse");
673 match cli.command {
674 Commands::Heatmap { weeks, color } => { assert_eq!(weeks, Some(60)); assert!(color); }
675 _ => panic!("Expected Heatmap with weeks+color"),
676 }
677
678 let cli_hyphen = Cli::parse_from_args(vec![
679 "git-insights".to_string(),
680 "heatmap".to_string(),
681 "-60".to_string(),
682 ]).expect("parse");
683 match cli_hyphen.command {
684 Commands::Heatmap { weeks, color } => { assert_eq!(weeks, Some(60)); assert!(color); }
685 _ => panic!("Expected Heatmap with -NN shorthand"),
686 }
687 }
688}