#[derive(Debug, Clone)]
pub enum HelpTopic {
Top,
Stats,
Json,
User,
Timeline,
Heatmap,
}
#[derive(Debug)]
pub enum Commands {
Stats { by_name: bool },
Json,
Timeline { weeks: Option<usize>, color: bool },
Heatmap { weeks: Option<usize>, color: bool },
User {
username: String,
ownership: bool,
by_email: bool,
top: Option<usize>,
sort: Option<String>,
},
Help { topic: HelpTopic },
Version,
}
#[derive(Debug)]
pub struct Cli {
pub command: Commands,
}
impl Cli {
pub fn parse() -> Result<Cli, String> {
let args: Vec<String> = std::env::args().collect();
Cli::parse_from_args(args)
}
pub fn parse_from_args(args: Vec<String>) -> Result<Cli, String> {
if args.len() < 2 {
return Ok(Cli {
command: Commands::Help {
topic: HelpTopic::Top,
},
});
}
let command_str = &args[1];
if command_str == "-h" || command_str == "--help" {
return Ok(Cli {
command: Commands::Help {
topic: HelpTopic::Top,
},
});
}
if command_str == "-v" || command_str == "--version" {
return Ok(Cli {
command: Commands::Version,
});
}
let command = match command_str.as_str() {
"stats" => {
if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
Commands::Help {
topic: HelpTopic::Stats,
}
} else {
let by_email =
has_flag(&args[2..], "--by-email") || has_flag(&args[2..], "-e");
let by_name = !by_email;
Commands::Stats { by_name }
}
}
"json" => {
if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
Commands::Help {
topic: HelpTopic::Json,
}
} else {
Commands::Json
}
}
"user" => {
if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
Commands::Help {
topic: HelpTopic::User,
}
} else {
if args.len() < 3 {
return Err("Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]".to_string());
}
let username = args[2].clone();
let mut ownership = false;
let mut by_email = false;
let mut top: Option<usize> = None;
let mut sort: Option<String> = None;
let rest = &args[3..];
let mut i = 0;
while i < rest.len() {
let a = &rest[i];
if a == "--ownership" {
ownership = true;
} else if a == "--by-email" || a == "-e" {
by_email = true;
} else if a == "--top" {
if i + 1 < rest.len() {
if let Ok(v) = rest[i + 1].parse::<usize>() {
top = Some(v);
}
i += 1;
}
} else if let Some(eq) = a.strip_prefix("--top=") {
if let Ok(v) = eq.parse::<usize>() {
top = Some(v);
}
} else if a == "--sort" {
if i + 1 < rest.len() {
sort = Some(rest[i + 1].to_lowercase());
i += 1;
}
} else if let Some(eq) = a.strip_prefix("--sort=") {
sort = Some(eq.to_lowercase());
}
i += 1;
}
Commands::User {
username,
ownership,
by_email,
top,
sort,
}
}
}
"timeline" => {
if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
Commands::Help { topic: HelpTopic::Timeline }
} else {
let mut weeks: Option<usize> = None;
let mut color = true;
let rest = &args[2..];
let mut i = 0;
while i < rest.len() {
let a = &rest[i];
if a == "--weeks" {
if i + 1 < rest.len() {
if let Ok(v) = rest[i + 1].parse::<usize>() {
weeks = Some(v);
}
i += 1;
}
} else if let Some(eq) = a.strip_prefix("--weeks=") {
if let Ok(v) = eq.parse::<usize>() {
weeks = Some(v);
}
} else if a == "--color" || a == "-c" {
color = true;
} else if a == "--no-color" {
color = false;
} else if let Some(num) = a.strip_prefix("--") {
if num.chars().all(|c| c.is_ascii_digit()) {
if let Ok(v) = num.parse::<usize>() {
weeks = Some(v);
}
}
} else if let Some(num) = a.strip_prefix('-') {
if num.chars().all(|c| c.is_ascii_digit()) {
if let Ok(v) = num.parse::<usize>() {
weeks = Some(v);
}
}
}
i += 1;
}
Commands::Timeline { weeks, color }
}
}
"heatmap" => {
if has_flag(&args[2..], "-h") || has_flag(&args[2..], "--help") {
Commands::Help { topic: HelpTopic::Heatmap }
} else {
let mut weeks: Option<usize> = None;
let mut color = true;
let rest = &args[2..];
let mut i = 0;
while i < rest.len() {
let a = &rest[i];
if a == "--weeks" {
if i + 1 < rest.len() {
if let Ok(v) = rest[i + 1].parse::<usize>() {
weeks = Some(v);
}
i += 1;
}
} else if let Some(eq) = a.strip_prefix("--weeks=") {
if let Ok(v) = eq.parse::<usize>() {
weeks = Some(v);
}
} else if a == "--color" || a == "-c" {
color = true;
} else if a == "--no-color" {
color = false;
} else if let Some(num) = a.strip_prefix("--") {
if num.chars().all(|c| c.is_ascii_digit()) {
if let Ok(v) = num.parse::<usize>() {
weeks = Some(v);
}
}
} else if let Some(num) = a.strip_prefix('-') {
if num.chars().all(|c| c.is_ascii_digit()) {
if let Ok(v) = num.parse::<usize>() {
weeks = Some(v);
}
}
}
i += 1;
}
Commands::Heatmap { weeks, color }
}
}
_ => {
return Err(format!(
"Unknown command: {}\n{}",
command_str,
render_help(HelpTopic::Top)
));
}
};
Ok(Cli { command })
}
}
fn has_flag(args: &[String], needle: &str) -> bool {
args.iter().any(|a| a == needle)
}
pub fn render_help(topic: HelpTopic) -> String {
match topic {
HelpTopic::Top => {
let ver = version_string();
format!(
"\
git-insights v{ver}
A CLI tool to generate Git repo stats and insights (no dependencies).
USAGE:
git-insights <COMMAND> [OPTIONS]
COMMANDS:
stats Show repository stats (surviving LOC, commits, files)
json Export stats to git-insights.json
timeline Show weekly commit activity as ASCII/Unicode sparkline
heatmap Show UTC commit heatmap (weekday x hour)
user <name> Show insights for a specific user
help Show this help
version Show version information
GLOBAL OPTIONS:
-h, --help Show help
-v, --version Show version
EXAMPLES:
git-insights stats
git-insights stats --by-email
git-insights json
git-insights user alice
See 'git-insights <COMMAND> --help' for command-specific options."
)
}
HelpTopic::Stats => {
"\
git-insights stats
Compute repository stats using a gitfame-like method:
- Surviving LOC via git blame --line-porcelain HEAD
- Commits via git shortlog -s -e HEAD
- Only text files considered (git grep -I --name-only . HEAD AND ls-files)
- Clean git commands (no pager), no dependencies
USAGE:
git-insights stats [OPTIONS]
OPTIONS:
-e, --by-email Group by \"Name <email>\" (default groups by name only)
-h, --help Show this help
EXAMPLES:
git-insights stats
git-insights stats --by-email"
.to_string()
}
HelpTopic::Json => {
"\
git-insights json
Export stats to a JSON file (git-insights.json) mapping:
author -> { loc, commits, files[] }
USAGE:
git-insights json
EXAMPLES:
git-insights json"
.to_string()
}
HelpTopic::User => {
"\
git-insights user
Show insights for a specific user.
Default behavior:
- Merged pull request count (via commit message heuristics)
- Tags where the user authored commits
Ownership mode (per-file \"ownership\" list):
- Computes surviving LOC per file attributed to this user at HEAD via blame
- Shows file path, user LOC, file LOC, and ownership percentage
USAGE:
git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]
OPTIONS:
--ownership Show per-file ownership table for this user
-e, --by-email Match by email (author-mail) instead of author name
--top N Limit to top N rows (default: 10)
--sort loc|pct Sort by user LOC (loc, default) or percentage (pct)
-h, --help Show this help
EXAMPLES:
git-insights user alice
git-insights user alice --ownership
git-insights user \"alice@example.com\" --ownership --by-email --top 5 --sort pct"
.to_string()
}
HelpTopic::Timeline => {
"\
git-insights timeline
Show weekly commit activity as a multi-row sparkline (ASCII/Unicode).
Color output is ON by default; use --no-color to disable.
USAGE:
git-insights timeline [--weeks N|--NN|-NN] [--no-color] [-c|--color]
OPTIONS:
--weeks N Number of weeks to display (default: 26). Shorthand: --52 or -52
-c, --color Force ANSI colors (default: ON)
--no-color Disable ANSI colors
-h, --help Show this help
EXAMPLES:
git-insights timeline
git-insights timeline --weeks 12
git-insights timeline --52
git-insights timeline -52 --no-color"
.to_string()
}
HelpTopic::Heatmap => {
"\
git-insights heatmap
Show a UTC commit heatmap (weekday x hour).
Color output is ON by default; use --no-color to disable.
USAGE:
git-insights heatmap [--weeks N|--NN|-NN] [--no-color] [-c|--color]
OPTIONS:
--weeks N Limit to the last N weeks (default: all history). Shorthand: --60 or -60
-c, --color Force ANSI colors (default: ON)
--no-color Disable ANSI colors
-h, --help Show this help
EXAMPLES:
git-insights heatmap
git-insights heatmap --60
git-insights heatmap -60 --no-color"
.to_string()
}
}
}
pub fn version_string() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_stats_default_by_name() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"stats".to_string(),
])
.expect("Failed to parse args");
match cli.command {
Commands::Stats { by_name } => assert!(by_name),
_ => panic!("Expected Stats command"),
}
}
#[test]
fn test_cli_stats_by_email_flag() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"stats".to_string(),
"--by-email".to_string(),
])
.expect("Failed to parse args");
match cli.command {
Commands::Stats { by_name } => assert!(!by_name),
_ => panic!("Expected Stats command"),
}
}
#[test]
fn test_cli_stats_short_e_flag() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"stats".to_string(),
"-e".to_string(),
])
.expect("Failed to parse args");
match cli.command {
Commands::Stats { by_name } => assert!(!by_name),
_ => panic!("Expected Stats command"),
}
}
#[test]
fn test_cli_json() {
let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "json".to_string()])
.expect("Failed to parse args");
assert!(matches!(cli.command, Commands::Json));
}
#[test]
fn test_cli_user() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"user".to_string(),
"testuser".to_string(),
])
.expect("Failed to parse args");
match cli.command {
Commands::User { username, ownership, by_email, top, sort } => {
assert_eq!(username, "testuser");
assert!(!ownership);
assert!(!by_email);
assert!(top.is_none());
assert!(sort.is_none());
}
_ => panic!("Expected User command"),
}
}
#[test]
fn test_cli_user_ownership_flags() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"user".to_string(),
"palash".to_string(),
"--ownership".to_string(),
"--by-email".to_string(),
"--top".to_string(),
"5".to_string(),
"--sort".to_string(),
"pct".to_string(),
])
.expect("Failed to parse args");
match cli.command {
Commands::User { username, ownership, by_email, top, sort } => {
assert_eq!(username, "palash");
assert!(ownership);
assert!(by_email);
assert_eq!(top, Some(5));
assert_eq!(sort.as_deref(), Some("pct"));
}
_ => panic!("Expected User command with ownership flags"),
}
let cli2 = Cli::parse_from_args(vec![
"git-insights".to_string(),
"user".to_string(),
"palash".to_string(),
"--ownership".to_string(),
"-e".to_string(),
"--top=3".to_string(),
"--sort=loc".to_string(),
])
.expect("Failed to parse args");
match cli2.command {
Commands::User { username, ownership, by_email, top, sort } => {
assert_eq!(username, "palash");
assert!(ownership);
assert!(by_email);
assert_eq!(top, Some(3));
assert_eq!(sort.as_deref(), Some("loc"));
}
_ => panic!("Expected User command with equals-style flags"),
}
}
#[test]
fn test_cli_no_args_yields_help() {
let cli = Cli::parse_from_args(vec!["git-insights".to_string()]).expect("parse");
match cli.command {
Commands::Help { topic } => match topic {
HelpTopic::Top => {}
_ => panic!("Expected top-level help"),
},
_ => panic!("Expected Help command"),
}
}
#[test]
fn test_cli_top_help_flag() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"--help".to_string(),
])
.expect("parse");
match cli.command {
Commands::Help { topic } => match topic {
HelpTopic::Top => {}
_ => panic!("Expected top-level help"),
},
_ => panic!("Expected Help command"),
}
}
#[test]
fn test_cli_stats_help_flag() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"stats".to_string(),
"--help".to_string(),
])
.expect("parse");
match cli.command {
Commands::Help { topic } => match topic {
HelpTopic::Stats => {}
_ => panic!("Expected stats help"),
},
_ => panic!("Expected Help command"),
}
}
#[test]
fn test_cli_version_flag() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"--version".to_string(),
])
.expect("parse");
assert!(matches!(cli.command, Commands::Version));
}
#[test]
fn test_cli_unknown_command() {
let err =
Cli::parse_from_args(vec!["git-insights".to_string(), "invalid".to_string()])
.expect_err("Expected an error for unknown command");
assert!(err.contains("Unknown command: invalid"));
}
#[test]
fn test_cli_user_no_username() {
let err = Cli::parse_from_args(vec!["git-insights".to_string(), "user".to_string()])
.expect_err("Expected an error for user command without username");
assert_eq!(err, "Usage: git-insights user <username> [--ownership] [--by-email|-e] [--top N] [--sort loc|pct]");
}
#[test]
fn test_cli_timeline_default() {
let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "timeline".to_string()])
.expect("parse");
match cli.command {
Commands::Timeline { weeks, color } => {
assert!(weeks.is_none());
assert!(color); }
_ => panic!("Expected Timeline command"),
}
}
#[test]
fn test_cli_timeline_weeks_flags() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"timeline".to_string(),
"--weeks".to_string(),
"12".to_string(),
]).expect("parse");
match cli.command {
Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(12)); assert!(color); }
_ => panic!("Expected Timeline command"),
}
let cli2 = Cli::parse_from_args(vec![
"git-insights".to_string(),
"timeline".to_string(),
"--weeks=8".to_string(),
]).expect("parse");
match cli2.command {
Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(8)); assert!(color); }
_ => panic!("Expected Timeline command"),
}
}
#[test]
fn test_cli_heatmap() {
let cli = Cli::parse_from_args(vec!["git-insights".to_string(), "heatmap".to_string()])
.expect("parse");
match cli.command {
Commands::Heatmap { weeks, color } => { assert!(weeks.is_none()); assert!(color); }
_ => panic!("Expected Heatmap"),
}
}
#[test]
fn test_cli_timeline_numeric_shorthand() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"timeline".to_string(),
"--52".to_string(),
]).expect("parse");
match cli.command {
Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(52)); assert!(color); }
_ => panic!("Expected Timeline command with numeric shorthand"),
}
let cli_hyphen = Cli::parse_from_args(vec![
"git-insights".to_string(),
"timeline".to_string(),
"-52".to_string(),
]).expect("parse");
match cli_hyphen.command {
Commands::Timeline { weeks, color } => { assert_eq!(weeks, Some(52)); assert!(color); }
_ => panic!("Expected Timeline command with -NN shorthand"),
}
}
#[test]
fn test_cli_heatmap_weeks_and_color() {
let cli = Cli::parse_from_args(vec![
"git-insights".to_string(),
"heatmap".to_string(),
"--60".to_string(),
"--color".to_string(),
]).expect("parse");
match cli.command {
Commands::Heatmap { weeks, color } => { assert_eq!(weeks, Some(60)); assert!(color); }
_ => panic!("Expected Heatmap with weeks+color"),
}
let cli_hyphen = Cli::parse_from_args(vec![
"git-insights".to_string(),
"heatmap".to_string(),
"-60".to_string(),
]).expect("parse");
match cli_hyphen.command {
Commands::Heatmap { weeks, color } => { assert_eq!(weeks, Some(60)); assert!(color); }
_ => panic!("Expected Heatmap with -NN shorthand"),
}
}
}