From 47fe307d4534da5f0c35bbf7244005fafd18803a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 7 Mar 2026 01:00:58 +0100 Subject: [PATCH] feat: add last_indexed_commit persistence to indexing backend Store last_indexed_commit and last_indexed_at in .laputa-index.json after every successful full or incremental index. Include these in IndexStatus so the frontend can display staleness. Add needs_reindex_after_sync() helper that compares HEAD vs stored commit. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/indexing.rs | 216 +++++++++++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/indexing.rs b/src-tauri/src/indexing.rs index a301a8e6..967940b4 100644 --- a/src-tauri/src/indexing.rs +++ b/src-tauri/src/indexing.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Mutex; @@ -223,10 +223,72 @@ pub struct IndexStatus { pub indexed_count: usize, pub embedded_count: usize, pub pending_embed: usize, + pub last_indexed_commit: Option, + pub last_indexed_at: Option, +} + +// --- Index metadata persistence --- + +#[derive(Debug, Serialize, Deserialize, Default)] +struct IndexMetadata { + #[serde(default)] + last_indexed_commit: Option, + #[serde(default)] + last_indexed_at: Option, +} + +fn index_metadata_path(vault_path: &str) -> PathBuf { + Path::new(vault_path).join(".laputa-index.json") +} + +pub 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() +} + +pub 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. +pub fn get_head_commit(vault_path: &str) -> Option { + 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 => { @@ -237,6 +299,8 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus { indexed_count: 0, embedded_count: 0, pending_embed: 0, + last_indexed_commit: meta.last_indexed_commit, + last_indexed_at: meta.last_indexed_at, } } }; @@ -244,7 +308,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus { let vault_name = vault_dir_name(vault_path); let output = qmd.command().args(["status"]).output(); - match 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) @@ -256,8 +320,14 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus { 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 { @@ -323,6 +393,8 @@ fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus indexed_count, embedded_count, pending_embed, + last_indexed_commit: None, + last_indexed_at: None, } } @@ -451,6 +523,7 @@ where 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, @@ -461,6 +534,8 @@ where return Ok(()); } + stamp_index_commit(vault_path); + on_progress(IndexingProgress { phase: "complete".to_string(), current: total, @@ -513,9 +588,22 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> { return Err(format!("qmd update failed: {stderr}")); } + stamp_index_commit(vault_path); + Ok(()) } +/// Check if HEAD has advanced past the last indexed commit. +pub 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::*; @@ -698,4 +786,126 @@ Collections // 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)); + } }