use crate::DocumentStore;
pub fn parse_terms(query: &str) -> Vec<String> {
query.split_whitespace().map(str::to_lowercase).collect()
}
pub fn contains_all_terms(haystack_lower: &str, terms: &[String]) -> bool {
terms.iter().all(|t| haystack_lower.contains(t.as_str()))
}
pub fn matching_lines(content: &str, terms: &[String]) -> Vec<(usize, String)> {
if terms.is_empty() {
return Vec::new();
}
content
.lines()
.enumerate()
.filter_map(|(i, line)| {
let lower = line.to_lowercase();
if terms.iter().any(|t| lower.contains(t.as_str())) {
Some((i + 1, line.to_string()))
} else {
None
}
})
.collect()
}
pub fn first_snippet(content: &str, terms: &[String]) -> Option<(usize, String)> {
if terms.is_empty() {
return None;
}
let mut best: Option<(usize, usize, String)> = None; for (i, line) in content.lines().enumerate() {
let lower = line.to_lowercase();
let hits = terms.iter().filter(|t| lower.contains(t.as_str())).count();
if hits == 0 {
continue;
}
if best.as_ref().map(|(b, _, _)| hits > *b).unwrap_or(true) {
best = Some((hits, i + 1, line.to_string()));
}
}
best.map(|(_, no, line)| (no, line.trim().to_string()))
}
pub struct NoteSearchResult {
pub name: String,
pub lines: Vec<(usize, String)>,
}
pub fn search_store(store: &DocumentStore, query: &str) -> Result<Vec<NoteSearchResult>, String> {
let terms = parse_terms(query);
if terms.is_empty() {
return Ok(Vec::new());
}
let mut names = store.list_all_documents()?;
names.sort();
let mut results = Vec::new();
for name in names {
let Ok(doc) = store.load(&name) else { continue };
let lower = doc.content.to_lowercase();
if !contains_all_terms(&lower, &terms) {
continue;
}
let lines = matching_lines(&doc.content, &terms);
results.push(NoteSearchResult { name, lines });
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_terms_lowercases_and_splits() {
assert_eq!(parse_terms(" Hello World "), vec!["hello", "world"]);
assert!(parse_terms(" ").is_empty());
}
#[test]
fn contains_all_terms_is_and_semantics() {
let hay = "the quick brown fox".to_string();
assert!(contains_all_terms(&hay, &parse_terms("quick fox")));
assert!(contains_all_terms(&hay, &parse_terms("QUICK FOX"))); assert!(!contains_all_terms(&hay, &parse_terms("quick cat")));
assert!(contains_all_terms(&hay, &[]));
}
#[test]
fn matching_lines_reports_line_numbers_for_any_term() {
let content = "alpha line\nbeta here\ngamma and beta\n";
let terms = parse_terms("beta");
assert_eq!(
matching_lines(content, &terms),
vec![
(2, "beta here".to_string()),
(3, "gamma and beta".to_string()),
]
);
}
#[test]
fn matching_lines_matches_any_of_multiple_terms() {
let content = "has alpha\nhas beta\nhas neither\n";
let terms = parse_terms("alpha beta");
assert_eq!(
matching_lines(content, &terms),
vec![(1, "has alpha".to_string()), (2, "has beta".to_string())]
);
}
#[test]
fn first_snippet_prefers_the_line_with_most_terms() {
let content = "just alpha here\nalpha and beta together\nbeta alone\n";
let terms = parse_terms("alpha beta");
assert_eq!(
first_snippet(content, &terms),
Some((2, "alpha and beta together".to_string()))
);
}
#[test]
fn first_snippet_trims_and_falls_back_to_none() {
assert_eq!(
first_snippet(" padded match \n", &parse_terms("match")),
Some((1, "padded match".to_string()))
);
assert_eq!(first_snippet("nothing here", &parse_terms("zzz")), None);
}
#[test]
fn search_store_finds_notes_with_all_terms() {
use std::env;
use std::fs;
let dir = env::temp_dir().join("piki-test-search-store");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.md"), "the quick brown fox").unwrap();
fs::write(dir.join("b.md"), "quick notes only").unwrap();
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub/c.md"), "a fox is quick and brown").unwrap();
let store = DocumentStore::new(dir.clone());
let results = search_store(&store, "quick brown").unwrap();
let names: Vec<_> = results.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["a", "sub/c"]);
assert_eq!(
results[0].lines,
vec![(1, "the quick brown fox".to_string())]
);
assert!(search_store(&store, " ").unwrap().is_empty());
fs::remove_dir_all(&dir).ok();
}
}