From 86aa35ad815abc0d3186e1e3be5df2764d13d446 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Dec 2025 20:14:30 +0000 Subject: [PATCH] feat: Implement fuzzy author matching and update version Co-authored-by: kylewrader --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 139 +++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 119 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a039a9..8c7d1af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,7 +221,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "1.0.0" +version = "1.0.1" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index f9ec644..458209d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loki-cli" -version = "1.0.0" +version = "1.0.1" authors = ["Kyle W. Rader"] description = "Loki: 🚀 A Git productivity tool" homepage = "https://github.com/kyle-rader/loki-cli" diff --git a/src/main.rs b/src/main.rs index 724079b..5337fa7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,7 @@ struct CommitOptions { message: Vec, } -#[derive(Debug, Parser)] +#[derive(Debug, Default, Parser)] struct RepoStatsOptions { /// Limit analysis to commits from the last N days. #[clap(long, conflicts_with_all = &["weeks", "months", "from"])] @@ -63,29 +63,15 @@ struct RepoStatsOptions { #[clap(long)] top: Option, - /// Only include commits authored by these names (repeatable, case-insensitive). + /// Only include commits authored by these names (repeatable, case-insensitive fuzzy match). #[clap(long = "name", value_name = "NAME")] names: Vec, - /// Only include commits authored by these emails (repeatable, case-insensitive). + /// Only include commits authored by these emails (repeatable, case-insensitive fuzzy match). #[clap(long = "email", value_name = "EMAIL")] emails: Vec, } -impl Default for RepoStatsOptions { - fn default() -> Self { - Self { - days: None, - weeks: None, - months: None, - from: None, - to: None, - top: None, - names: Vec::new(), - emails: Vec::new(), - } - } -} #[derive(Debug, Subcommand)] enum RepoSubcommand { @@ -500,7 +486,7 @@ fn matches_author_filters(name: &str, email: &str, options: &RepoStatsOptions) - || !options .names .iter() - .any(|filter| name.eq_ignore_ascii_case(filter))) + .any(|filter| name.to_lowercase().contains(&filter.to_lowercase()))) { return false; } @@ -510,7 +496,7 @@ fn matches_author_filters(name: &str, email: &str, options: &RepoStatsOptions) - || !options .emails .iter() - .any(|filter| email.eq_ignore_ascii_case(filter))) + .any(|filter| email.to_lowercase().contains(&filter.to_lowercase()))) { return false; } @@ -735,7 +721,7 @@ mod tests { } #[test] - fn matches_author_filters_by_name() { + fn matches_author_filters_by_name_exact() { let mut options = RepoStatsOptions::default(); options.names = vec![String::from("Example User")]; @@ -752,7 +738,49 @@ mod tests { } #[test] - fn matches_author_filters_by_email() { + fn matches_author_filters_by_name_fuzzy() { + let mut options = RepoStatsOptions::default(); + options.names = vec![String::from("example")]; + + // Fuzzy match: "example" is a substring of "Example User" + assert!(matches_author_filters( + "Example User", + "user@example.com", + &options + )); + // Case insensitive fuzzy match + assert!(matches_author_filters( + "EXAMPLE USER", + "user@example.com", + &options + )); + // No match + assert!(!matches_author_filters( + "Someone Else", + "user@example.com", + &options + )); + } + + #[test] + fn matches_author_filters_by_name_case_insensitive() { + let mut options = RepoStatsOptions::default(); + options.names = vec![String::from("EXAMPLE USER")]; + + assert!(matches_author_filters( + "example user", + "user@example.com", + &options + )); + assert!(matches_author_filters( + "Example User", + "user@example.com", + &options + )); + } + + #[test] + fn matches_author_filters_by_email_exact() { let mut options = RepoStatsOptions::default(); options.emails = vec![String::from("user@example.com")]; @@ -768,6 +796,48 @@ mod tests { )); } + #[test] + fn matches_author_filters_by_email_fuzzy() { + let mut options = RepoStatsOptions::default(); + options.emails = vec![String::from("example.com")]; + + // Fuzzy match: "example.com" is a substring of "user@example.com" + assert!(matches_author_filters( + "Example User", + "user@example.com", + &options + )); + // Also matches other emails from the same domain + assert!(matches_author_filters( + "Example User", + "other@example.com", + &options + )); + // No match for different domain + assert!(!matches_author_filters( + "Example User", + "user@other.com", + &options + )); + } + + #[test] + fn matches_author_filters_by_email_case_insensitive() { + let mut options = RepoStatsOptions::default(); + options.emails = vec![String::from("USER@EXAMPLE.COM")]; + + assert!(matches_author_filters( + "Example User", + "user@example.com", + &options + )); + assert!(matches_author_filters( + "Example User", + "User@Example.Com", + &options + )); + } + #[test] fn matches_author_filters_requires_all_filters() { let mut options = RepoStatsOptions::default(); @@ -781,7 +851,7 @@ mod tests { )); assert!(!matches_author_filters( "Example User", - "other@example.com", + "other@other.com", &options )); assert!(!matches_author_filters( @@ -790,4 +860,29 @@ mod tests { &options )); } + + #[test] + fn matches_author_filters_fuzzy_with_multiple_filters() { + let mut options = RepoStatsOptions::default(); + options.names = vec![String::from("john"), String::from("jane")]; + + // Matches first filter + assert!(matches_author_filters( + "John Smith", + "john@example.com", + &options + )); + // Matches second filter + assert!(matches_author_filters( + "Jane Doe", + "jane@example.com", + &options + )); + // No match + assert!(!matches_author_filters( + "Bob Wilson", + "bob@example.com", + &options + )); + } }