1use std::path::Path;
7
8use rusqlite::{Connection, params};
9use tracing::debug;
10
11use crate::error::{Result, SedimentError};
12
13pub struct GraphStore {
15 conn: Connection,
16}
17
18impl GraphStore {
19 pub fn open(path: &Path) -> Result<Self> {
22 let conn = Connection::open(path).map_err(|e| {
23 SedimentError::Database(format!("Failed to open graph database: {}", e))
24 })?;
25
26 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") {
27 tracing::warn!("Failed to set SQLite PRAGMAs (graph): {}", e);
28 }
29
30 conn.execute_batch(
31 "CREATE TABLE IF NOT EXISTS graph_nodes (
32 id TEXT PRIMARY KEY,
33 project_id TEXT NOT NULL DEFAULT '',
34 created_at INTEGER NOT NULL
35 );
36
37 CREATE TABLE IF NOT EXISTS graph_edges (
38 from_id TEXT NOT NULL,
39 to_id TEXT NOT NULL,
40 edge_type TEXT NOT NULL,
41 strength REAL NOT NULL DEFAULT 0.0,
42 rel_type TEXT NOT NULL DEFAULT '',
43 count INTEGER NOT NULL DEFAULT 0,
44 last_at INTEGER NOT NULL DEFAULT 0,
45 created_at INTEGER NOT NULL,
46 UNIQUE(from_id, to_id, edge_type)
47 );
48
49 CREATE INDEX IF NOT EXISTS idx_edges_from ON graph_edges(from_id, edge_type);
50 CREATE INDEX IF NOT EXISTS idx_edges_to ON graph_edges(to_id, edge_type);",
51 )
52 .map_err(|e| SedimentError::Database(format!("Failed to create graph tables: {}", e)))?;
53
54 Ok(Self { conn })
55 }
56
57 pub fn add_node(&self, id: &str, project_id: Option<&str>, created_at: i64) -> Result<()> {
59 let pid = project_id.unwrap_or("");
60
61 self.conn
62 .execute(
63 "INSERT OR IGNORE INTO graph_nodes (id, project_id, created_at) VALUES (?1, ?2, ?3)",
64 params![id, pid, created_at],
65 )
66 .map_err(|e| SedimentError::Database(format!("Failed to add node: {}", e)))?;
67
68 debug!("Added graph node: {}", id);
69 Ok(())
70 }
71
72 pub fn remove_node(&self, id: &str) -> Result<()> {
74 self.conn
77 .execute(
78 "DELETE FROM graph_edges WHERE from_id = ?1 OR (to_id = ?1 AND edge_type != 'supersedes')",
79 params![id],
80 )
81 .map_err(|e| SedimentError::Database(format!("Failed to remove edges: {}", e)))?;
82
83 self.conn
84 .execute("DELETE FROM graph_nodes WHERE id = ?1", params![id])
85 .map_err(|e| SedimentError::Database(format!("Failed to remove node: {}", e)))?;
86
87 debug!("Removed graph node: {}", id);
88 Ok(())
89 }
90
91 pub fn add_related_edge(
93 &self,
94 from_id: &str,
95 to_id: &str,
96 strength: f64,
97 rel_type: &str,
98 ) -> Result<()> {
99 let now = chrono::Utc::now().timestamp();
100
101 self.conn
102 .execute(
103 "INSERT OR IGNORE INTO graph_edges (from_id, to_id, edge_type, strength, rel_type, created_at)
104 VALUES (?1, ?2, 'related', ?3, ?4, ?5)",
105 params![from_id, to_id, strength, rel_type, now],
106 )
107 .map_err(|e| SedimentError::Database(format!("Failed to add related edge: {}", e)))?;
108
109 debug!(
110 "Added RELATED edge: {} -> {} ({})",
111 from_id, to_id, rel_type
112 );
113 Ok(())
114 }
115
116 pub fn add_supersedes_edge(&self, new_id: &str, old_id: &str) -> Result<()> {
118 let now = chrono::Utc::now().timestamp();
119
120 self.conn
121 .execute(
122 "INSERT OR IGNORE INTO graph_edges (from_id, to_id, edge_type, strength, created_at)
123 VALUES (?1, ?2, 'supersedes', 1.0, ?3)",
124 params![new_id, old_id, now],
125 )
126 .map_err(|e| SedimentError::Database(format!("Failed to add supersedes edge: {}", e)))?;
127
128 debug!("Added SUPERSEDES edge: {} -> {}", new_id, old_id);
129 Ok(())
130 }
131
132 pub fn get_neighbors(
139 &self,
140 ids: &[&str],
141 min_strength: f64,
142 ) -> Result<Vec<(String, String, f64)>> {
143 if ids.is_empty() {
144 return Ok(Vec::new());
145 }
146
147 let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
148 let ph = placeholders.join(",");
149 let strength_idx = ids.len() + 1;
150
151 let sql = format!(
152 "SELECT
153 CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
154 CASE WHEN edge_type = 'related' THEN rel_type ELSE 'supersedes' END AS rtype,
155 strength
156 FROM graph_edges
157 WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
158 AND edge_type IN ('related', 'supersedes')
159 AND strength >= ?{strength_idx}
160 LIMIT 100"
161 );
162
163 let mut stmt = self.conn.prepare(&sql).map_err(|e| {
164 SedimentError::Database(format!("Failed to prepare neighbors query: {}", e))
165 })?;
166
167 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
168 for id in ids {
169 param_values.push(Box::new(id.to_string()));
170 }
171 param_values.push(Box::new(min_strength));
172
173 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
174 param_values.iter().map(|b| b.as_ref()).collect();
175
176 let rows = stmt
177 .query_map(params_ref.as_slice(), |row| {
178 Ok((
179 row.get::<_, String>(0)?,
180 row.get::<_, String>(1)?,
181 row.get::<_, f64>(2)?,
182 ))
183 })
184 .map_err(|e| SedimentError::Database(format!("Failed to query neighbors: {}", e)))?;
185
186 let input_set: std::collections::HashSet<&str> = ids.iter().copied().collect();
188 let mut results = Vec::new();
189 for row in rows {
190 let r = row
191 .map_err(|e| SedimentError::Database(format!("Failed to read neighbor: {}", e)))?;
192 if !input_set.contains(r.0.as_str()) {
193 results.push(r);
194 }
195 }
196
197 Ok(results)
198 }
199
200 pub fn record_co_access(&self, item_ids: &[String]) -> Result<()> {
203 if item_ids.len() < 2 {
204 return Ok(());
205 }
206
207 let item_ids = if item_ids.len() > 3 {
211 &item_ids[..3]
212 } else {
213 item_ids
214 };
215
216 let now = chrono::Utc::now().timestamp();
217
218 for i in 0..item_ids.len() {
219 for j in (i + 1)..item_ids.len() {
220 let (a, b) = if item_ids[i] <= item_ids[j] {
223 (&item_ids[i], &item_ids[j])
224 } else {
225 (&item_ids[j], &item_ids[i])
226 };
227
228 self.conn
229 .execute(
230 "INSERT INTO graph_edges (from_id, to_id, edge_type, count, last_at, created_at)
231 VALUES (?1, ?2, 'co_accessed', 1, ?3, ?3)
232 ON CONFLICT(from_id, to_id, edge_type)
233 DO UPDATE SET count = count + 1, last_at = ?3",
234 params![a, b, now],
235 )
236 .map_err(|e| {
237 SedimentError::Database(format!("Failed to record co-access: {}", e))
238 })?;
239 }
240 }
241
242 Ok(())
243 }
244
245 pub fn get_co_accessed(&self, ids: &[&str], min_count: i64) -> Result<Vec<(String, i64)>> {
248 if ids.is_empty() {
249 return Ok(Vec::new());
250 }
251
252 let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
253 let ph = placeholders.join(",");
254 let min_idx = ids.len() + 1;
255
256 let sql = format!(
257 "SELECT
258 CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
259 count
260 FROM graph_edges
261 WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
262 AND edge_type = 'co_accessed'
263 AND count >= ?{min_idx}
264 ORDER BY count DESC
265 LIMIT 50"
266 );
267
268 let mut stmt = self.conn.prepare(&sql).map_err(|e| {
269 SedimentError::Database(format!("Failed to prepare co-access query: {}", e))
270 })?;
271
272 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
273 for id in ids {
274 param_values.push(Box::new(id.to_string()));
275 }
276 param_values.push(Box::new(min_count));
277
278 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
279 param_values.iter().map(|b| b.as_ref()).collect();
280
281 let rows = stmt
282 .query_map(params_ref.as_slice(), |row| {
283 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
284 })
285 .map_err(|e| SedimentError::Database(format!("Failed to query co-access: {}", e)))?;
286
287 let mut results = Vec::new();
288 for row in rows {
289 let r = row
290 .map_err(|e| SedimentError::Database(format!("Failed to read co-access: {}", e)))?;
291 results.push(r);
292 }
293
294 results.sort_by(|a, b| b.1.cmp(&a.1));
296 let mut seen = std::collections::HashSet::new();
297 results.retain(|(id, _)| seen.insert(id.clone()));
298
299 Ok(results)
300 }
301
302 pub fn transfer_edges(&self, from_id: &str, to_id: &str) -> Result<()> {
304 let mut stmt = self
306 .conn
307 .prepare(
308 "SELECT from_id, to_id, strength, rel_type, created_at
309 FROM graph_edges
310 WHERE (from_id = ?1 OR to_id = ?1)
311 AND edge_type = 'related'
312 AND from_id != ?2 AND to_id != ?2",
313 )
314 .map_err(|e| {
315 SedimentError::Database(format!("Failed to prepare transfer query: {}", e))
316 })?;
317
318 let edges: Vec<(String, f64, String, i64)> = stmt
319 .query_map(params![from_id, to_id], |row| {
320 let fid: String = row.get(0)?;
321 let tid: String = row.get(1)?;
322 let neighbor = if fid == from_id { tid } else { fid };
323 Ok((neighbor, row.get(2)?, row.get(3)?, row.get(4)?))
324 })
325 .map_err(|e| {
326 SedimentError::Database(format!("Failed to query edges for transfer: {}", e))
327 })?
328 .filter_map(|r| match r {
329 Ok(v) => Some(v),
330 Err(e) => {
331 tracing::warn!("transfer_edges: failed to read row: {}", e);
332 None
333 }
334 })
335 .collect();
336
337 for (neighbor, strength, rel_type, _) in &edges {
339 if let Err(e) = self.add_related_edge(to_id, neighbor, *strength, rel_type) {
340 tracing::warn!("transfer edge to {} failed: {}", neighbor, e);
341 }
342 }
343
344 Ok(())
345 }
346
347 pub fn detect_clusters(&self) -> Result<Vec<(String, String, String)>> {
350 let mut stmt = self
351 .conn
352 .prepare(
353 "WITH biedges AS (
354 SELECT from_id AS a, to_id AS b FROM graph_edges WHERE edge_type = 'related'
355 UNION ALL
356 SELECT to_id AS a, from_id AS b FROM graph_edges WHERE edge_type = 'related'
357 )
358 SELECT DISTINCT e1.a, e1.b, e2.b
359 FROM biedges e1
360 JOIN biedges e2 ON e1.b = e2.a
361 JOIN biedges e3 ON e2.b = e3.a AND e3.b = e1.a
362 WHERE e1.a < e1.b AND e1.b < e2.b
363 LIMIT 50",
364 )
365 .map_err(|e| SedimentError::Database(format!("Failed to detect clusters: {}", e)))?;
366
367 let rows = stmt
368 .query_map([], |row| {
369 Ok((
370 row.get::<_, String>(0)?,
371 row.get::<_, String>(1)?,
372 row.get::<_, String>(2)?,
373 ))
374 })
375 .map_err(|e| SedimentError::Database(format!("Failed to read clusters: {}", e)))?;
376
377 let mut clusters = Vec::new();
378 for r in rows.flatten() {
379 clusters.push(r);
380 }
381
382 Ok(clusters)
383 }
384
385 pub fn get_neighbors_mapped(
390 &self,
391 ids: &[&str],
392 min_strength: f64,
393 ) -> Result<std::collections::HashMap<String, Vec<String>>> {
394 if ids.is_empty() {
395 return Ok(std::collections::HashMap::new());
396 }
397
398 let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
399 let ph = placeholders.join(",");
400 let strength_idx = ids.len() + 1;
401
402 let sql = format!(
403 "SELECT
404 CASE WHEN from_id IN ({ph}) THEN from_id ELSE to_id END AS source,
405 CASE WHEN from_id IN ({ph}) THEN to_id ELSE from_id END AS neighbor,
406 strength
407 FROM graph_edges
408 WHERE (from_id IN ({ph}) OR to_id IN ({ph}))
409 AND edge_type IN ('related', 'supersedes')
410 AND strength >= ?{strength_idx}
411 LIMIT 500"
412 );
413
414 let mut stmt = self.conn.prepare(&sql).map_err(|e| {
415 SedimentError::Database(format!("Failed to prepare neighbors_mapped query: {}", e))
416 })?;
417
418 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
419 for id in ids {
420 param_values.push(Box::new(id.to_string()));
421 }
422 param_values.push(Box::new(min_strength));
423
424 let params_ref: Vec<&dyn rusqlite::types::ToSql> =
425 param_values.iter().map(|b| b.as_ref()).collect();
426
427 let rows = stmt
428 .query_map(params_ref.as_slice(), |row| {
429 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
430 })
431 .map_err(|e| {
432 SedimentError::Database(format!("Failed to query neighbors_mapped: {}", e))
433 })?;
434
435 let input_set: std::collections::HashSet<&str> = ids.iter().copied().collect();
436 let mut map: std::collections::HashMap<String, Vec<String>> =
437 std::collections::HashMap::new();
438
439 for row in rows {
440 let (source, neighbor) = row.map_err(|e| {
441 SedimentError::Database(format!("Failed to read neighbor_mapped: {}", e))
442 })?;
443 if !input_set.contains(neighbor.as_str()) {
444 map.entry(source).or_default().push(neighbor);
445 }
446 }
447
448 Ok(map)
449 }
450
451 pub fn get_edge_counts(&self, ids: &[&str]) -> Result<std::collections::HashMap<String, u32>> {
455 if ids.is_empty() {
456 return Ok(std::collections::HashMap::new());
457 }
458
459 let unions: String = (1..=ids.len())
461 .map(|i| {
462 if i == 1 {
463 format!("SELECT ?{} AS id", i)
464 } else {
465 format!("UNION ALL SELECT ?{}", i)
466 }
467 })
468 .collect::<Vec<_>>()
469 .join(" ");
470
471 let sql = format!(
473 "SELECT ids.id, COUNT(e.rowid) FROM ({unions}) ids
474 LEFT JOIN graph_edges e ON e.from_id = ids.id OR e.to_id = ids.id
475 GROUP BY ids.id"
476 );
477
478 let mut stmt = self.conn.prepare(&sql).map_err(|e| {
479 SedimentError::Database(format!("Failed to prepare edge_counts query: {}", e))
480 })?;
481
482 let params: Vec<&dyn rusqlite::types::ToSql> = ids
483 .iter()
484 .map(|id| id as &dyn rusqlite::types::ToSql)
485 .collect();
486
487 let rows = stmt
488 .query_map(params.as_slice(), |row| {
489 Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
490 })
491 .map_err(|e| SedimentError::Database(format!("Failed to query edge_counts: {}", e)))?;
492
493 let mut map = std::collections::HashMap::new();
494 for row in rows {
495 let (id, count) = row.map_err(|e| {
496 SedimentError::Database(format!("Failed to read edge_count: {}", e))
497 })?;
498 map.insert(id, count);
499 }
500
501 Ok(map)
502 }
503
504 pub fn migrate_project_id(&self, old_id: &str, new_id: &str) -> Result<usize> {
508 let updated = self
509 .conn
510 .execute(
511 "UPDATE graph_nodes SET project_id = ?1 WHERE project_id = ?2",
512 params![new_id, old_id],
513 )
514 .map_err(|e| {
515 SedimentError::Database(format!("Failed to migrate graph nodes: {}", e))
516 })?;
517
518 if updated > 0 {
519 debug!(
520 "Migrated {} graph nodes from project {} to {}",
521 updated, old_id, new_id
522 );
523 }
524 Ok(updated)
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use tempfile::NamedTempFile;
532
533 #[derive(Debug, Clone)]
534 #[allow(dead_code)]
535 struct ConnectionInfo {
536 target_id: String,
537 rel_type: String,
538 strength: f64,
539 count: Option<i64>,
540 }
541
542 impl GraphStore {
543 fn get_full_connections(&self, item_id: &str) -> Result<Vec<ConnectionInfo>> {
544 let mut stmt = self
545 .conn
546 .prepare(
547 "SELECT
548 CASE WHEN from_id = ?1 THEN to_id ELSE from_id END AS neighbor,
549 edge_type, strength, rel_type, count
550 FROM graph_edges WHERE from_id = ?1 OR to_id = ?1",
551 )
552 .map_err(|e| {
553 SedimentError::Database(format!("Failed to prepare connections query: {}", e))
554 })?;
555
556 let rows = stmt
557 .query_map(params![item_id], |row| {
558 let neighbor: String = row.get(0)?;
559 let edge_type: String = row.get(1)?;
560 let strength: f64 = row.get(2)?;
561 let rel_type_val: String = row.get(3)?;
562 let count: i64 = row.get(4)?;
563 let display_type = match edge_type.as_str() {
564 "related" => rel_type_val.clone(),
565 "supersedes" => "supersedes".to_string(),
566 "co_accessed" => "co_accessed".to_string(),
567 _ => edge_type.clone(),
568 };
569 Ok(ConnectionInfo {
570 target_id: neighbor,
571 rel_type: display_type,
572 strength,
573 count: if edge_type == "co_accessed" {
574 Some(count)
575 } else {
576 None
577 },
578 })
579 })
580 .map_err(|e| {
581 SedimentError::Database(format!("Failed to query connections: {}", e))
582 })?;
583
584 let mut connections = Vec::new();
585 for row in rows {
586 let r = row.map_err(|e| {
587 SedimentError::Database(format!("Failed to read connection: {}", e))
588 })?;
589 connections.push(r);
590 }
591 Ok(connections)
592 }
593
594 fn get_edge_count(&self, item_id: &str) -> Result<u32> {
595 let count: i64 = self
596 .conn
597 .query_row(
598 "SELECT COUNT(*) FROM graph_edges WHERE from_id = ?1 OR to_id = ?1",
599 params![item_id],
600 |row| row.get(0),
601 )
602 .map_err(|e| SedimentError::Database(format!("Failed to count edges: {}", e)))?;
603 Ok(count as u32)
604 }
605 }
606
607 fn open_test_graph() -> GraphStore {
608 let tmp = NamedTempFile::new().unwrap();
609 GraphStore::open(tmp.path()).unwrap()
610 }
611
612 #[test]
613 fn test_get_neighbors_excludes_input_ids() {
614 let graph = open_test_graph();
616 let now = chrono::Utc::now().timestamp();
617 graph.add_node("A", Some("proj"), now).unwrap();
618 graph.add_node("B", Some("proj"), now).unwrap();
619 graph.add_node("C", Some("proj"), now).unwrap();
620
621 graph.add_related_edge("A", "B", 0.9, "test").unwrap();
623 graph.add_related_edge("B", "C", 0.9, "test").unwrap();
624
625 let neighbors = graph.get_neighbors(&["A", "B"], 0.0).unwrap();
627 let neighbor_ids: Vec<&str> = neighbors.iter().map(|(id, _, _)| id.as_str()).collect();
628 assert!(neighbor_ids.contains(&"C"));
629 assert!(!neighbor_ids.contains(&"A"));
630 assert!(!neighbor_ids.contains(&"B"));
631 }
632
633 #[test]
634 fn test_co_access_normalized_direction() {
635 let graph = open_test_graph();
637 let now = chrono::Utc::now().timestamp();
638 graph.add_node("Z", Some("proj"), now).unwrap();
639 graph.add_node("A", Some("proj"), now).unwrap();
640
641 graph
643 .record_co_access(&["Z".to_string(), "A".to_string()])
644 .unwrap();
645 graph
647 .record_co_access(&["A".to_string(), "Z".to_string()])
648 .unwrap();
649
650 let count: i64 = graph
652 .conn
653 .query_row(
654 "SELECT COUNT(*) FROM graph_edges WHERE edge_type = 'co_accessed'",
655 [],
656 |row| row.get(0),
657 )
658 .unwrap();
659 assert_eq!(count, 1, "Should have exactly 1 co-access edge");
660
661 let edge_count: i64 = graph
662 .conn
663 .query_row(
664 "SELECT count FROM graph_edges WHERE edge_type = 'co_accessed'",
665 [],
666 |row| row.get(0),
667 )
668 .unwrap();
669 assert_eq!(edge_count, 2, "Edge count should be 2 (incremented twice)");
670 }
671
672 #[test]
673 fn test_transfer_edges_preserves_relationships() {
674 let graph = open_test_graph();
676 let now = chrono::Utc::now().timestamp();
677 graph.add_node("old", Some("proj"), now).unwrap();
678 graph.add_node("new", Some("proj"), now).unwrap();
679 graph.add_node("friend", Some("proj"), now).unwrap();
680
681 graph
682 .add_related_edge("old", "friend", 0.9, "test")
683 .unwrap();
684
685 graph.transfer_edges("old", "new").unwrap();
687
688 let neighbors = graph.get_neighbors(&["new"], 0.0).unwrap();
690 assert!(
691 !neighbors.is_empty(),
692 "New node should have inherited edges"
693 );
694 let neighbor_ids: Vec<&str> = neighbors.iter().map(|(id, _, _)| id.as_str()).collect();
695 assert!(neighbor_ids.contains(&"friend"));
696 }
697
698 #[test]
699 fn test_remove_node_preserves_incoming_supersedes() {
700 let graph = open_test_graph();
702 let now = chrono::Utc::now().timestamp();
703 graph.add_node("new", Some("proj"), now).unwrap();
704 graph.add_node("old", Some("proj"), now).unwrap();
705
706 graph.add_supersedes_edge("new", "old").unwrap();
708
709 graph.remove_node("old").unwrap();
711
712 let connections = graph.get_full_connections("new").unwrap();
714 assert_eq!(connections.len(), 1, "SUPERSEDES edge should be preserved");
715 assert_eq!(connections[0].target_id, "old");
716 assert_eq!(connections[0].rel_type, "supersedes");
717 }
718
719 #[test]
720 fn test_get_neighbors_bounded() {
721 let graph = open_test_graph();
723 let now = chrono::Utc::now().timestamp();
724 graph.add_node("center", Some("proj"), now).unwrap();
725
726 for i in 0..150 {
727 let id = format!("n{}", i);
728 graph.add_node(&id, Some("proj"), now).unwrap();
729 graph.add_related_edge("center", &id, 0.9, "test").unwrap();
730 }
731
732 let neighbors = graph.get_neighbors(&["center"], 0.0).unwrap();
733 assert!(
734 neighbors.len() <= 100,
735 "get_neighbors should return at most 100, got {}",
736 neighbors.len()
737 );
738 }
739
740 #[test]
741 fn test_get_neighbors_mapped() {
742 let graph = open_test_graph();
743 let now = chrono::Utc::now().timestamp();
744 graph.add_node("A", Some("proj"), now).unwrap();
745 graph.add_node("B", Some("proj"), now).unwrap();
746 graph.add_node("C", Some("proj"), now).unwrap();
747 graph.add_node("D", Some("proj"), now).unwrap();
748
749 graph.add_related_edge("A", "C", 0.9, "test").unwrap();
750 graph.add_related_edge("B", "D", 0.9, "test").unwrap();
751
752 let map = graph.get_neighbors_mapped(&["A", "B"], 0.0).unwrap();
753
754 assert!(map.get("A").unwrap().contains(&"C".to_string()));
756 assert!(map.get("B").unwrap().contains(&"D".to_string()));
757 for neighbors in map.values() {
759 assert!(!neighbors.contains(&"A".to_string()));
760 assert!(!neighbors.contains(&"B".to_string()));
761 }
762 }
763
764 #[test]
765 fn test_get_edge_counts() {
766 let graph = open_test_graph();
767 let now = chrono::Utc::now().timestamp();
768 graph.add_node("X", Some("proj"), now).unwrap();
769 graph.add_node("Y", Some("proj"), now).unwrap();
770 graph.add_node("Z", Some("proj"), now).unwrap();
771
772 graph.add_related_edge("X", "Y", 0.9, "test").unwrap();
773 graph.add_related_edge("X", "Z", 0.9, "test").unwrap();
774 graph.add_related_edge("Y", "Z", 0.9, "test").unwrap();
775
776 let counts = graph.get_edge_counts(&["X", "Y", "Z"]).unwrap();
777
778 assert_eq!(
780 counts.get("X").copied().unwrap_or(0),
781 graph.get_edge_count("X").unwrap()
782 );
783 assert_eq!(
784 counts.get("Y").copied().unwrap_or(0),
785 graph.get_edge_count("Y").unwrap()
786 );
787 assert_eq!(
788 counts.get("Z").copied().unwrap_or(0),
789 graph.get_edge_count("Z").unwrap()
790 );
791
792 assert_eq!(counts["X"], 2);
794 assert_eq!(counts["Y"], 2);
795 assert_eq!(counts["Z"], 2);
796 }
797}