@@ -133,6 +133,30 @@ impl Store {
133133 // Always ensure the index exists (safe for both fresh and migrated DBs).
134134 self . conn
135135 . execute_batch ( "CREATE INDEX IF NOT EXISTS idx_files_docid ON files(docid);" ) ?;
136+
137+ // Check if edges table exists.
138+ let has_edges: bool = {
139+ let mut stmt = self
140+ . conn
141+ . prepare ( "SELECT name FROM sqlite_master WHERE type='table' AND name='edges'" ) ?;
142+ let mut rows = stmt. query_map ( [ ] , |row| row. get :: < _ , String > ( 0 ) ) ?;
143+ rows. next ( ) . is_some ( )
144+ } ;
145+ if !has_edges {
146+ self . conn . execute_batch (
147+ "CREATE TABLE IF NOT EXISTS edges (
148+ id INTEGER PRIMARY KEY,
149+ from_file INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
150+ to_file INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
151+ edge_type TEXT NOT NULL,
152+ UNIQUE(from_file, to_file, edge_type)
153+ );
154+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_file);
155+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_file);
156+ CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(edge_type);" ,
157+ ) ?;
158+ }
159+
136160 Ok ( ( ) )
137161 }
138162
@@ -380,6 +404,100 @@ impl Store {
380404 Ok ( ( ) )
381405 }
382406
407+ // ── Edges ──────────────────────────────────────────────────
408+
409+ /// Insert an edge. Uses INSERT OR IGNORE for the UNIQUE constraint.
410+ pub fn insert_edge ( & self , from_file : i64 , to_file : i64 , edge_type : & str ) -> Result < ( ) > {
411+ self . conn . execute (
412+ "INSERT OR IGNORE INTO edges (from_file, to_file, edge_type) VALUES (?1, ?2, ?3)" ,
413+ params ! [ from_file, to_file, edge_type] ,
414+ ) ?;
415+ Ok ( ( ) )
416+ }
417+
418+ /// Delete all edges involving a file (both directions: from_file OR to_file).
419+ pub fn delete_edges_for_file ( & self , file_id : i64 ) -> Result < ( ) > {
420+ self . conn . execute (
421+ "DELETE FROM edges WHERE from_file = ?1 OR to_file = ?1" ,
422+ params ! [ file_id] ,
423+ ) ?;
424+ Ok ( ( ) )
425+ }
426+
427+ /// Clear all edges (used during --rebuild).
428+ pub fn clear_edges ( & self ) -> Result < ( ) > {
429+ self . conn . execute ( "DELETE FROM edges" , [ ] ) ?;
430+ Ok ( ( ) )
431+ }
432+
433+ /// Get outgoing edges, optionally filtered by type.
434+ pub fn get_outgoing (
435+ & self ,
436+ file_id : i64 ,
437+ edge_type : Option < & str > ,
438+ ) -> Result < Vec < ( i64 , String ) > > {
439+ let mut results = Vec :: new ( ) ;
440+ match edge_type {
441+ Some ( et) => {
442+ let mut stmt = self . conn . prepare (
443+ "SELECT to_file, edge_type FROM edges WHERE from_file = ?1 AND edge_type = ?2" ,
444+ ) ?;
445+ let rows = stmt. query_map ( params ! [ file_id, et] , |row| {
446+ Ok ( ( row. get :: < _ , i64 > ( 0 ) ?, row. get :: < _ , String > ( 1 ) ?) )
447+ } ) ?;
448+ for row in rows {
449+ results. push ( row?) ;
450+ }
451+ }
452+ None => {
453+ let mut stmt = self
454+ . conn
455+ . prepare ( "SELECT to_file, edge_type FROM edges WHERE from_file = ?1" ) ?;
456+ let rows = stmt. query_map ( params ! [ file_id] , |row| {
457+ Ok ( ( row. get :: < _ , i64 > ( 0 ) ?, row. get :: < _ , String > ( 1 ) ?) )
458+ } ) ?;
459+ for row in rows {
460+ results. push ( row?) ;
461+ }
462+ }
463+ }
464+ Ok ( results)
465+ }
466+
467+ /// Get incoming edges, optionally filtered by type.
468+ pub fn get_incoming (
469+ & self ,
470+ file_id : i64 ,
471+ edge_type : Option < & str > ,
472+ ) -> Result < Vec < ( i64 , String ) > > {
473+ let mut results = Vec :: new ( ) ;
474+ match edge_type {
475+ Some ( et) => {
476+ let mut stmt = self . conn . prepare (
477+ "SELECT from_file, edge_type FROM edges WHERE to_file = ?1 AND edge_type = ?2" ,
478+ ) ?;
479+ let rows = stmt. query_map ( params ! [ file_id, et] , |row| {
480+ Ok ( ( row. get :: < _ , i64 > ( 0 ) ?, row. get :: < _ , String > ( 1 ) ?) )
481+ } ) ?;
482+ for row in rows {
483+ results. push ( row?) ;
484+ }
485+ }
486+ None => {
487+ let mut stmt = self
488+ . conn
489+ . prepare ( "SELECT from_file, edge_type FROM edges WHERE to_file = ?1" ) ?;
490+ let rows = stmt. query_map ( params ! [ file_id] , |row| {
491+ Ok ( ( row. get :: < _ , i64 > ( 0 ) ?, row. get :: < _ , String > ( 1 ) ?) )
492+ } ) ?;
493+ for row in rows {
494+ results. push ( row?) ;
495+ }
496+ }
497+ }
498+ Ok ( results)
499+ }
500+
383501 // ── Stats ───────────────────────────────────────────────────
384502
385503 pub fn stats ( & self ) -> Result < StoreStats > {
@@ -773,4 +891,122 @@ mod tests {
773891 // Non-existent docid returns None.
774892 assert ! ( store. get_file_by_docid( "ffffff" ) . unwrap( ) . is_none( ) ) ;
775893 }
894+
895+ // ── Edge tests ─────────────────────────────────────────────
896+
897+ /// Helper: create two files and return their IDs.
898+ fn setup_two_files ( store : & Store ) -> ( i64 , i64 ) {
899+ let a = store
900+ . insert_file ( "notes/a.md" , "ha" , 100 , & [ ] , & generate_docid ( "notes/a.md" ) )
901+ . unwrap ( ) ;
902+ let b = store
903+ . insert_file ( "notes/b.md" , "hb" , 100 , & [ ] , & generate_docid ( "notes/b.md" ) )
904+ . unwrap ( ) ;
905+ ( a, b)
906+ }
907+
908+ #[ test]
909+ fn test_insert_and_get_edges ( ) {
910+ let store = Store :: open_memory ( ) . unwrap ( ) ;
911+ let ( a, b) = setup_two_files ( & store) ;
912+
913+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ;
914+
915+ let out = store. get_outgoing ( a, None ) . unwrap ( ) ;
916+ assert_eq ! ( out. len( ) , 1 ) ;
917+ assert_eq ! ( out[ 0 ] , ( b, "wikilink" . to_string( ) ) ) ;
918+
919+ let inc = store. get_incoming ( b, None ) . unwrap ( ) ;
920+ assert_eq ! ( inc. len( ) , 1 ) ;
921+ assert_eq ! ( inc[ 0 ] , ( a, "wikilink" . to_string( ) ) ) ;
922+
923+ // No edges in the other direction.
924+ assert ! ( store. get_outgoing( b, None ) . unwrap( ) . is_empty( ) ) ;
925+ assert ! ( store. get_incoming( a, None ) . unwrap( ) . is_empty( ) ) ;
926+ }
927+
928+ #[ test]
929+ fn test_delete_edges_for_file_both_directions ( ) {
930+ let store = Store :: open_memory ( ) . unwrap ( ) ;
931+ let ( a, b) = setup_two_files ( & store) ;
932+ let c = store
933+ . insert_file ( "notes/c.md" , "hc" , 100 , & [ ] , & generate_docid ( "notes/c.md" ) )
934+ . unwrap ( ) ;
935+
936+ // a -> b, c -> a
937+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ;
938+ store. insert_edge ( c, a, "mention" ) . unwrap ( ) ;
939+
940+ // Delete edges for file a — should remove both.
941+ store. delete_edges_for_file ( a) . unwrap ( ) ;
942+
943+ assert ! ( store. get_outgoing( a, None ) . unwrap( ) . is_empty( ) ) ;
944+ assert ! ( store. get_incoming( a, None ) . unwrap( ) . is_empty( ) ) ;
945+ assert ! ( store. get_incoming( b, None ) . unwrap( ) . is_empty( ) ) ;
946+ assert ! ( store. get_outgoing( c, None ) . unwrap( ) . is_empty( ) ) ;
947+ }
948+
949+ #[ test]
950+ fn test_edge_cascade_on_file_delete ( ) {
951+ let store = Store :: open_memory ( ) . unwrap ( ) ;
952+ let ( a, b) = setup_two_files ( & store) ;
953+ let c = store
954+ . insert_file ( "notes/c.md" , "hc" , 100 , & [ ] , & generate_docid ( "notes/c.md" ) )
955+ . unwrap ( ) ;
956+
957+ // a -> b, b -> c
958+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ;
959+ store. insert_edge ( b, c, "mention" ) . unwrap ( ) ;
960+
961+ // Delete file b — CASCADE should remove both edges.
962+ store. delete_file ( b) . unwrap ( ) ;
963+
964+ assert ! ( store. get_outgoing( a, None ) . unwrap( ) . is_empty( ) ) ;
965+ assert ! ( store. get_incoming( c, None ) . unwrap( ) . is_empty( ) ) ;
966+ }
967+
968+ #[ test]
969+ fn test_duplicate_edge_ignored ( ) {
970+ let store = Store :: open_memory ( ) . unwrap ( ) ;
971+ let ( a, b) = setup_two_files ( & store) ;
972+
973+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ;
974+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ; // duplicate
975+
976+ let out = store. get_outgoing ( a, None ) . unwrap ( ) ;
977+ assert_eq ! ( out. len( ) , 1 ) ;
978+
979+ // Same pair with different type is NOT a duplicate.
980+ store. insert_edge ( a, b, "mention" ) . unwrap ( ) ;
981+ let out = store. get_outgoing ( a, None ) . unwrap ( ) ;
982+ assert_eq ! ( out. len( ) , 2 ) ;
983+ }
984+
985+ #[ test]
986+ fn test_get_outgoing_filtered_by_type ( ) {
987+ let store = Store :: open_memory ( ) . unwrap ( ) ;
988+ let ( a, b) = setup_two_files ( & store) ;
989+ let c = store
990+ . insert_file ( "notes/c.md" , "hc" , 100 , & [ ] , & generate_docid ( "notes/c.md" ) )
991+ . unwrap ( ) ;
992+
993+ store. insert_edge ( a, b, "wikilink" ) . unwrap ( ) ;
994+ store. insert_edge ( a, c, "mention" ) . unwrap ( ) ;
995+
996+ let wikilinks = store. get_outgoing ( a, Some ( "wikilink" ) ) . unwrap ( ) ;
997+ assert_eq ! ( wikilinks. len( ) , 1 ) ;
998+ assert_eq ! ( wikilinks[ 0 ] . 0 , b) ;
999+
1000+ let mentions = store. get_outgoing ( a, Some ( "mention" ) ) . unwrap ( ) ;
1001+ assert_eq ! ( mentions. len( ) , 1 ) ;
1002+ assert_eq ! ( mentions[ 0 ] . 0 , c) ;
1003+
1004+ // Incoming filtered.
1005+ let inc = store. get_incoming ( b, Some ( "wikilink" ) ) . unwrap ( ) ;
1006+ assert_eq ! ( inc. len( ) , 1 ) ;
1007+ assert_eq ! ( inc[ 0 ] . 0 , a) ;
1008+
1009+ let inc = store. get_incoming ( b, Some ( "mention" ) ) . unwrap ( ) ;
1010+ assert ! ( inc. is_empty( ) ) ;
1011+ }
7761012}
0 commit comments