Skip to main content

ctx/analytics/
duckdb_impl.rs

1//! Analytics module using DuckDB for complex graph queries.
2//!
3//! This module provides:
4//! - Recursive call graph traversal
5//! - Impact analysis (reverse call graph)
6//! - Module dependency analysis
7//! - Codebase statistics aggregations
8
9use std::path::Path;
10
11use duckdb::{params, Connection, Result};
12
13use super::{CallGraphNode, ComplexityResult, FileStats, ImpactNode};
14use crate::index::{CTX_DIR, DB_FILE};
15
16/// DuckDB analytics engine for code intelligence.
17pub struct Analytics {
18    conn: Connection,
19}
20
21impl Analytics {
22    /// Open DuckDB and attach the SQLite database.
23    pub fn open(root: &Path) -> Result<Self> {
24        let ctx_dir = root.join(CTX_DIR);
25        let sqlite_path = ctx_dir.join(DB_FILE);
26
27        // Create in-memory DuckDB and attach SQLite
28        let conn = Connection::open_in_memory()?;
29
30        // Attach SQLite database with properly escaped path
31        // DuckDB uses single quotes for strings, so we need to escape any single quotes in the path
32        let path_str = sqlite_path.display().to_string();
33        let escaped_path = path_str.replace('\'', "''");
34        conn.execute(
35            &format!("ATTACH '{}' AS code (TYPE sqlite, READ_ONLY)", escaped_path),
36            [],
37        )?;
38
39        // Create materialized views for better performance
40        let analytics = Self { conn };
41        analytics.create_materialized_views()?;
42
43        Ok(analytics)
44    }
45
46    /// Create materialized views for common analytical queries.
47    fn create_materialized_views(&self) -> Result<()> {
48        // Call graph view - joins edges with symbols for fast traversal
49        self.conn.execute_batch(
50            r#"
51            CREATE OR REPLACE VIEW call_graph AS
52            SELECT 
53                e.source_id,
54                e.target_name,
55                e.target_id,
56                e.kind as edge_kind,
57                e.line,
58                e.context,
59                s.file_path as source_file,
60                s.name as source_name,
61                s.kind as source_kind,
62                t.file_path as target_file,
63                t.name as target_name_resolved,
64                t.kind as target_kind
65            FROM code.edges e
66            JOIN code.symbols s ON e.source_id = s.id
67            LEFT JOIN code.symbols t ON e.target_id = t.id
68            WHERE e.kind = 'calls';
69
70            -- File statistics view
71            CREATE OR REPLACE VIEW file_stats AS
72            SELECT 
73                file_path,
74                COUNT(*) as symbol_count,
75                COUNT(*) FILTER (WHERE kind IN ('function', 'method')) as functions,
76                COUNT(*) FILTER (WHERE kind = 'struct') as structs,
77                COUNT(*) FILTER (WHERE kind = 'enum') as enums,
78                COUNT(*) FILTER (WHERE visibility = 'public') as public_symbols
79            FROM code.symbols
80            GROUP BY file_path;
81
82            -- Symbol summary view
83            CREATE OR REPLACE VIEW symbol_summary AS
84            SELECT 
85                kind,
86                COUNT(*) as count,
87                COUNT(*) FILTER (WHERE visibility = 'public') as public_count
88            FROM code.symbols
89            GROUP BY kind
90            ORDER BY count DESC;
91            "#,
92        )?;
93
94        Ok(())
95    }
96
97    /// Get the full call graph starting from a symbol (forward traversal).
98    ///
99    /// The `start_name` can be:
100    /// - A simple name like "new" (matches first symbol with that name)
101    /// - A qualified name like "LocalProvider::new"
102    /// - A full ID like "src/embeddings/local.rs::LocalProvider::new@20"
103    pub fn call_graph(&self, start_name: &str, max_depth: i32) -> Result<Vec<CallGraphNode>> {
104        let mut stmt = self.conn.prepare(
105            r#"
106            WITH RECURSIVE start_symbol AS (
107                -- Resolve the starting symbol by ID, qualified name, or name
108                SELECT id, name, file_path, kind
109                FROM code.symbols
110                WHERE id = ?
111                   OR qualified_name = ?
112                   OR (name = ? AND NOT EXISTS (
113                       SELECT 1 FROM code.symbols s2 
114                       WHERE s2.id = ? OR s2.qualified_name = ?
115                   ))
116                ORDER BY file_path, line_start
117                LIMIT 1
118            ),
119            graph AS (
120                -- Base case: start from resolved symbol
121                SELECT 
122                    s.name,
123                    s.file_path,
124                    s.kind,
125                    1 as depth,
126                    s.id as current_id
127                FROM code.symbols s
128                JOIN start_symbol ss ON s.id = ss.id
129                
130                UNION ALL
131                
132                -- Recursive case: follow outgoing edges (only resolved edges)
133                SELECT 
134                    t.name,
135                    t.file_path,
136                    t.kind,
137                    g.depth + 1 as depth,
138                    t.id as current_id
139                FROM graph g
140                JOIN code.edges e ON e.source_id = g.current_id
141                JOIN code.symbols t ON e.target_id = t.id
142                WHERE g.depth < ?
143                  AND e.kind = 'calls'
144                  AND e.target_id IS NOT NULL
145            )
146            SELECT DISTINCT name, file_path, kind, MIN(depth) as depth
147            FROM graph
148            WHERE depth > 1  -- Exclude the starting symbol itself
149            GROUP BY name, file_path, kind
150            ORDER BY depth, name
151            "#,
152        )?;
153
154        let rows = stmt.query_map(
155            params![start_name, start_name, start_name, start_name, start_name, max_depth],
156            |row| {
157                Ok(CallGraphNode {
158                    name: row.get(0)?,
159                    file_path: row.get(1)?,
160                    kind: row.get(2)?,
161                    depth: row.get(3)?,
162                })
163            },
164        )?;
165
166        rows.collect()
167    }
168
169    /// Impact analysis: find all symbols that would be affected by changing the target.
170    ///
171    /// The `target_name` can be:
172    /// - A simple name like "new" (matches first symbol with that name)
173    /// - A qualified name like "LocalProvider::new"
174    /// - A full ID like "src/embeddings/local.rs::LocalProvider::new@20"
175    pub fn impact_analysis(&self, target_name: &str, max_depth: i32) -> Result<Vec<ImpactNode>> {
176        let mut stmt = self.conn.prepare(
177            r#"
178            WITH RECURSIVE target_symbol AS (
179                -- Resolve the target symbol by ID, qualified name, or name
180                SELECT id, name, file_path
181                FROM code.symbols
182                WHERE id = ?
183                   OR qualified_name = ?
184                   OR (name = ? AND NOT EXISTS (
185                       SELECT 1 FROM code.symbols s2 
186                       WHERE s2.id = ? OR s2.qualified_name = ?
187                   ))
188                ORDER BY file_path, line_start
189                LIMIT 1
190            ),
191            impact AS (
192                -- Base case: find direct callers of the specific target symbol
193                SELECT 
194                    s.name,
195                    s.file_path,
196                    s.kind,
197                    1 as distance,
198                    s.id as current_id
199                FROM code.edges e
200                JOIN code.symbols s ON e.source_id = s.id
201                JOIN target_symbol ts ON 
202                    -- Only traverse resolved edges to avoid cross-file false positives
203                    e.target_id IS NOT NULL AND e.target_id = ts.id
204                WHERE e.kind = 'calls'
205                
206                UNION ALL
207                
208                -- Recursive case: find callers of callers (reverse traversal)
209                SELECT 
210                    s.name,
211                    s.file_path,
212                    s.kind,
213                    i.distance + 1 as distance,
214                    s.id as current_id
215                FROM impact i
216                JOIN code.edges e ON 
217                    -- Only traverse resolved edges to avoid cross-file false positives
218                    e.target_id IS NOT NULL AND e.target_id = i.current_id
219                JOIN code.symbols s ON e.source_id = s.id
220                WHERE i.distance < ?
221                  AND e.kind = 'calls'
222            )
223            SELECT DISTINCT name, file_path, kind, MIN(distance) as distance
224            FROM impact
225            GROUP BY name, file_path, kind
226            ORDER BY distance, name
227            "#,
228        )?;
229
230        let rows = stmt.query_map(
231            params![
232                target_name,
233                target_name,
234                target_name,
235                target_name,
236                target_name,
237                max_depth
238            ],
239            |row| {
240                Ok(ImpactNode {
241                    name: row.get(0)?,
242                    file_path: row.get(1)?,
243                    kind: row.get(2)?,
244                    distance: row.get(3)?,
245                })
246            },
247        )?;
248
249        rows.collect()
250    }
251
252    /// Get file statistics for all indexed files.
253    pub fn file_statistics(&self) -> Result<Vec<FileStats>> {
254        let mut stmt = self.conn.prepare(
255            r#"
256            SELECT file_path, symbol_count, functions, structs, enums, public_symbols
257            FROM file_stats
258            ORDER BY symbol_count DESC
259            "#,
260        )?;
261
262        let rows = stmt.query_map([], |row| {
263            Ok(FileStats {
264                file_path: row.get(0)?,
265                symbol_count: row.get(1)?,
266                functions: row.get(2)?,
267                structs: row.get(3)?,
268                enums: row.get(4)?,
269                public_symbols: row.get(5)?,
270            })
271        })?;
272
273        rows.collect()
274    }
275
276    /// Get symbol counts by kind.
277    #[allow(dead_code)]
278    pub fn symbol_summary(&self) -> Result<Vec<(String, i64, i64)>> {
279        let mut stmt = self.conn.prepare(
280            r#"
281            SELECT kind, count, public_count
282            FROM symbol_summary
283            "#,
284        )?;
285
286        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
287
288        rows.collect()
289    }
290
291    /// Find if a path exists between two symbols (simplified version).
292    ///
293    /// Both `from_name` and `to_name` can be symbol IDs, qualified names, or simple names.
294    #[allow(dead_code)]
295    pub fn has_path(&self, from_name: &str, to_name: &str, max_depth: i32) -> Result<bool> {
296        let mut stmt = self.conn.prepare(
297            r#"
298            WITH RECURSIVE source_symbol AS (
299                SELECT id, name FROM code.symbols
300                WHERE id = ? OR qualified_name = ? OR name = ?
301                ORDER BY file_path
302                LIMIT 1
303            ),
304            target_symbol AS (
305                SELECT id, name FROM code.symbols
306                WHERE id = ? OR qualified_name = ? OR name = ?
307                ORDER BY file_path
308                LIMIT 1
309            ),
310            reachable AS (
311                -- Base case: start from source
312                SELECT 
313                    s.name,
314                    s.id as current_id,
315                    1 as depth
316                FROM code.symbols s
317                JOIN source_symbol src ON s.id = src.id
318                
319                UNION
320                
321                -- Follow edges using only resolved target_id to avoid cross-file false positives
322                SELECT 
323                    t.name,
324                    t.id,
325                    r.depth + 1
326                FROM reachable r
327                JOIN code.edges e ON e.source_id = r.current_id
328                JOIN code.symbols t ON e.target_id = t.id
329                WHERE r.depth < ?
330                  AND e.kind = 'calls'
331                  AND e.target_id IS NOT NULL
332            )
333            SELECT COUNT(*) > 0
334            FROM reachable r
335            JOIN target_symbol tgt ON r.current_id = tgt.id
336            "#,
337        )?;
338
339        let result: bool = stmt.query_row(
340            params![from_name, from_name, from_name, to_name, to_name, to_name, max_depth],
341            |row| row.get(0),
342        )?;
343        Ok(result)
344    }
345
346    /// Get the most connected symbols (highest in/out degree).
347    ///
348    /// This correctly counts incoming edges per symbol ID, not by name,
349    /// avoiding over-counting for common names like "new".
350    pub fn most_connected(&self, limit: i32) -> Result<Vec<(String, String, i64, i64)>> {
351        let mut stmt = self.conn.prepare(
352            r#"
353            WITH outgoing AS (
354                SELECT source_id, COUNT(*) as out_degree
355                FROM code.edges
356                WHERE kind = 'calls'
357                GROUP BY source_id
358            ),
359            incoming AS (
360                -- Count incoming edges by target_id when available
361                SELECT target_id as id, COUNT(*) as in_degree
362                FROM code.edges
363                WHERE kind = 'calls' AND target_id IS NOT NULL
364                GROUP BY target_id
365            )
366            SELECT 
367                s.name,
368                s.file_path,
369                COALESCE(o.out_degree, 0) as out_degree,
370                COALESCE(i.in_degree, 0) as in_degree
371            FROM code.symbols s
372            LEFT JOIN outgoing o ON s.id = o.source_id
373            LEFT JOIN incoming i ON s.id = i.id
374            WHERE s.kind IN ('function', 'method')
375            ORDER BY (COALESCE(o.out_degree, 0) + COALESCE(i.in_degree, 0)) DESC
376            LIMIT ?
377            "#,
378        )?;
379
380        let rows = stmt.query_map(params![limit], |row| {
381            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
382        })?;
383
384        rows.collect()
385    }
386
387    /// Check if there are any self-recursive functions.
388    ///
389    /// Uses target_id when available for accurate recursion detection.
390    #[allow(dead_code)]
391    pub fn find_recursive_functions(&self) -> Result<Vec<(String, String)>> {
392        let mut stmt = self.conn.prepare(
393            r#"
394            SELECT DISTINCT s.name, s.file_path
395            FROM code.edges e
396            JOIN code.symbols s ON e.source_id = s.id
397            WHERE e.kind = 'calls'
398              AND (
399                  (e.target_id IS NOT NULL AND e.target_id = s.id)
400                  OR (e.target_id IS NULL AND e.target_name = s.name)
401              )
402            ORDER BY s.name
403            "#,
404        )?;
405
406        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
407        rows.collect()
408    }
409
410    /// Get dependency graph between files (module-level).
411    ///
412    /// Uses target_id when available for accurate file resolution.
413    pub fn file_dependencies(&self) -> Result<Vec<(String, String, i64)>> {
414        let mut stmt = self.conn.prepare(
415            r#"
416            SELECT 
417                s.file_path as source_file,
418                COALESCE(t.file_path, 'external') as target_file,
419                COUNT(*) as call_count
420            FROM code.edges e
421            JOIN code.symbols s ON e.source_id = s.id
422            LEFT JOIN code.symbols t ON 
423                (e.target_id IS NOT NULL AND e.target_id = t.id)
424                OR (e.target_id IS NULL AND e.target_name = t.name)
425            WHERE e.kind = 'calls'
426              AND s.file_path != COALESCE(t.file_path, '')
427            GROUP BY s.file_path, COALESCE(t.file_path, 'external')
428            ORDER BY call_count DESC
429            "#,
430        )?;
431
432        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
433
434        rows.collect()
435    }
436
437    /// Analyze code complexity based on fan-out (outgoing calls) and fan-in (incoming calls).
438    ///
439    /// This correctly counts incoming edges per symbol ID, not by name,
440    /// avoiding over-counting for common names like "new".
441    pub fn complexity_analysis(&self, threshold: i64) -> Result<Vec<ComplexityResult>> {
442        let mut stmt = self.conn.prepare(
443            r#"
444            WITH outgoing AS (
445                SELECT source_id, COUNT(*) as out_degree
446                FROM code.edges
447                WHERE kind = 'calls'
448                GROUP BY source_id
449            ),
450            incoming AS (
451                -- Count incoming edges by target_id when available
452                SELECT target_id as id, COUNT(*) as in_degree
453                FROM code.edges
454                WHERE kind = 'calls' AND target_id IS NOT NULL
455                GROUP BY target_id
456            )
457            SELECT 
458                s.name,
459                s.file_path,
460                s.line_start,
461                COALESCE(o.out_degree, 0) as fan_out,
462                COALESCE(i.in_degree, 0) as fan_in,
463                -- Complexity score: weighted combination of fan-out (more important) and fan-in
464                (COALESCE(o.out_degree, 0) * 2 + COALESCE(i.in_degree, 0)) as complexity_score,
465                CASE 
466                    WHEN COALESCE(o.out_degree, 0) > 50 THEN 'critical'
467                    WHEN COALESCE(o.out_degree, 0) > 30 THEN 'high'
468                    WHEN COALESCE(o.out_degree, 0) > ? THEN 'medium'
469                    ELSE 'low'
470                END as severity
471            FROM code.symbols s
472            LEFT JOIN outgoing o ON s.id = o.source_id
473            LEFT JOIN incoming i ON s.id = i.id
474            WHERE s.kind IN ('function', 'method')
475            ORDER BY complexity_score DESC
476            "#,
477        )?;
478
479        let rows = stmt.query_map(params![threshold], |row| {
480            Ok(ComplexityResult {
481                name: row.get(0)?,
482                file_path: row.get(1)?,
483                line: row.get(2)?,
484                fan_out: row.get(3)?,
485                fan_in: row.get(4)?,
486                complexity_score: row.get(5)?,
487                severity: row.get(6)?,
488            })
489        })?;
490
491        rows.collect()
492    }
493
494    /// Get the full call graph (all edges with resolved symbols).
495    ///
496    /// Uses target_id when available for accurate symbol resolution.
497    pub fn full_call_graph(
498        &self,
499        _max_depth: i32,
500    ) -> Result<Vec<(String, String, String, String)>> {
501        let mut stmt = self.conn.prepare(
502            r#"
503            SELECT DISTINCT
504                s.file_path as source_file,
505                s.name as source_name,
506                COALESCE(t.file_path, 'external') as target_file,
507                COALESCE(t.name, e.target_name) as target_name
508            FROM code.edges e
509            JOIN code.symbols s ON e.source_id = s.id
510            LEFT JOIN code.symbols t ON 
511                (e.target_id IS NOT NULL AND e.target_id = t.id)
512                OR (e.target_id IS NULL AND e.target_name = t.name)
513            WHERE e.kind = 'calls'
514            ORDER BY s.file_path, s.name
515            "#,
516        )?;
517
518        let rows = stmt.query_map([], |row| {
519            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
520        })?;
521
522        rows.collect()
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use duckdb::Connection;
530
531    fn setup_test_db() -> Result<Connection> {
532        let conn = Connection::open_in_memory()?;
533
534        // Create schema
535        conn.execute_batch(
536            r#"
537            CREATE SCHEMA IF NOT EXISTS code;
538            
539            CREATE TABLE code.symbols (
540                id TEXT PRIMARY KEY,
541                name TEXT NOT NULL,
542                qualified_name TEXT,
543                kind TEXT NOT NULL,
544                file_path TEXT NOT NULL,
545                line_start INTEGER NOT NULL,
546                line_end INTEGER NOT NULL,
547                visibility TEXT
548            );
549            
550            CREATE TABLE code.edges (
551                source_id TEXT NOT NULL,
552                target_id TEXT,
553                target_name TEXT NOT NULL,
554                kind TEXT NOT NULL,
555                context TEXT,
556                FOREIGN KEY (source_id) REFERENCES code.symbols(id)
557            );
558            
559            -- Insert test data: main -> run -> helper
560            INSERT INTO code.symbols VALUES 
561                ('main@1', 'main', NULL, 'function', 'test.rs', 1, 10, 'public'),
562                ('run@11', 'run', NULL, 'function', 'test.rs', 11, 20, 'private'),
563                ('helper@21', 'helper', NULL, 'function', 'test.rs', 21, 30, 'private');
564            
565            INSERT INTO code.edges VALUES 
566                ('main@1', 'run@11', 'run', 'calls', NULL),
567                ('run@11', 'helper@21', 'helper', 'calls', NULL);
568            "#,
569        )?;
570
571        Ok(conn)
572    }
573
574    #[test]
575    fn test_call_graph_syntax() {
576        let conn = setup_test_db().expect("Failed to setup test db");
577        let analytics = Analytics { conn };
578
579        let result = analytics.call_graph("main", 5);
580        assert!(
581            result.is_ok(),
582            "call_graph query failed: {:?}",
583            result.err()
584        );
585
586        let nodes = result.unwrap();
587        assert_eq!(nodes.len(), 2, "Expected 2 nodes (run, helper)");
588        assert_eq!(nodes[0].name, "run");
589        assert_eq!(nodes[1].name, "helper");
590    }
591
592    #[test]
593    fn test_impact_analysis_syntax() {
594        let conn = setup_test_db().expect("Failed to setup test db");
595        let analytics = Analytics { conn };
596
597        let result = analytics.impact_analysis("helper", 5);
598        assert!(
599            result.is_ok(),
600            "impact_analysis query failed: {:?}",
601            result.err()
602        );
603
604        let nodes = result.unwrap();
605        assert_eq!(nodes.len(), 2, "Expected 2 nodes (run, main)");
606        // run calls helper directly (distance 1)
607        // main calls run which calls helper (distance 2)
608        assert!(nodes.iter().any(|n| n.name == "run" && n.distance == 1));
609        assert!(nodes.iter().any(|n| n.name == "main" && n.distance == 2));
610    }
611
612    #[test]
613    fn test_has_path_syntax() {
614        let conn = setup_test_db().expect("Failed to setup test db");
615        let analytics = Analytics { conn };
616
617        // main -> run -> helper (path exists)
618        let result = analytics.has_path("main", "helper", 5);
619        assert!(result.is_ok(), "has_path query failed: {:?}", result.err());
620        assert!(result.unwrap(), "Expected path from main to helper");
621
622        // helper -> main (no path in reverse direction)
623        let result = analytics.has_path("helper", "main", 5);
624        assert!(result.is_ok(), "has_path query failed: {:?}", result.err());
625        assert!(!result.unwrap(), "Expected no path from helper to main");
626    }
627
628    #[test]
629    fn test_sql_injection_in_path_escaping() {
630        // Test that paths with special SQL characters are properly escaped
631        // This is a unit test for the escaping logic - the actual open() function
632        // requires a real SQLite file, so we test the escaping directly
633
634        // Test paths that would cause SQL injection if not escaped
635        let test_paths = [
636            "normal/path.db",
637            "path with spaces/file.db",
638            "path'with'quotes/file.db",
639            "path''with''double/file.db",
640            "path;DROP TABLE code;/file.db",
641            "path' OR '1'='1/file.db",
642        ];
643
644        for path in &test_paths {
645            // Simulate the escaping done in Analytics::open()
646            let escaped = path.replace('\'', "''");
647            let sql = format!("ATTACH '{}' AS code", escaped);
648
649            // The escaped SQL should have balanced quotes
650            let quote_count = sql.chars().filter(|c| *c == '\'').count();
651            assert_eq!(
652                quote_count % 2,
653                0,
654                "SQL for path '{}' has unbalanced quotes: {}",
655                path,
656                sql
657            );
658
659            // Single quotes in the path should be doubled
660            if path.contains('\'') {
661                assert!(
662                    escaped.contains("''"),
663                    "Path with quote should have doubled quotes: {}",
664                    escaped
665                );
666            }
667        }
668    }
669}