Files
tolaria/src-tauri/src/search.rs
Test e581ad3691 refactor: remove Trash system — delete is now permanent with confirm modal
Remove all vestiges of the abandoned Trash system: trashed/trashedAt fields
from types, frontmatter parsing, sidebar filtering, editor banners, inspector
components, mock data, and all related tests. Delete is already permanent via
useDeleteActions with a confirmation dialog. Notes with trashed:true in
existing vault frontmatter are now treated as normal notes (the flag is
ignored by the parser).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:21:56 +02:00

170 lines
4.5 KiB
Rust

use serde::Serialize;
use std::path::Path;
use std::time::Instant;
use walkdir::WalkDir;
#[derive(Debug, Serialize, Clone)]
pub struct SearchResult {
pub title: String,
pub path: String,
pub snippet: String,
pub score: f64,
pub note_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub elapsed_ms: u64,
pub query: String,
pub mode: String,
}
fn extract_snippet(content: &str, query_lower: &str) -> String {
let content_lower = content.to_lowercase();
let pos = match content_lower.find(query_lower) {
Some(p) => p,
None => return String::new(),
};
let start = content[..pos]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(pos.saturating_sub(60));
let end = content[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or_else(|| (pos + 120).min(content.len()));
let snippet = &content[start..end];
if snippet.len() > 200 {
format!("{}", &snippet[..200])
} else {
snippet.to_string()
}
}
fn score_match(title_lower: &str, content_lower: &str, query_lower: &str) -> f64 {
let title_exact = title_lower.contains(query_lower);
let title_word = title_lower.split_whitespace().any(|w| w == query_lower);
let content_count = content_lower.matches(query_lower).count();
let mut score = 0.0;
if title_word {
score += 10.0;
} else if title_exact {
score += 5.0;
}
score += (content_count as f64).min(20.0) * 0.5;
score
}
pub fn search_vault(
vault_path: &str,
query: &str,
_mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
let start = Instant::now();
let query_lower = query.to_lowercase();
let vault_dir = Path::new(vault_path);
let mut results: Vec<SearchResult> = Vec::new();
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "md") {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let content_lower = content.to_lowercase();
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let title_lower = title.to_lowercase();
if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) {
continue;
}
let score = score_match(&title_lower, &content_lower, &query_lower);
let snippet = extract_snippet(&content, &query_lower);
let full_path = path.to_string_lossy().to_string();
results.push(SearchResult {
title,
path: full_path,
snippet,
score,
note_type: None,
});
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResponse {
results,
elapsed_ms,
query: query.to_string(),
mode: "keyword".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_snippet_basic() {
let content = "line one\nline with keyword here\nline three";
let snippet = extract_snippet(content, "keyword");
assert!(snippet.contains("keyword"));
}
#[test]
fn test_extract_snippet_no_match() {
let snippet = extract_snippet("nothing here", "missing");
assert!(snippet.is_empty());
}
#[test]
fn test_score_match_title_word() {
let score = score_match("my keyword", "", "keyword");
assert!(score >= 10.0);
}
#[test]
fn test_score_match_content_only() {
let score = score_match("unrelated", "some keyword text keyword", "keyword");
assert!(score > 0.0);
assert!(score < 10.0);
}
#[test]
fn test_extract_snippet_long() {
let long_line = "a".repeat(300);
let content = format!("start\n{}keyword{}\nend", long_line, long_line);
let snippet = extract_snippet(&content, "keyword");
assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8)
}
}