use anyhow::Result;
use sqlitegraph::SqliteGraph;
#[derive(Clone, Debug, serde::Serialize)]
pub struct HopgraphHit {
pub entity_id: i64,
pub score: f32,
pub name: String,
pub kind: String,
pub file_path: Option<String>,
pub start_line: usize,
#[serde(skip_serializing_if = "is_zero")]
pub hop_distance: u32,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
pub fn fts_search_symbols(
conn: &rusqlite::Connection,
query: &str,
k: usize,
) -> Result<Vec<(i64, f32)>> {
let safe = query.replace('"', "\"\"");
let pattern = format!("{}*", safe);
let mut stmt = conn
.prepare_cached("SELECT rowid FROM symbol_fts WHERE name MATCH ? ORDER BY rank LIMIT ?")?;
let ids: Vec<i64> = stmt
.query_map(rusqlite::params![pattern, (k * 2) as i64], |row| {
row.get::<_, i64>(0)
})?
.filter_map(|r| r.ok())
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
let n = ids.len() as f32;
Ok(ids
.into_iter()
.enumerate()
.map(|(i, id)| (id, 1.0_f32 - (i as f32 / n) * 0.5))
.collect())
}
pub fn bulk_add_to_search_index(
_graph: &SqliteGraph,
_entries: &[(i64, Vec<f32>)],
) -> Result<usize> {
Ok(0)
}
pub fn remove_from_search_index(_graph: &SqliteGraph, _entity_id: i64) -> Result<()> {
Ok(())
}
pub fn clear_search_index(_graph: &SqliteGraph) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn open_test_conn() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE VIRTUAL TABLE symbol_fts USING fts5(name, content='', tokenize='unicode61');",
)
.unwrap();
conn
}
fn insert_symbol(conn: &rusqlite::Connection, rowid: i64, name: &str) {
conn.execute(
"INSERT INTO symbol_fts(rowid, name) VALUES (?, ?)",
rusqlite::params![rowid, name],
)
.unwrap();
}
#[test]
fn test_fts_search_finds_prefix_match() {
let conn = open_test_conn();
insert_symbol(&conn, 1, "parse_rust");
insert_symbol(&conn, 2, "parse_python");
insert_symbol(&conn, 3, "compile_rust");
let results = fts_search_symbols(&conn, "parse_rust", 5).unwrap();
assert!(!results.is_empty(), "should find results");
let ids: Vec<i64> = results.iter().map(|(id, _)| *id).collect();
assert!(ids.contains(&1), "parse_rust should be found");
}
#[test]
fn test_fts_search_scores_descending() {
let conn = open_test_conn();
insert_symbol(&conn, 1, "parse_rust");
insert_symbol(&conn, 2, "parse_rust_file");
insert_symbol(&conn, 3, "parse_python");
let results = fts_search_symbols(&conn, "parse_rust", 10).unwrap();
assert!(results.len() >= 2);
for w in results.windows(2) {
assert!(w[0].1 >= w[1].1, "scores should be non-increasing");
}
}
#[test]
fn test_fts_search_empty_query_returns_empty() {
let conn = open_test_conn();
insert_symbol(&conn, 1, "foo");
let _ = fts_search_symbols(&conn, "", 5);
}
#[test]
fn test_fts_search_no_match_returns_empty() {
let conn = open_test_conn();
insert_symbol(&conn, 1, "foo_bar");
let results = fts_search_symbols(&conn, "zzz_nonexistent", 5).unwrap();
assert!(results.is_empty());
}
}