1use okf_core::{ConceptId, Status, TrustTier};
9use std::str::FromStr;
10
11#[derive(Clone, Debug)]
13pub struct SearchEntry {
14 pub id: ConceptId,
16 pub title: String,
18 pub description: String,
20 pub tags: Vec<String>,
22 pub headings: Vec<String>,
24 pub type_: String,
26 pub tier: TrustTier,
28 pub status: Status,
30 pub stale: bool,
32 pub broken: bool,
34}
35
36#[derive(Clone, Debug, Default)]
38pub struct SearchIndex {
39 pub entries: Vec<SearchEntry>,
41}
42
43#[derive(Clone, Debug)]
45pub struct SearchHit {
46 pub id: ConceptId,
48 pub heading: Option<String>,
50 pub score: i32,
52 pub indices: Vec<usize>,
54 pub label: String,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum Filter {
61 Tag(String),
63 Type(String),
65 Tier(TrustTier),
67 Status(String),
69 Stale,
71 Broken,
73}
74
75#[derive(Clone, Debug, Default)]
77pub struct Query {
78 pub text: String,
80 pub filters: Vec<Filter>,
82}
83
84impl Query {
85 #[must_use]
89 pub fn parse(raw: &str) -> Self {
90 let mut text_terms: Vec<&str> = Vec::new();
91 let mut filters = Vec::new();
92 for term in raw.split_whitespace() {
93 if let Some(tag) = term.strip_prefix('#') {
94 if !tag.is_empty() {
95 filters.push(Filter::Tag(tag.to_string()));
96 continue;
97 }
98 } else if let Some(t) = term.strip_prefix("type:") {
99 filters.push(Filter::Type(t.to_string()));
100 continue;
101 } else if let Some(t) = term.strip_prefix("tier:") {
102 if let Ok(tier) = TrustTier::from_str(t) {
103 filters.push(Filter::Tier(tier));
104 continue;
105 }
106 } else if let Some(s) = term.strip_prefix("status:") {
107 filters.push(Filter::Status(s.to_string()));
108 continue;
109 } else if term == "is:stale" {
110 filters.push(Filter::Stale);
111 continue;
112 } else if term == "is:broken" {
113 filters.push(Filter::Broken);
114 continue;
115 }
116 text_terms.push(term);
117 }
118 Self {
119 text: text_terms.join(" "),
120 filters,
121 }
122 }
123
124 #[must_use]
126 pub fn matches_filters(&self, entry: &SearchEntry) -> bool {
127 self.filters.iter().all(|f| match f {
128 Filter::Tag(tag) => entry.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)),
129 Filter::Type(t) => entry.type_.eq_ignore_ascii_case(t),
130 Filter::Tier(tier) => entry.tier == *tier,
131 Filter::Status(s) => entry.status.as_str().eq_ignore_ascii_case(s),
132 Filter::Stale => entry.stale,
133 Filter::Broken => entry.broken,
134 })
135 }
136}
137
138impl SearchIndex {
139 #[must_use]
143 pub fn search(&self, raw_query: &str, limit: usize) -> Vec<SearchHit> {
144 let query = Query::parse(raw_query);
145 let mut hits: Vec<SearchHit> = Vec::new();
146 for entry in &self.entries {
147 if !query.matches_filters(entry) {
148 continue;
149 }
150 if query.text.is_empty() {
151 hits.push(SearchHit {
152 id: entry.id.clone(),
153 heading: None,
154 score: 0,
155 indices: Vec::new(),
156 label: entry.id.to_string(),
157 });
158 continue;
159 }
160 let id_str = entry.id.to_string();
163 let mut best: Option<SearchHit> = None;
164 let candidates: Vec<&str> = std::iter::once(id_str.as_str())
165 .chain(std::iter::once(entry.title.as_str()))
166 .chain(std::iter::once(entry.description.as_str()))
167 .chain(entry.tags.iter().map(String::as_str))
168 .collect();
169 for hay in candidates {
170 if let Some((score, indices)) = fuzzy_match(&query.text, hay)
171 && best.as_ref().is_none_or(|b| score > b.score)
172 {
173 best = Some(SearchHit {
174 id: entry.id.clone(),
175 heading: None,
176 score,
177 indices,
178 label: hay.to_string(),
179 });
180 }
181 }
182 if let Some(hit) = best {
183 hits.push(hit);
184 }
185 for heading in &entry.headings {
187 if let Some((score, indices)) = fuzzy_match(&query.text, heading) {
188 hits.push(SearchHit {
189 id: entry.id.clone(),
190 heading: Some(heading.clone()),
191 score: score - 1,
193 indices,
194 label: heading.clone(),
195 });
196 }
197 }
198 }
199 hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.id.cmp(&b.id)));
200 hits.truncate(limit);
201 hits
202 }
203}
204
205const BONUS_BOUNDARY: i32 = 16;
206const BONUS_CAMEL: i32 = 12;
207const BONUS_CONSECUTIVE: i32 = 8;
208const BONUS_FIRST_CHAR: i32 = 20;
209const PENALTY_GAP_START: i32 = -3;
210const PENALTY_GAP_EXTEND: i32 = -1;
211const MATCH_SCORE: i32 = 16;
212
213#[must_use]
224pub fn fuzzy_match(query: &str, haystack: &str) -> Option<(i32, Vec<usize>)> {
225 const NEG: i32 = i32::MIN / 4;
226
227 let query_chars: Vec<char> = query.chars().filter(|c| !c.is_whitespace()).collect();
228 let haystack_chars: Vec<char> = haystack.chars().collect();
229 if query_chars.is_empty() {
230 return Some((0, Vec::new()));
231 }
232 if query_chars.len() > haystack_chars.len() {
233 return None;
234 }
235
236 let eq = |qc: char, hc: char| {
237 if qc.is_uppercase() {
238 qc == hc
239 } else {
240 qc.to_lowercase().eq(hc.to_lowercase())
241 }
242 };
243 let bonus_at = |idx: usize| -> i32 {
244 if idx == 0 {
245 return BONUS_FIRST_CHAR;
246 }
247 let prev = haystack_chars[idx - 1];
248 if matches!(prev, '/' | '_' | '-' | '.' | ':') || prev.is_whitespace() {
249 BONUS_BOUNDARY
250 } else if prev.is_lowercase() && haystack_chars[idx].is_uppercase() {
251 BONUS_CAMEL
252 } else {
253 0
254 }
255 };
256
257 let (q_len, h_len) = (query_chars.len(), haystack_chars.len());
258 let mut dp = vec![vec![NEG; h_len]; q_len];
261 let mut parent = vec![vec![usize::MAX; h_len]; q_len];
262
263 for (j, &hc) in haystack_chars.iter().enumerate() {
264 if eq(query_chars[0], hc) {
265 dp[0][j] = MATCH_SCORE + bonus_at(j);
268 }
269 }
270 for i in 1..q_len {
271 let mut best_prev = NEG;
275 let mut best_prev_j = usize::MAX;
276 for j in i..h_len {
277 if best_prev > NEG {
278 best_prev += PENALTY_GAP_EXTEND;
279 }
280 if j >= 2 && dp[i - 1][j - 2] > NEG {
281 let candidate = dp[i - 1][j - 2] + PENALTY_GAP_START;
282 if candidate > best_prev {
283 best_prev = candidate;
284 best_prev_j = j - 2;
285 }
286 }
287 if !eq(query_chars[i], haystack_chars[j]) {
288 continue;
289 }
290 let consecutive = if dp[i - 1][j - 1] > NEG {
291 dp[i - 1][j - 1] + MATCH_SCORE + BONUS_CONSECUTIVE + bonus_at(j)
292 } else {
293 NEG
294 };
295 let gapped = if best_prev > NEG {
296 best_prev + MATCH_SCORE + bonus_at(j)
297 } else {
298 NEG
299 };
300 if consecutive >= gapped {
301 if consecutive > NEG {
302 dp[i][j] = consecutive;
303 parent[i][j] = j - 1;
304 }
305 } else {
306 dp[i][j] = gapped;
307 parent[i][j] = best_prev_j;
308 }
309 }
310 }
311
312 let (mut best_j, mut best_score) = (usize::MAX, NEG);
313 for (j, &score) in dp[q_len - 1].iter().enumerate() {
314 if score > best_score {
315 best_score = score;
316 best_j = j;
317 }
318 }
319 if best_j == usize::MAX {
320 return None;
321 }
322 let mut indices = vec![0usize; q_len];
323 let mut cursor = best_j;
324 for i in (0..q_len).rev() {
325 indices[i] = cursor;
326 if i > 0 {
327 cursor = parent[i][cursor];
328 }
329 }
330 Some((best_score, indices))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn matches_segment_initials() {
339 let (score, idx) = fuzzy_match("pte", "policies/travel_expenses").unwrap();
340 assert!(score > 0);
341 assert_eq!(idx, vec![0, 9, 16]);
342 }
343
344 #[test]
345 fn prefers_boundary_matches() {
346 let (loose, _) = fuzzy_match("te", "notes").unwrap();
347 let (boundary, _) = fuzzy_match("te", "travel_expenses").unwrap();
348 assert!(boundary > loose);
349 }
350
351 #[test]
352 fn non_subsequence_is_none() {
353 assert!(fuzzy_match("xyz", "policies").is_none());
354 assert!(fuzzy_match("aa", "a").is_none());
355 }
356
357 #[test]
358 fn smart_case() {
359 assert!(fuzzy_match("Pol", "policies").is_none());
360 assert!(fuzzy_match("pol", "Policies").is_some());
361 }
362
363 #[test]
364 fn query_syntax_parses_filters() {
365 let q = Query::parse("trav #hr type:Policy tier:unverified is:stale is:broken");
366 assert_eq!(q.text, "trav");
367 assert_eq!(q.filters.len(), 5);
368 assert!(q.filters.contains(&Filter::Tag("hr".into())));
369 assert!(q.filters.contains(&Filter::Type("Policy".into())));
370 assert!(q.filters.contains(&Filter::Tier(TrustTier::Unverified)));
371 assert!(q.filters.contains(&Filter::Stale));
372 assert!(q.filters.contains(&Filter::Broken));
373 }
374}