Skip to main content

lb_rs/search/
content.rs

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    /// Update the active filter and refresh results for the current query.
110    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    /// Update the search query. Results available via `results()`.
116    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    /// Rebuild `results` from the current query and filter. Shared by `query` and
126    /// `update_filter`.
127    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    /// Get search results.
213    pub fn results(&self) -> &[SearchResult] {
214        &self.results
215    }
216
217    /// How long it took to build the search index.
218    pub fn build_time(&self) -> Duration {
219        self.build_time
220    }
221
222    /// Get document content by ID.
223    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    /// Extract snippet with context. Returns (prefix, matched, suffix).
231    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        // Snap to whitespace
250        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}