1use crate::config::SearchMode;
2use crate::model::normalize_for_search;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum TimeFilterWindow {
6 Today,
7 Week,
8 Month,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ParsedQuery {
13 pub raw: String,
14 pub free_text: String,
15 pub mode_override: Option<SearchMode>,
16 pub kind_filter: Option<String>,
17 pub extension_filter: Option<String>,
18 pub include_groups: Vec<Vec<String>>,
19 pub exclude_terms: Vec<String>,
20 pub modified_within: Option<TimeFilterWindow>,
21 pub created_within: Option<TimeFilterWindow>,
22 pub command_mode: bool,
23}
24
25impl ParsedQuery {
26 pub fn parse(query: &str, dsl_enabled: bool) -> Self {
27 let raw = query.trim().to_string();
28 if raw.is_empty() {
29 return Self {
30 raw,
31 free_text: String::new(),
32 mode_override: None,
33 kind_filter: None,
34 extension_filter: None,
35 include_groups: Vec::new(),
36 exclude_terms: Vec::new(),
37 modified_within: None,
38 created_within: None,
39 command_mode: false,
40 };
41 }
42
43 if !dsl_enabled {
44 return Self {
45 free_text: raw.clone(),
46 raw,
47 mode_override: None,
48 kind_filter: None,
49 extension_filter: None,
50 include_groups: Vec::new(),
51 exclude_terms: Vec::new(),
52 modified_within: None,
53 created_within: None,
54 command_mode: false,
55 };
56 }
57
58 let mut command_mode = false;
59 let mut working = raw.clone();
60 if let Some(rest) = working.strip_prefix('>') {
61 command_mode = true;
62 working = rest.trim_start().to_string();
63 }
64
65 let tokens = tokenize(&working);
66 let mut mode_override = if command_mode {
67 Some(SearchMode::Actions)
68 } else {
69 None
70 };
71 let mut kind_filter: Option<String> = None;
72 let mut extension_filter: Option<String> = None;
73 let mut include_groups: Vec<Vec<String>> = vec![Vec::new()];
74 let mut exclude_terms = Vec::new();
75 let mut free_terms = Vec::new();
76 let mut expect_not = false;
77 let mut modified_within = None;
78 let mut created_within = None;
79
80 for token in tokens {
81 let token_trimmed = token.trim();
82 if token_trimmed.is_empty() {
83 continue;
84 }
85
86 let upper = token_trimmed.to_ascii_uppercase();
87 if upper == "AND" {
88 continue;
89 }
90 if upper == "OR" {
91 include_groups.push(Vec::new());
92 expect_not = false;
93 continue;
94 }
95 if upper == "NOT" {
96 expect_not = true;
97 continue;
98 }
99
100 if let Some(mode) = parse_mode_token(token_trimmed) {
101 mode_override = Some(mode);
102 expect_not = false;
103 continue;
104 }
105
106 if let Some(value) = parse_prefixed(token_trimmed, "mode:") {
107 if let Some(mode) = SearchMode::parse(value) {
108 mode_override = Some(mode);
109 }
110 expect_not = false;
111 continue;
112 }
113 if let Some(value) = parse_prefixed(token_trimmed, "kind:") {
114 let normalized = value.trim().to_ascii_lowercase();
115 if !normalized.is_empty() {
116 kind_filter = Some(normalized);
117 }
118 expect_not = false;
119 continue;
120 }
121 if let Some(value) = parse_prefixed(token_trimmed, "ext:")
122 .or_else(|| parse_prefixed(token_trimmed, "extension:"))
123 {
124 let normalized = normalize_extension_filter(value);
125 if !normalized.is_empty() {
126 extension_filter = Some(normalized);
127 }
128 expect_not = false;
129 continue;
130 }
131 if let Some(value) = parse_prefixed(token_trimmed, "modified:") {
132 modified_within = parse_time_filter(value);
133 expect_not = false;
134 continue;
135 }
136 if let Some(value) = parse_prefixed(token_trimmed, "created:") {
137 created_within = parse_time_filter(value);
138 expect_not = false;
139 continue;
140 }
141
142 let is_negative_literal = token_trimmed.starts_with('-') && token_trimmed.len() > 1;
143 let target = if is_negative_literal {
144 &token_trimmed[1..]
145 } else {
146 token_trimmed
147 };
148 let normalized = normalize_for_search(target);
149 if normalized.is_empty() {
150 expect_not = false;
151 continue;
152 }
153
154 if expect_not || is_negative_literal {
155 exclude_terms.push(normalized.clone());
156 } else {
157 if include_groups.is_empty() {
158 include_groups.push(Vec::new());
159 }
160 if let Some(group) = include_groups.last_mut() {
161 group.push(normalized.clone());
162 }
163 free_terms.push(target.to_string());
164 }
165 expect_not = false;
166 }
167
168 include_groups.retain(|group| !group.is_empty());
169 let free_text = free_terms.join(" ");
170
171 Self {
172 raw,
173 free_text,
174 mode_override,
175 kind_filter,
176 extension_filter,
177 include_groups,
178 exclude_terms,
179 modified_within,
180 created_within,
181 command_mode,
182 }
183 }
184}
185
186fn normalize_extension_filter(value: &str) -> String {
187 value
188 .trim()
189 .trim_start_matches('.')
190 .chars()
191 .flat_map(|ch| ch.to_lowercase())
192 .collect()
193}
194
195fn parse_prefixed<'a>(token: &'a str, prefix: &str) -> Option<&'a str> {
196 token
197 .strip_prefix(prefix)
198 .or_else(|| token.strip_prefix(&prefix.to_ascii_uppercase()))
199}
200
201fn parse_mode_token(token: &str) -> Option<SearchMode> {
202 let token = token.trim();
203 if !token.starts_with('@') {
204 return None;
205 }
206 SearchMode::parse(token.trim_start_matches('@'))
207}
208
209fn parse_time_filter(value: &str) -> Option<TimeFilterWindow> {
210 match value.trim().to_ascii_lowercase().as_str() {
211 "today" => Some(TimeFilterWindow::Today),
212 "week" | "last_week" => Some(TimeFilterWindow::Week),
213 "month" | "last_month" => Some(TimeFilterWindow::Month),
214 _ => None,
215 }
216}
217
218fn tokenize(input: &str) -> Vec<String> {
219 let mut tokens = Vec::new();
220 let mut current = String::new();
221 let mut in_quotes = false;
222
223 for ch in input.chars() {
224 if ch == '"' {
225 in_quotes = !in_quotes;
226 continue;
227 }
228
229 if ch.is_whitespace() && !in_quotes {
230 if !current.is_empty() {
231 tokens.push(std::mem::take(&mut current));
232 }
233 continue;
234 }
235
236 current.push(ch);
237 }
238
239 if !current.is_empty() {
240 tokens.push(current);
241 }
242
243 tokens
244}
245
246#[cfg(test)]
247mod tests {
248 use super::{ParsedQuery, TimeFilterWindow};
249 use crate::config::SearchMode;
250
251 #[test]
252 fn parses_mode_kind_and_filters() {
253 let parsed = ParsedQuery::parse(
254 r#"@apps kind:file ext:md report OR notes NOT draft -temp modified:week"#,
255 true,
256 );
257 assert_eq!(parsed.mode_override, Some(SearchMode::Apps));
258 assert_eq!(parsed.kind_filter.as_deref(), Some("file"));
259 assert_eq!(parsed.extension_filter.as_deref(), Some("md"));
260 assert_eq!(parsed.modified_within, Some(TimeFilterWindow::Week));
261 assert_eq!(parsed.include_groups.len(), 2);
262 assert!(parsed.exclude_terms.contains(&"draft".to_string()));
263 assert!(parsed.exclude_terms.contains(&"temp".to_string()));
264 }
265
266 #[test]
267 fn parses_command_mode_prefix() {
268 let parsed = ParsedQuery::parse(">logs", true);
269 assert!(parsed.command_mode);
270 assert_eq!(parsed.mode_override, Some(SearchMode::Actions));
271 assert_eq!(parsed.free_text, "logs");
272 }
273
274 #[test]
275 fn disables_dsl_when_disabled() {
276 let parsed = ParsedQuery::parse("kind:file notes", false);
277 assert_eq!(parsed.free_text, "kind:file notes");
278 assert!(parsed.mode_override.is_none());
279 assert!(parsed.include_groups.is_empty());
280 }
281}