refactor: remove QMD semantic indexing — keep keyword search only

Remove the entire QMD search engine (semantic indexing, hybrid search,
vector embeddings) and replace with a simple walkdir-based keyword
search. QMD never worked reliably in production, added bundle bloat,
and showed "Index failed" in the status bar.

What changed:
- Rust: deleted indexing.rs, rewrote search.rs as pure keyword search
- Frontend: removed useIndexing hook, IndexingBadge, hybrid search
- Menu: removed "Reindex Vault" command from palette and menu bar
- Config: removed qmd from bundle resources and build script
- Deleted tools/qmd/ (QMD CLI tool, MCP server, tests)
- Updated docs (ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-24 16:18:05 +01:00
parent 50ad7e0e8c
commit ecbb94ae83
42 changed files with 148 additions and 16520 deletions

View File

@@ -10,12 +10,11 @@ use crate::git::{
PulseCommit,
};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::vault::{RenameResult, VaultEntry};
use crate::vault_list::VaultList;
use crate::{frontmatter, git, github, indexing, menu, search, vault, vault_list};
use crate::{frontmatter, git, github, menu, search, vault, vault_list};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -42,20 +41,6 @@ pub fn parse_build_label(version: &str) -> String {
}
}
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
@@ -423,7 +408,7 @@ pub async fn stream_claude_agent(
.map_err(|e| format!("Task failed: {e}"))?
}
// ── Search & indexing commands ──────────────────────────────────────────────
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
@@ -439,66 +424,6 @@ pub async fn search_vault(
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
pub fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
pub async fn start_indexing(
app_handle: tauri::AppHandle,
vault_path: String,
) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {e}"));
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
// ── MCP commands ────────────────────────────────────────────────────────────
#[tauri::command]

View File

@@ -1,914 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
/// Resolved qmd binary location: path + optional working directory.
/// The working dir is required for the bundled binary to find its node_modules.
#[derive(Debug, Clone)]
pub struct QmdBinary {
pub path: String,
pub work_dir: Option<PathBuf>,
}
impl QmdBinary {
/// Create a `Command` pre-configured with the correct working directory.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.path);
if let Some(ref dir) = self.work_dir {
cmd.current_dir(dir);
}
cmd
}
}
static QMD_CACHE: Mutex<Option<QmdBinary>> = Mutex::new(None);
/// Locate the qmd binary, checking bundled resource first, then known locations.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<QmdBinary> {
if let Ok(guard) = QMD_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
}
let result = find_qmd_binary_uncached();
if let Some(ref bin) = result {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = Some(bin.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
// 1. Check bundled binary (Tauri resource)
if let Some(bin) = find_bundled_qmd() {
return Some(bin);
}
// 2. Check known system locations
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(QmdBinary {
path: candidate,
work_dir: None,
});
}
}
// 3. Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(QmdBinary {
path: String::from_utf8_lossy(&o.stdout).trim().to_string(),
work_dir: None,
})
} else {
None
}
})
}
/// Look for the bundled qmd binary inside the app bundle or dev resources.
fn find_bundled_qmd() -> Option<QmdBinary> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/Contents/Resources/qmd/qmd
let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&bundle_dir) {
return Some(bin);
}
// Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("qmd");
if let Some(bin) = prepare_bundled_dir(&dev_dir) {
return Some(bin);
}
// Dev mode fallback: walk up from exe_dir to find the project root
let mut dir = exe_dir.to_path_buf();
for _ in 0..6 {
let qmd_dir = dir.join("resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&qmd_dir) {
return Some(bin);
}
if !dir.pop() {
break;
}
}
None
}
/// Validate a bundled qmd directory and prepare the binary for execution.
/// Sets execute permissions and removes macOS quarantine attributes.
fn prepare_bundled_dir(qmd_dir: &Path) -> Option<QmdBinary> {
let qmd_path = qmd_dir.join("qmd");
if !qmd_path.exists() {
return None;
}
ensure_executable(&qmd_path);
// Remove macOS quarantine attributes that block execution of bundled binaries
#[cfg(target_os = "macos")]
{
let _ = Command::new("xattr")
.args(["-rd", "com.apple.quarantine"])
.arg(qmd_dir)
.output();
}
Some(QmdBinary {
path: qmd_path.to_string_lossy().to_string(),
work_dir: Some(qmd_dir.to_path_buf()),
})
}
/// Ensure a file has execute permission.
#[cfg(unix)]
fn ensure_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
let mut perms = metadata.permissions();
perms.set_mode(mode | 0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
fn ensure_executable(_path: &Path) {}
/// Try to install qmd globally using bun. Returns Ok if installation succeeded.
pub fn try_auto_install_qmd() -> Result<(), String> {
let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?;
log::info!("Auto-installing qmd via bun...");
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to run bun install: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("bun install -g qmd failed: {stderr}"));
}
// Clear cache so the newly installed binary is discovered
clear_qmd_cache();
log::info!("qmd auto-install succeeded");
Ok(())
}
/// Locate bun binary for auto-installing qmd.
fn find_bun() -> Option<PathBuf> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun")),
Some(PathBuf::from("/opt/homebrew/bin/bun")),
Some(PathBuf::from("/usr/local/bin/bun")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(PathBuf::from(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
))
} else {
None
}
})
}
/// Clear the cached qmd binary (e.g. after path changes or installation).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = None;
}
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexStatus {
pub available: bool,
pub qmd_installed: bool,
pub collection_exists: bool,
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
pub last_indexed_commit: Option<String>,
pub last_indexed_at: Option<u64>,
}
// --- Index metadata persistence ---
#[derive(Debug, Serialize, Deserialize, Default)]
struct IndexMetadata {
#[serde(default)]
last_indexed_commit: Option<String>,
#[serde(default)]
last_indexed_at: Option<u64>,
}
fn index_metadata_path(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".laputa-index.json")
}
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
let path = index_metadata_path(vault_path);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
let path = index_metadata_path(vault_path);
let json =
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
}
/// Get the current HEAD commit hash for a vault.
fn get_head_commit(vault_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(vault_path)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Record the current HEAD as the last indexed commit.
fn stamp_index_commit(vault_path: &str) {
if let Some(commit) = get_head_commit(vault_path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = IndexMetadata {
last_indexed_commit: Some(commit),
last_indexed_at: Some(now),
};
let _ = save_index_metadata(vault_path, &meta);
}
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let meta = load_index_metadata(vault_path);
let qmd = match find_qmd_binary() {
Some(b) => b,
None => {
return IndexStatus {
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: meta.last_indexed_commit,
last_indexed_at: meta.last_indexed_at,
}
}
};
let vault_name = vault_dir_name(vault_path);
let output = qmd.command().args(["status"]).output();
let mut status = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
}
_ => IndexStatus {
available: false,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: None,
last_indexed_at: None,
},
};
status.last_indexed_commit = meta.last_indexed_commit;
status.last_indexed_at = meta.last_indexed_at;
status
}
fn vault_dir_name(vault_path: &str) -> String {
Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase()
}
fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus {
let mut collection_exists = false;
let mut indexed_count = 0;
let mut embedded_count = 0;
let mut pending_embed = 0;
// Look for collection section matching vault name
let mut in_vault_section = false;
for line in status_output.lines() {
let trimmed = line.trim();
// Collection headers look like: " laputa (qmd://laputa/)"
if trimmed.contains(&format!("qmd://{vault_name}/")) {
collection_exists = true;
in_vault_section = true;
continue;
}
// New collection section starts
if trimmed.contains("qmd://") && !trimmed.contains(vault_name) {
in_vault_section = false;
continue;
}
if in_vault_section {
if let Some(count_str) = extract_count_from_line(trimmed, "Files:") {
indexed_count = count_str;
}
}
}
// Global counts from the Documents section
for line in status_output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Total:") {
if let Some(n) = extract_first_number(trimmed) {
if embedded_count == 0 && indexed_count == 0 {
indexed_count = n;
}
}
} else if trimmed.starts_with("Vectors:") {
if let Some(n) = extract_first_number(trimmed) {
embedded_count = n;
}
} else if trimmed.starts_with("Pending:") {
if let Some(n) = extract_first_number(trimmed) {
pending_embed = n;
}
}
}
IndexStatus {
available: true,
qmd_installed: true,
collection_exists,
indexed_count,
embedded_count,
pending_embed,
last_indexed_commit: None,
last_indexed_at: None,
}
}
fn extract_count_from_line(line: &str, prefix: &str) -> Option<usize> {
if !line.starts_with(prefix) {
return None;
}
extract_first_number(line)
}
fn extract_first_number(s: &str) -> Option<usize> {
s.split_whitespace()
.find_map(|word| word.parse::<usize>().ok())
}
/// Ensure a qmd collection exists for this vault. Creates one if missing.
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains(&format!("qmd://{vault_name}/")) {
return Ok(());
}
}
// Create collection
qmd.command()
.args([
"collection",
"add",
vault_path,
"--name",
&vault_name,
"--mask",
"**/*.md",
])
.output()
.map_err(|e| format!("Failed to create collection: {e}"))?;
Ok(())
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexingProgress {
pub phase: String,
pub current: usize,
pub total: usize,
pub done: bool,
pub error: Option<String>,
}
/// Run full indexing: update + embed. Returns progress updates via callback.
pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
where
F: Fn(IndexingProgress),
{
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
ensure_collection(vault_path)?;
let vault_name = vault_dir_name(vault_path);
// Phase 1: update (scan files) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
total: 0,
done: false,
error: None,
});
let update_output = qmd
.command()
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
if !update_output.status.success() {
let stderr = String::from_utf8_lossy(&update_output.stderr);
let err = format!("qmd update failed: {stderr}");
on_progress(IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(err.clone()),
});
return Err(err);
}
// Parse update output for counts
let update_stdout = String::from_utf8_lossy(&update_output.stdout);
let total = parse_indexed_count(&update_stdout);
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: total,
total,
done: false,
error: None,
});
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
total,
done: false,
error: None,
});
let embed_output = qmd
.command()
.args(["embed", "-c", &vault_name])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
if !embed_output.status.success() {
let stderr = String::from_utf8_lossy(&embed_output.stderr);
// Embedding failure is non-fatal — keyword search still works
log::warn!("qmd embed failed (keyword search still works): {stderr}");
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: Some("Embedding failed — keyword search only".to_string()),
});
return Ok(());
}
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: None,
});
Ok(())
}
fn parse_indexed_count(update_output: &str) -> usize {
// qmd update output typically contains lines like "Indexed 9078 files"
for line in update_output.lines() {
if let Some(n) = extract_first_number(line) {
return n;
}
}
0
}
/// Run incremental update for a single file change.
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if list_output.status.success() {
let stdout = String::from_utf8_lossy(&list_output.stdout);
if !stdout.contains(&format!("qmd://{vault_name}/")) {
// Collection doesn't exist yet — skip incremental, full index needed
return Ok(());
}
}
let output = qmd
.command()
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd update failed: {stderr}"));
}
stamp_index_commit(vault_path);
Ok(())
}
/// Check if HEAD has advanced past the last indexed commit.
#[cfg(test)]
fn needs_reindex_after_sync(vault_path: &str) -> bool {
let meta = load_index_metadata(vault_path);
let head = get_head_commit(vault_path);
match (meta.last_indexed_commit, head) {
(Some(last), Some(current)) => last != current,
(None, Some(_)) => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qmd_binary_command_sets_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: Some(PathBuf::from("/tmp")),
};
let cmd = qmd.command();
let dbg = format!("{:?}", cmd);
assert!(dbg.contains("/bin/echo"));
}
#[test]
fn qmd_binary_command_no_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: None,
};
let output = qmd.command().arg("hello").output().unwrap();
assert!(output.status.success());
}
#[test]
fn vault_dir_name_extracts_last_segment() {
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault");
}
#[test]
fn vault_dir_name_fallback() {
assert_eq!(vault_dir_name(""), "laputa");
}
#[test]
fn extract_first_number_works() {
assert_eq!(
extract_first_number("Total: 9078 files indexed"),
Some(9078)
);
assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676));
assert_eq!(extract_first_number("no numbers here"), None);
}
#[test]
fn parse_status_finds_collection() {
let status = r#"
QMD Status
Index: /Users/luca/.cache/qmd/index.sqlite
Size: 100.9 MB
Documents
Total: 9115 files indexed
Vectors: 14676 embedded
Pending: 26 need embedding
Collections
laputa (qmd://laputa/)
Pattern: **/*.md
Files: 9078 (updated 20d ago)
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(result.collection_exists);
assert_eq!(result.indexed_count, 9078);
assert_eq!(result.embedded_count, 14676);
assert_eq!(result.pending_embed, 26);
}
#[test]
fn parse_status_missing_collection() {
let status = r#"
QMD Status
Documents
Total: 100 files indexed
Vectors: 50 embedded
Pending: 0
Collections
other (qmd://other/)
Files: 100
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(!result.collection_exists);
}
#[test]
fn extract_count_from_line_works() {
assert_eq!(
extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"),
Some(9078)
);
assert_eq!(extract_count_from_line("Pattern: **/*.md", "Files:"), None);
}
#[test]
fn parse_indexed_count_from_output() {
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
assert_eq!(parse_indexed_count("No output"), 0);
}
#[test]
fn ensure_executable_sets_permission() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
// Start with no execute permission
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
ensure_executable(&file);
assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
}
}
#[test]
fn ensure_executable_noop_when_already_executable() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
ensure_executable(&file);
let mode = fs::metadata(&file).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0);
}
}
#[test]
fn prepare_bundled_dir_returns_none_for_missing_binary() {
let dir = tempfile::tempdir().unwrap();
assert!(prepare_bundled_dir(dir.path()).is_none());
}
#[test]
fn prepare_bundled_dir_finds_and_prepares_binary() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let qmd_path = dir.path().join("qmd");
fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap();
}
let result = prepare_bundled_dir(dir.path());
assert!(result.is_some());
let bin = result.unwrap();
assert!(bin.path.ends_with("qmd"));
assert_eq!(bin.work_dir.unwrap(), dir.path());
// Verify execute permission was set
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(
fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111,
0
);
}
}
#[test]
fn find_bun_returns_some_if_available() {
// This test may succeed or fail depending on the system.
// It verifies the function doesn't panic.
let _ = find_bun();
}
#[test]
fn index_metadata_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Default when no file exists
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
assert!(meta.last_indexed_at.is_none());
// Write and read back
let meta = IndexMetadata {
last_indexed_commit: Some("abc123def456".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let loaded = load_index_metadata(vault);
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
assert_eq!(loaded.last_indexed_at, Some(1709000000));
}
#[test]
fn index_metadata_survives_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Write garbage
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
}
#[test]
fn needs_reindex_after_sync_no_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Init a git repo so get_head_commit works
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
// No metadata → needs reindex
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_same_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let head = get_head_commit(vault).unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some(head),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(!needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_different_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("old_commit_hash".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn check_index_status_includes_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("abc123".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let status = check_index_status(vault);
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
assert_eq!(status.last_indexed_at, Some(1709000000));
}
}

View File

@@ -4,7 +4,6 @@ mod commands;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod indexing;
pub mod mcp;
pub mod menu;
pub mod search;
@@ -149,9 +148,6 @@ pub fn run() {
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::get_index_status,
commands::start_indexing,
commands::trigger_incremental_index,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,

View File

@@ -51,7 +51,6 @@ const VAULT_PULL: &str = "vault-pull";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const VAULT_RELOAD: &str = "vault-reload";
const VAULT_REPAIR: &str = "vault-repair";
@@ -96,7 +95,6 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
VAULT_REPAIR,
];
@@ -358,9 +356,6 @@ fn build_vault_menu(app: &App) -> MenuResult {
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
.id(VAULT_RELOAD)
.build(app)?;
@@ -378,7 +373,6 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reindex)
.item(&reload)
.item(&repair)
.item(&install_mcp)
@@ -499,7 +493,6 @@ mod tests {
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
];
for id in &expected {

View File

@@ -1,12 +1,10 @@
use crate::indexing;
use crate::vault;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
use std::time::Instant;
use walkdir::WalkDir;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Clone)]
pub struct SearchResult {
pub title: String,
pub path: String,
@@ -23,159 +21,108 @@ pub struct SearchResponse {
pub mode: String,
}
#[derive(Debug, Deserialize)]
struct QmdResult {
pub file: String,
pub title: String,
pub snippet: String,
pub score: f64,
}
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
}
}
static COLLECTION_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
fn detect_collection_name(vault_path: &str) -> String {
// Check cache first
if let Ok(guard) = COLLECTION_CACHE.lock() {
if let Some(ref cache) = *guard {
if let Some(name) = cache.get(vault_path) {
return name.clone();
}
}
}
let result = detect_collection_name_uncached(vault_path);
// Store in cache
if let Ok(mut guard) = COLLECTION_CACHE.lock() {
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(vault_path.to_string(), result.clone());
}
result
}
fn detect_collection_name_uncached(vault_path: &str) -> String {
let qmd = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_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 output = qmd.command().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();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
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(),
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,
_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 qmd = indexing::find_qmd_binary().ok_or_else(|| "qmd binary not found".to_string())?;
let mut results: Vec<SearchResult> = Vec::new();
let collection = detect_collection_name(vault_path);
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;
}
if vault::is_file_trashed(path) {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let search_cmd = match mode {
"semantic" => "vsearch",
"hybrid" => "query",
_ => "search", // "keyword" default
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let limit_str = limit.to_string();
let output = qmd
.command()
.args([
search_cmd,
query,
"--collection",
&collection,
"--json",
"-n",
&limit_str,
])
.output()
.map_err(|e| format!("Failed to run qmd: {}", e))?;
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 !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd search failed: {}", stderr));
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,
});
}
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()
.filter_map(|r| {
let path = qmd_uri_to_vault_path(&r.file, vault_path);
if vault::is_file_trashed(Path::new(&path)) {
return None;
}
let snippet = extract_clean_snippet(&r.snippet);
Some(SearchResult {
title: r.title,
path,
snippet,
score: r.score,
note_type: None,
})
})
.collect();
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;
@@ -183,7 +130,7 @@ pub fn search_vault(
results,
elapsed_ms,
query: query.to_string(),
mode: mode.to_string(),
mode: "keyword".to_string(),
})
}
@@ -192,59 +139,36 @@ 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"
);
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_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"));
fn test_extract_snippet_no_match() {
let snippet = extract_snippet("nothing here", "missing");
assert!(snippet.is_empty());
}
#[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("..."));
fn test_score_match_title_word() {
let score = score_match("my keyword", "", "keyword");
assert!(score >= 10.0);
}
#[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
);
}
#[test]
fn test_qmd_uri_fallback() {
// Covers fallback branch when URI doesn't start with "qmd://"
let result = qmd_uri_to_vault_path("invalid-uri", "/vault");
assert!(result.contains("invalid-uri"));
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_clean_snippet_no_header() {
// No @@ header — content_start = 0
let snippet = extract_clean_snippet("plain content line");
assert_eq!(snippet, "plain content line");
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)
}
}