nodedb_fts/search/
query_parser.rs1#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ParsedQuery {
30 pub positive: Vec<String>,
32 pub negative: Vec<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum InvalidQuery {
39 #[error(
45 "FTS query must contain at least one positive term; \
46 a NOT-only query (e.g. 'NOT python') is not supported"
47 )]
48 NegativeOnly,
49
50 #[error(
56 "FTS query contains parenthesised NOT groups which are not supported; \
57 use flat negations instead, e.g. 'rust NOT python NOT ruby' \
58 rather than 'rust NOT (python OR ruby)'"
59 )]
60 ParenthesesNotSupported,
61}
62
63pub fn parse_query(query: &str) -> Result<ParsedQuery, InvalidQuery> {
75 let mut positive = Vec::new();
76 let mut negative = Vec::new();
77
78 let tokens: Vec<&str> = query.split_whitespace().collect();
79 let mut i = 0;
80 while i < tokens.len() {
81 let tok = tokens[i];
82
83 if tok == "NOT" {
84 i += 1;
86 if i >= tokens.len() {
87 break;
89 }
90 let next = tokens[i];
91 if next.starts_with('(') {
92 return Err(InvalidQuery::ParenthesesNotSupported);
93 }
94 negative.push(next.to_string());
95 } else if let Some(stripped) = tok.strip_prefix('-') {
96 if stripped.is_empty() {
97 positive.push(tok.to_string());
99 } else {
100 negative.push(stripped.to_string());
101 }
102 } else {
103 positive.push(tok.to_string());
104 }
105 i += 1;
106 }
107
108 if positive.is_empty() && !negative.is_empty() {
109 return Err(InvalidQuery::NegativeOnly);
110 }
111
112 Ok(ParsedQuery { positive, negative })
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn pos(terms: &[&str]) -> Vec<String> {
120 terms.iter().map(|s| s.to_string()).collect()
121 }
122
123 #[test]
124 fn simple_positive_terms() {
125 let pq = parse_query("rust python").unwrap();
126 assert_eq!(pq.positive, pos(&["rust", "python"]));
127 assert!(pq.negative.is_empty());
128 }
129
130 #[test]
131 fn not_keyword() {
132 let pq = parse_query("rust NOT python").unwrap();
133 assert_eq!(pq.positive, pos(&["rust"]));
134 assert_eq!(pq.negative, pos(&["python"]));
135 }
136
137 #[test]
138 fn dash_prefix() {
139 let pq = parse_query("rust -python").unwrap();
140 assert_eq!(pq.positive, pos(&["rust"]));
141 assert_eq!(pq.negative, pos(&["python"]));
142 }
143
144 #[test]
145 fn multiple_negations() {
146 let pq = parse_query("rust NOT python NOT ruby").unwrap();
147 assert_eq!(pq.positive, pos(&["rust"]));
148 assert_eq!(pq.negative, pos(&["python", "ruby"]));
149 }
150
151 #[test]
152 fn multiple_dash_negations() {
153 let pq = parse_query("rust -python -ruby").unwrap();
154 assert_eq!(pq.positive, pos(&["rust"]));
155 assert_eq!(pq.negative, pos(&["python", "ruby"]));
156 }
157
158 #[test]
159 fn negative_only_returns_error() {
160 let err = parse_query("NOT python").unwrap_err();
161 assert_eq!(err, InvalidQuery::NegativeOnly);
162 }
163
164 #[test]
165 fn dash_only_negative_returns_error() {
166 let err = parse_query("-python").unwrap_err();
167 assert_eq!(err, InvalidQuery::NegativeOnly);
168 }
169
170 #[test]
171 fn parentheses_after_not_returns_error() {
172 let err = parse_query("rust NOT (python OR ruby)").unwrap_err();
173 assert_eq!(err, InvalidQuery::ParenthesesNotSupported);
174 }
175
176 #[test]
177 fn bare_dash_treated_as_positive() {
178 let pq = parse_query("hello - world").unwrap();
179 assert_eq!(pq.positive, pos(&["hello", "-", "world"]));
180 assert!(pq.negative.is_empty());
181 }
182
183 #[test]
184 fn trailing_not_ignored() {
185 let pq = parse_query("rust NOT").unwrap();
187 assert_eq!(pq.positive, pos(&["rust"]));
188 assert!(pq.negative.is_empty());
189 }
190
191 #[test]
192 fn no_positive_only_negatives_multiple() {
193 let err = parse_query("NOT python NOT ruby").unwrap_err();
194 assert_eq!(err, InvalidQuery::NegativeOnly);
195 }
196}