Skip to main content

piki_core/
search.rs

1//! In-memory full-text search over notes.
2//!
3//! A personal wiki is tiny — a few hundred notes, well under a megabyte of text
4//! — so there is deliberately **no index and no external `ripgrep`**: we simply
5//! scan the note text in-process. Reading and scanning the whole corpus is a
6//! handful of milliseconds, which keeps live filtering (see the GUI note picker)
7//! comfortably interactive without the staleness and complexity an index would
8//! add.
9//!
10//! Matching is case-insensitive and **AND-of-terms**: a note matches when
11//! *every* whitespace-separated query term appears somewhere in it. This module
12//! only concerns itself with note *content*; matching against note names is left
13//! to the caller (the GUI picker still fuzzy-matches names on top of this).
14
15use crate::DocumentStore;
16
17/// Split a query into lowercase, whitespace-separated terms, dropping empties.
18///
19/// Callers pass the resulting terms to the matching helpers below; keeping the
20/// terms pre-lowercased means the per-note hot path never re-lowercases them.
21pub fn parse_terms(query: &str) -> Vec<String> {
22    query.split_whitespace().map(str::to_lowercase).collect()
23}
24
25/// True when `haystack_lower` — which the caller must have already lowercased —
26/// contains every term. An empty term list matches everything.
27///
28/// This is the hot path for live filtering: the GUI lowercases each note's body
29/// once when the picker opens and then calls this per keystroke, so it stays a
30/// plain substring scan with no per-keypress allocation.
31pub fn contains_all_terms(haystack_lower: &str, terms: &[String]) -> bool {
32    terms.iter().all(|t| haystack_lower.contains(t.as_str()))
33}
34
35/// Every line of `content` that contains at least one term, returned as
36/// `(1-based line number, line text)` pairs. Case-insensitive.
37///
38/// Note the asymmetry with [`contains_all_terms`]: inclusion of a note is
39/// AND-of-terms (all terms present *somewhere*), but the lines shown are those
40/// matching *any* term — the grep-like behaviour you want when displaying where
41/// the matches are.
42pub fn matching_lines(content: &str, terms: &[String]) -> Vec<(usize, String)> {
43    if terms.is_empty() {
44        return Vec::new();
45    }
46    content
47        .lines()
48        .enumerate()
49        .filter_map(|(i, line)| {
50            let lower = line.to_lowercase();
51            if terms.iter().any(|t| lower.contains(t.as_str())) {
52                Some((i + 1, line.to_string()))
53            } else {
54                None
55            }
56        })
57        .collect()
58}
59
60/// The single best snippet line for `content`: the line matching the most
61/// distinct terms, ties broken by appearing earliest. Returns
62/// `(1-based line number, trimmed line text)`, or `None` when nothing matches.
63///
64/// Used by the GUI picker to show *where* a content-only hit matched, in place
65/// of the note's generic preview.
66pub fn first_snippet(content: &str, terms: &[String]) -> Option<(usize, String)> {
67    if terms.is_empty() {
68        return None;
69    }
70    let mut best: Option<(usize, usize, String)> = None; // (distinct hits, line no, line)
71    for (i, line) in content.lines().enumerate() {
72        let lower = line.to_lowercase();
73        let hits = terms.iter().filter(|t| lower.contains(t.as_str())).count();
74        if hits == 0 {
75            continue;
76        }
77        if best.as_ref().map(|(b, _, _)| hits > *b).unwrap_or(true) {
78            best = Some((hits, i + 1, line.to_string()));
79        }
80    }
81    best.map(|(_, no, line)| (no, line.trim().to_string()))
82}
83
84/// One note's search result: its name and every line that matched a term.
85pub struct NoteSearchResult {
86    pub name: String,
87    pub lines: Vec<(usize, String)>,
88}
89
90/// Search every note in `store` for `query`, returning the notes that contain
91/// *all* terms, sorted by name, each with its matching lines.
92///
93/// This reads every note once; for a personal wiki that is a few milliseconds.
94/// An empty (or all-whitespace) query matches nothing.
95pub fn search_store(store: &DocumentStore, query: &str) -> Result<Vec<NoteSearchResult>, String> {
96    let terms = parse_terms(query);
97    if terms.is_empty() {
98        return Ok(Vec::new());
99    }
100
101    let mut names = store.list_all_documents()?;
102    names.sort();
103
104    let mut results = Vec::new();
105    for name in names {
106        // A note that can't be read (e.g. deleted mid-scan) is simply skipped.
107        let Ok(doc) = store.load(&name) else { continue };
108        let lower = doc.content.to_lowercase();
109        if !contains_all_terms(&lower, &terms) {
110            continue;
111        }
112        let lines = matching_lines(&doc.content, &terms);
113        results.push(NoteSearchResult { name, lines });
114    }
115    Ok(results)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parse_terms_lowercases_and_splits() {
124        assert_eq!(parse_terms("  Hello   World "), vec!["hello", "world"]);
125        assert!(parse_terms("   ").is_empty());
126    }
127
128    #[test]
129    fn contains_all_terms_is_and_semantics() {
130        let hay = "the quick brown fox".to_string();
131        assert!(contains_all_terms(&hay, &parse_terms("quick fox")));
132        assert!(contains_all_terms(&hay, &parse_terms("QUICK FOX"))); // caller lowercases hay; terms lowercased here
133        assert!(!contains_all_terms(&hay, &parse_terms("quick cat")));
134        // Empty term list matches everything.
135        assert!(contains_all_terms(&hay, &[]));
136    }
137
138    #[test]
139    fn matching_lines_reports_line_numbers_for_any_term() {
140        let content = "alpha line\nbeta here\ngamma and beta\n";
141        let terms = parse_terms("beta");
142        assert_eq!(
143            matching_lines(content, &terms),
144            vec![
145                (2, "beta here".to_string()),
146                (3, "gamma and beta".to_string()),
147            ]
148        );
149    }
150
151    #[test]
152    fn matching_lines_matches_any_of_multiple_terms() {
153        let content = "has alpha\nhas beta\nhas neither\n";
154        let terms = parse_terms("alpha beta");
155        // A line needs only one of the terms to be shown.
156        assert_eq!(
157            matching_lines(content, &terms),
158            vec![(1, "has alpha".to_string()), (2, "has beta".to_string())]
159        );
160    }
161
162    #[test]
163    fn first_snippet_prefers_the_line_with_most_terms() {
164        let content = "just alpha here\nalpha and beta together\nbeta alone\n";
165        let terms = parse_terms("alpha beta");
166        assert_eq!(
167            first_snippet(content, &terms),
168            Some((2, "alpha and beta together".to_string()))
169        );
170    }
171
172    #[test]
173    fn first_snippet_trims_and_falls_back_to_none() {
174        assert_eq!(
175            first_snippet("   padded match  \n", &parse_terms("match")),
176            Some((1, "padded match".to_string()))
177        );
178        assert_eq!(first_snippet("nothing here", &parse_terms("zzz")), None);
179    }
180
181    #[test]
182    fn search_store_finds_notes_with_all_terms() {
183        use std::env;
184        use std::fs;
185
186        let dir = env::temp_dir().join("piki-test-search-store");
187        let _ = fs::remove_dir_all(&dir);
188        fs::create_dir_all(&dir).unwrap();
189        fs::write(dir.join("a.md"), "the quick brown fox").unwrap();
190        fs::write(dir.join("b.md"), "quick notes only").unwrap();
191        fs::create_dir_all(dir.join("sub")).unwrap();
192        fs::write(dir.join("sub/c.md"), "a fox is quick and brown").unwrap();
193
194        let store = DocumentStore::new(dir.clone());
195        let results = search_store(&store, "quick brown").unwrap();
196
197        // Both a.md and sub/c.md contain "quick" AND "brown"; b.md does not.
198        let names: Vec<_> = results.iter().map(|r| r.name.as_str()).collect();
199        assert_eq!(names, vec!["a", "sub/c"]);
200        assert_eq!(
201            results[0].lines,
202            vec![(1, "the quick brown fox".to_string())]
203        );
204
205        // Empty query matches nothing.
206        assert!(search_store(&store, "   ").unwrap().is_empty());
207
208        fs::remove_dir_all(&dir).ok();
209    }
210}