git_insights/
cli.rs

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    // Default grouping by author name; pass --by-email/-e to group by name+email
14    Stats { by_name: bool },
15    Json,
16    Timeline { weeks: Option<usize>, color: bool },
17    Heatmap { weeks: Option<usize>, color: bool },
18    // user <username> [--ownership] [--by-email|-e] [--top N|--top=N] [--sort loc|pct|--sort=loc]
19    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                    // Default: color ON. Allow disabling with --no-color
147                    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                            // support shorthand like: timeline --52
170                            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                            // support shorthand like: timeline -52
177                            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                    // Default: color ON. Allow disabling with --no-color
194                    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                            // support shorthand like: heatmap --60 (weeks)
217                            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                            // support shorthand like: heatmap -60 (weeks)
224                            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
401// Expose version pulled from Cargo metadata
402pub 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        // equals-style flags should also parse
504        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); // default color ON
604            }
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}