Skip to main content

the_code_graph_storage/
graph_store.rs

1use std::path::Path;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use domain::error::Result;
5use domain::model::*;
6use domain::ports::GraphStore;
7
8use crate::mapping::*;
9use crate::SqliteStore;
10
11impl SqliteStore {
12    fn query_symbols(
13        &self,
14        stmt: &mut rusqlite::CachedStatement<'_>,
15        params: impl rusqlite::Params,
16    ) -> Result<Vec<SymbolNode>> {
17        let rows = stmt
18            .query_map(params, |row| {
19                Ok((
20                    row.get::<_, String>(0)?,
21                    row.get::<_, String>(1)?,
22                    row.get::<_, String>(2)?,
23                    row.get::<_, String>(3)?,
24                    row.get::<_, i64>(4)?,
25                    row.get::<_, i64>(5)?,
26                    row.get::<_, i64>(6)?,
27                    row.get::<_, i64>(7)?,
28                    row.get::<_, String>(8)?,
29                    row.get::<_, i32>(9)?,
30                    row.get::<_, i32>(10)?,
31                    row.get::<_, i32>(11)?,
32                    row.get::<_, Option<String>>(12)?,
33                    row.get::<_, Option<String>>(13)?,
34                ))
35            })
36            .map_err(map_rusqlite_error)?;
37        let mut symbols = Vec::new();
38        for row in rows {
39            let (qn, name, kind, file, ls, le, cs, ce, vis, exp, asy, tst, dec, sig) =
40                row.map_err(map_rusqlite_error)?;
41            let decorators: Vec<String> = match dec {
42                Some(ref s) => serde_json::from_str(s)
43                    .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?,
44                None => vec![],
45            };
46            symbols.push(SymbolNode {
47                qualified_name: qn,
48                name,
49                kind: symbol_kind_from_str(&kind)?,
50                location: Location {
51                    file: file.into(),
52                    line_start: ls as usize,
53                    line_end: le as usize,
54                    col_start: cs as usize,
55                    col_end: ce as usize,
56                },
57                visibility: visibility_from_str(&vis)?,
58                is_exported: exp != 0,
59                is_async: asy != 0,
60                is_test: tst != 0,
61                decorators,
62                signature: sig,
63            });
64        }
65        Ok(symbols)
66    }
67}
68
69fn now_epoch() -> i64 {
70    SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .unwrap_or_default()
73        .as_secs() as i64
74}
75
76impl GraphStore for SqliteStore {
77    fn upsert_file(&self, file: &FileNode) -> Result<()> {
78        let conn = self.conn()?;
79        conn.prepare_cached(
80            "INSERT OR REPLACE INTO files (path, language, hash, updated_at) VALUES (?1, ?2, ?3, ?4)",
81        )
82        .map_err(map_rusqlite_error)?
83        .execute(rusqlite::params![
84            file.path.to_str().unwrap_or_default(),
85            language_to_str(&file.language),
86            &file.hash,
87            now_epoch(),
88        ])
89        .map_err(map_rusqlite_error)?;
90        Ok(())
91    }
92
93    fn upsert_symbol(&self, symbol: &SymbolNode) -> Result<()> {
94        let conn = self.conn()?;
95        let decorators_json = serde_json::to_string(&symbol.decorators)
96            .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?;
97        conn.prepare_cached(
98            "INSERT OR REPLACE INTO symbols (
99                qualified_name, name, kind, file_path,
100                line_start, line_end, col_start, col_end,
101                visibility, is_exported, is_async, is_test,
102                decorators, signature, updated_at
103            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
104        )
105        .map_err(map_rusqlite_error)?
106        .execute(rusqlite::params![
107            &symbol.qualified_name,
108            &symbol.name,
109            symbol_kind_to_str(&symbol.kind),
110            symbol.location.file.to_str().unwrap_or_default(),
111            symbol.location.line_start as i64,
112            symbol.location.line_end as i64,
113            symbol.location.col_start as i64,
114            symbol.location.col_end as i64,
115            visibility_to_str(&symbol.visibility),
116            symbol.is_exported as i32,
117            symbol.is_async as i32,
118            symbol.is_test as i32,
119            &decorators_json,
120            &symbol.signature,
121            now_epoch(),
122        ])
123        .map_err(map_rusqlite_error)?;
124        Ok(())
125    }
126
127    fn upsert_edge(&self, edge: &Edge) -> Result<()> {
128        let conn = self.conn()?;
129        conn.prepare_cached(
130            "INSERT OR REPLACE INTO edges (kind, source_qualified, target_qualified, metadata)
131             VALUES (?1, ?2, ?3, ?4)",
132        )
133        .map_err(map_rusqlite_error)?
134        .execute(rusqlite::params![
135            edge_kind_to_str(&edge.kind),
136            &edge.source,
137            &edge.target,
138            &edge.metadata,
139        ])
140        .map_err(map_rusqlite_error)?;
141        Ok(())
142    }
143
144    fn get_file(&self, path: &Path) -> Result<Option<FileNode>> {
145        let conn = self.conn()?;
146        let mut stmt = conn
147            .prepare_cached("SELECT path, language, hash FROM files WHERE path = ?1")
148            .map_err(map_rusqlite_error)?;
149        let result = stmt.query_row(
150            rusqlite::params![path.to_str().unwrap_or_default()],
151            |row| {
152                Ok((
153                    row.get::<_, String>(0)?,
154                    row.get::<_, String>(1)?,
155                    row.get::<_, String>(2)?,
156                ))
157            },
158        );
159        match result {
160            Ok((p, lang, hash)) => Ok(Some(FileNode {
161                path: p.into(),
162                language: language_from_str(&lang)?,
163                hash,
164            })),
165            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
166            Err(e) => Err(map_rusqlite_error(e)),
167        }
168    }
169
170    fn get_symbol(&self, qualified_name: &str) -> Result<Option<SymbolNode>> {
171        let conn = self.conn()?;
172        let mut stmt = conn
173            .prepare_cached(
174                "SELECT qualified_name, name, kind, file_path,
175                        line_start, line_end, col_start, col_end,
176                        visibility, is_exported, is_async, is_test,
177                        decorators, signature
178                 FROM symbols WHERE qualified_name = ?1",
179            )
180            .map_err(map_rusqlite_error)?;
181        let result = stmt.query_row(rusqlite::params![qualified_name], |row| {
182            Ok((
183                row.get::<_, String>(0)?,
184                row.get::<_, String>(1)?,
185                row.get::<_, String>(2)?,
186                row.get::<_, String>(3)?,
187                row.get::<_, i64>(4)?,
188                row.get::<_, i64>(5)?,
189                row.get::<_, i64>(6)?,
190                row.get::<_, i64>(7)?,
191                row.get::<_, String>(8)?,
192                row.get::<_, i32>(9)?,
193                row.get::<_, i32>(10)?,
194                row.get::<_, i32>(11)?,
195                row.get::<_, Option<String>>(12)?,
196                row.get::<_, Option<String>>(13)?,
197            ))
198        });
199        match result {
200            Ok((qn, name, kind, file, ls, le, cs, ce, vis, exp, asy, tst, dec, sig)) => {
201                let decorators: Vec<String> = match dec {
202                    Some(ref s) => serde_json::from_str(s)
203                        .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?,
204                    None => vec![],
205                };
206                Ok(Some(SymbolNode {
207                    qualified_name: qn,
208                    name,
209                    kind: symbol_kind_from_str(&kind)?,
210                    location: Location {
211                        file: file.into(),
212                        line_start: ls as usize,
213                        line_end: le as usize,
214                        col_start: cs as usize,
215                        col_end: ce as usize,
216                    },
217                    visibility: visibility_from_str(&vis)?,
218                    is_exported: exp != 0,
219                    is_async: asy != 0,
220                    is_test: tst != 0,
221                    decorators,
222                    signature: sig,
223                }))
224            }
225            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
226            Err(e) => Err(map_rusqlite_error(e)),
227        }
228    }
229
230    fn get_edges_from(&self, source: &str) -> Result<Vec<Edge>> {
231        let conn = self.conn()?;
232        let mut stmt = conn
233            .prepare_cached(
234                "SELECT kind, source_qualified, target_qualified, metadata
235                 FROM edges WHERE source_qualified = ?1",
236            )
237            .map_err(map_rusqlite_error)?;
238        let rows = stmt
239            .query_map(rusqlite::params![source], |row| {
240                Ok((
241                    row.get::<_, String>(0)?,
242                    row.get::<_, String>(1)?,
243                    row.get::<_, String>(2)?,
244                    row.get::<_, Option<String>>(3)?,
245                ))
246            })
247            .map_err(map_rusqlite_error)?;
248        let mut edges = Vec::new();
249        for row in rows {
250            let (kind, src, tgt, meta) = row.map_err(map_rusqlite_error)?;
251            edges.push(Edge {
252                kind: edge_kind_from_str(&kind)?,
253                source: src,
254                target: tgt,
255                metadata: meta,
256            });
257        }
258        Ok(edges)
259    }
260
261    fn get_edges_to(&self, target: &str) -> Result<Vec<Edge>> {
262        let conn = self.conn()?;
263        let mut stmt = conn
264            .prepare_cached(
265                "SELECT kind, source_qualified, target_qualified, metadata
266                 FROM edges WHERE target_qualified = ?1",
267            )
268            .map_err(map_rusqlite_error)?;
269        let rows = stmt
270            .query_map(rusqlite::params![target], |row| {
271                Ok((
272                    row.get::<_, String>(0)?,
273                    row.get::<_, String>(1)?,
274                    row.get::<_, String>(2)?,
275                    row.get::<_, Option<String>>(3)?,
276                ))
277            })
278            .map_err(map_rusqlite_error)?;
279        let mut edges = Vec::new();
280        for row in rows {
281            let (kind, src, tgt, meta) = row.map_err(map_rusqlite_error)?;
282            edges.push(Edge {
283                kind: edge_kind_from_str(&kind)?,
284                source: src,
285                target: tgt,
286                metadata: meta,
287            });
288        }
289        Ok(edges)
290    }
291
292    fn all_files(&self) -> Result<Vec<FileNode>> {
293        let conn = self.conn()?;
294        let mut stmt = conn
295            .prepare_cached("SELECT path, language, hash FROM files")
296            .map_err(map_rusqlite_error)?;
297        let rows = stmt
298            .query_map([], |row| {
299                Ok((
300                    row.get::<_, String>(0)?,
301                    row.get::<_, String>(1)?,
302                    row.get::<_, String>(2)?,
303                ))
304            })
305            .map_err(map_rusqlite_error)?;
306        let mut files = Vec::new();
307        for row in rows {
308            let (path, lang, hash) = row.map_err(map_rusqlite_error)?;
309            files.push(FileNode {
310                path: path.into(),
311                language: language_from_str(&lang)?,
312                hash,
313            });
314        }
315        Ok(files)
316    }
317
318    fn all_symbols(&self) -> Result<Vec<SymbolNode>> {
319        let conn = self.conn()?;
320        let mut stmt = conn
321            .prepare_cached(
322                "SELECT qualified_name, name, kind, file_path,
323                        line_start, line_end, col_start, col_end,
324                        visibility, is_exported, is_async, is_test,
325                        decorators, signature
326                 FROM symbols",
327            )
328            .map_err(map_rusqlite_error)?;
329        let rows = stmt
330            .query_map([], |row| {
331                Ok((
332                    row.get::<_, String>(0)?,
333                    row.get::<_, String>(1)?,
334                    row.get::<_, String>(2)?,
335                    row.get::<_, String>(3)?,
336                    row.get::<_, i64>(4)?,
337                    row.get::<_, i64>(5)?,
338                    row.get::<_, i64>(6)?,
339                    row.get::<_, i64>(7)?,
340                    row.get::<_, String>(8)?,
341                    row.get::<_, i32>(9)?,
342                    row.get::<_, i32>(10)?,
343                    row.get::<_, i32>(11)?,
344                    row.get::<_, Option<String>>(12)?,
345                    row.get::<_, Option<String>>(13)?,
346                ))
347            })
348            .map_err(map_rusqlite_error)?;
349        let mut symbols = Vec::new();
350        for row in rows {
351            let (qn, name, kind, file, ls, le, cs, ce, vis, exp, asy, tst, dec, sig) =
352                row.map_err(map_rusqlite_error)?;
353            let decorators: Vec<String> = match dec {
354                Some(ref s) => serde_json::from_str(s)
355                    .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?,
356                None => vec![],
357            };
358            symbols.push(SymbolNode {
359                qualified_name: qn,
360                name,
361                kind: symbol_kind_from_str(&kind)?,
362                location: Location {
363                    file: file.into(),
364                    line_start: ls as usize,
365                    line_end: le as usize,
366                    col_start: cs as usize,
367                    col_end: ce as usize,
368                },
369                visibility: visibility_from_str(&vis)?,
370                is_exported: exp != 0,
371                is_async: asy != 0,
372                is_test: tst != 0,
373                decorators,
374                signature: sig,
375            });
376        }
377        Ok(symbols)
378    }
379
380    fn all_edges(&self) -> Result<Vec<Edge>> {
381        let conn = self.conn()?;
382        let mut stmt = conn
383            .prepare_cached("SELECT kind, source_qualified, target_qualified, metadata FROM edges")
384            .map_err(map_rusqlite_error)?;
385        let rows = stmt
386            .query_map([], |row| {
387                Ok((
388                    row.get::<_, String>(0)?,
389                    row.get::<_, String>(1)?,
390                    row.get::<_, String>(2)?,
391                    row.get::<_, Option<String>>(3)?,
392                ))
393            })
394            .map_err(map_rusqlite_error)?;
395        let mut edges = Vec::new();
396        for row in rows {
397            let (kind, src, tgt, meta) = row.map_err(map_rusqlite_error)?;
398            edges.push(Edge {
399                kind: edge_kind_from_str(&kind)?,
400                source: src,
401                target: tgt,
402                metadata: meta,
403            });
404        }
405        Ok(edges)
406    }
407
408    fn remove_file(&self, path: &Path) -> Result<()> {
409        let conn = self.conn()?;
410        conn.prepare_cached("DELETE FROM files WHERE path = ?1")
411            .map_err(map_rusqlite_error)?
412            .execute(rusqlite::params![path.to_str().unwrap_or_default()])
413            .map_err(map_rusqlite_error)?;
414        Ok(())
415    }
416
417    fn remove_symbols_in_file(&self, path: &Path) -> Result<()> {
418        let conn = self.conn()?;
419        conn.prepare_cached("DELETE FROM symbols WHERE file_path = ?1")
420            .map_err(map_rusqlite_error)?
421            .execute(rusqlite::params![path.to_str().unwrap_or_default()])
422            .map_err(map_rusqlite_error)?;
423        Ok(())
424    }
425
426    fn find_by_name(&self, pattern: &str) -> Result<Vec<SymbolNode>> {
427        let conn = self.conn()?;
428        // Phase 1: exact match on name
429        let mut stmt = conn
430            .prepare_cached(
431                "SELECT qualified_name, name, kind, file_path,
432                        line_start, line_end, col_start, col_end,
433                        visibility, is_exported, is_async, is_test,
434                        decorators, signature
435                 FROM symbols WHERE name = ?1",
436            )
437            .map_err(map_rusqlite_error)?;
438        let exact = self.query_symbols(&mut stmt, rusqlite::params![pattern])?;
439        if !exact.is_empty() {
440            return Ok(exact);
441        }
442        // Phase 2: prefix fallback (escape LIKE metacharacters)
443        let escaped = pattern
444            .replace('\\', "\\\\")
445            .replace('%', "\\%")
446            .replace('_', "\\_");
447        let prefix_pattern = format!("{escaped}%");
448        let mut stmt = conn
449            .prepare_cached(
450                "SELECT qualified_name, name, kind, file_path,
451                        line_start, line_end, col_start, col_end,
452                        visibility, is_exported, is_async, is_test,
453                        decorators, signature
454                 FROM symbols WHERE name LIKE ?1 ESCAPE '\\'",
455            )
456            .map_err(map_rusqlite_error)?;
457        self.query_symbols(&mut stmt, rusqlite::params![&prefix_pattern])
458    }
459
460    fn stats(&self) -> Result<GraphStats> {
461        let conn = self.conn()?;
462        let files: usize = conn
463            .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
464            .map_err(map_rusqlite_error)?;
465        let symbols: usize = conn
466            .query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))
467            .map_err(map_rusqlite_error)?;
468        let edges: usize = conn
469            .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
470            .map_err(map_rusqlite_error)?;
471        Ok(GraphStats {
472            files,
473            symbols,
474            edges,
475            entry_point_count: None,
476            avg_criticality: None,
477            clone_clusters: None,
478            duplication_pct: None,
479            most_duplicated: None,
480            avg_risk: None,
481            p90_risk: None,
482            community_count: None,
483            modularity: None,
484        })
485    }
486
487    fn store_file_data(
488        &self,
489        file: &FileNode,
490        symbols: &[SymbolNode],
491        edges: &[Edge],
492    ) -> Result<()> {
493        let mut conn = self.conn()?;
494        let tx = conn
495            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
496            .map_err(map_rusqlite_error)?;
497
498        let path_str = file.path.to_str().unwrap_or_default();
499
500        // Remove stale edges referencing this file's existing symbols
501        tx.prepare_cached(
502            "DELETE FROM edges
503             WHERE source_qualified IN (SELECT qualified_name FROM symbols WHERE file_path = ?1)
504                OR target_qualified IN (SELECT qualified_name FROM symbols WHERE file_path = ?1)",
505        )
506        .map_err(map_rusqlite_error)?
507        .execute(rusqlite::params![path_str])
508        .map_err(map_rusqlite_error)?;
509
510        // Remove stale symbols for this file
511        tx.prepare_cached("DELETE FROM symbols WHERE file_path = ?1")
512            .map_err(map_rusqlite_error)?
513            .execute(rusqlite::params![path_str])
514            .map_err(map_rusqlite_error)?;
515
516        // Upsert file
517        tx.prepare_cached(
518            "INSERT OR REPLACE INTO files (path, language, hash, updated_at) VALUES (?1, ?2, ?3, ?4)",
519        )
520        .map_err(map_rusqlite_error)?
521        .execute(rusqlite::params![
522            path_str,
523            language_to_str(&file.language),
524            &file.hash,
525            now_epoch(),
526        ])
527        .map_err(map_rusqlite_error)?;
528
529        // Insert each symbol
530        for symbol in symbols {
531            let decorators_json = serde_json::to_string(&symbol.decorators)
532                .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?;
533            tx.prepare_cached(
534                "INSERT OR REPLACE INTO symbols (
535                    qualified_name, name, kind, file_path,
536                    line_start, line_end, col_start, col_end,
537                    visibility, is_exported, is_async, is_test,
538                    decorators, signature, updated_at
539                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
540            )
541            .map_err(map_rusqlite_error)?
542            .execute(rusqlite::params![
543                &symbol.qualified_name,
544                &symbol.name,
545                symbol_kind_to_str(&symbol.kind),
546                symbol.location.file.to_str().unwrap_or_default(),
547                symbol.location.line_start as i64,
548                symbol.location.line_end as i64,
549                symbol.location.col_start as i64,
550                symbol.location.col_end as i64,
551                visibility_to_str(&symbol.visibility),
552                symbol.is_exported as i32,
553                symbol.is_async as i32,
554                symbol.is_test as i32,
555                &decorators_json,
556                &symbol.signature,
557                now_epoch(),
558            ])
559            .map_err(map_rusqlite_error)?;
560        }
561
562        // Upsert each edge
563        for edge in edges {
564            tx.prepare_cached(
565                "INSERT OR REPLACE INTO edges (kind, source_qualified, target_qualified, metadata)
566                 VALUES (?1, ?2, ?3, ?4)",
567            )
568            .map_err(map_rusqlite_error)?
569            .execute(rusqlite::params![
570                edge_kind_to_str(&edge.kind),
571                &edge.source,
572                &edge.target,
573                &edge.metadata,
574            ])
575            .map_err(map_rusqlite_error)?;
576        }
577
578        tx.commit().map_err(map_rusqlite_error)?;
579        Ok(())
580    }
581
582    fn symbols_for_files(&self, paths: &[&Path]) -> Result<Vec<SymbolNode>> {
583        if paths.is_empty() {
584            return Ok(vec![]);
585        }
586        let conn = self.conn()?;
587        // Chunk paths to stay within SQLite's SQLITE_MAX_VARIABLE_NUMBER limit
588        const CHUNK_SIZE: usize = 500;
589        let mut symbols = Vec::new();
590        for chunk in paths.chunks(CHUNK_SIZE) {
591            // SAFETY: placeholders are numeric indices (?1, ?2, ...) derived from the chunk
592            // length — no user data is interpolated into SQL. Values are bound via params_from_iter.
593            let placeholders: String = (1..=chunk.len())
594                .map(|i| format!("?{i}"))
595                .collect::<Vec<_>>()
596                .join(", ");
597            let sql = format!(
598                "SELECT qualified_name, name, kind, file_path,
599                        line_start, line_end, col_start, col_end,
600                        visibility, is_exported, is_async, is_test,
601                        decorators, signature
602                 FROM symbols WHERE file_path IN ({placeholders})"
603            );
604            let mut stmt = conn.prepare(&sql).map_err(map_rusqlite_error)?;
605            let params: Vec<&str> = chunk
606                .iter()
607                .map(|p| p.to_str().unwrap_or_default())
608                .collect();
609            let rows = stmt
610                .query_map(rusqlite::params_from_iter(params), |row| {
611                    Ok((
612                        row.get::<_, String>(0)?,
613                        row.get::<_, String>(1)?,
614                        row.get::<_, String>(2)?,
615                        row.get::<_, String>(3)?,
616                        row.get::<_, i64>(4)?,
617                        row.get::<_, i64>(5)?,
618                        row.get::<_, i64>(6)?,
619                        row.get::<_, i64>(7)?,
620                        row.get::<_, String>(8)?,
621                        row.get::<_, i32>(9)?,
622                        row.get::<_, i32>(10)?,
623                        row.get::<_, i32>(11)?,
624                        row.get::<_, Option<String>>(12)?,
625                        row.get::<_, Option<String>>(13)?,
626                    ))
627                })
628                .map_err(map_rusqlite_error)?;
629            for row in rows {
630                let (qn, name, kind, file, ls, le, cs, ce, vis, exp, asy, tst, dec, sig) =
631                    row.map_err(map_rusqlite_error)?;
632                let decorators: Vec<String> = match dec {
633                    Some(ref s) => serde_json::from_str(s)
634                        .map_err(|e| domain::error::CodeGraphError::Storage(e.to_string()))?,
635                    None => vec![],
636                };
637                symbols.push(SymbolNode {
638                    qualified_name: qn,
639                    name,
640                    kind: symbol_kind_from_str(&kind)?,
641                    location: Location {
642                        file: file.into(),
643                        line_start: ls as usize,
644                        line_end: le as usize,
645                        col_start: cs as usize,
646                        col_end: ce as usize,
647                    },
648                    visibility: visibility_from_str(&vis)?,
649                    is_exported: exp != 0,
650                    is_async: asy != 0,
651                    is_test: tst != 0,
652                    decorators,
653                    signature: sig,
654                });
655            }
656        }
657        Ok(symbols)
658    }
659
660    fn edges_streaming(&self, callback: &mut dyn FnMut(Edge) -> Result<()>) -> Result<()> {
661        let conn = self.conn()?;
662        let mut stmt = conn
663            .prepare_cached("SELECT kind, source_qualified, target_qualified, metadata FROM edges")
664            .map_err(map_rusqlite_error)?;
665        let rows = stmt
666            .query_map([], |row| {
667                Ok((
668                    row.get::<_, String>(0)?,
669                    row.get::<_, String>(1)?,
670                    row.get::<_, String>(2)?,
671                    row.get::<_, Option<String>>(3)?,
672                ))
673            })
674            .map_err(map_rusqlite_error)?;
675        for row in rows {
676            let (kind, src, tgt, meta) = row.map_err(map_rusqlite_error)?;
677            callback(Edge {
678                kind: edge_kind_from_str(&kind)?,
679                source: src,
680                target: tgt,
681                metadata: meta,
682            })?;
683        }
684        Ok(())
685    }
686
687    fn remove_file_data(&self, path: &Path) -> Result<()> {
688        let mut conn = self.conn()?;
689        let tx = conn
690            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
691            .map_err(map_rusqlite_error)?;
692
693        let path_str = path.to_str().unwrap_or_default();
694
695        // Delete edges where source or target is a symbol from this file
696        tx.prepare_cached(
697            "DELETE FROM edges
698             WHERE source_qualified IN (SELECT qualified_name FROM symbols WHERE file_path = ?1)
699                OR target_qualified IN (SELECT qualified_name FROM symbols WHERE file_path = ?1)",
700        )
701        .map_err(map_rusqlite_error)?
702        .execute(rusqlite::params![path_str])
703        .map_err(map_rusqlite_error)?;
704
705        // Delete file (CASCADE removes symbols)
706        tx.prepare_cached("DELETE FROM files WHERE path = ?1")
707            .map_err(map_rusqlite_error)?
708            .execute(rusqlite::params![path_str])
709            .map_err(map_rusqlite_error)?;
710
711        tx.commit().map_err(map_rusqlite_error)?;
712        Ok(())
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use domain::ports::GraphStore;
720
721    fn test_store() -> SqliteStore {
722        SqliteStore::open_in_memory().unwrap()
723    }
724
725    fn sample_file() -> FileNode {
726        FileNode {
727            path: "src/main.rs".into(),
728            language: Language::Rust,
729            hash: "abc123".into(),
730        }
731    }
732
733    fn sample_symbol() -> SymbolNode {
734        SymbolNode {
735            name: "foo".into(),
736            qualified_name: "src/main.rs::foo".into(),
737            kind: SymbolKind::Function,
738            location: Location {
739                file: "src/main.rs".into(),
740                line_start: 1,
741                line_end: 10,
742                col_start: 0,
743                col_end: 1,
744            },
745            visibility: Visibility::Public,
746            is_exported: true,
747            is_async: false,
748            is_test: false,
749            decorators: vec!["inline".into()],
750            signature: Some("fn foo() -> bool".into()),
751        }
752    }
753
754    fn sample_edge() -> Edge {
755        Edge {
756            kind: EdgeKind::Calls,
757            source: "src/main.rs::foo".into(),
758            target: "src/lib.rs::bar".into(),
759            metadata: None,
760        }
761    }
762
763    #[test]
764    fn upsert_file_insert_then_update() {
765        let store = test_store();
766        let mut file = sample_file();
767        store.upsert_file(&file).unwrap();
768        let got = store.get_file(&file.path).unwrap().unwrap();
769        assert_eq!(got.hash, "abc123");
770
771        file.hash = "def456".into();
772        store.upsert_file(&file).unwrap();
773        let got = store.get_file(&file.path).unwrap().unwrap();
774        assert_eq!(got.hash, "def456");
775    }
776
777    #[test]
778    fn get_file_missing_returns_none() {
779        let store = test_store();
780        assert!(store.get_file("nonexistent".as_ref()).unwrap().is_none());
781    }
782
783    #[test]
784    fn upsert_symbol_insert_then_update() {
785        let store = test_store();
786        store.upsert_file(&sample_file()).unwrap();
787        let mut sym = sample_symbol();
788        store.upsert_symbol(&sym).unwrap();
789        let got = store.get_symbol(&sym.qualified_name).unwrap().unwrap();
790        assert_eq!(got.name, "foo");
791
792        sym.name = "foo_renamed".into();
793        store.upsert_symbol(&sym).unwrap();
794        let got = store.get_symbol(&sym.qualified_name).unwrap().unwrap();
795        assert_eq!(got.name, "foo_renamed");
796    }
797
798    #[test]
799    fn get_symbol_missing_returns_none() {
800        let store = test_store();
801        assert!(store.get_symbol("nonexistent").unwrap().is_none());
802    }
803
804    #[test]
805    fn upsert_edge_idempotent() {
806        let store = test_store();
807        let edge = sample_edge();
808        store.upsert_edge(&edge).unwrap();
809        store.upsert_edge(&edge).unwrap();
810        let edges = store.get_edges_from(&edge.source).unwrap();
811        assert_eq!(edges.len(), 1);
812    }
813
814    #[test]
815    fn get_edges_from_and_to() {
816        let store = test_store();
817        store.upsert_edge(&sample_edge()).unwrap();
818        let from = store.get_edges_from("src/main.rs::foo").unwrap();
819        assert_eq!(from.len(), 1);
820        let to = store.get_edges_to("src/lib.rs::bar").unwrap();
821        assert_eq!(to.len(), 1);
822        assert!(store.get_edges_from("none").unwrap().is_empty());
823        assert!(store.get_edges_to("none").unwrap().is_empty());
824    }
825
826    #[test]
827    fn all_files_symbols_edges() {
828        let store = test_store();
829        store.upsert_file(&sample_file()).unwrap();
830        store.upsert_symbol(&sample_symbol()).unwrap();
831        store.upsert_edge(&sample_edge()).unwrap();
832        assert_eq!(store.all_files().unwrap().len(), 1);
833        assert_eq!(store.all_symbols().unwrap().len(), 1);
834        assert_eq!(store.all_edges().unwrap().len(), 1);
835    }
836
837    #[test]
838    fn remove_file_cascades_to_symbols() {
839        let store = test_store();
840        store.upsert_file(&sample_file()).unwrap();
841        store.upsert_symbol(&sample_symbol()).unwrap();
842        store.remove_file("src/main.rs".as_ref()).unwrap();
843        assert!(store.get_file("src/main.rs".as_ref()).unwrap().is_none());
844        assert!(store.get_symbol("src/main.rs::foo").unwrap().is_none());
845    }
846
847    #[test]
848    fn remove_symbols_in_file_keeps_file() {
849        let store = test_store();
850        store.upsert_file(&sample_file()).unwrap();
851        store.upsert_symbol(&sample_symbol()).unwrap();
852        store
853            .remove_symbols_in_file("src/main.rs".as_ref())
854            .unwrap();
855        assert!(store.get_file("src/main.rs".as_ref()).unwrap().is_some());
856        assert!(store.get_symbol("src/main.rs::foo").unwrap().is_none());
857    }
858
859    #[test]
860    fn stats_returns_correct_counts() {
861        let store = test_store();
862        store.upsert_file(&sample_file()).unwrap();
863        store.upsert_symbol(&sample_symbol()).unwrap();
864        store.upsert_edge(&sample_edge()).unwrap();
865        let s = store.stats().unwrap();
866        assert_eq!(s.files, 1);
867        assert_eq!(s.symbols, 1);
868        assert_eq!(s.edges, 1);
869    }
870
871    // --- Batch operations (T05) ---
872
873    #[test]
874    fn store_file_data_stores_all() {
875        let store = test_store();
876        let file = sample_file();
877        let symbols = vec![sample_symbol()];
878        let edges = vec![sample_edge()];
879        store.store_file_data(&file, &symbols, &edges).unwrap();
880        assert!(store.get_file(&file.path).unwrap().is_some());
881        assert!(store.get_symbol("src/main.rs::foo").unwrap().is_some());
882        assert_eq!(store.all_edges().unwrap().len(), 1);
883    }
884
885    #[test]
886    fn store_file_data_replaces_existing() {
887        let store = test_store();
888        let file = sample_file();
889        let sym1 = sample_symbol();
890        store.store_file_data(&file, &[sym1], &[]).unwrap();
891
892        let sym2 = SymbolNode {
893            name: "bar".into(),
894            qualified_name: "src/main.rs::bar".into(),
895            kind: SymbolKind::Function,
896            location: Location {
897                file: "src/main.rs".into(),
898                line_start: 20,
899                line_end: 30,
900                col_start: 0,
901                col_end: 1,
902            },
903            visibility: Visibility::Private,
904            is_exported: false,
905            is_async: false,
906            is_test: false,
907            decorators: vec![],
908            signature: None,
909        };
910        store.store_file_data(&file, &[sym2], &[]).unwrap();
911        // Old symbol must be gone, new one present
912        assert!(store.get_symbol("src/main.rs::foo").unwrap().is_none());
913        assert!(store.get_symbol("src/main.rs::bar").unwrap().is_some());
914    }
915
916    #[test]
917    fn remove_file_data_cleans_edges() {
918        let store = test_store();
919        let file = sample_file();
920        let lib_file = FileNode {
921            path: "src/lib.rs".into(),
922            language: Language::Rust,
923            hash: "xyz".into(),
924        };
925        store.upsert_file(&lib_file).unwrap();
926        let sym = sample_symbol();
927        let edge = sample_edge();
928        store.store_file_data(&file, &[sym], &[edge]).unwrap();
929
930        store.remove_file_data("src/main.rs".as_ref()).unwrap();
931
932        assert!(store.get_file("src/main.rs".as_ref()).unwrap().is_none());
933        assert!(store.get_symbol("src/main.rs::foo").unwrap().is_none());
934        assert!(store.all_edges().unwrap().is_empty());
935    }
936
937    // --- Field fidelity ---
938
939    #[test]
940    fn symbol_roundtrip_preserves_all_fields() {
941        let store = test_store();
942        store.upsert_file(&sample_file()).unwrap();
943        let sym = sample_symbol();
944        store.upsert_symbol(&sym).unwrap();
945        let got = store.get_symbol(&sym.qualified_name).unwrap().unwrap();
946        assert_eq!(got.name, sym.name);
947        assert_eq!(got.kind, sym.kind);
948        assert_eq!(got.visibility, sym.visibility);
949        assert_eq!(got.is_exported, sym.is_exported);
950        assert_eq!(got.is_async, sym.is_async);
951        assert_eq!(got.is_test, sym.is_test);
952        assert_eq!(got.decorators, sym.decorators);
953        assert_eq!(got.signature, sym.signature);
954        assert_eq!(got.location.line_start, sym.location.line_start);
955        assert_eq!(got.location.line_end, sym.location.line_end);
956    }
957
958    // --- find_by_name (T04) ---
959
960    fn make_named_symbol(name: &str, qn: &str) -> SymbolNode {
961        SymbolNode {
962            name: name.into(),
963            qualified_name: qn.into(),
964            kind: SymbolKind::Function,
965            location: Location {
966                file: "src/main.rs".into(),
967                line_start: 1,
968                line_end: 10,
969                col_start: 0,
970                col_end: 1,
971            },
972            visibility: Visibility::Public,
973            is_exported: true,
974            is_async: false,
975            is_test: false,
976            decorators: vec![],
977            signature: None,
978        }
979    }
980
981    #[test]
982    fn find_by_name_exact_match() {
983        let store = test_store();
984        store.upsert_file(&sample_file()).unwrap();
985        store
986            .upsert_symbol(&make_named_symbol("foo", "src/main.rs::foo"))
987            .unwrap();
988        store
989            .upsert_symbol(&make_named_symbol("foobar", "src/main.rs::foobar"))
990            .unwrap();
991        store
992            .upsert_symbol(&make_named_symbol("bar", "src/main.rs::bar"))
993            .unwrap();
994
995        let results = store.find_by_name("foo").unwrap();
996        assert_eq!(results.len(), 1);
997        assert_eq!(results[0].name, "foo");
998    }
999
1000    #[test]
1001    fn find_by_name_prefix_fallback() {
1002        let store = test_store();
1003        store.upsert_file(&sample_file()).unwrap();
1004        store
1005            .upsert_symbol(&make_named_symbol("foobar", "src/main.rs::foobar"))
1006            .unwrap();
1007        store
1008            .upsert_symbol(&make_named_symbol("foobaz", "src/main.rs::foobaz"))
1009            .unwrap();
1010
1011        let results = store.find_by_name("foo").unwrap();
1012        assert_eq!(results.len(), 2);
1013        let mut names: Vec<&str> = results.iter().map(|s| s.name.as_str()).collect();
1014        names.sort();
1015        assert_eq!(names, vec!["foobar", "foobaz"]);
1016    }
1017
1018    #[test]
1019    fn find_by_name_no_match() {
1020        let store = test_store();
1021        let results = store.find_by_name("nonexistent").unwrap();
1022        assert!(results.is_empty());
1023    }
1024
1025    #[test]
1026    fn find_by_name_case_sensitive() {
1027        // The exact-match phase (=) is case-sensitive, so "Foo" != "foo".
1028        // However, SQLite LIKE is case-insensitive for ASCII, so the
1029        // prefix fallback matches "foo" when searching "Foo".
1030        let store = test_store();
1031        store.upsert_file(&sample_file()).unwrap();
1032        store
1033            .upsert_symbol(&make_named_symbol("foo", "src/main.rs::foo"))
1034            .unwrap();
1035
1036        // Exact match skipped (case-sensitive =), but prefix LIKE catches it
1037        let results = store.find_by_name("Foo").unwrap();
1038        assert_eq!(results.len(), 1);
1039        assert_eq!(results[0].name, "foo");
1040    }
1041
1042    #[test]
1043    fn find_by_name_escapes_like_metacharacters() {
1044        let store = test_store();
1045        store.upsert_file(&sample_file()).unwrap();
1046        store
1047            .upsert_symbol(&make_named_symbol("__init__", "src/main.rs::__init__"))
1048            .unwrap();
1049        store
1050            .upsert_symbol(&make_named_symbol(
1051                "__init_extra",
1052                "src/main.rs::__init_extra",
1053            ))
1054            .unwrap();
1055        store
1056            .upsert_symbol(&make_named_symbol("axbycz", "src/main.rs::axbycz"))
1057            .unwrap();
1058
1059        // Exact match for __init__
1060        let results = store.find_by_name("__init__").unwrap();
1061        assert_eq!(results.len(), 1);
1062        assert_eq!(results[0].name, "__init__");
1063
1064        // Prefix search for "__init" should match both __init__ and __init_extra,
1065        // but NOT "axbycz" (underscore must not act as single-char wildcard)
1066        let results = store.find_by_name("__init").unwrap();
1067        assert_eq!(results.len(), 2);
1068        let mut names: Vec<&str> = results.iter().map(|s| s.name.as_str()).collect();
1069        names.sort();
1070        assert_eq!(names, vec!["__init__", "__init_extra"]);
1071    }
1072
1073    // --- Filtered queries (T06) ---
1074
1075    #[test]
1076    fn symbols_for_files_returns_filtered_subset() {
1077        let store = test_store();
1078        // Insert file for a.rs
1079        store
1080            .upsert_file(&FileNode {
1081                path: "src/a.rs".into(),
1082                language: Language::Rust,
1083                hash: "h1".into(),
1084            })
1085            .unwrap();
1086        // Insert file for b.rs
1087        store
1088            .upsert_file(&FileNode {
1089                path: "src/b.rs".into(),
1090                language: Language::Rust,
1091                hash: "h2".into(),
1092            })
1093            .unwrap();
1094        // Symbols in a.rs
1095        store
1096            .upsert_symbol(&SymbolNode {
1097                name: "foo".into(),
1098                qualified_name: "src/a.rs::foo".into(),
1099                kind: SymbolKind::Function,
1100                location: Location {
1101                    file: "src/a.rs".into(),
1102                    line_start: 1,
1103                    line_end: 10,
1104                    col_start: 0,
1105                    col_end: 1,
1106                },
1107                visibility: Visibility::Public,
1108                is_exported: true,
1109                is_async: false,
1110                is_test: false,
1111                decorators: vec![],
1112                signature: None,
1113            })
1114            .unwrap();
1115        // Symbols in b.rs
1116        store
1117            .upsert_symbol(&SymbolNode {
1118                name: "bar".into(),
1119                qualified_name: "src/b.rs::bar".into(),
1120                kind: SymbolKind::Function,
1121                location: Location {
1122                    file: "src/b.rs".into(),
1123                    line_start: 1,
1124                    line_end: 10,
1125                    col_start: 0,
1126                    col_end: 1,
1127                },
1128                visibility: Visibility::Public,
1129                is_exported: true,
1130                is_async: false,
1131                is_test: false,
1132                decorators: vec![],
1133                signature: None,
1134            })
1135            .unwrap();
1136        // Filter for a.rs only
1137        let results = store.symbols_for_files(&[Path::new("src/a.rs")]).unwrap();
1138        assert_eq!(results.len(), 1);
1139        assert_eq!(results[0].name, "foo");
1140    }
1141
1142    #[test]
1143    fn symbols_for_files_empty_paths_returns_empty() {
1144        let store = test_store();
1145        store.upsert_file(&sample_file()).unwrap();
1146        store.upsert_symbol(&sample_symbol()).unwrap();
1147        let results = store.symbols_for_files(&[]).unwrap();
1148        assert!(results.is_empty());
1149    }
1150
1151    #[test]
1152    fn edges_streaming_invokes_callback_per_row() {
1153        let store = test_store();
1154        store
1155            .upsert_edge(&Edge {
1156                kind: EdgeKind::Calls,
1157                source: "a::foo".into(),
1158                target: "b::bar".into(),
1159                metadata: None,
1160            })
1161            .unwrap();
1162        store
1163            .upsert_edge(&Edge {
1164                kind: EdgeKind::ImportsFrom,
1165                source: "c::baz".into(),
1166                target: "d::qux".into(),
1167                metadata: None,
1168            })
1169            .unwrap();
1170        store
1171            .upsert_edge(&Edge {
1172                kind: EdgeKind::Contains,
1173                source: "e::quux".into(),
1174                target: "f::corge".into(),
1175                metadata: None,
1176            })
1177            .unwrap();
1178        let mut count = 0usize;
1179        store
1180            .edges_streaming(&mut |_edge| {
1181                count += 1;
1182                Ok(())
1183            })
1184            .unwrap();
1185        assert_eq!(count, 3);
1186        assert_eq!(store.all_edges().unwrap().len(), 3);
1187    }
1188}