1use std::collections::HashSet;
4
5use rusqlite::{Connection, params};
6
7use super::store::{read_edges, read_nodes};
8use super::types::{EdgeDirection, Graph, GraphEdge, GraphNodeSummary};
9
10const MAX_GRAPH_EDGES: usize = 2000;
12
13const MAX_SEED_IDS: usize = 100;
15
16pub fn graph_neighbors(conn: &Connection, seed_ids: &[String]) -> Vec<(String, f64)> {
26 neighbors_weighted(conn, seed_ids, EdgeDirection::Both, None)
27}
28
29pub(crate) fn neighbors_weighted(
40 conn: &Connection,
41 seed_ids: &[String],
42 dir: EdgeDirection,
43 relation: Option<&str>,
44) -> Vec<(String, f64)> {
45 if seed_ids.is_empty() {
46 return vec![];
47 }
48 let seed_ids = if seed_ids.len() > MAX_SEED_IDS {
49 &seed_ids[..MAX_SEED_IDS]
50 } else {
51 seed_ids
52 };
53
54 let ph = seed_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
55 let rel_filter = relation.map(|_| " AND relation = ?").unwrap_or("");
56
57 let out_half = format!(
58 "SELECT target AS nb, SUM(weight) AS w FROM edges WHERE source IN ({ph}){rel_filter} GROUP BY target"
59 );
60 let in_half = format!(
61 "SELECT source AS nb, SUM(weight) AS w FROM edges WHERE target IN ({ph}){rel_filter} GROUP BY source"
62 );
63 let sql = match dir {
64 EdgeDirection::Out => out_half,
65 EdgeDirection::In => in_half,
66 EdgeDirection::Both => format!("{out_half} UNION ALL {in_half}"),
67 };
68
69 let mut stmt = match conn.prepare(&sql) {
70 Ok(s) => s,
71 Err(_) => return vec![],
72 };
73
74 let halves = if matches!(dir, EdgeDirection::Both) {
76 2
77 } else {
78 1
79 };
80 let mut binds: Vec<&str> = Vec::with_capacity(halves * (seed_ids.len() + 1));
81 for _ in 0..halves {
82 binds.extend(seed_ids.iter().map(String::as_str));
83 if let Some(r) = relation {
84 binds.push(r);
85 }
86 }
87
88 let rows: Vec<(String, f64)> = stmt
89 .query_map(rusqlite::params_from_iter(binds.iter()), |row| {
90 Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
91 })
92 .map(|rows| rows.flatten().collect())
93 .unwrap_or_default();
94
95 let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
96 let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
97 for (nid, w) in rows {
98 if !seed_set.contains(nid.as_str()) {
99 *weights.entry(nid).or_default() += w;
100 }
101 }
102
103 let mut result: Vec<(String, f64)> = weights.into_iter().collect();
104 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
105 result
106}
107
108pub fn related_nodes(conn: &Connection, start_id: &str, depth: usize) -> Vec<String> {
113 let sql = "
114 WITH RECURSIVE bfs(node_id, lvl) AS (
115 SELECT target, 1 FROM edges WHERE source = ?1
116 UNION SELECT source, 1 FROM edges WHERE target = ?1
117 UNION SELECT e.target, bfs.lvl + 1 FROM edges e
118 JOIN bfs ON e.source = bfs.node_id
119 WHERE e.target != ?1 AND bfs.lvl < ?2
120 UNION SELECT e.source, bfs.lvl + 1 FROM edges e
121 JOIN bfs ON e.target = bfs.node_id
122 WHERE e.source != ?1 AND bfs.lvl < ?2
123 )
124 SELECT DISTINCT node_id FROM bfs
125 LIMIT 500
126 ";
127
128 conn.prepare(sql)
129 .and_then(|mut stmt| {
130 stmt.query_map(params![start_id, depth as i64], |row| {
131 row.get::<_, String>(0)
132 })
133 .map(|rows| rows.flatten().collect())
134 })
135 .unwrap_or_default()
136}
137
138pub fn build_graph(conn: &Connection) -> crate::error::Result<Graph> {
140 let ids = super::store::list_node_ids(conn)?;
141 let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
142 let nodes: Vec<GraphNodeSummary> = read_nodes(conn, &id_refs)?
143 .into_iter()
144 .map(|node| GraphNodeSummary {
145 id: node.id,
146 title: node.title,
147 node_type: node.node_type,
148 tags: node.tags,
149 importance: node.importance,
150 })
151 .collect();
152 let edges: Vec<GraphEdge> = read_edges(conn, MAX_GRAPH_EDGES)?;
153 Ok(Graph { nodes, edges })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::graph::schema::init_graph_schema;
160 use crate::graph::store::append_edge;
161 use rusqlite::Connection;
162
163 fn mem_db() -> Connection {
164 let conn = Connection::open_in_memory().unwrap();
165 init_graph_schema(&conn).unwrap();
166 conn
167 }
168
169 fn insert_edge(conn: &Connection, id: &str, src: &str, tgt: &str) {
170 let e = GraphEdge {
171 id: id.to_string(),
172 source: src.to_string(),
173 target: tgt.to_string(),
174 relation: "related".to_string(),
175 weight: 1.0,
176 ts: "2026-01-01T00:00:00Z".to_string(),
177 };
178 append_edge(conn, &e).unwrap();
179 }
180
181 #[test]
182 fn neighbors_returns_direct_connections() {
183 let conn = mem_db();
184 insert_edge(&conn, "e1", "A", "B");
185 insert_edge(&conn, "e2", "A", "C");
186 insert_edge(&conn, "e3", "D", "A");
187
188 let mut result = graph_neighbors(&conn, &["A".to_string()]);
189 result.sort_by(|a, b| a.0.cmp(&b.0));
190 let ids: Vec<&str> = result.iter().map(|r| r.0.as_str()).collect();
191 assert!(ids.contains(&"B"));
192 assert!(ids.contains(&"C"));
193 assert!(ids.contains(&"D"));
194 assert!(!ids.contains(&"A"));
195 }
196
197 #[test]
198 fn neighbors_excludes_seeds() {
199 let conn = mem_db();
200 insert_edge(&conn, "e1", "A", "B");
201 insert_edge(&conn, "e2", "B", "C");
202
203 let result = graph_neighbors(&conn, &["A".to_string(), "B".to_string()]);
204 let ids: Vec<&str> = result.iter().map(|r| r.0.as_str()).collect();
205 assert!(ids.contains(&"C"));
206 assert!(!ids.contains(&"A"));
207 assert!(!ids.contains(&"B"));
208 }
209
210 #[test]
211 fn neighbors_empty_seeds() {
212 let conn = mem_db();
213 assert!(graph_neighbors(&conn, &[]).is_empty());
214 }
215
216 #[test]
217 fn related_nodes_recursive_bfs() {
218 let conn = mem_db();
219 insert_edge(&conn, "e1", "A", "B");
220 insert_edge(&conn, "e2", "B", "C");
221
222 let result = related_nodes(&conn, "A", 2);
223 assert!(result.contains(&"B".to_string()));
224 assert!(result.contains(&"C".to_string()));
225 assert!(!result.contains(&"A".to_string()));
226 }
227
228 #[test]
229 fn related_nodes_handles_cycles() {
230 let conn = mem_db();
231 insert_edge(&conn, "e1", "A", "B");
232 insert_edge(&conn, "e2", "B", "C");
233 insert_edge(&conn, "e3", "C", "A");
234
235 let result = related_nodes(&conn, "A", 3);
236 let unique: HashSet<_> = result.iter().collect();
237 assert_eq!(result.len(), unique.len(), "no duplicates in cycle");
238 assert!(!result.contains(&"A".to_string()));
239 }
240
241 #[test]
242 fn neighbor_weight_accumulation() {
243 let conn = mem_db();
244 insert_edge(&conn, "e1", "A", "C");
245 insert_edge(&conn, "e2", "B", "C");
246
247 let result = graph_neighbors(&conn, &["A".to_string(), "B".to_string()]);
248 let c_weight = result.iter().find(|(id, _)| id == "C").map(|(_, w)| *w);
249 assert_eq!(c_weight, Some(2.0));
250 }
251
252 #[test]
253 fn neighbors_weighted_out_direction_excludes_in_edges() {
254 let conn = mem_db();
255 insert_edge(&conn, "e1", "A", "B"); insert_edge(&conn, "e2", "C", "A"); let result = neighbors_weighted(&conn, &["A".to_string()], EdgeDirection::Out, None);
258 let ids: Vec<&str> = result.iter().map(|(id, _)| id.as_str()).collect();
259 assert!(ids.contains(&"B"));
260 assert!(!ids.contains(&"C"));
261 }
262
263 #[test]
264 fn neighbors_weighted_relation_filter_restricts_walk() {
265 let conn = mem_db();
266 append_edge(
267 &conn,
268 &GraphEdge {
269 id: "c1".into(),
270 source: "A".into(),
271 target: "B".into(),
272 relation: "cites".into(),
273 weight: 1.0,
274 ts: "t".into(),
275 },
276 )
277 .unwrap();
278 append_edge(
279 &conn,
280 &GraphEdge {
281 id: "s1".into(),
282 source: "A".into(),
283 target: "D".into(),
284 relation: "see_also".into(),
285 weight: 1.0,
286 ts: "t".into(),
287 },
288 )
289 .unwrap();
290 let only_cites = neighbors_weighted(
291 &conn,
292 &["A".to_string()],
293 EdgeDirection::Both,
294 Some("cites"),
295 );
296 let ids: Vec<&str> = only_cites.iter().map(|(id, _)| id.as_str()).collect();
297 assert_eq!(ids, vec!["B"]);
298 }
299}