//! Volltext-Suche übers Panel-Archiv: SQLite-FTS5-Index, bei jeder Anfrage //! frisch in-memory aus dem Archiv-Baum gebaut. Bei den Archiv-Größen dieser //! App ist der Aufbau Millisekundensache; damit gibt es keinen persistierten //! Index, keine Staleness und nichts, was gesynct werden könnte. Die //! Tool-Schnittstelle bleibt engine-unabhängig. use std::path::Path; use rusqlite::Connection; use crate::domain::archive_index::scan_with_bodies; #[derive(serde::Serialize)] pub(crate) struct Hit { /// Pfad relativ zum Archiv-Home. pub(crate) relpath: String, pub(crate) title: String, /// Textausschnitt um die Fundstelle, Treffer in `**…**`. pub(crate) snippet: String, } /// Durchsucht das Archiv unter `home`. `query` ist FTS5-Syntax (Wörter, /// "Phrasen", Präfix*); `tag` engt auf ein Schlagwort ein. Treffer nach /// BM25-Rang, höchstens `limit`. pub(crate) fn search( home: &Path, query: &str, tag: Option<&str>, limit: usize, ) -> Result, String> { let conn = build_index(home)?; let expr = match_expr(query, tag)?; let mut stmt = conn .prepare( "SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \ FROM docs WHERE docs MATCH ?1 ORDER BY rank LIMIT ?2", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map(rusqlite::params![expr, limit as i64], |row| { Ok(Hit { relpath: row.get(0)?, title: row.get(1)?, snippet: row.get(2)? }) }) .map_err(|e| format!("Suchausdruck „{query}“: {e}"))?; rows.collect::, _>>().map_err(|e| e.to_string()) } /// MATCH-Ausdruck aus Query und optionalem Tag-Filter. fn match_expr(query: &str, tag: Option<&str>) -> Result { let q = query.trim(); let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', ""))); match (q.is_empty(), t) { (false, Some(t)) => Ok(format!("({q}) AND {t}")), (false, None) => Ok(q.to_string()), (true, Some(t)) => Ok(t), (true, None) => Err("leere Suchanfrage".into()), } } /// Baut den FTS5-Index in-memory aus dem Archiv-Baum. fn build_index(home: &Path) -> Result { let conn = Connection::open_in_memory().map_err(|e| e.to_string())?; conn .execute_batch( "CREATE VIRTUAL TABLE docs USING fts5(relpath UNINDEXED, name, title, description, tags, body)", ) .map_err(|e| e.to_string())?; let docs = scan_with_bodies(home)?; let mut insert = conn .prepare("INSERT INTO docs (relpath, name, title, description, tags, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6)") .map_err(|e| e.to_string())?; for (doc, body) in &docs { insert .execute(rusqlite::params![ doc.relpath, doc.name, doc.title, doc.description.as_deref().unwrap_or(""), doc.tags.join(" "), body, ]) .map_err(|e| e.to_string())?; } drop(insert); Ok(conn) } #[cfg(test)] mod tests { use super::*; use crate::domain::testutil::tmp_paths; use std::fs; fn archiv() -> std::path::PathBuf { let home = tmp_paths().home.join("archiv"); fs::create_dir_all(home.join("konzepte")).unwrap(); fs::write( home.join("2026-07-19_1000-adr-logging.md"), "---\ntitle: \"ADR Logging\"\ntags: [\"adr\", \"infra\"]\n---\n\nStrukturiertes Logging mit tracing vereinheitlichen.\n", ) .unwrap(); fs::write( home.join("konzepte/2026-07-19_1005-notiz-deploy.md"), "---\ntitle: \"Notiz Deploy\"\ntags: [\"infra\"]\n---\n\nDeploy braucht lsregister auf macOS.\n", ) .unwrap(); home } #[test] fn findet_nach_inhaltswort() { let home = archiv(); let hits = search(&home, "tracing", None, 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].title, "ADR Logging"); assert!(hits[0].snippet.contains("**tracing**")); } #[test] fn tag_filter_engt_ein() { let home = archiv(); assert_eq!(search(&home, "", Some("infra"), 10).unwrap().len(), 2); assert_eq!(search(&home, "", Some("adr"), 10).unwrap().len(), 1); let hits = search(&home, "deploy", Some("adr"), 10).unwrap(); assert!(hits.is_empty()); } #[test] fn phrase_und_praefix() { let home = archiv(); assert_eq!(search(&home, "\"Strukturiertes Logging\"", None, 10).unwrap().len(), 1); assert_eq!(search(&home, "lsregist*", None, 10).unwrap().len(), 1); } #[test] fn leere_anfrage_scheitert() { let home = archiv(); assert!(search(&home, " ", None, 10).is_err()); } }