1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{GraphNode, NODE_COLUMNS_PREFIXED, escape_like, row_to_node};
8
9pub 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
30pub 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 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}