Files
tolaria/src-tauri/src/search.rs
Luca Rossi 2903196da6 feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64)
* feat: add search backend (qmd CLI) and design file

- Add search.rs: qmd integration for keyword (BM25), semantic (vsearch),
  and hybrid search modes via CLI JSON output
- Register search_vault Tauri command in lib.rs
- Design file with 3 frames: empty, results, no-results states
- Fix pre-existing test failures: install @tauri-apps/plugin-opener,
  add mock in test setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add SearchPanel UI with Cmd+Shift+F shortcut

- SearchPanel component: full-text search overlay with keyword/semantic
  mode toggle, debounced search (200ms), arrow key navigation, result
  count + elapsed time display, note type badges from vault entries
- Cmd+Shift+F shortcut registered in useAppKeyboard
- Mock search_vault handler in mock-tauri for browser dev mode
- SearchResult/SearchResponse types in types.ts
- Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut
- scrollIntoView mock in test setup for jsdom compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct SearchPanel imports for Tauri/browser dual-mode

Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri
with a searchCall wrapper, fixing the TypeScript build error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make test_detect_collection_fallback robust when qmd not installed

* fix: reset localStorage between App tests to prevent view mode state leak

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:09:50 +00:00

237 lines
6.9 KiB
Rust

use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::time::Instant;
#[derive(Debug, Serialize, Deserialize, 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,
}
#[derive(Debug, Deserialize)]
struct QmdResult {
pub file: String,
pub title: String,
pub snippet: String,
pub score: f64,
}
fn find_qmd_binary() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
// qmd://laputa/essay/foo.md → essay/foo.md
let relative = uri
.strip_prefix("qmd://")
.and_then(|s| s.find('/').map(|i| &s[i + 1..]))
.unwrap_or(uri);
format!("{}/{}", vault_path, relative)
}
fn extract_clean_snippet(raw_snippet: &str) -> String {
// qmd snippets start with "@@ -N,N @@ (N before, N after)\n"
// We want just the content lines
let lines: Vec<&str> = raw_snippet.lines().collect();
let content_start = lines.iter().position(|l| !l.starts_with("@@")).unwrap_or(0);
let content: String = lines[content_start..]
.iter()
.filter(|l| !l.starts_with("---"))
.take(3)
.copied()
.collect::<Vec<&str>>()
.join(" ");
// Trim to reasonable length
if content.len() > 200 {
format!("{}...", &content[..200])
} else {
content
}
}
fn detect_collection_name(vault_path: &str) -> String {
// Try to find which qmd collection maps to this vault path
let qmd_bin = match find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
};
let output = Command::new(&qmd_bin).args(["collection", "list"]).output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let vault_name = Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase();
// Look for collection that matches vault directory name
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
// Extract collection name from "name (qmd://name/)"
if let Some(name) = trimmed.split_whitespace().next() {
return name.to_string();
}
}
}
vault_name
}
_ => Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase(),
}
}
pub fn search_vault(
vault_path: &str,
query: &str,
mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
let start = Instant::now();
let qmd_bin =
find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let collection = detect_collection_name(vault_path);
let search_cmd = match mode {
"semantic" => "vsearch",
"hybrid" => "query",
_ => "search", // "keyword" default
};
let limit_str = limit.to_string();
let output = Command::new(&qmd_bin)
.args([
search_cmd,
query,
"--collection",
&collection,
"--json",
"-n",
&limit_str,
])
.output()
.map_err(|e| format!("Failed to run qmd: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd search failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let qmd_results: Vec<QmdResult> =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse qmd output: {}", e))?;
let results: Vec<SearchResult> = qmd_results
.into_iter()
.map(|r| {
let path = qmd_uri_to_vault_path(&r.file, vault_path);
let snippet = extract_clean_snippet(&r.snippet);
SearchResult {
title: r.title,
path,
snippet,
score: r.score,
note_type: None,
}
})
.collect();
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResponse {
results,
elapsed_ms,
query: query.to_string(),
mode: mode.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qmd_uri_to_vault_path() {
assert_eq!(
qmd_uri_to_vault_path("qmd://laputa/essay/foo.md", "/Users/luca/Laputa"),
"/Users/luca/Laputa/essay/foo.md"
);
assert_eq!(
qmd_uri_to_vault_path(
"qmd://laputa/event/2025-10-15-retreat.md",
"/Users/luca/Laputa"
),
"/Users/luca/Laputa/event/2025-10-15-retreat.md"
);
}
#[test]
fn test_extract_clean_snippet() {
let raw = "@@ -2,4 @@ (1 before, 9 after)\naliases:\n - \"Refactoring Retreat\"\n\"Is A\":\n - Event";
let clean = extract_clean_snippet(raw);
assert!(clean.starts_with("aliases:"));
assert!(clean.contains("Refactoring Retreat"));
}
#[test]
fn test_extract_clean_snippet_long() {
let raw = format!("@@ -1,1 @@\n{}", "a".repeat(300));
let clean = extract_clean_snippet(&raw);
assert!(clean.len() <= 203); // 200 + "..."
assert!(clean.ends_with("..."));
}
#[test]
fn test_detect_collection_fallback() {
// With a non-existent vault path, should return either the lowercase dir name
// (if qmd is available and collection list succeeds) or "laputa" (if qmd is not installed).
// Both are valid fallbacks — this test verifies the function doesn't panic.
let name = detect_collection_name("/tmp/test-vault");
assert!(
name == "test-vault" || name == "laputa",
"Expected 'test-vault' or 'laputa', got '{}'",
name
);
}
}