1use crate::DocumentStore;
16
17pub fn parse_terms(query: &str) -> Vec<String> {
22 query.split_whitespace().map(str::to_lowercase).collect()
23}
24
25pub fn contains_all_terms(haystack_lower: &str, terms: &[String]) -> bool {
32 terms.iter().all(|t| haystack_lower.contains(t.as_str()))
33}
34
35pub 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
60pub 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; 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
84pub struct NoteSearchResult {
86 pub name: String,
87 pub lines: Vec<(usize, String)>,
88}
89
90pub 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 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"))); assert!(!contains_all_terms(&hay, &parse_terms("quick cat")));
134 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 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 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 assert!(search_store(&store, " ").unwrap().is_empty());
207
208 fs::remove_dir_all(&dir).ok();
209 }
210}