1use super::path::split_path;
2use super::{ContentMatch, SearchFilter, SearchResult, build_descendants};
3use crate::blocking::Lb;
4use crate::model::file::File;
5use std::cmp::Reverse;
6use std::collections::{HashMap, HashSet};
7use std::ops::Range;
8use std::sync::{Arc, Mutex};
9use std::thread;
10use std::time::{Duration, Instant};
11use uuid::Uuid;
12
13struct Document {
14 file: File,
15 filename: String,
16 parent_path: String,
17 lowercased_content: String,
18}
19
20pub struct ContentSearcher {
21 documents: Vec<Document>,
22 results: Vec<SearchResult>,
23 submitted_query: String,
24 build_time: Duration,
25 descendants: HashMap<Uuid, Vec<Uuid>>,
26 path_to_id: HashMap<String, Uuid>,
27 filter_ids: Option<HashSet<Uuid>>,
28}
29
30impl ContentSearcher {
31 pub fn new(lb: &Lb) -> Self {
32 let start = Instant::now();
33 let metas = lb.list_metadatas().unwrap_or_default();
34 let paths = Arc::new(lb.list_paths_with_ids(None).unwrap_or_default());
35
36 let descendants = build_descendants(&metas);
37 let path_to_id = paths.iter().map(|(id, path)| (path.clone(), *id)).collect();
38
39 let md_files: Vec<File> = metas
40 .into_iter()
41 .filter(|m| m.is_document() && m.name.ends_with(".md"))
42 .collect();
43
44 let queue = Arc::new(Mutex::new(md_files));
45 let documents = Arc::new(Mutex::new(Vec::<Document>::new()));
46
47 let handles: Vec<_> = (0..thread::available_parallelism()
48 .map(|n| n.get())
49 .unwrap_or(4))
50 .map(|_| {
51 let queue = queue.clone();
52 let paths = paths.clone();
53 let documents = documents.clone();
54 let lb = lb.clone();
55 thread::spawn(move || {
56 loop {
57 let Some(meta) = queue.lock().unwrap().pop() else {
58 return;
59 };
60
61 let id = meta.id;
62 let doc = lb
63 .read_document(meta.id, false)
64 .ok()
65 .and_then(|bytes| String::from_utf8(bytes).ok());
66
67 if let Some(content) = doc {
68 let path = paths
69 .iter()
70 .find(|(i, _)| *i == id)
71 .map(|(_, p)| p.clone())
72 .unwrap_or_default();
73
74 let (parent, name) = split_path(&path);
75
76 documents.lock().unwrap().push(Document {
77 file: meta,
78 filename: name.to_string(),
79 parent_path: parent.to_string(),
80 lowercased_content: content.to_lowercase(),
81 });
82 }
83 }
84 })
85 })
86 .collect();
87
88 for h in handles {
89 h.join().unwrap();
90 }
91
92 let documents = Arc::try_unwrap(documents)
93 .ok()
94 .expect("all workers joined")
95 .into_inner()
96 .unwrap();
97
98 Self {
99 documents,
100 results: Vec::new(),
101 submitted_query: String::new(),
102 build_time: start.elapsed(),
103 descendants,
104 path_to_id,
105 filter_ids: None,
106 }
107 }
108
109 pub fn update_filter(&mut self, filter: Option<SearchFilter>) {
111 self.filter_ids = super::resolve_filter(filter, &self.path_to_id, &self.descendants);
112 self.rebuild();
113 }
114
115 pub fn query(&mut self, input: &str) {
117 let query = input.trim().to_lowercase();
118 if self.submitted_query == query {
119 return;
120 }
121 self.submitted_query = query;
122 self.rebuild();
123 }
124
125 fn rebuild(&mut self) {
128 self.results.clear();
129 if self.submitted_query.is_empty() {
130 return;
131 }
132
133 let query = &self.submitted_query;
134 let words: Vec<&str> = query.split_whitespace().collect();
135
136 let mut scored = Vec::new();
137
138 for doc in &self.documents {
139 if let Some(ids) = &self.filter_ids {
140 if !ids.contains(&doc.file.id) {
141 continue;
142 }
143 }
144
145 let parent = doc.parent_path.to_lowercase();
146 let name = doc.filename.to_lowercase();
147 let (path, name_start) = if parent == "/" {
148 (format!("/{name}"), 1)
149 } else {
150 let name_start = parent.len() + 1;
151 (format!("{parent}/{name}"), name_start)
152 };
153
154 let mut matched_words = vec![false; words.len()];
155 let path_matches = collect_matches(&path, query, &words, &mut matched_words);
156 let content_matches =
157 collect_matches(&doc.lowercased_content, query, &words, &mut matched_words);
158
159 let all_words_matched = matched_words.iter().all(|&m| m);
160 let has_match = !path_matches.is_empty() || !content_matches.is_empty();
161
162 if all_words_matched && has_match {
163 let filename_exact = path_matches
164 .iter()
165 .filter(|m| m.exact && m.range.end > name_start)
166 .count();
167 let filename_partial = path_matches
168 .iter()
169 .filter(|m| !m.exact && m.range.end > name_start)
170 .count();
171 let content_exact = content_matches.iter().filter(|m| m.exact).count();
172
173 let tier = if filename_exact > 0 {
174 0
175 } else if content_exact > 0 {
176 1
177 } else if filename_partial > 0 {
178 2
179 } else {
180 3
181 };
182
183 let key = (
184 tier,
185 Reverse(filename_exact),
186 Reverse(content_exact),
187 Reverse(filename_partial),
188 Reverse(path_matches.len()),
189 Reverse(content_matches.len()),
190 path,
191 );
192
193 scored.push((
194 key,
195 SearchResult {
196 id: doc.file.id,
197 filename: doc.filename.clone(),
198 parent_path: doc.parent_path.clone(),
199 is_folder: false,
200 path_indices: Vec::new(),
201 path_matches,
202 content_matches,
203 },
204 ));
205 }
206 }
207
208 scored.sort_by(|a, b| a.0.cmp(&b.0));
209 self.results = scored.into_iter().map(|(_, r)| r).collect();
210 }
211
212 pub fn results(&self) -> &[SearchResult] {
214 &self.results
215 }
216
217 pub fn build_time(&self) -> Duration {
219 self.build_time
220 }
221
222 pub fn content(&self, id: Uuid) -> Option<&str> {
224 self.documents
225 .iter()
226 .find(|d| d.file.id == id)
227 .map(|d| d.lowercased_content.as_str())
228 }
229
230 pub fn snippet(
232 &self, id: Uuid, range: &Range<usize>, context_chars: usize,
233 ) -> Option<(&str, &str, &str)> {
234 let content = self.content(id)?;
235 let char_indices: Vec<(usize, char)> = content.char_indices().collect();
236
237 let match_start_ci = char_indices
238 .iter()
239 .position(|(byte, _)| *byte >= range.start)
240 .unwrap_or(char_indices.len());
241 let match_end_ci = char_indices
242 .iter()
243 .position(|(byte, _)| *byte >= range.end)
244 .unwrap_or(char_indices.len());
245
246 let start_ci = match_start_ci.saturating_sub(context_chars);
247 let end_ci = (match_end_ci + context_chars).min(char_indices.len());
248
249 let mut start = start_ci;
251 if start > 0 {
252 for (i, &(_, c)) in char_indices
253 .iter()
254 .enumerate()
255 .take(match_start_ci)
256 .skip(start)
257 {
258 if c.is_whitespace() {
259 start = i + 1;
260 break;
261 }
262 }
263 }
264
265 let mut end = end_ci;
266 if end < char_indices.len() {
267 for i in (match_end_ci..end).rev() {
268 if char_indices[i].1.is_whitespace() {
269 end = i;
270 break;
271 }
272 }
273 }
274
275 let get_byte = |ci: usize| {
276 char_indices
277 .get(ci)
278 .map(|(b, _)| *b)
279 .unwrap_or(content.len())
280 };
281
282 let start_byte = get_byte(start);
283 let match_start_byte = get_byte(match_start_ci);
284 let match_end_byte = get_byte(match_end_ci);
285 let end_byte = get_byte(end);
286
287 Some((
288 &content[start_byte..match_start_byte],
289 &content[match_start_byte..match_end_byte],
290 &content[match_end_byte..end_byte],
291 ))
292 }
293}
294
295fn collect_matches(
296 haystack: &str, query: &str, words: &[&str], matched_words: &mut [bool],
297) -> Vec<ContentMatch> {
298 let mut matches = Vec::new();
299
300 for (idx, _) in haystack.match_indices(query) {
301 matches.push(ContentMatch { range: idx..idx + query.len(), exact: true });
302 }
303
304 for (wi, word) in words.iter().enumerate() {
305 for (idx, _) in haystack.match_indices(word) {
306 matched_words[wi] = true;
307 if matches.iter().any(|m| m.range.contains(&idx)) {
308 continue;
309 }
310 matches.push(ContentMatch { range: idx..idx + word.len(), exact: false });
311 }
312 }
313
314 matches
315}