git_insights/
cli.rs

1#[derive(Debug, Clone)]
2pub enum HelpTopic {
3    Top,
4    Stats,
5    Json,
6    User,
7}
8
9#[derive(Debug)]
10pub enum Commands {
11    // Default grouping by author name; pass --by-email/-e to group by name+email
12    Stats { by_name: bool },
13    Json,
14    // user <username> [--ownership] [--by-email|-e] [--top N|--top=N] [--sort loc|pct|--sort=loc]
15    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
256// Expose version pulled from Cargo metadata
257pub 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        // equals-style flags should also parse
359        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}