Skip to main content

llm_kernel/graph/
search.rs

1//! FTS5 full-text search for knowledge graph nodes.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{GraphNode, NODE_COLUMNS_PREFIXED, escape_like, row_to_node};
8
9/// Search nodes using FTS5 MATCH. Results ranked by importance DESC.
10pub fn search_nodes(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
11    let sql = format!(
12        "SELECT {NODE_COLUMNS_PREFIXED}
13         FROM nodes n
14         JOIN nodes_fts ON n.rowid = nodes_fts.rowid
15         WHERE nodes_fts MATCH ?1
16         ORDER BY n.importance DESC
17         LIMIT ?2"
18    );
19    let mut stmt = conn
20        .prepare(&sql)
21        .map_err(|e| KernelError::Store(e.to_string()))?;
22    let nodes: Vec<GraphNode> = stmt
23        .query_map(params![query, limit as i64], row_to_node)
24        .map_err(|e| KernelError::Store(e.to_string()))?
25        .filter_map(|r| r.ok())
26        .collect();
27    Ok(nodes)
28}
29
30/// Dynamic filter query: filter by tag, node_type, and/or project.
31pub fn query_nodes(
32    conn: &Connection,
33    tag: Option<&str>,
34    node_type: Option<&str>,
35    project: Option<&str>,
36    limit: usize,
37) -> Result<Vec<GraphNode>> {
38    let limit = limit.min(200);
39
40    let mut condition_strs: Vec<&str> = vec![];
41    let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
42
43    if let Some(t) = tag {
44        condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
45        param_vals.push(Box::new(escape_like(t)));
46    }
47    if let Some(nt) = node_type {
48        condition_strs.push("type = ?");
49        param_vals.push(Box::new(nt.to_string()));
50    }
51    if let Some(p) = project {
52        condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
53        param_vals.push(Box::new(escape_like(p)));
54    }
55
56    let where_clause = if condition_strs.is_empty() {
57        String::new()
58    } else {
59        format!("WHERE {}", condition_strs.join(" AND "))
60    };
61
62    let node_columns = super::types::NODE_COLUMNS;
63    let sql = format!(
64        "SELECT {node_columns} FROM nodes {where_clause} ORDER BY updated DESC LIMIT {}",
65        limit as i64,
66    );
67
68    let mut stmt = conn
69        .prepare(&sql)
70        .map_err(|e| KernelError::Store(e.to_string()))?;
71    let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
72    let nodes: Vec<GraphNode> = stmt
73        .query_map(refs.as_slice(), row_to_node)
74        .map_err(|e| KernelError::Store(e.to_string()))?
75        .filter_map(|r| r.ok())
76        .collect();
77    Ok(nodes)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::graph::schema::init_graph_schema;
84    use crate::graph::store::upsert_node;
85    use crate::graph::types::GraphNode;
86    use rusqlite::Connection;
87
88    fn mem_db() -> Connection {
89        let conn = Connection::open_in_memory().unwrap();
90        init_graph_schema(&conn).unwrap();
91        conn
92    }
93
94    fn test_node(id: &str, title: &str, body: &str, tags: Vec<&str>) -> GraphNode {
95        GraphNode {
96            id: id.to_string(),
97            node_type: "concept".to_string(),
98            title: title.to_string(),
99            body: body.to_string(),
100            tags: tags.into_iter().map(|s| s.to_string()).collect(),
101            projects: vec![],
102            agents: vec![],
103            created: "2026-01-01T00:00:00Z".to_string(),
104            updated: "2026-01-01T00:00:00Z".to_string(),
105            importance: 0.7,
106            access_count: 0,
107            accessed_at: String::new(),
108        }
109    }
110
111    #[test]
112    fn search_finds_by_title() {
113        let conn = mem_db();
114        upsert_node(
115            &conn,
116            &test_node("n1", "Rust ownership", "borrow checker", vec![]),
117        )
118        .unwrap();
119        upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
120        let results = search_nodes(&conn, "Rust", 10).unwrap();
121        assert_eq!(results.len(), 1);
122        assert_eq!(results[0].id, "n1");
123    }
124
125    #[test]
126    fn search_finds_by_body() {
127        let conn = mem_db();
128        upsert_node(
129            &conn,
130            &test_node("n1", "Title", "machine learning models", vec![]),
131        )
132        .unwrap();
133        let results = search_nodes(&conn, "machine learning", 10).unwrap();
134        assert_eq!(results.len(), 1);
135    }
136
137    #[test]
138    fn query_filters_by_tag() {
139        let conn = mem_db();
140        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
141        upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
142        let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
143        assert_eq!(results.len(), 1);
144        assert_eq!(results[0].id, "n1");
145    }
146
147    #[test]
148    fn query_filters_by_type() {
149        let conn = mem_db();
150        let mut n1 = test_node("n1", "A", "body", vec![]);
151        n1.node_type = "decision".to_string();
152        upsert_node(&conn, &n1).unwrap();
153        let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
154        assert_eq!(results.len(), 1);
155    }
156
157    #[test]
158    fn query_tag_wildcard_is_escaped() {
159        let conn = mem_db();
160        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
161        // "ru%t" would match "rust" as a LIKE wildcard, but escape_like prevents it
162        let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
163        assert!(results.is_empty());
164    }
165
166    #[test]
167    fn query_project_wildcard_is_escaped() {
168        let conn = mem_db();
169        let mut n1 = test_node("n1", "A", "body", vec![]);
170        n1.projects = vec!["myproj".to_string()];
171        upsert_node(&conn, &n1).unwrap();
172        let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
173        assert!(results.is_empty());
174    }
175}