//! Search query parser for structured filter extraction. //! //! Parses search queries containing filter prefixes like `is:overdue`, `priority:high`, //! `in:ProjectName`, and `tag:urgent` into structured filter data. //! //! # Supported Filters //! //! - `is:overdue` - Tasks past their due date //! - `is:today` - Tasks due today //! - `is:tomorrow` - Tasks due tomorrow //! - `is:thisweek` - Tasks due this week //! - `is:snoozed` - Currently snoozed tasks //! - `is:pending` - Tasks with pending status //! - `is:started` - Tasks with started status //! - `is:completed` - Tasks with completed status //! - `is:waiting` - Tasks with waiting_for_response flag //! - `priority:high` / `priority:h` - High priority //! - `priority:medium` / `priority:m` - Medium priority //! - `priority:low` / `priority:l` - Low priority //! - `in:ProjectName` - Filter to specific project by name //! - `type:task` / `type:email` / `type:event` / `type:project` - Filter result types //! - `tag:name` - Include items with tag //! - `-tag:name` - Exclude items with tag //! - `after:date` / `from:date` - Items due on or after date //! - `before:date` / `to:date` - Items due on or before date //! //! # Example //! //! ```rust //! use goingson_core::search_parser::parse_search_query; //! //! let parsed = parse_search_query("is:pending priority:high meeting notes"); //! assert!(parsed.is_filters.contains(&goingson_core::search_parser::IsFilter::Pending)); //! assert_eq!(parsed.priority, Some(goingson_core::Priority::High)); //! assert_eq!(parsed.text, "meeting notes"); //! ``` use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use crate::constants::{APPROXIMATE_DAYS_PER_MONTH, DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, MAX_RELATIVE_DATE_DAYS}; use crate::models::Priority; use crate::repository::SearchResultType; /// Time and state-based filters. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IsFilter { // Time-based /// Past due date Overdue, /// Due today Today, /// Due tomorrow Tomorrow, /// Due this week (through Sunday) ThisWeek, /// Currently snoozed Snoozed, // State-based /// Pending status Pending, /// Started status Started, /// Completed status Completed, /// Waiting for response flag set Waiting, } impl IsFilter { /// Parse an `is:` filter value. pub fn parse(s: &str) -> Option { match s.to_lowercase().as_str() { "overdue" => Some(Self::Overdue), "today" => Some(Self::Today), "tomorrow" => Some(Self::Tomorrow), "thisweek" | "this_week" | "week" => Some(Self::ThisWeek), "snoozed" | "snooze" => Some(Self::Snoozed), "pending" => Some(Self::Pending), "started" | "inprogress" | "in_progress" => Some(Self::Started), "completed" | "done" | "finished" => Some(Self::Completed), "waiting" | "wait" => Some(Self::Waiting), _ => None, } } } /// A parsed search query with extracted filters. #[derive(Debug, Clone, Default)] pub struct ParsedSearchQuery { /// Remaining free-text for FTS search (after filter tokens removed). pub text: String, /// `is:` filters (e.g., is:overdue, is:pending). pub is_filters: Vec, /// `priority:` filter. pub priority: Option, /// `in:` filter for project name (partial match). pub project_name: Option, /// `type:` filter for result types. pub result_types: Vec, /// `tag:` filters (include items with these tags). pub tags_include: Vec, /// `-tag:` filters (exclude items with these tags). pub tags_exclude: Vec, /// `after:` / `from:` date filter. pub date_from: Option>, /// `before:` / `to:` date filter. pub date_to: Option>, } /// Parse a search query string into structured filters. /// /// Extracts filter prefixes from the query and returns the remaining text /// for full-text search along with the parsed filters. pub fn parse_search_query(input: &str) -> ParsedSearchQuery { let mut result = ParsedSearchQuery::default(); let mut text_parts = Vec::new(); for token in input.split_whitespace() { // Check for negated tag first (-tag:name) if let Some(tag) = strip_prefix_ci(token, "-tag:") { if !tag.is_empty() { result.tags_exclude.push(tag.to_string()); } continue; } // is: filter if let Some(value) = strip_prefix_ci(token, "is:") { if let Some(filter) = IsFilter::parse(value) { if !result.is_filters.contains(&filter) { result.is_filters.push(filter); } } continue; } // priority: filter if let Some(value) = strip_prefix_ci(token, "priority:") .or_else(|| strip_prefix_ci(token, "pri:")) { if let Some(priority) = parse_priority(value) { result.priority = Some(priority); } continue; } // in: filter (project name) if let Some(value) = strip_prefix_ci(token, "in:") { if !value.is_empty() { result.project_name = Some(value.to_string()); } continue; } // type: filter if let Some(value) = strip_prefix_ci(token, "type:") { if let Some(result_type) = parse_result_type(value) { if !result.result_types.contains(&result_type) { result.result_types.push(result_type); } } continue; } // tag: filter (include) if let Some(tag) = strip_prefix_ci(token, "tag:") { if !tag.is_empty() { result.tags_include.push(tag.to_string()); } continue; } // after: / from: date filter if let Some(value) = strip_prefix_ci(token, "after:") .or_else(|| strip_prefix_ci(token, "from:")) { if let Some(date) = parse_date(value) { result.date_from = Some(date); } continue; } // before: / to: date filter if let Some(value) = strip_prefix_ci(token, "before:") .or_else(|| strip_prefix_ci(token, "to:")) { if let Some(date) = parse_date(value) { result.date_to = Some(date); } continue; } // Not a filter - add to text text_parts.push(token); } result.text = text_parts.join(" "); result } use crate::text_utils::strip_prefix_ci; /// Parse priority from string. fn parse_priority(s: &str) -> Option { match s.to_lowercase().as_str() { "h" | "high" => Some(Priority::High), "m" | "medium" | "med" => Some(Priority::Medium), "l" | "low" => Some(Priority::Low), _ => None, } } /// Parse result type from string. fn parse_result_type(s: &str) -> Option { match s.to_lowercase().as_str() { "task" | "tasks" => Some(SearchResultType::Task), "email" | "emails" => Some(SearchResultType::Email), "event" | "events" => Some(SearchResultType::Event), "project" | "projects" => Some(SearchResultType::Project), "contact" | "contacts" => Some(SearchResultType::Contact), _ => None, } } /// Parse date from various formats. fn parse_date(s: &str) -> Option> { let s_lower = s.to_lowercase(); let today = Utc::now().date_naive(); let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?; match s_lower.as_str() { "today" | "tod" => { let dt = today.and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "tomorrow" | "tom" => { let dt = (today + Duration::days(1)).and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "yesterday" | "yes" => { let dt = (today - Duration::days(1)).and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "thisweek" | "week" => { // End of this week (Sunday) use chrono::Datelike; let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1; let sunday = today + Duration::days(days_until_sunday as i64); let dt = sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "nextweek" => { use chrono::Datelike; let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1; let next_sunday = today + Duration::days((days_until_sunday + 7) as i64); let dt = next_sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } "thismonth" | "month" => { use chrono::Datelike; let last_day = NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1) .unwrap_or_else(|| NaiveDate::from_ymd_opt(today.year() + 1, 1, 1).expect("Jan 1 of next year is valid")) - Duration::days(1); let dt = last_day.and_time(NaiveTime::from_hms_opt(23, 59, 59)?); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } // Day names - find next occurrence "monday" | "mon" => next_weekday(today, chrono::Weekday::Mon, default_time), "tuesday" | "tue" => next_weekday(today, chrono::Weekday::Tue, default_time), "wednesday" | "wed" => next_weekday(today, chrono::Weekday::Wed, default_time), "thursday" | "thu" => next_weekday(today, chrono::Weekday::Thu, default_time), "friday" | "fri" => next_weekday(today, chrono::Weekday::Fri, default_time), "saturday" | "sat" => next_weekday(today, chrono::Weekday::Sat, default_time), "sunday" | "sun" => next_weekday(today, chrono::Weekday::Sun, default_time), // Relative dates: +1d, -2w, +3m _ if s_lower.starts_with('+') || s_lower.starts_with('-') => { parse_relative_date(&s_lower, today, default_time) } // ISO date format: 2026-02-15 _ => { if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { let dt = date.and_time(default_time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } else { None } } } } /// Find the next occurrence of a weekday. fn next_weekday(from: NaiveDate, target: chrono::Weekday, time: NaiveTime) -> Option> { use chrono::Datelike; let current = from.weekday(); let current_num = current.num_days_from_monday(); let target_num = target.num_days_from_monday(); let days_ahead = if target_num > current_num { target_num - current_num } else if target_num < current_num { 7 - (current_num - target_num) } else { 7 // Same day, go to next week }; let dt = (from + Duration::days(days_ahead as i64)).and_time(time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } /// Parse relative date like "+1d", "-2w", "+1m". fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option> { if s.len() < 2 { return None; } let sign: i64 = if s.starts_with('-') { -1 } else { 1 }; let rest = s.trim_start_matches(['+', '-']); if rest.is_empty() { return None; } let (num_str, unit) = rest.split_at(rest.len() - 1); let raw: i64 = num_str.parse::().ok()?; if raw > MAX_RELATIVE_DATE_DAYS { return None; } let num: i64 = raw * sign; let target = match unit { "d" => from + Duration::days(num), "w" => from + Duration::weeks(num), "m" => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH), _ => return None, }; let dt = target.and_time(time); Some(DateTime::from_naive_utc_and_offset(dt, Utc)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_filter_overdue() { let q = parse_search_query("is:overdue meeting"); assert_eq!(q.is_filters, vec![IsFilter::Overdue]); assert_eq!(q.text, "meeting"); } #[test] fn test_is_filter_pending() { let q = parse_search_query("is:pending task review"); assert_eq!(q.is_filters, vec![IsFilter::Pending]); assert_eq!(q.text, "task review"); } #[test] fn test_priority_filter() { let q = parse_search_query("priority:high important"); assert_eq!(q.priority, Some(Priority::High)); assert_eq!(q.text, "important"); let q2 = parse_search_query("pri:l low priority"); assert_eq!(q2.priority, Some(Priority::Low)); assert_eq!(q2.text, "low priority"); } #[test] fn test_combined_filters() { let q = parse_search_query("is:pending priority:high in:Work call"); assert_eq!(q.is_filters, vec![IsFilter::Pending]); assert_eq!(q.priority, Some(Priority::High)); assert_eq!(q.project_name, Some("Work".into())); assert_eq!(q.text, "call"); } #[test] fn test_tag_filters() { let q = parse_search_query("tag:urgent -tag:personal find"); assert_eq!(q.tags_include, vec!["urgent"]); assert_eq!(q.tags_exclude, vec!["personal"]); assert_eq!(q.text, "find"); } #[test] fn test_type_filter() { let q = parse_search_query("type:task type:email search"); assert_eq!(q.result_types, vec![SearchResultType::Task, SearchResultType::Email]); assert_eq!(q.text, "search"); } #[test] fn test_date_filters() { let q = parse_search_query("after:today before:friday stuff"); assert!(q.date_from.is_some()); assert!(q.date_to.is_some()); assert_eq!(q.text, "stuff"); } #[test] fn test_multiple_is_filters() { let q = parse_search_query("is:overdue is:pending urgent"); assert_eq!(q.is_filters.len(), 2); assert!(q.is_filters.contains(&IsFilter::Overdue)); assert!(q.is_filters.contains(&IsFilter::Pending)); assert_eq!(q.text, "urgent"); } #[test] fn test_text_only() { let q = parse_search_query("just some text"); assert!(q.is_filters.is_empty()); assert!(q.priority.is_none()); assert!(q.project_name.is_none()); assert_eq!(q.text, "just some text"); } #[test] fn test_case_insensitive() { let q = parse_search_query("IS:OVERDUE PRIORITY:HIGH"); assert_eq!(q.is_filters, vec![IsFilter::Overdue]); assert_eq!(q.priority, Some(Priority::High)); } #[test] fn test_no_duplicates() { let q = parse_search_query("is:pending is:pending"); assert_eq!(q.is_filters.len(), 1); } }