Skip to main content

max / goingson

14.9 KB · 436 lines History Blame Raw
1 //! Search query parser for structured filter extraction.
2 //!
3 //! Parses search queries containing filter prefixes like `is:overdue`, `priority:high`,
4 //! `in:ProjectName`, and `tag:urgent` into structured filter data.
5 //!
6 //! # Supported Filters
7 //!
8 //! - `is:overdue` - Tasks past their due date
9 //! - `is:today` - Tasks due today
10 //! - `is:tomorrow` - Tasks due tomorrow
11 //! - `is:thisweek` - Tasks due this week
12 //! - `is:snoozed` - Currently snoozed tasks
13 //! - `is:pending` - Tasks with pending status
14 //! - `is:started` - Tasks with started status
15 //! - `is:completed` - Tasks with completed status
16 //! - `is:waiting` - Tasks with waiting_for_response flag
17 //! - `priority:high` / `priority:h` - High priority
18 //! - `priority:medium` / `priority:m` - Medium priority
19 //! - `priority:low` / `priority:l` - Low priority
20 //! - `in:ProjectName` - Filter to specific project by name
21 //! - `type:task` / `type:email` / `type:event` / `type:project` - Filter result types
22 //! - `tag:name` - Include items with tag
23 //! - `-tag:name` - Exclude items with tag
24 //! - `after:date` / `from:date` - Items due on or after date
25 //! - `before:date` / `to:date` - Items due on or before date
26 //!
27 //! # Example
28 //!
29 //! ```rust
30 //! use goingson_core::search_parser::parse_search_query;
31 //!
32 //! let parsed = parse_search_query("is:pending priority:high meeting notes");
33 //! assert!(parsed.is_filters.contains(&goingson_core::search_parser::IsFilter::Pending));
34 //! assert_eq!(parsed.priority, Some(goingson_core::Priority::High));
35 //! assert_eq!(parsed.text, "meeting notes");
36 //! ```
37
38 use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc};
39 use serde::{Deserialize, Serialize};
40
41 use crate::constants::{APPROXIMATE_DAYS_PER_MONTH, DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, MAX_RELATIVE_DATE_DAYS};
42 use crate::models::Priority;
43 use crate::repository::SearchResultType;
44
45 /// Time and state-based filters.
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47 #[serde(rename_all = "lowercase")]
48 pub enum IsFilter {
49 // Time-based
50 /// Past due date
51 Overdue,
52 /// Due today
53 Today,
54 /// Due tomorrow
55 Tomorrow,
56 /// Due this week (through Sunday)
57 ThisWeek,
58 /// Currently snoozed
59 Snoozed,
60 // State-based
61 /// Pending status
62 Pending,
63 /// Started status
64 Started,
65 /// Completed status
66 Completed,
67 /// Waiting for response flag set
68 Waiting,
69 }
70
71 impl IsFilter {
72 /// Parse an `is:` filter value.
73 pub fn parse(s: &str) -> Option<Self> {
74 match s.to_lowercase().as_str() {
75 "overdue" => Some(Self::Overdue),
76 "today" => Some(Self::Today),
77 "tomorrow" => Some(Self::Tomorrow),
78 "thisweek" | "this_week" | "week" => Some(Self::ThisWeek),
79 "snoozed" | "snooze" => Some(Self::Snoozed),
80 "pending" => Some(Self::Pending),
81 "started" | "inprogress" | "in_progress" => Some(Self::Started),
82 "completed" | "done" | "finished" => Some(Self::Completed),
83 "waiting" | "wait" => Some(Self::Waiting),
84 _ => None,
85 }
86 }
87 }
88
89 /// A parsed search query with extracted filters.
90 #[derive(Debug, Clone, Default)]
91 pub struct ParsedSearchQuery {
92 /// Remaining free-text for FTS search (after filter tokens removed).
93 pub text: String,
94 /// `is:` filters (e.g., is:overdue, is:pending).
95 pub is_filters: Vec<IsFilter>,
96 /// `priority:` filter.
97 pub priority: Option<Priority>,
98 /// `in:` filter for project name (partial match).
99 pub project_name: Option<String>,
100 /// `type:` filter for result types.
101 pub result_types: Vec<SearchResultType>,
102 /// `tag:` filters (include items with these tags).
103 pub tags_include: Vec<String>,
104 /// `-tag:` filters (exclude items with these tags).
105 pub tags_exclude: Vec<String>,
106 /// `after:` / `from:` date filter.
107 pub date_from: Option<DateTime<Utc>>,
108 /// `before:` / `to:` date filter.
109 pub date_to: Option<DateTime<Utc>>,
110 }
111
112 /// Parse a search query string into structured filters.
113 ///
114 /// Extracts filter prefixes from the query and returns the remaining text
115 /// for full-text search along with the parsed filters.
116 pub fn parse_search_query(input: &str) -> ParsedSearchQuery {
117 let mut result = ParsedSearchQuery::default();
118 let mut text_parts = Vec::new();
119
120 for token in input.split_whitespace() {
121 // Check for negated tag first (-tag:name)
122 if let Some(tag) = strip_prefix_ci(token, "-tag:") {
123 if !tag.is_empty() {
124 result.tags_exclude.push(tag.to_string());
125 }
126 continue;
127 }
128
129 // is: filter
130 if let Some(value) = strip_prefix_ci(token, "is:") {
131 if let Some(filter) = IsFilter::parse(value) {
132 if !result.is_filters.contains(&filter) {
133 result.is_filters.push(filter);
134 }
135 }
136 continue;
137 }
138
139 // priority: filter
140 if let Some(value) = strip_prefix_ci(token, "priority:")
141 .or_else(|| strip_prefix_ci(token, "pri:"))
142 {
143 if let Some(priority) = parse_priority(value) {
144 result.priority = Some(priority);
145 }
146 continue;
147 }
148
149 // in: filter (project name)
150 if let Some(value) = strip_prefix_ci(token, "in:") {
151 if !value.is_empty() {
152 result.project_name = Some(value.to_string());
153 }
154 continue;
155 }
156
157 // type: filter
158 if let Some(value) = strip_prefix_ci(token, "type:") {
159 if let Some(result_type) = parse_result_type(value) {
160 if !result.result_types.contains(&result_type) {
161 result.result_types.push(result_type);
162 }
163 }
164 continue;
165 }
166
167 // tag: filter (include)
168 if let Some(tag) = strip_prefix_ci(token, "tag:") {
169 if !tag.is_empty() {
170 result.tags_include.push(tag.to_string());
171 }
172 continue;
173 }
174
175 // after: / from: date filter
176 if let Some(value) = strip_prefix_ci(token, "after:")
177 .or_else(|| strip_prefix_ci(token, "from:"))
178 {
179 if let Some(date) = parse_date(value) {
180 result.date_from = Some(date);
181 }
182 continue;
183 }
184
185 // before: / to: date filter
186 if let Some(value) = strip_prefix_ci(token, "before:")
187 .or_else(|| strip_prefix_ci(token, "to:"))
188 {
189 if let Some(date) = parse_date(value) {
190 result.date_to = Some(date);
191 }
192 continue;
193 }
194
195 // Not a filter - add to text
196 text_parts.push(token);
197 }
198
199 result.text = text_parts.join(" ");
200 result
201 }
202
203 use crate::text_utils::strip_prefix_ci;
204
205 /// Parse priority from string.
206 fn parse_priority(s: &str) -> Option<Priority> {
207 match s.to_lowercase().as_str() {
208 "h" | "high" => Some(Priority::High),
209 "m" | "medium" | "med" => Some(Priority::Medium),
210 "l" | "low" => Some(Priority::Low),
211 _ => None,
212 }
213 }
214
215 /// Parse result type from string.
216 fn parse_result_type(s: &str) -> Option<SearchResultType> {
217 match s.to_lowercase().as_str() {
218 "task" | "tasks" => Some(SearchResultType::Task),
219 "email" | "emails" => Some(SearchResultType::Email),
220 "event" | "events" => Some(SearchResultType::Event),
221 "project" | "projects" => Some(SearchResultType::Project),
222 "contact" | "contacts" => Some(SearchResultType::Contact),
223 _ => None,
224 }
225 }
226
227 /// Parse date from various formats.
228 fn parse_date(s: &str) -> Option<DateTime<Utc>> {
229 let s_lower = s.to_lowercase();
230 let today = Utc::now().date_naive();
231 let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
232
233 match s_lower.as_str() {
234 "today" | "tod" => {
235 let dt = today.and_time(default_time);
236 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
237 }
238 "tomorrow" | "tom" => {
239 let dt = (today + Duration::days(1)).and_time(default_time);
240 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
241 }
242 "yesterday" | "yes" => {
243 let dt = (today - Duration::days(1)).and_time(default_time);
244 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
245 }
246 "thisweek" | "week" => {
247 // End of this week (Sunday)
248 use chrono::Datelike;
249 let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1;
250 let sunday = today + Duration::days(days_until_sunday as i64);
251 let dt = sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
252 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
253 }
254 "nextweek" => {
255 use chrono::Datelike;
256 let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1;
257 let next_sunday = today + Duration::days((days_until_sunday + 7) as i64);
258 let dt = next_sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
259 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
260 }
261 "thismonth" | "month" => {
262 use chrono::Datelike;
263 let last_day = NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1)
264 .unwrap_or_else(|| NaiveDate::from_ymd_opt(today.year() + 1, 1, 1).expect("Jan 1 of next year is valid"))
265 - Duration::days(1);
266 let dt = last_day.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
267 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
268 }
269 // Day names - find next occurrence
270 "monday" | "mon" => next_weekday(today, chrono::Weekday::Mon, default_time),
271 "tuesday" | "tue" => next_weekday(today, chrono::Weekday::Tue, default_time),
272 "wednesday" | "wed" => next_weekday(today, chrono::Weekday::Wed, default_time),
273 "thursday" | "thu" => next_weekday(today, chrono::Weekday::Thu, default_time),
274 "friday" | "fri" => next_weekday(today, chrono::Weekday::Fri, default_time),
275 "saturday" | "sat" => next_weekday(today, chrono::Weekday::Sat, default_time),
276 "sunday" | "sun" => next_weekday(today, chrono::Weekday::Sun, default_time),
277 // Relative dates: +1d, -2w, +3m
278 _ if s_lower.starts_with('+') || s_lower.starts_with('-') => {
279 parse_relative_date(&s_lower, today, default_time)
280 }
281 // ISO date format: 2026-02-15
282 _ => {
283 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
284 let dt = date.and_time(default_time);
285 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
286 } else {
287 None
288 }
289 }
290 }
291 }
292
293 /// Find the next occurrence of a weekday.
294 fn next_weekday(from: NaiveDate, target: chrono::Weekday, time: NaiveTime) -> Option<DateTime<Utc>> {
295 use chrono::Datelike;
296
297 let current = from.weekday();
298 let current_num = current.num_days_from_monday();
299 let target_num = target.num_days_from_monday();
300
301 let days_ahead = if target_num > current_num {
302 target_num - current_num
303 } else if target_num < current_num {
304 7 - (current_num - target_num)
305 } else {
306 7 // Same day, go to next week
307 };
308
309 let dt = (from + Duration::days(days_ahead as i64)).and_time(time);
310 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
311 }
312
313 /// Parse relative date like "+1d", "-2w", "+1m".
314 fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<DateTime<Utc>> {
315 if s.len() < 2 {
316 return None;
317 }
318
319 let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
320 let rest = s.trim_start_matches(['+', '-']);
321
322 if rest.is_empty() {
323 return None;
324 }
325
326 let (num_str, unit) = rest.split_at(rest.len() - 1);
327 let raw: i64 = num_str.parse::<i64>().ok()?;
328 if raw > MAX_RELATIVE_DATE_DAYS {
329 return None;
330 }
331 let num: i64 = raw * sign;
332
333 let target = match unit {
334 "d" => from + Duration::days(num),
335 "w" => from + Duration::weeks(num),
336 "m" => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH),
337 _ => return None,
338 };
339
340 let dt = target.and_time(time);
341 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
342 }
343
344 #[cfg(test)]
345 mod tests {
346 use super::*;
347
348 #[test]
349 fn test_is_filter_overdue() {
350 let q = parse_search_query("is:overdue meeting");
351 assert_eq!(q.is_filters, vec![IsFilter::Overdue]);
352 assert_eq!(q.text, "meeting");
353 }
354
355 #[test]
356 fn test_is_filter_pending() {
357 let q = parse_search_query("is:pending task review");
358 assert_eq!(q.is_filters, vec![IsFilter::Pending]);
359 assert_eq!(q.text, "task review");
360 }
361
362 #[test]
363 fn test_priority_filter() {
364 let q = parse_search_query("priority:high important");
365 assert_eq!(q.priority, Some(Priority::High));
366 assert_eq!(q.text, "important");
367
368 let q2 = parse_search_query("pri:l low priority");
369 assert_eq!(q2.priority, Some(Priority::Low));
370 assert_eq!(q2.text, "low priority");
371 }
372
373 #[test]
374 fn test_combined_filters() {
375 let q = parse_search_query("is:pending priority:high in:Work call");
376 assert_eq!(q.is_filters, vec![IsFilter::Pending]);
377 assert_eq!(q.priority, Some(Priority::High));
378 assert_eq!(q.project_name, Some("Work".into()));
379 assert_eq!(q.text, "call");
380 }
381
382 #[test]
383 fn test_tag_filters() {
384 let q = parse_search_query("tag:urgent -tag:personal find");
385 assert_eq!(q.tags_include, vec!["urgent"]);
386 assert_eq!(q.tags_exclude, vec!["personal"]);
387 assert_eq!(q.text, "find");
388 }
389
390 #[test]
391 fn test_type_filter() {
392 let q = parse_search_query("type:task type:email search");
393 assert_eq!(q.result_types, vec![SearchResultType::Task, SearchResultType::Email]);
394 assert_eq!(q.text, "search");
395 }
396
397 #[test]
398 fn test_date_filters() {
399 let q = parse_search_query("after:today before:friday stuff");
400 assert!(q.date_from.is_some());
401 assert!(q.date_to.is_some());
402 assert_eq!(q.text, "stuff");
403 }
404
405 #[test]
406 fn test_multiple_is_filters() {
407 let q = parse_search_query("is:overdue is:pending urgent");
408 assert_eq!(q.is_filters.len(), 2);
409 assert!(q.is_filters.contains(&IsFilter::Overdue));
410 assert!(q.is_filters.contains(&IsFilter::Pending));
411 assert_eq!(q.text, "urgent");
412 }
413
414 #[test]
415 fn test_text_only() {
416 let q = parse_search_query("just some text");
417 assert!(q.is_filters.is_empty());
418 assert!(q.priority.is_none());
419 assert!(q.project_name.is_none());
420 assert_eq!(q.text, "just some text");
421 }
422
423 #[test]
424 fn test_case_insensitive() {
425 let q = parse_search_query("IS:OVERDUE PRIORITY:HIGH");
426 assert_eq!(q.is_filters, vec![IsFilter::Overdue]);
427 assert_eq!(q.priority, Some(Priority::High));
428 }
429
430 #[test]
431 fn test_no_duplicates() {
432 let q = parse_search_query("is:pending is:pending");
433 assert_eq!(q.is_filters.len(), 1);
434 }
435 }
436