Skip to main content

max / goingson

15.6 KB · 448 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 && !result.is_filters.contains(&filter) {
133 result.is_filters.push(filter);
134 }
135 continue;
136 }
137
138 // priority: filter
139 if let Some(value) = strip_prefix_ci(token, "priority:")
140 .or_else(|| strip_prefix_ci(token, "pri:"))
141 {
142 if let Some(priority) = parse_priority(value) {
143 result.priority = Some(priority);
144 }
145 continue;
146 }
147
148 // in: filter (project name)
149 if let Some(value) = strip_prefix_ci(token, "in:") {
150 if !value.is_empty() {
151 result.project_name = Some(value.to_string());
152 }
153 continue;
154 }
155
156 // type: filter
157 if let Some(value) = strip_prefix_ci(token, "type:") {
158 if let Some(result_type) = parse_result_type(value)
159 && !result.result_types.contains(&result_type) {
160 result.result_types.push(result_type);
161 }
162 continue;
163 }
164
165 // tag: filter (include)
166 if let Some(tag) = strip_prefix_ci(token, "tag:") {
167 if !tag.is_empty() {
168 result.tags_include.push(tag.to_string());
169 }
170 continue;
171 }
172
173 // after: / from: date filter
174 if let Some(value) = strip_prefix_ci(token, "after:")
175 .or_else(|| strip_prefix_ci(token, "from:"))
176 {
177 if let Some(date) = parse_date(value) {
178 result.date_from = Some(date);
179 }
180 continue;
181 }
182
183 // before: / to: date filter
184 if let Some(value) = strip_prefix_ci(token, "before:")
185 .or_else(|| strip_prefix_ci(token, "to:"))
186 {
187 if let Some(date) = parse_date(value) {
188 result.date_to = Some(date);
189 }
190 continue;
191 }
192
193 // Not a filter - add to text
194 text_parts.push(token);
195 }
196
197 result.text = text_parts.join(" ");
198 result
199 }
200
201 use crate::text_utils::strip_prefix_ci;
202
203 /// Parse priority from string.
204 fn parse_priority(s: &str) -> Option<Priority> {
205 match s.to_lowercase().as_str() {
206 "h" | "high" => Some(Priority::High),
207 "m" | "medium" | "med" => Some(Priority::Medium),
208 "l" | "low" => Some(Priority::Low),
209 _ => None,
210 }
211 }
212
213 /// Parse result type from string.
214 fn parse_result_type(s: &str) -> Option<SearchResultType> {
215 match s.to_lowercase().as_str() {
216 "task" | "tasks" => Some(SearchResultType::Task),
217 "email" | "emails" => Some(SearchResultType::Email),
218 "event" | "events" => Some(SearchResultType::Event),
219 "project" | "projects" => Some(SearchResultType::Project),
220 "contact" | "contacts" => Some(SearchResultType::Contact),
221 _ => None,
222 }
223 }
224
225 /// Parse date from various formats.
226 fn parse_date(s: &str) -> Option<DateTime<Utc>> {
227 let s_lower = s.to_lowercase();
228 let today = Utc::now().date_naive();
229 let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
230
231 match s_lower.as_str() {
232 "today" | "tod" => {
233 let dt = today.and_time(default_time);
234 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
235 }
236 "tomorrow" | "tom" => {
237 let dt = (today + Duration::days(1)).and_time(default_time);
238 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
239 }
240 "yesterday" | "yes" => {
241 let dt = (today - Duration::days(1)).and_time(default_time);
242 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
243 }
244 "thisweek" | "week" => {
245 // End of this week (Sunday)
246 use chrono::Datelike;
247 let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1;
248 let sunday = today + Duration::days(days_until_sunday as i64);
249 let dt = sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
250 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
251 }
252 "nextweek" => {
253 use chrono::Datelike;
254 let days_until_sunday = 7 - today.weekday().num_days_from_monday() - 1;
255 let next_sunday = today + Duration::days((days_until_sunday + 7) as i64);
256 let dt = next_sunday.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
257 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
258 }
259 "thismonth" | "month" => {
260 use chrono::Datelike;
261 let last_day = NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1)
262 .unwrap_or_else(|| NaiveDate::from_ymd_opt(today.year() + 1, 1, 1).expect("Jan 1 of next year is valid"))
263 - Duration::days(1);
264 let dt = last_day.and_time(NaiveTime::from_hms_opt(23, 59, 59)?);
265 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
266 }
267 // Day names - find next occurrence
268 "monday" | "mon" => next_weekday(today, chrono::Weekday::Mon, default_time),
269 "tuesday" | "tue" => next_weekday(today, chrono::Weekday::Tue, default_time),
270 "wednesday" | "wed" => next_weekday(today, chrono::Weekday::Wed, default_time),
271 "thursday" | "thu" => next_weekday(today, chrono::Weekday::Thu, default_time),
272 "friday" | "fri" => next_weekday(today, chrono::Weekday::Fri, default_time),
273 "saturday" | "sat" => next_weekday(today, chrono::Weekday::Sat, default_time),
274 "sunday" | "sun" => next_weekday(today, chrono::Weekday::Sun, default_time),
275 // Relative dates: +1d, -2w, +3m
276 _ if s_lower.starts_with('+') || s_lower.starts_with('-') => {
277 parse_relative_date(&s_lower, today, default_time)
278 }
279 // ISO date format: 2026-02-15
280 _ => {
281 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
282 let dt = date.and_time(default_time);
283 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
284 } else {
285 None
286 }
287 }
288 }
289 }
290
291 /// Find the next occurrence of a weekday.
292 fn next_weekday(from: NaiveDate, target: chrono::Weekday, time: NaiveTime) -> Option<DateTime<Utc>> {
293 use chrono::Datelike;
294
295 let current = from.weekday();
296 let current_num = current.num_days_from_monday();
297 let target_num = target.num_days_from_monday();
298
299 let days_ahead = if target_num > current_num {
300 target_num - current_num
301 } else if target_num < current_num {
302 7 - (current_num - target_num)
303 } else {
304 7 // Same day, go to next week
305 };
306
307 let dt = (from + Duration::days(days_ahead as i64)).and_time(time);
308 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
309 }
310
311 /// Parse relative date like "+1d", "-2w", "+1m".
312 fn parse_relative_date(s: &str, from: NaiveDate, time: NaiveTime) -> Option<DateTime<Utc>> {
313 if s.len() < 2 {
314 return None;
315 }
316
317 let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
318 let rest = s.trim_start_matches(['+', '-']);
319
320 if rest.is_empty() {
321 return None;
322 }
323
324 // Split off the last char (the unit) using char-boundary-safe indexing so a
325 // trailing multibyte char (e.g. "+1😀") returns None instead of panicking.
326 let (unit_idx, unit) = rest.char_indices().next_back()?;
327 let num_str = &rest[..unit_idx];
328 let raw: i64 = num_str.parse::<i64>().ok()?;
329 if raw > MAX_RELATIVE_DATE_DAYS {
330 return None;
331 }
332 let num: i64 = raw * sign;
333
334 let target = match unit {
335 'd' => from + Duration::days(num),
336 'w' => from + Duration::weeks(num),
337 'm' => from + Duration::days(num * APPROXIMATE_DAYS_PER_MONTH),
338 _ => return None,
339 };
340
341 let dt = target.and_time(time);
342 Some(DateTime::from_naive_utc_and_offset(dt, Utc))
343 }
344
345 #[cfg(test)]
346 mod tests {
347 use super::*;
348
349 #[test]
350 fn test_is_filter_overdue() {
351 let q = parse_search_query("is:overdue meeting");
352 assert_eq!(q.is_filters, vec![IsFilter::Overdue]);
353 assert_eq!(q.text, "meeting");
354 }
355
356 #[test]
357 fn test_is_filter_pending() {
358 let q = parse_search_query("is:pending task review");
359 assert_eq!(q.is_filters, vec![IsFilter::Pending]);
360 assert_eq!(q.text, "task review");
361 }
362
363 #[test]
364 fn test_priority_filter() {
365 let q = parse_search_query("priority:high important");
366 assert_eq!(q.priority, Some(Priority::High));
367 assert_eq!(q.text, "important");
368
369 let q2 = parse_search_query("pri:l low priority");
370 assert_eq!(q2.priority, Some(Priority::Low));
371 assert_eq!(q2.text, "low priority");
372 }
373
374 #[test]
375 fn test_combined_filters() {
376 let q = parse_search_query("is:pending priority:high in:Work call");
377 assert_eq!(q.is_filters, vec![IsFilter::Pending]);
378 assert_eq!(q.priority, Some(Priority::High));
379 assert_eq!(q.project_name, Some("Work".into()));
380 assert_eq!(q.text, "call");
381 }
382
383 #[test]
384 fn test_tag_filters() {
385 let q = parse_search_query("tag:urgent -tag:personal find");
386 assert_eq!(q.tags_include, vec!["urgent"]);
387 assert_eq!(q.tags_exclude, vec!["personal"]);
388 assert_eq!(q.text, "find");
389 }
390
391 #[test]
392 fn test_type_filter() {
393 let q = parse_search_query("type:task type:email search");
394 assert_eq!(q.result_types, vec![SearchResultType::Task, SearchResultType::Email]);
395 assert_eq!(q.text, "search");
396 }
397
398 #[test]
399 fn test_date_filters() {
400 let q = parse_search_query("after:today before:friday stuff");
401 assert!(q.date_from.is_some());
402 assert!(q.date_to.is_some());
403 assert_eq!(q.text, "stuff");
404 }
405
406 #[test]
407 fn test_multiple_is_filters() {
408 let q = parse_search_query("is:overdue is:pending urgent");
409 assert_eq!(q.is_filters.len(), 2);
410 assert!(q.is_filters.contains(&IsFilter::Overdue));
411 assert!(q.is_filters.contains(&IsFilter::Pending));
412 assert_eq!(q.text, "urgent");
413 }
414
415 #[test]
416 fn test_text_only() {
417 let q = parse_search_query("just some text");
418 assert!(q.is_filters.is_empty());
419 assert!(q.priority.is_none());
420 assert!(q.project_name.is_none());
421 assert_eq!(q.text, "just some text");
422 }
423
424 #[test]
425 fn test_case_insensitive() {
426 let q = parse_search_query("IS:OVERDUE PRIORITY:HIGH");
427 assert_eq!(q.is_filters, vec![IsFilter::Overdue]);
428 assert_eq!(q.priority, Some(Priority::High));
429 }
430
431 #[test]
432 fn test_no_duplicates() {
433 let q = parse_search_query("is:pending is:pending");
434 assert_eq!(q.is_filters.len(), 1);
435 }
436
437 #[test]
438 fn test_relative_date_multibyte_does_not_panic() {
439 // Regression: a trailing multibyte char used to panic in split_at(len-1).
440 for input in ["after:+1😀", "before:-3€", "after:+😀", "after:+1d"] {
441 let _ = parse_search_query(input);
442 }
443 // "+1d" is the only valid relative date; multibyte input yields no filter.
444 assert!(parse_search_query("after:+1😀").date_from.is_none());
445 assert!(parse_search_query("after:+1d").date_from.is_some());
446 }
447 }
448