use anyhow::Context;
use rusqlite::Connection;
pub fn rebuild(conn: &Connection) -> anyhow::Result<()> {
conn.execute("INSERT INTO tools_fts(tools_fts) VALUES('rebuild')", [])
.context("rebuild tools_fts")?;
Ok(())
}
pub fn search(conn: &Connection, query: &str, limit: usize) -> anyhow::Result<Vec<(String, f32)>> {
let mut stmt = conn.prepare(
"SELECT t.id, bm25(tools_fts, 5.0, 2.0, 1.0, 3.0, 1.0, 1.0) AS rank
FROM tools_fts
JOIN tools t ON t.rowid = tools_fts.rowid
WHERE tools_fts MATCH ?1
ORDER BY rank
LIMIT ?2",
)?;
let rows = stmt
.query_map(rusqlite::params![query, limit as i64], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)? as f32))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{open, tools};
use chrono::Utc;
use toolhub_core::tool::{ToolMeta, ToolType};
fn sample(id: &str, name: &str, desc: &str) -> ToolMeta {
let now = Utc::now();
ToolMeta {
id: id.into(),
r#type: ToolType::Skill,
name: name.into(),
source_repo: None,
install_path: None,
description: Some(desc.into()),
long_description: None,
category: None,
triggers: vec![],
examples: vec![],
invocation: None,
requires: vec![],
enabled: true,
added_at: now,
last_seen_at: now,
last_used_at: None,
}
}
#[test]
fn rebuild_then_search_finds_tool() {
let dir = tempfile::tempdir().unwrap();
let conn = open(&dir.path().join("f.sqlite")).unwrap();
tools::upsert(
&conn,
&sample("skill:design-md", "design-md", "extract design tokens"),
)
.unwrap();
tools::upsert(
&conn,
&sample(
"skill:caveman",
"caveman",
"compress markdown caveman speak",
),
)
.unwrap();
rebuild(&conn).unwrap();
let hits = search(&conn, "design", 10).unwrap();
assert!(hits.iter().any(|(id, _)| id == "skill:design-md"));
let hits = search(&conn, "caveman", 10).unwrap();
assert!(hits.iter().any(|(id, _)| id == "skill:caveman"));
}
}