Skip to main content

smriti/search/
query_parser.rs

1//! Search query parsing.
2
3use super::date_parser::{DateParser, DateRange};
4
5/// A parsed search filter.
6#[derive(Debug, Clone)]
7pub enum SearchFilter {
8    Person(String),
9    Location(String),
10    DateRange(DateRange),
11    Text,
12}
13
14/// A complete parsed search query.
15#[derive(Debug, Clone, Default)]
16pub struct SearchQuery {
17    pub filters: Vec<SearchFilter>,
18}
19
20impl SearchQuery {
21    pub fn new() -> Self {
22        Self {
23            filters: Vec::new(),
24        }
25    }
26
27    pub fn is_empty(&self) -> bool {
28        self.filters.is_empty()
29    }
30
31    pub fn person(&self) -> Option<&str> {
32        self.filters.iter().find_map(|f| match f {
33            SearchFilter::Person(name) => Some(name.as_str()),
34            _ => None,
35        })
36    }
37
38    pub fn location(&self) -> Option<&str> {
39        self.filters.iter().find_map(|f| match f {
40            SearchFilter::Location(loc) => Some(loc.as_str()),
41            _ => None,
42        })
43    }
44
45    pub fn date_range(&self) -> Option<&DateRange> {
46        self.filters.iter().find_map(|f| match f {
47            SearchFilter::DateRange(range) => Some(range),
48            _ => None,
49        })
50    }
51}
52
53/// Query parser.
54pub struct QueryParser;
55
56impl QueryParser {
57    pub fn parse(input: &str) -> SearchQuery {
58        let input = input.trim();
59        if input.is_empty() {
60            return SearchQuery::new();
61        }
62
63        let mut query = SearchQuery::new();
64        let mut remaining = input.to_string();
65
66        if let Some((before, location)) = Self::extract_in_location(&remaining) {
67            query.filters.push(SearchFilter::Location(location));
68            remaining = before;
69        }
70
71        if let Some((non_date, date_range)) = Self::extract_date(&remaining) {
72            query.filters.push(SearchFilter::DateRange(date_range));
73            remaining = non_date;
74        }
75
76        let remaining = remaining.trim();
77        if !remaining.is_empty() {
78            if Self::looks_like_location(remaining) {
79                query
80                    .filters
81                    .push(SearchFilter::Location(remaining.to_string()));
82            } else {
83                query
84                    .filters
85                    .push(SearchFilter::Person(remaining.to_string()));
86            }
87        }
88
89        if query.is_empty() {
90            query.filters.push(SearchFilter::Text);
91        }
92
93        query
94    }
95
96    fn extract_in_location(input: &str) -> Option<(String, String)> {
97        let lower = input.to_lowercase();
98        if let Some(idx) = lower.rfind(" in ") {
99            let before = input[..idx].trim().to_string();
100            let location = input[idx + 4..].trim().to_string();
101            if !location.is_empty() {
102                return Some((before, location));
103            }
104        }
105        None
106    }
107
108    fn extract_date(input: &str) -> Option<(String, DateRange)> {
109        if let Some(range) = DateParser::parse(input) {
110            return Some((String::new(), range));
111        }
112
113        let words: Vec<&str> = input.split_whitespace().collect();
114
115        if words.len() >= 2 {
116            let last_two = format!("{} {}", words[words.len() - 2], words[words.len() - 1]);
117            if let Some(range) = DateParser::parse(&last_two) {
118                let before = words[..words.len() - 2].join(" ");
119                return Some((before, range));
120            }
121        }
122
123        if let Some(last) = words.last() {
124            if let Some(range) = DateParser::parse(last) {
125                let before = words[..words.len() - 1].join(" ");
126                return Some((before, range));
127            }
128        }
129
130        None
131    }
132
133    fn looks_like_location(text: &str) -> bool {
134        let lower = text.to_lowercase();
135        let location_words = [
136            "city",
137            "country",
138            "beach",
139            "mountain",
140            "park",
141            "airport",
142            "station",
143            "hotel",
144            "restaurant",
145            "museum",
146            "temple",
147            "shrine",
148        ];
149
150        if location_words.iter().any(|w| lower.contains(w)) {
151            return true;
152        }
153
154        let known_locations = [
155            "japan",
156            "tokyo",
157            "usa",
158            "new york",
159            "london",
160            "paris",
161            "france",
162            "germany",
163            "berlin",
164            "italy",
165            "rome",
166            "spain",
167            "china",
168            "beijing",
169            "australia",
170            "sydney",
171            "canada",
172            "toronto",
173            "india",
174            "hyderabad",
175            "delhi",
176        ];
177
178        known_locations.iter().any(|loc| lower.contains(loc))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_parse_person_in_location() {
188        let query = QueryParser::parse("Dad in Tokyo");
189        assert_eq!(query.person(), Some("Dad"));
190        assert_eq!(query.location(), Some("Tokyo"));
191    }
192
193    #[test]
194    fn test_parse_date_only() {
195        let query = QueryParser::parse("March 2019");
196        assert!(query.date_range().is_some());
197    }
198
199    #[test]
200    fn test_parse_person_with_date() {
201        let query = QueryParser::parse("Dad March 2019");
202        assert_eq!(query.person(), Some("Dad"));
203        assert!(query.date_range().is_some());
204    }
205}