Skip to main content

ragfs_query/
parser.rs

1//! Query DSL parser.
2
3use ragfs_core::SearchFilter;
4
5/// Parsed query with text and filters.
6#[derive(Debug, Clone)]
7pub struct ParsedQuery {
8    /// Main query text
9    pub text: String,
10    /// Extracted filters
11    pub filters: Vec<SearchFilter>,
12    /// Result limit
13    pub limit: usize,
14    /// Optional directory scope from `scope:` (same as `ragfs query --scope`).
15    /// Pre-upgrade indexes are migrated on open; see `USER_GUIDE`.
16    pub scope_prefix: Option<String>,
17}
18
19/// Query parser for the DSL.
20pub struct QueryParser {
21    /// Default result limit
22    default_limit: usize,
23}
24
25impl QueryParser {
26    /// Create a new query parser.
27    #[must_use]
28    pub fn new(default_limit: usize) -> Self {
29        Self { default_limit }
30    }
31
32    /// Parse a query string.
33    ///
34    /// Supports filters like:
35    /// - `lang:rust` or `language:python`
36    /// - `path:src/**`
37    /// - `type:code` or `type:text`
38    /// - `limit:10`
39    #[must_use]
40    pub fn parse(&self, query: &str) -> ParsedQuery {
41        let mut text_parts = Vec::new();
42        let mut filters = Vec::new();
43        let mut limit = self.default_limit;
44        let mut scope_prefix = None;
45
46        for part in query.split_whitespace() {
47            if let Some((key, value)) = part.split_once(':') {
48                match key.to_lowercase().as_str() {
49                    "lang" | "language" => {
50                        filters.push(SearchFilter::Language(value.to_string()));
51                    }
52                    "path" => {
53                        if value.contains('*') {
54                            filters.push(SearchFilter::PathGlob(value.to_string()));
55                        } else {
56                            filters.push(SearchFilter::PathPrefix(value.to_string()));
57                        }
58                    }
59                    "type" | "mime" => {
60                        filters.push(SearchFilter::MimeType(value.to_string()));
61                    }
62                    "limit" => {
63                        if let Ok(n) = value.parse() {
64                            limit = n;
65                        }
66                    }
67                    "depth" => {
68                        if let Ok(n) = value.parse() {
69                            filters.push(SearchFilter::MaxDepth(n));
70                        }
71                    }
72                    "scope" => {
73                        scope_prefix = Some(value.to_string());
74                    }
75                    _ => {
76                        // Unknown filter, treat as text
77                        text_parts.push(part);
78                    }
79                }
80            } else {
81                text_parts.push(part);
82            }
83        }
84
85        ParsedQuery {
86            text: text_parts.join(" "),
87            filters,
88            limit,
89            scope_prefix,
90        }
91    }
92}
93
94impl Default for QueryParser {
95    fn default() -> Self {
96        Self::new(10)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_parse_simple() {
106        let parser = QueryParser::default();
107        let result = parser.parse("how to implement auth");
108
109        assert_eq!(result.text, "how to implement auth");
110        assert!(result.filters.is_empty());
111        assert_eq!(result.limit, 10);
112    }
113
114    #[test]
115    fn test_parse_with_filters() {
116        let parser = QueryParser::default();
117        let result = parser.parse("authentication lang:rust path:src/** limit:5");
118
119        assert_eq!(result.text, "authentication");
120        assert_eq!(result.filters.len(), 2);
121        assert_eq!(result.limit, 5);
122    }
123
124    #[test]
125    fn test_parse_empty_query() {
126        let parser = QueryParser::default();
127        let result = parser.parse("");
128
129        assert_eq!(result.text, "");
130        assert!(result.filters.is_empty());
131        assert_eq!(result.limit, 10);
132    }
133
134    #[test]
135    fn test_parse_language_filter() {
136        let parser = QueryParser::default();
137        let result = parser.parse("lang:rust");
138
139        assert_eq!(result.text, "");
140        assert_eq!(result.filters.len(), 1);
141        assert!(matches!(&result.filters[0], SearchFilter::Language(l) if l == "rust"));
142    }
143
144    #[test]
145    fn test_parse_path_prefix() {
146        let parser = QueryParser::default();
147        let result = parser.parse("path:src/lib");
148
149        assert_eq!(result.filters.len(), 1);
150        assert!(matches!(&result.filters[0], SearchFilter::PathPrefix(p) if p == "src/lib"));
151    }
152
153    #[test]
154    fn test_parse_depth_filter() {
155        let parser = QueryParser::default();
156        let result = parser.parse("depth:2 search term");
157
158        assert_eq!(result.text, "search term");
159        assert_eq!(result.filters.len(), 1);
160        assert!(matches!(&result.filters[0], SearchFilter::MaxDepth(2)));
161    }
162
163    #[test]
164    fn test_parse_invalid_limit() {
165        let parser = QueryParser::default();
166        let result = parser.parse("limit:abc search");
167
168        assert_eq!(result.text, "search");
169        assert_eq!(result.limit, 10); // Default preserved when invalid
170    }
171
172    #[test]
173    fn test_parse_unknown_filter_as_text() {
174        let parser = QueryParser::default();
175        let result = parser.parse("unknown:value search");
176
177        assert_eq!(result.text, "unknown:value search");
178        assert!(result.filters.is_empty());
179    }
180
181    #[test]
182    fn test_parse_scope_prefix() {
183        let parser = QueryParser::default();
184        let result = parser.parse("authentication scope:src/auth");
185
186        assert_eq!(result.text, "authentication");
187        assert_eq!(result.scope_prefix.as_deref(), Some("src/auth"));
188        assert!(result.filters.is_empty());
189    }
190}