1#![allow(clippy::module_name_repetitions)]
34
35use std::collections::HashMap;
36
37use anyhow::{Context, Result};
38use petgraph::graph::{DiGraph, NodeIndex};
39use rusqlite::Connection;
40use tracing::instrument;
41
42#[derive(Debug)]
55pub struct RawGraph {
56 pub graph: DiGraph<String, ()>,
58 pub node_map: HashMap<String, NodeIndex>,
60 pub content_hash: String,
62}
63
64impl RawGraph {
65 #[instrument(skip(conn))]
79 #[allow(clippy::similar_names)]
80 pub fn from_sqlite(conn: &Connection) -> Result<Self> {
81 let item_ids = load_item_ids(conn)?;
83
84 let mut graph = DiGraph::<String, ()>::new();
85 let mut node_map: HashMap<String, NodeIndex> = HashMap::with_capacity(item_ids.len());
86
87 for id in item_ids {
88 let idx = graph.add_node(id.clone());
89 node_map.insert(id, idx);
90 }
91
92 let edges = load_blocking_edges(conn)?;
94
95 let content_hash = compute_edge_hash(&edges);
97
98 for (blocker, blocked) in edges {
99 let blocker_idx = *node_map
103 .entry(blocker.clone())
104 .or_insert_with(|| graph.add_node(blocker.clone()));
105 let blocked_idx = *node_map
106 .entry(blocked.clone())
107 .or_insert_with(|| graph.add_node(blocked.clone()));
108
109 if !graph.contains_edge(blocker_idx, blocked_idx) {
111 graph.add_edge(blocker_idx, blocked_idx, ());
112 }
113 }
114
115 Ok(Self {
116 graph,
117 node_map,
118 content_hash,
119 })
120 }
121
122 #[must_use]
124 pub fn node_count(&self) -> usize {
125 self.graph.node_count()
126 }
127
128 #[must_use]
130 pub fn edge_count(&self) -> usize {
131 self.graph.edge_count()
132 }
133
134 #[must_use]
136 pub fn node_index(&self, item_id: &str) -> Option<NodeIndex> {
137 self.node_map.get(item_id).copied()
138 }
139
140 #[must_use]
142 pub fn item_id(&self, idx: NodeIndex) -> Option<&str> {
143 self.graph.node_weight(idx).map(String::as_str)
144 }
145}
146
147fn load_item_ids(conn: &Connection) -> Result<Vec<String>> {
153 let mut stmt = conn
154 .prepare("SELECT item_id FROM items WHERE is_deleted = 0")
155 .context("prepare item_ids query")?;
156
157 let ids = stmt
158 .query_map([], |row| row.get::<_, String>(0))
159 .context("execute item_ids query")?
160 .collect::<Result<Vec<_>, _>>()
161 .context("collect item_ids")?;
162
163 Ok(ids)
164}
165
166#[allow(clippy::similar_names)]
170fn load_blocking_edges(conn: &Connection) -> Result<Vec<(String, String)>> {
171 let mut stmt = conn
175 .prepare(
176 "SELECT depends_on_item_id, item_id
177 FROM item_dependencies
178 WHERE link_type IN ('blocks', 'blocked_by')
179 ORDER BY depends_on_item_id, item_id",
180 )
181 .context("prepare blocking_edges query")?;
182
183 let edges = stmt
184 .query_map([], |row| {
185 let blocker: String = row.get(0)?;
186 let blocked: String = row.get(1)?;
187 Ok((blocker, blocked))
188 })
189 .context("execute blocking_edges query")?
190 .collect::<Result<Vec<_>, _>>()
191 .context("collect blocking edges")?;
192
193 Ok(edges)
194}
195
196fn compute_edge_hash(edges: &[(String, String)]) -> String {
198 let mut hasher = blake3::Hasher::new();
199 for (blocker, blocked) in edges {
200 hasher.update(blocker.as_bytes());
201 hasher.update(b"\x00");
202 hasher.update(blocked.as_bytes());
203 hasher.update(b"\x00");
204 }
205 format!("blake3:{}", hasher.finalize())
206}
207
208#[cfg(test)]
213mod tests {
214 use super::*;
215 use bones_core::db::migrations;
216 use rusqlite::params;
217
218 fn setup_db() -> rusqlite::Connection {
219 let mut conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
220 migrations::migrate(&mut conn).expect("migrate");
221 conn
222 }
223
224 fn insert_item(conn: &rusqlite::Connection, item_id: &str) {
225 conn.execute(
226 "INSERT INTO items (item_id, title, kind, state, urgency, is_deleted, created_at_us, updated_at_us)
227 VALUES (?1, ?1, 'task', 'open', 'default', 0, 1000, 1000)",
228 params![item_id],
229 )
230 .expect("insert item");
231 }
232
233 fn insert_dep(conn: &rusqlite::Connection, item_id: &str, depends_on: &str) {
234 conn.execute(
235 "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
236 VALUES (?1, ?2, 'blocks', 1000)",
237 params![item_id, depends_on],
238 )
239 .expect("insert dependency");
240 }
241
242 #[test]
243 fn empty_db_produces_empty_graph() {
244 let conn = setup_db();
245 let graph = RawGraph::from_sqlite(&conn).expect("build graph");
246 assert_eq!(graph.node_count(), 0);
247 assert_eq!(graph.edge_count(), 0);
248 assert!(graph.content_hash.starts_with("blake3:"));
250 }
251
252 #[test]
253 fn items_without_deps_are_nodes_only() {
254 let conn = setup_db();
255 insert_item(&conn, "bn-001");
256 insert_item(&conn, "bn-002");
257
258 let graph = RawGraph::from_sqlite(&conn).expect("build graph");
259 assert_eq!(graph.node_count(), 2);
260 assert_eq!(graph.edge_count(), 0);
261 assert!(graph.node_index("bn-001").is_some());
262 assert!(graph.node_index("bn-002").is_some());
263 }
264
265 #[test]
266 fn single_blocking_edge_direction() {
267 let conn = setup_db();
268 insert_item(&conn, "bn-001");
269 insert_item(&conn, "bn-002");
270 insert_dep(&conn, "bn-002", "bn-001");
272
273 let graph = RawGraph::from_sqlite(&conn).expect("build graph");
274 assert_eq!(graph.edge_count(), 1);
275
276 let a = graph.node_index("bn-001").expect("bn-001 node");
277 let b = graph.node_index("bn-002").expect("bn-002 node");
278 assert!(graph.graph.contains_edge(a, b), "expected bn-001 → bn-002");
280 assert!(!graph.graph.contains_edge(b, a), "no reverse edge");
281 }
282
283 #[test]
284 fn deleted_items_excluded_as_nodes() {
285 let conn = setup_db();
286 insert_item(&conn, "bn-001");
287 conn.execute(
288 "INSERT INTO items (item_id, title, kind, state, urgency, is_deleted, created_at_us, updated_at_us)
289 VALUES ('bn-deleted', 'deleted', 'task', 'open', 'default', 1, 1000, 1000)",
290 [],
291 )
292 .expect("insert deleted item");
293
294 let graph = RawGraph::from_sqlite(&conn).expect("build graph");
295 assert_eq!(graph.node_count(), 1);
296 assert!(graph.node_index("bn-001").is_some());
297 assert!(graph.node_index("bn-deleted").is_none());
298 }
299
300 #[test]
301 fn duplicate_edges_not_added() {
302 let conn = setup_db();
303 insert_item(&conn, "bn-001");
304 insert_item(&conn, "bn-002");
305 insert_dep(&conn, "bn-002", "bn-001");
306
307 conn.execute(
310 "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
311 VALUES ('bn-002', 'bn-001', 'blocked_by', 2000)",
312 [],
313 )
314 .expect("insert blocked_by dep");
315
316 let graph = RawGraph::from_sqlite(&conn).expect("build graph");
317 assert_eq!(graph.edge_count(), 1);
319 }
320
321 #[test]
322 fn content_hash_changes_with_edges() {
323 let conn = setup_db();
324 insert_item(&conn, "bn-001");
325 insert_item(&conn, "bn-002");
326
327 let empty_hash = RawGraph::from_sqlite(&conn)
328 .expect("build graph")
329 .content_hash;
330
331 insert_dep(&conn, "bn-002", "bn-001");
332 let with_edge_hash = RawGraph::from_sqlite(&conn)
333 .expect("build graph")
334 .content_hash;
335
336 assert_ne!(
337 empty_hash, with_edge_hash,
338 "hash must change when edges added"
339 );
340 }
341
342 #[test]
343 fn chain_of_deps() {
344 let conn = setup_db();
345 insert_item(&conn, "bn-001");
346 insert_item(&conn, "bn-002");
347 insert_item(&conn, "bn-003");
348 insert_dep(&conn, "bn-002", "bn-001"); insert_dep(&conn, "bn-003", "bn-002"); let graph = RawGraph::from_sqlite(&conn).expect("build graph");
352 assert_eq!(graph.node_count(), 3);
353 assert_eq!(graph.edge_count(), 2);
354
355 let n1 = graph.node_index("bn-001").unwrap();
356 let n2 = graph.node_index("bn-002").unwrap();
357 let n3 = graph.node_index("bn-003").unwrap();
358 assert!(graph.graph.contains_edge(n1, n2));
359 assert!(graph.graph.contains_edge(n2, n3));
360 }
361}