Previously called 'qmd update' and 'qmd embed' without arguments, which updated all global qmd collections. If any other collection had a broken path, the entire indexing would fail. Now passes the vault name explicitly: qmd update <vault_name> qmd embed -c <vault_name> This makes reindex independent of other qmd collections on the system.
915 lines
26 KiB
Rust
915 lines
26 KiB
Rust
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));
|
|
}
|
|
}
|