Skip to main content

nex_core/
query_dsl.rs

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}