use chrono::{DateTime, Duration, Local, NaiveDate};
use crate::event::{GitEvent, GitEventKind};
#[derive(Debug, Clone, Default)]
pub struct FilterQuery {
pub author: Option<String>,
pub since: Option<DateTime<Local>>,
pub until: Option<DateTime<Local>>,
pub file_pattern: Option<String>,
pub message_pattern: Option<String>,
pub commit_type: Option<GitEventKind>,
pub plain_text: Option<String>,
pub hash_range: Option<(String, String)>,
pub session: Option<u32>,
}
impl FilterQuery {
pub fn new() -> Self {
Self::default()
}
pub fn parse(input: &str) -> Self {
let input = input.trim();
if input.is_empty() {
return Self::default();
}
if !input.starts_with('/') {
return Self {
plain_text: Some(input.to_lowercase()),
..Default::default()
};
}
let mut query = Self::default();
let content = &input[1..];
let tokens: Vec<&str> = content.split_whitespace().collect();
for token in tokens {
if let Some(value) = token.strip_prefix("author:") {
query.author = Some(value.to_lowercase());
} else if let Some(value) = token.strip_prefix("since:") {
query.since = parse_date(value);
} else if let Some(value) = token.strip_prefix("until:") {
query.until = parse_date(value);
} else if let Some(value) = token.strip_prefix("file:") {
query.file_pattern = Some(value.to_string());
} else if let Some(value) = token.strip_prefix("message:") {
query.message_pattern = Some(value.to_lowercase());
} else if let Some(value) = token.strip_prefix("type:") {
query.commit_type = parse_commit_type(value);
} else if let Some(value) = token.strip_prefix("hash:") {
if let Some((start, end)) = value.split_once("..") {
if !start.is_empty() && !end.is_empty() {
query.hash_range = Some((start.to_string(), end.to_string()));
}
}
} else if let Some(value) = token.strip_prefix("session:") {
query.session = value.parse().ok();
} else {
if let Some(existing) = query.message_pattern.take() {
query.message_pattern = Some(format!("{} {}", existing, token.to_lowercase()));
} else {
query.message_pattern = Some(token.to_lowercase());
}
}
}
query
}
pub fn matches(&self, event: &GitEvent, files: Option<&[String]>) -> bool {
if let Some(ref text) = self.plain_text {
return event.message.to_lowercase().contains(text)
|| event.author.to_lowercase().contains(text)
|| event.short_hash.to_lowercase().contains(text);
}
if let Some(session_id) = self.session {
if event.session_id != Some(session_id) {
return false;
}
}
if let Some(ref kind) = self.commit_type {
if event.kind != *kind {
return false;
}
}
if let Some(since) = self.since {
if event.timestamp < since {
return false;
}
}
if let Some(until) = self.until {
if event.timestamp > until {
return false;
}
}
if let Some(ref author) = self.author {
if !event.author.to_lowercase().contains(author) {
return false;
}
}
if let Some(ref pattern) = self.message_pattern {
if !event.message.to_lowercase().contains(pattern) {
return false;
}
}
if let Some(ref pattern) = self.file_pattern {
if let Some(file_list) = files {
let pattern_lower = pattern.to_lowercase();
if !file_list
.iter()
.any(|f| f.to_lowercase().contains(&pattern_lower))
{
return false;
}
} else {
return false;
}
}
true
}
pub fn has_file_filter(&self) -> bool {
self.file_pattern.is_some()
}
pub fn is_empty(&self) -> bool {
self.author.is_none()
&& self.since.is_none()
&& self.until.is_none()
&& self.file_pattern.is_none()
&& self.message_pattern.is_none()
&& self.commit_type.is_none()
&& self.plain_text.is_none()
&& self.hash_range.is_none()
&& self.session.is_none()
}
pub fn has_hash_range(&self) -> bool {
self.hash_range.is_some()
}
pub fn description(&self) -> String {
if self.is_empty() {
return String::new();
}
if let Some(ref text) = self.plain_text {
return format!("\"{}\"", text);
}
let mut parts = Vec::new();
if let Some(ref author) = self.author {
parts.push(format!("author:{}", author));
}
if self.since.is_some() {
parts.push("since:...".to_string());
}
if self.until.is_some() {
parts.push("until:...".to_string());
}
if let Some(ref pattern) = self.message_pattern {
parts.push(format!("message:{}", pattern));
}
if let Some(ref kind) = self.commit_type {
let kind_str = match kind {
GitEventKind::Commit => "commit",
GitEventKind::Merge => "merge",
GitEventKind::BranchSwitch => "switch",
};
parts.push(format!("type:{}", kind_str));
}
if let Some(ref pattern) = self.file_pattern {
parts.push(format!("file:{}", pattern));
}
if let Some((ref start, ref end)) = self.hash_range {
parts.push(format!("hash:{}..{}", start, end));
}
parts.join(" ")
}
}
fn parse_date(input: &str) -> Option<DateTime<Local>> {
let input = input.trim().to_lowercase();
if let Some(num_str) = input.strip_suffix("day") {
if let Ok(days) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::days(days));
}
}
if let Some(num_str) = input.strip_suffix("days") {
if let Ok(days) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::days(days));
}
}
if let Some(num_str) = input.strip_suffix("week") {
if let Ok(weeks) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::weeks(weeks));
}
}
if let Some(num_str) = input.strip_suffix("weeks") {
if let Ok(weeks) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::weeks(weeks));
}
}
if let Some(num_str) = input.strip_suffix("month") {
if let Ok(months) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::days(months * 30));
}
}
if let Some(num_str) = input.strip_suffix("months") {
if let Ok(months) = num_str.parse::<i64>() {
return Some(Local::now() - Duration::days(months * 30));
}
}
if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y-%m-%d") {
let datetime = date.and_hms_opt(0, 0, 0)?;
return datetime.and_local_timezone(Local).single();
}
None
}
fn parse_commit_type(input: &str) -> Option<GitEventKind> {
match input.to_lowercase().as_str() {
"commit" => Some(GitEventKind::Commit),
"merge" => Some(GitEventKind::Merge),
"switch" => Some(GitEventKind::BranchSwitch),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
fn create_test_event(message: &str, author: &str) -> GitEvent {
GitEvent::commit(
"abc1234".to_string(),
message.to_string(),
author.to_string(),
Local::now(),
0,
0,
)
}
#[test]
fn test_parse_empty_returns_default() {
let query = FilterQuery::parse("");
assert!(query.is_empty());
}
#[test]
fn test_parse_plain_text_without_slash() {
let query = FilterQuery::parse("fix bug");
assert_eq!(query.plain_text, Some("fix bug".to_string()));
assert!(query.author.is_none());
}
#[test]
fn test_parse_author_filter() {
let query = FilterQuery::parse("/author:john");
assert_eq!(query.author, Some("john".to_string()));
}
#[test]
fn test_parse_message_filter() {
let query = FilterQuery::parse("/message:fix");
assert_eq!(query.message_pattern, Some("fix".to_string()));
}
#[test]
fn test_parse_type_merge() {
let query = FilterQuery::parse("/type:merge");
assert_eq!(query.commit_type, Some(GitEventKind::Merge));
}
#[test]
fn test_parse_type_commit() {
let query = FilterQuery::parse("/type:commit");
assert_eq!(query.commit_type, Some(GitEventKind::Commit));
}
#[test]
fn test_parse_file_filter() {
let query = FilterQuery::parse("/file:src/auth");
assert_eq!(query.file_pattern, Some("src/auth".to_string()));
}
#[test]
fn test_parse_since_relative_days() {
let query = FilterQuery::parse("/since:7days");
assert!(query.since.is_some());
let since = query.since.unwrap();
let expected = Local::now() - Duration::days(7);
assert!((since - expected).num_seconds().abs() < 1);
}
#[test]
fn test_parse_since_relative_week() {
let query = FilterQuery::parse("/since:1week");
assert!(query.since.is_some());
let since = query.since.unwrap();
let expected = Local::now() - Duration::weeks(1);
assert!((since - expected).num_seconds().abs() < 1);
}
#[test]
fn test_parse_since_absolute_date() {
let query = FilterQuery::parse("/since:2024-01-15");
assert!(query.since.is_some());
}
#[test]
fn test_parse_combined_query() {
let query = FilterQuery::parse("/author:john since:1week fix");
assert_eq!(query.author, Some("john".to_string()));
assert!(query.since.is_some());
assert_eq!(query.message_pattern, Some("fix".to_string()));
}
#[test]
fn test_parse_multiple_keywords() {
let query = FilterQuery::parse("/author:john message:fix bug");
assert_eq!(query.author, Some("john".to_string()));
assert_eq!(query.message_pattern, Some("fix bug".to_string()));
}
#[test]
fn test_matches_plain_text_in_message() {
let query = FilterQuery::parse("feat");
let event = create_test_event("feat: add feature", "author");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_plain_text_in_author() {
let query = FilterQuery::parse("john");
let event = create_test_event("some message", "John Doe");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_plain_text_in_hash() {
let query = FilterQuery::parse("abc");
let event = create_test_event("message", "author");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_plain_text_case_insensitive() {
let query = FilterQuery::parse("FEAT");
let event = create_test_event("feat: add feature", "author");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_author_filter() {
let query = FilterQuery::parse("/author:john");
let event = create_test_event("message", "John Doe");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_author_filter_no_match() {
let query = FilterQuery::parse("/author:alice");
let event = create_test_event("message", "John Doe");
assert!(!query.matches(&event, None));
}
#[test]
fn test_matches_message_filter() {
let query = FilterQuery::parse("/message:fix");
let event = create_test_event("fix: bug fix", "author");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_type_merge() {
let query = FilterQuery::parse("/type:merge");
let event = GitEvent::merge(
"abc1234".to_string(),
"Merge branch".to_string(),
"author".to_string(),
Local::now(),
);
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_type_commit_not_merge() {
let query = FilterQuery::parse("/type:commit");
let event = GitEvent::merge(
"abc1234".to_string(),
"Merge branch".to_string(),
"author".to_string(),
Local::now(),
);
assert!(!query.matches(&event, None));
}
#[test]
fn test_matches_since_filter() {
let query = FilterQuery::parse("/since:1day");
let event = create_test_event("message", "author");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_since_filter_old_event() {
let query = FilterQuery {
since: Some(Local::now()),
..Default::default()
};
let mut event = create_test_event("message", "author");
event.timestamp = Local::now() - Duration::days(2);
assert!(!query.matches(&event, None));
}
#[test]
fn test_matches_file_filter_with_files() {
let query = FilterQuery::parse("/file:src/auth");
let event = create_test_event("message", "author");
let files = vec!["src/auth/login.rs".to_string(), "README.md".to_string()];
assert!(query.matches(&event, Some(&files)));
}
#[test]
fn test_matches_file_filter_no_match() {
let query = FilterQuery::parse("/file:src/auth");
let event = create_test_event("message", "author");
let files = vec!["src/main.rs".to_string(), "README.md".to_string()];
assert!(!query.matches(&event, Some(&files)));
}
#[test]
fn test_matches_file_filter_without_files() {
let query = FilterQuery::parse("/file:src/auth");
let event = create_test_event("message", "author");
assert!(!query.matches(&event, None));
}
#[test]
fn test_matches_combined_filters() {
let query = FilterQuery::parse("/author:john message:fix");
let event = create_test_event("fix: bug fix", "John Doe");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_combined_filters_partial_fail() {
let query = FilterQuery::parse("/author:john message:fix");
let event = create_test_event("feat: new feature", "John Doe");
assert!(!query.matches(&event, None));
}
#[test]
fn test_matches_japanese_message() {
let query = FilterQuery::parse("修正");
let event = create_test_event("バグ修正: ログイン問題を解決", "田中太郎");
assert!(query.matches(&event, None));
}
#[test]
fn test_matches_japanese_author() {
let query = FilterQuery::parse("/author:田中");
let event = create_test_event("feat: new feature", "田中太郎");
assert!(query.matches(&event, None));
}
#[test]
fn test_has_file_filter_true() {
let query = FilterQuery::parse("/file:src");
assert!(query.has_file_filter());
}
#[test]
fn test_has_file_filter_false() {
let query = FilterQuery::parse("/author:john");
assert!(!query.has_file_filter());
}
#[test]
fn test_is_empty_true() {
let query = FilterQuery::parse("");
assert!(query.is_empty());
}
#[test]
fn test_is_empty_false() {
let query = FilterQuery::parse("/author:john");
assert!(!query.is_empty());
}
#[test]
fn test_description_plain_text() {
let query = FilterQuery::parse("fix bug");
assert_eq!(query.description(), "\"fix bug\"");
}
#[test]
fn test_description_smart_filter() {
let query = FilterQuery::parse("/author:john type:merge");
let desc = query.description();
assert!(desc.contains("author:john"));
assert!(desc.contains("type:merge"));
}
#[test]
fn test_parse_hash_range_filter() {
let query = FilterQuery::parse("/hash:abc1234..def5678");
assert!(query.hash_range.is_some());
let (start, end) = query.hash_range.unwrap();
assert_eq!(start, "abc1234");
assert_eq!(end, "def5678");
}
#[test]
fn test_parse_hash_range_with_other_filters() {
let query = FilterQuery::parse("/hash:abc..def author:john");
assert!(query.hash_range.is_some());
assert_eq!(query.author, Some("john".to_string()));
let (start, end) = query.hash_range.unwrap();
assert_eq!(start, "abc");
assert_eq!(end, "def");
}
#[test]
fn test_parse_hash_range_invalid_format() {
let query = FilterQuery::parse("/hash:abc");
assert!(query.hash_range.is_none());
}
#[test]
fn test_parse_hash_range_empty_parts() {
let query = FilterQuery::parse("/hash:..def");
assert!(query.hash_range.is_none());
let query = FilterQuery::parse("/hash:abc..");
assert!(query.hash_range.is_none());
}
#[test]
fn test_has_hash_range_true() {
let query = FilterQuery::parse("/hash:abc..def");
assert!(query.has_hash_range());
}
#[test]
fn test_has_hash_range_false() {
let query = FilterQuery::parse("/author:john");
assert!(!query.has_hash_range());
}
#[test]
fn test_is_empty_false_with_hash_range() {
let query = FilterQuery::parse("/hash:abc..def");
assert!(!query.is_empty());
}
#[test]
fn test_description_with_hash_range() {
let query = FilterQuery::parse("/hash:abc..def");
let desc = query.description();
assert!(desc.contains("hash:abc..def"));
}
}