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;
10use std::sync::Arc;
11
12use duckdb::types::ValueRef;
13use duckdb::{params, Connection, InterruptHandle, Result};
14
15use super::{CallGraphNode, ComplexityResult, FileStats, ImpactNode};
16use crate::index::{CTX_DIR, DB_FILE};
17
18/// DuckDB analytics engine for code intelligence.
19pub struct Analytics {
20    conn: Connection,
21}
22
23impl Analytics {
24    /// Open DuckDB and attach the SQLite database.
25    pub fn open(root: &Path) -> Result<Self> {
26        let ctx_dir = root.join(CTX_DIR);
27        let sqlite_path = ctx_dir.join(DB_FILE);
28
29        // Create in-memory DuckDB and attach SQLite
30        let conn = Connection::open_in_memory()?;
31
32        // Attach SQLite database with properly escaped path
33        // DuckDB uses single quotes for strings, so we need to escape any single quotes in the path
34        let path_str = sqlite_path.display().to_string();
35        let escaped_path = path_str.replace('\'', "''");
36        conn.execute(
37            &format!("ATTACH '{}' AS code (TYPE sqlite, READ_ONLY)", escaped_path),
38            [],
39        )?;
40
41        // Create materialized views for better performance
42        let analytics = Self { conn };
43        analytics.create_materialized_views()?;
44
45        Ok(analytics)
46    }
47
48    /// Create materialized views for common analytical queries.
49    fn create_materialized_views(&self) -> Result<()> {
50        // Call graph view - joins edges with symbols for fast traversal
51        self.conn.execute_batch(
52            r#"
53            CREATE OR REPLACE VIEW call_graph AS
54            SELECT 
55                e.source_id,
56                e.target_name,
57                e.target_id,
58                e.kind as edge_kind,
59                e.line,
60                e.context,
61                s.file_path as source_file,
62                s.name as source_name,
63                s.kind as source_kind,
64                t.file_path as target_file,
65                t.name as target_name_resolved,
66                t.kind as target_kind
67            FROM code.edges e
68            JOIN code.symbols s ON e.source_id = s.id
69            LEFT JOIN code.symbols t ON e.target_id = t.id
70            WHERE e.kind = 'calls';
71
72            -- File statistics view
73            CREATE OR REPLACE VIEW file_stats AS
74            SELECT 
75                file_path,
76                COUNT(*) as symbol_count,
77                COUNT(*) FILTER (WHERE kind IN ('function', 'method')) as functions,
78                COUNT(*) FILTER (WHERE kind = 'struct') as structs,
79                COUNT(*) FILTER (WHERE kind = 'enum') as enums,
80                COUNT(*) FILTER (WHERE visibility = 'public') as public_symbols
81            FROM code.symbols
82            GROUP BY file_path;
83
84            -- Symbol summary view
85            CREATE OR REPLACE VIEW symbol_summary AS
86            SELECT 
87                kind,
88                COUNT(*) as count,
89                COUNT(*) FILTER (WHERE visibility = 'public') as public_count
90            FROM code.symbols
91            GROUP BY kind
92            ORDER BY count DESC;
93            "#,
94        )?;
95
96        Ok(())
97    }
98
99    /// Get the full call graph starting from a symbol (forward traversal).
100    ///
101    /// The `start_name` can be:
102    /// - A simple name like "new" (matches first symbol with that name)
103    /// - A qualified name like "LocalProvider::new"
104    /// - A full ID like "src/embeddings/local.rs::LocalProvider::new@20"
105    pub fn call_graph(&self, start_name: &str, max_depth: i32) -> Result<Vec<CallGraphNode>> {
106        let mut stmt = self.conn.prepare(
107            r#"
108            WITH RECURSIVE start_symbol AS (
109                -- Resolve the starting symbol by ID, qualified name, or name
110                SELECT id, name, file_path, kind
111                FROM code.symbols
112                WHERE id = ?
113                   OR qualified_name = ?
114                   OR (name = ? AND NOT EXISTS (
115                       SELECT 1 FROM code.symbols s2 
116                       WHERE s2.id = ? OR s2.qualified_name = ?
117                   ))
118                ORDER BY file_path, line_start
119                LIMIT 1
120            ),
121            graph AS (
122                -- Base case: start from resolved symbol
123                SELECT 
124                    s.name,
125                    s.file_path,
126                    s.kind,
127                    1 as depth,
128                    s.id as current_id
129                FROM code.symbols s
130                JOIN start_symbol ss ON s.id = ss.id
131                
132                UNION ALL
133                
134                -- Recursive case: follow outgoing edges (only resolved edges)
135                SELECT 
136                    t.name,
137                    t.file_path,
138                    t.kind,
139                    g.depth + 1 as depth,
140                    t.id as current_id
141                FROM graph g
142                JOIN code.edges e ON e.source_id = g.current_id
143                JOIN code.symbols t ON e.target_id = t.id
144                WHERE g.depth < ?
145                  AND e.kind = 'calls'
146                  AND e.target_id IS NOT NULL
147            )
148            SELECT DISTINCT name, file_path, kind, MIN(depth) as depth
149            FROM graph
150            WHERE depth > 1  -- Exclude the starting symbol itself
151            GROUP BY name, file_path, kind
152            ORDER BY depth, name
153            "#,
154        )?;
155
156        let rows = stmt.query_map(
157            params![start_name, start_name, start_name, start_name, start_name, max_depth],
158            |row| {
159                Ok(CallGraphNode {
160                    name: row.get(0)?,
161                    file_path: row.get(1)?,
162                    kind: row.get(2)?,
163                    depth: row.get(3)?,
164                })
165            },
166        )?;
167
168        rows.collect()
169    }
170
171    /// Impact analysis: find all symbols that would be affected by changing the target.
172    ///
173    /// The `target_name` can be:
174    /// - A simple name like "new" (matches first symbol with that name)
175    /// - A qualified name like "LocalProvider::new"
176    /// - A full ID like "src/embeddings/local.rs::LocalProvider::new@20"
177    pub fn impact_analysis(&self, target_name: &str, max_depth: i32) -> Result<Vec<ImpactNode>> {
178        let mut stmt = self.conn.prepare(
179            r#"
180            WITH RECURSIVE target_symbol AS (
181                -- Resolve the target symbol by ID, qualified name, or name
182                SELECT id, name, file_path
183                FROM code.symbols
184                WHERE id = ?
185                   OR qualified_name = ?
186                   OR (name = ? AND NOT EXISTS (
187                       SELECT 1 FROM code.symbols s2 
188                       WHERE s2.id = ? OR s2.qualified_name = ?
189                   ))
190                ORDER BY file_path, line_start
191                LIMIT 1
192            ),
193            impact AS (
194                -- Base case: find direct callers of the specific target symbol
195                SELECT 
196                    s.name,
197                    s.file_path,
198                    s.kind,
199                    1 as distance,
200                    s.id as current_id
201                FROM code.edges e
202                JOIN code.symbols s ON e.source_id = s.id
203                JOIN target_symbol ts ON 
204                    -- Only traverse resolved edges to avoid cross-file false positives
205                    e.target_id IS NOT NULL AND e.target_id = ts.id
206                WHERE e.kind = 'calls'
207                
208                UNION ALL
209                
210                -- Recursive case: find callers of callers (reverse traversal)
211                SELECT 
212                    s.name,
213                    s.file_path,
214                    s.kind,
215                    i.distance + 1 as distance,
216                    s.id as current_id
217                FROM impact i
218                JOIN code.edges e ON 
219                    -- Only traverse resolved edges to avoid cross-file false positives
220                    e.target_id IS NOT NULL AND e.target_id = i.current_id
221                JOIN code.symbols s ON e.source_id = s.id
222                WHERE i.distance < ?
223                  AND e.kind = 'calls'
224            )
225            SELECT DISTINCT name, file_path, kind, MIN(distance) as distance
226            FROM impact
227            GROUP BY name, file_path, kind
228            ORDER BY distance, name
229            "#,
230        )?;
231
232        let rows = stmt.query_map(
233            params![
234                target_name,
235                target_name,
236                target_name,
237                target_name,
238                target_name,
239                max_depth
240            ],
241            |row| {
242                Ok(ImpactNode {
243                    name: row.get(0)?,
244                    file_path: row.get(1)?,
245                    kind: row.get(2)?,
246                    distance: row.get(3)?,
247                })
248            },
249        )?;
250
251        rows.collect()
252    }
253
254    /// Get file statistics for all indexed files.
255    pub fn file_statistics(&self) -> Result<Vec<FileStats>> {
256        let mut stmt = self.conn.prepare(
257            r#"
258            SELECT file_path, symbol_count, functions, structs, enums, public_symbols
259            FROM file_stats
260            ORDER BY symbol_count DESC
261            "#,
262        )?;
263
264        let rows = stmt.query_map([], |row| {
265            Ok(FileStats {
266                file_path: row.get(0)?,
267                symbol_count: row.get(1)?,
268                functions: row.get(2)?,
269                structs: row.get(3)?,
270                enums: row.get(4)?,
271                public_symbols: row.get(5)?,
272            })
273        })?;
274
275        rows.collect()
276    }
277
278    /// Get symbol counts by kind.
279    #[allow(dead_code)]
280    pub fn symbol_summary(&self) -> Result<Vec<(String, i64, i64)>> {
281        let mut stmt = self.conn.prepare(
282            r#"
283            SELECT kind, count, public_count
284            FROM symbol_summary
285            "#,
286        )?;
287
288        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
289
290        rows.collect()
291    }
292
293    /// Find if a path exists between two symbols (simplified version).
294    ///
295    /// Both `from_name` and `to_name` can be symbol IDs, qualified names, or simple names.
296    #[allow(dead_code)]
297    pub fn has_path(&self, from_name: &str, to_name: &str, max_depth: i32) -> Result<bool> {
298        let mut stmt = self.conn.prepare(
299            r#"
300            WITH RECURSIVE source_symbol AS (
301                SELECT id, name FROM code.symbols
302                WHERE id = ? OR qualified_name = ? OR name = ?
303                ORDER BY file_path
304                LIMIT 1
305            ),
306            target_symbol AS (
307                SELECT id, name FROM code.symbols
308                WHERE id = ? OR qualified_name = ? OR name = ?
309                ORDER BY file_path
310                LIMIT 1
311            ),
312            reachable AS (
313                -- Base case: start from source
314                SELECT 
315                    s.name,
316                    s.id as current_id,
317                    1 as depth
318                FROM code.symbols s
319                JOIN source_symbol src ON s.id = src.id
320                
321                UNION
322                
323                -- Follow edges using only resolved target_id to avoid cross-file false positives
324                SELECT 
325                    t.name,
326                    t.id,
327                    r.depth + 1
328                FROM reachable r
329                JOIN code.edges e ON e.source_id = r.current_id
330                JOIN code.symbols t ON e.target_id = t.id
331                WHERE r.depth < ?
332                  AND e.kind = 'calls'
333                  AND e.target_id IS NOT NULL
334            )
335            SELECT COUNT(*) > 0
336            FROM reachable r
337            JOIN target_symbol tgt ON r.current_id = tgt.id
338            "#,
339        )?;
340
341        let result: bool = stmt.query_row(
342            params![from_name, from_name, from_name, to_name, to_name, to_name, max_depth],
343            |row| row.get(0),
344        )?;
345        Ok(result)
346    }
347
348    /// Get the most connected symbols (highest in/out degree).
349    ///
350    /// This correctly counts incoming edges per symbol ID, not by name,
351    /// avoiding over-counting for common names like "new".
352    pub fn most_connected(&self, limit: i32) -> Result<Vec<(String, String, i64, i64)>> {
353        let mut stmt = self.conn.prepare(
354            r#"
355            WITH outgoing AS (
356                SELECT source_id, COUNT(*) as out_degree
357                FROM code.edges
358                WHERE kind = 'calls'
359                GROUP BY source_id
360            ),
361            incoming AS (
362                -- Count incoming edges by target_id when available
363                SELECT target_id as id, COUNT(*) as in_degree
364                FROM code.edges
365                WHERE kind = 'calls' AND target_id IS NOT NULL
366                GROUP BY target_id
367            )
368            SELECT 
369                s.name,
370                s.file_path,
371                COALESCE(o.out_degree, 0) as out_degree,
372                COALESCE(i.in_degree, 0) as in_degree
373            FROM code.symbols s
374            LEFT JOIN outgoing o ON s.id = o.source_id
375            LEFT JOIN incoming i ON s.id = i.id
376            WHERE s.kind IN ('function', 'method')
377            ORDER BY (COALESCE(o.out_degree, 0) + COALESCE(i.in_degree, 0)) DESC
378            LIMIT ?
379            "#,
380        )?;
381
382        let rows = stmt.query_map(params![limit], |row| {
383            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
384        })?;
385
386        rows.collect()
387    }
388
389    /// Check if there are any self-recursive functions.
390    ///
391    /// Uses target_id when available for accurate recursion detection.
392    #[allow(dead_code)]
393    pub fn find_recursive_functions(&self) -> Result<Vec<(String, String)>> {
394        let mut stmt = self.conn.prepare(
395            r#"
396            SELECT DISTINCT s.name, s.file_path
397            FROM code.edges e
398            JOIN code.symbols s ON e.source_id = s.id
399            WHERE e.kind = 'calls'
400              AND (
401                  (e.target_id IS NOT NULL AND e.target_id = s.id)
402                  OR (e.target_id IS NULL AND e.target_name = s.name)
403              )
404            ORDER BY s.name
405            "#,
406        )?;
407
408        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
409        rows.collect()
410    }
411
412    /// Get dependency graph between files (module-level).
413    ///
414    /// Uses target_id when available for accurate file resolution.
415    pub fn file_dependencies(&self) -> Result<Vec<(String, String, i64)>> {
416        let mut stmt = self.conn.prepare(
417            r#"
418            SELECT 
419                s.file_path as source_file,
420                COALESCE(t.file_path, 'external') as target_file,
421                COUNT(*) as call_count
422            FROM code.edges e
423            JOIN code.symbols s ON e.source_id = s.id
424            LEFT JOIN code.symbols t ON 
425                (e.target_id IS NOT NULL AND e.target_id = t.id)
426                OR (e.target_id IS NULL AND e.target_name = t.name)
427            WHERE e.kind = 'calls'
428              AND s.file_path != COALESCE(t.file_path, '')
429            GROUP BY s.file_path, COALESCE(t.file_path, 'external')
430            ORDER BY call_count DESC
431            "#,
432        )?;
433
434        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
435
436        rows.collect()
437    }
438
439    /// Analyze code complexity based on fan-out (outgoing calls) and fan-in (incoming calls).
440    ///
441    /// This correctly counts incoming edges per symbol ID, not by name,
442    /// avoiding over-counting for common names like "new".
443    pub fn complexity_analysis(&self, threshold: i64) -> Result<Vec<ComplexityResult>> {
444        let mut stmt = self.conn.prepare(
445            r#"
446            WITH outgoing AS (
447                SELECT source_id, COUNT(*) as out_degree
448                FROM code.edges
449                WHERE kind = 'calls'
450                GROUP BY source_id
451            ),
452            incoming AS (
453                -- Count incoming edges by target_id when available
454                SELECT target_id as id, COUNT(*) as in_degree
455                FROM code.edges
456                WHERE kind = 'calls' AND target_id IS NOT NULL
457                GROUP BY target_id
458            )
459            SELECT 
460                s.name,
461                s.file_path,
462                s.line_start,
463                COALESCE(o.out_degree, 0) as fan_out,
464                COALESCE(i.in_degree, 0) as fan_in,
465                -- Complexity score: weighted combination of fan-out (more important) and fan-in
466                (COALESCE(o.out_degree, 0) * 2 + COALESCE(i.in_degree, 0)) as complexity_score,
467                CASE 
468                    WHEN COALESCE(o.out_degree, 0) > 50 THEN 'critical'
469                    WHEN COALESCE(o.out_degree, 0) > 30 THEN 'high'
470                    WHEN COALESCE(o.out_degree, 0) > ? THEN 'medium'
471                    ELSE 'low'
472                END as severity
473            FROM code.symbols s
474            LEFT JOIN outgoing o ON s.id = o.source_id
475            LEFT JOIN incoming i ON s.id = i.id
476            WHERE s.kind IN ('function', 'method')
477            ORDER BY complexity_score DESC
478            "#,
479        )?;
480
481        let rows = stmt.query_map(params![threshold], |row| {
482            Ok(ComplexityResult {
483                name: row.get(0)?,
484                file_path: row.get(1)?,
485                line: row.get(2)?,
486                fan_out: row.get(3)?,
487                fan_in: row.get(4)?,
488                complexity_score: row.get(5)?,
489                severity: row.get(6)?,
490            })
491        })?;
492
493        rows.collect()
494    }
495
496    /// Get the full call graph (all edges with resolved symbols).
497    ///
498    /// Uses target_id when available for accurate symbol resolution.
499    pub fn full_call_graph(
500        &self,
501        _max_depth: i32,
502    ) -> Result<Vec<(String, String, String, String)>> {
503        let mut stmt = self.conn.prepare(
504            r#"
505            SELECT DISTINCT
506                s.file_path as source_file,
507                s.name as source_name,
508                COALESCE(t.file_path, 'external') as target_file,
509                COALESCE(t.name, e.target_name) as target_name
510            FROM code.edges e
511            JOIN code.symbols s ON e.source_id = s.id
512            LEFT JOIN code.symbols t ON 
513                (e.target_id IS NOT NULL AND e.target_id = t.id)
514                OR (e.target_id IS NULL AND e.target_name = t.name)
515            WHERE e.kind = 'calls'
516            ORDER BY s.file_path, s.name
517            "#,
518        )?;
519
520        let rows = stmt.query_map([], |row| {
521            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
522        })?;
523
524        rows.collect()
525    }
526
527    // ========================================================================
528    // Raw SQL surface (`ctx sql`) — hardened, read-only query sandbox.
529    // ========================================================================
530
531    /// Open DuckDB for the `ctx sql` command: attach the SQLite index read-only,
532    /// build the public `v1` view layer, then lock the engine down so untrusted
533    /// user SQL cannot touch the filesystem, load extensions, or re-enable any
534    /// of that. Safety is enforced entirely by engine configuration — never by
535    /// inspecting the SQL text.
536    pub fn open_sql_sandbox(root: &Path) -> Result<Self> {
537        let ctx_dir = root.join(CTX_DIR);
538        let sqlite_path = ctx_dir.join(DB_FILE);
539
540        let conn = Connection::open_in_memory()?;
541
542        // Attach the SQLite index read-only (single-quote-escape the path).
543        let path_str = sqlite_path.display().to_string();
544        let escaped_path = path_str.replace('\'', "''");
545        conn.execute(
546            &format!("ATTACH '{}' AS code (TYPE sqlite, READ_ONLY)", escaped_path),
547            [],
548        )?;
549
550        let analytics = Self { conn };
551
552        // Build the public `v1` contract views BEFORE hardening — creating views
553        // and reading the attached DB must happen while access is still allowed.
554        let index_root = root.display().to_string();
555        analytics.create_public_schema_v1(env!("CARGO_PKG_VERSION"), &index_root)?;
556
557        // Engine-level hardening (order matters: memory + external-access first,
558        // then lock configuration, which blocks any further `SET`).
559        //
560        // `enable_external_access = false` disables the filesystem (COPY,
561        // read_csv, file-based ATTACH), extension installation, and config
562        // changes; `lock_configuration = true` prevents user SQL from re-enabling
563        // any of it. The SQLite index is attached READ_ONLY, so it cannot be
564        // mutated. Note: DuckDB 1.4 has no engine toggle to reject a bare
565        // in-memory `ATTACH ':memory:'`; that is permitted but inert (no
566        // filesystem access, the index stays read-only, and the one-result-set
567        // rule blocks chaining a write onto it). We do not add SQL-text filtering
568        // for it — safety is enforced by engine configuration, not by parsing.
569        let mem_limit =
570            std::env::var("CTX_SQL_MEMORY_LIMIT").unwrap_or_else(|_| "512MB".to_string());
571        let mem_escaped = mem_limit.replace('\'', "''");
572        analytics.conn.execute_batch(&format!(
573            "SET memory_limit = '{}';\n\
574             SET enable_external_access = false;\n\
575             SET lock_configuration = true;",
576            mem_escaped
577        ))?;
578
579        Ok(analytics)
580    }
581
582    /// Create the versioned public schema `v1` — the stable query surface.
583    ///
584    /// These views (not the physical `code.*` tables) are the contract. Column
585    /// lists here are the documented `v1` schema; derived columns (fan-in/out,
586    /// complexity) are computed in-view.
587    fn create_public_schema_v1(&self, ctx_version: &str, index_root: &str) -> Result<()> {
588        // Literals injected into `v1.meta`; single-quote-escape defensively.
589        let ctx_version_lit = ctx_version.replace('\'', "''");
590        let index_root_lit = index_root.replace('\'', "''");
591
592        self.conn.execute_batch(&format!(
593            r#"
594            CREATE SCHEMA v1;
595
596            CREATE VIEW v1.symbols AS
597            WITH fan_out_counts AS (
598                SELECT source_id AS id, COUNT(*) AS fan_out
599                FROM code.edges
600                WHERE kind = 'calls'
601                GROUP BY source_id
602            ),
603            fan_in_counts AS (
604                SELECT target_id AS id, COUNT(*) AS fan_in
605                FROM code.edges
606                WHERE kind = 'calls' AND target_id IS NOT NULL
607                GROUP BY target_id
608            )
609            SELECT
610                s.id,
611                s.name,
612                s.qualified_name,
613                s.kind,
614                s.file_path AS file,
615                s.line_start,
616                s.line_end,
617                (s.visibility = 'public') AS is_public,
618                (COALESCE(fo.fan_out, 0) * 2 + COALESCE(fi.fan_in, 0)) AS complexity,
619                COALESCE(fi.fan_in, 0) AS fan_in,
620                COALESCE(fo.fan_out, 0) AS fan_out,
621                COALESCE(s.docstring, s.brief) AS doc
622            FROM code.symbols s
623            LEFT JOIN fan_out_counts fo ON fo.id = s.id
624            LEFT JOIN fan_in_counts fi ON fi.id = s.id;
625
626            CREATE VIEW v1.edges AS
627            SELECT
628                e.source_id,
629                s.name AS source_name,
630                s.file_path AS source_file,
631                e.target_id,
632                e.target_name,
633                t.file_path AS target_file,
634                e.kind,
635                e.line
636            FROM code.edges e
637            JOIN code.symbols s ON e.source_id = s.id
638            LEFT JOIN code.symbols t ON e.target_id = t.id;
639
640            CREATE VIEW v1.files AS
641            SELECT
642                f.path,
643                f.language,
644                COALESCE(agg.symbol_count, 0) AS symbol_count,
645                COALESCE(agg.total_complexity, 0) AS total_complexity,
646                f.last_indexed AS indexed_at
647            FROM code.files f
648            LEFT JOIN (
649                SELECT file, COUNT(*) AS symbol_count, SUM(complexity) AS total_complexity
650                FROM v1.symbols
651                GROUP BY file
652            ) agg ON agg.file = f.path;
653
654            CREATE VIEW v1.meta AS
655            SELECT
656                1 AS schema_version,
657                '{ctx_version}' AS ctx_version,
658                (SELECT MIN(last_indexed) FROM code.files) AS index_created_at,
659                '{index_root}' AS index_root;
660            "#,
661            ctx_version = ctx_version_lit,
662            index_root = index_root_lit,
663        ))?;
664
665        Ok(())
666    }
667
668    /// A handle that can interrupt an in-flight query from another thread
669    /// (used to enforce `--timeout`). `InterruptHandle` is `Send + Sync`.
670    pub fn interrupt_handle(&self) -> Arc<InterruptHandle> {
671        self.conn.interrupt_handle()
672    }
673
674    /// Execute a non-final statement in a multi-statement submission.
675    ///
676    /// Returns `Ok(true)` if the statement produces a result set — the caller
677    /// rejects that (only the final statement may return rows, per the one
678    /// result-set rule). Statements that produce no result set (e.g.
679    /// `CREATE TEMP TABLE …`, `SET …`) are executed for their side effects and
680    /// return `Ok(false)`.
681    ///
682    /// (DuckDB only knows a statement's column count *after* execution, so the
683    /// statement is always run — harmless here, since the index is read-only
684    /// and the in-memory DuckDB is throwaway.)
685    pub fn exec_non_final_statement(&self, sql: &str) -> Result<bool> {
686        let mut stmt = self.conn.prepare(sql)?;
687        let rows = stmt.query([])?;
688        let produced = rows
689            .as_ref()
690            .map(|s| {
691                let n = s.column_count();
692                if n == 0 {
693                    return false;
694                }
695                // DuckDB reports DDL/DML — CREATE/INSERT/UPDATE/DELETE, including
696                // `CREATE TABLE … AS SELECT` — as a single BIGINT column named
697                // "Count" (rows affected). That is not a user-facing result set,
698                // so such statements are allowed to precede the final one.
699                !(n == 1 && s.column_name(0).map(|c| c == "Count").unwrap_or(false))
700            })
701            .unwrap_or(false);
702        Ok(produced)
703    }
704
705    /// Run the final (result-producing) statement, streaming rows and stopping
706    /// once `max_rows` is reached (`0` = unlimited). One extra row is fetched to
707    /// detect truncation, so the full result is never materialized in Rust.
708    pub fn run_final_statement(&self, sql: &str, max_rows: usize) -> Result<SqlResult> {
709        let mut stmt = self.conn.prepare(sql)?;
710        let mut rows = stmt.query([])?;
711
712        // Column metadata is only available after the statement has executed.
713        let columns: Vec<SqlColumn> = {
714            let executed = rows.as_ref().expect("statement is available after query()");
715            let col_count = executed.column_count();
716            let names = executed.column_names();
717            (0..col_count)
718                .map(|i| SqlColumn {
719                    name: names.get(i).cloned().unwrap_or_default(),
720                    type_name: duckdb_type_name(&executed.column_type(i)),
721                })
722                .collect()
723        };
724        let col_count = columns.len();
725
726        let cap = if max_rows == 0 { usize::MAX } else { max_rows };
727        let mut rows_out: Vec<Vec<serde_json::Value>> = Vec::new();
728        let mut truncated = false;
729
730        while let Some(row) = rows.next()? {
731            if rows_out.len() >= cap {
732                truncated = true;
733                break;
734            }
735            let mut record = Vec::with_capacity(col_count);
736            for i in 0..col_count {
737                record.push(value_ref_to_json(row.get_ref(i)?));
738            }
739            rows_out.push(record);
740        }
741
742        Ok(SqlResult {
743            columns,
744            rows: rows_out,
745            truncated,
746        })
747    }
748}
749
750/// A column in a `ctx sql` result: its name and DuckDB type name.
751#[derive(Debug, Clone)]
752pub struct SqlColumn {
753    pub name: String,
754    pub type_name: String,
755}
756
757/// The result of a `ctx sql` query: columns plus row-capped data.
758#[derive(Debug, Clone)]
759pub struct SqlResult {
760    pub columns: Vec<SqlColumn>,
761    pub rows: Vec<Vec<serde_json::Value>>,
762    pub truncated: bool,
763}
764
765/// Map an Arrow `DataType` (what duckdb-rs reports for a column via
766/// `column_type`) to a DuckDB type name for the JSON envelope (e.g. `VARCHAR`,
767/// `BIGINT`).
768fn duckdb_type_name(dt: &duckdb::arrow::datatypes::DataType) -> String {
769    use duckdb::arrow::datatypes::DataType as D;
770    match dt {
771        D::Null => "NULL",
772        D::Boolean => "BOOLEAN",
773        D::Int8 => "TINYINT",
774        D::Int16 => "SMALLINT",
775        D::Int32 => "INTEGER",
776        D::Int64 => "BIGINT",
777        D::UInt8 => "UTINYINT",
778        D::UInt16 => "USMALLINT",
779        D::UInt32 => "UINTEGER",
780        D::UInt64 => "UBIGINT",
781        D::Float16 | D::Float32 => "FLOAT",
782        D::Float64 => "DOUBLE",
783        D::Utf8 | D::LargeUtf8 | D::Utf8View => "VARCHAR",
784        D::Binary | D::LargeBinary | D::BinaryView => "BLOB",
785        D::Date32 | D::Date64 => "DATE",
786        D::Timestamp(_, _) => "TIMESTAMP",
787        D::Time32(_) | D::Time64(_) => "TIME",
788        D::Decimal128(_, _) | D::Decimal256(_, _) => "DECIMAL",
789        other => return format!("{:?}", other),
790    }
791    .to_string()
792}
793
794/// Convert a DuckDB `ValueRef` into a `serde_json::Value` for output. Exotic
795/// types (temporal, decimal, nested) fall back to a debug string so rendering
796/// never panics.
797fn value_ref_to_json(v: ValueRef<'_>) -> serde_json::Value {
798    use serde_json::Value as J;
799    match v {
800        ValueRef::Null => J::Null,
801        ValueRef::Boolean(b) => J::Bool(b),
802        ValueRef::TinyInt(n) => J::from(n),
803        ValueRef::SmallInt(n) => J::from(n),
804        ValueRef::Int(n) => J::from(n),
805        ValueRef::BigInt(n) => J::from(n),
806        ValueRef::UTinyInt(n) => J::from(n),
807        ValueRef::USmallInt(n) => J::from(n),
808        ValueRef::UInt(n) => J::from(n),
809        ValueRef::UBigInt(n) => J::from(n),
810        ValueRef::HugeInt(n) => J::String(n.to_string()),
811        ValueRef::Float(f) => serde_json::Number::from_f64(f as f64)
812            .map(J::Number)
813            .unwrap_or(J::Null),
814        ValueRef::Double(f) => serde_json::Number::from_f64(f)
815            .map(J::Number)
816            .unwrap_or(J::Null),
817        ValueRef::Text(bytes) => J::String(String::from_utf8_lossy(bytes).into_owned()),
818        ValueRef::Blob(bytes) => J::String(format!("<blob: {} bytes>", bytes.len())),
819        other => J::String(format!("{:?}", other)),
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use duckdb::Connection;
827
828    fn setup_test_db() -> Result<Connection> {
829        let conn = Connection::open_in_memory()?;
830
831        // Create schema
832        conn.execute_batch(
833            r#"
834            CREATE SCHEMA IF NOT EXISTS code;
835            
836            CREATE TABLE code.symbols (
837                id TEXT PRIMARY KEY,
838                name TEXT NOT NULL,
839                qualified_name TEXT,
840                kind TEXT NOT NULL,
841                file_path TEXT NOT NULL,
842                line_start INTEGER NOT NULL,
843                line_end INTEGER NOT NULL,
844                visibility TEXT
845            );
846            
847            CREATE TABLE code.edges (
848                source_id TEXT NOT NULL,
849                target_id TEXT,
850                target_name TEXT NOT NULL,
851                kind TEXT NOT NULL,
852                context TEXT,
853                FOREIGN KEY (source_id) REFERENCES code.symbols(id)
854            );
855            
856            -- Insert test data: main -> run -> helper
857            INSERT INTO code.symbols VALUES 
858                ('main@1', 'main', NULL, 'function', 'test.rs', 1, 10, 'public'),
859                ('run@11', 'run', NULL, 'function', 'test.rs', 11, 20, 'private'),
860                ('helper@21', 'helper', NULL, 'function', 'test.rs', 21, 30, 'private');
861            
862            INSERT INTO code.edges VALUES 
863                ('main@1', 'run@11', 'run', 'calls', NULL),
864                ('run@11', 'helper@21', 'helper', 'calls', NULL);
865            "#,
866        )?;
867
868        Ok(conn)
869    }
870
871    #[test]
872    fn test_call_graph_syntax() {
873        let conn = setup_test_db().expect("Failed to setup test db");
874        let analytics = Analytics { conn };
875
876        let result = analytics.call_graph("main", 5);
877        assert!(
878            result.is_ok(),
879            "call_graph query failed: {:?}",
880            result.err()
881        );
882
883        let nodes = result.unwrap();
884        assert_eq!(nodes.len(), 2, "Expected 2 nodes (run, helper)");
885        assert_eq!(nodes[0].name, "run");
886        assert_eq!(nodes[1].name, "helper");
887    }
888
889    #[test]
890    fn test_impact_analysis_syntax() {
891        let conn = setup_test_db().expect("Failed to setup test db");
892        let analytics = Analytics { conn };
893
894        let result = analytics.impact_analysis("helper", 5);
895        assert!(
896            result.is_ok(),
897            "impact_analysis query failed: {:?}",
898            result.err()
899        );
900
901        let nodes = result.unwrap();
902        assert_eq!(nodes.len(), 2, "Expected 2 nodes (run, main)");
903        // run calls helper directly (distance 1)
904        // main calls run which calls helper (distance 2)
905        assert!(nodes.iter().any(|n| n.name == "run" && n.distance == 1));
906        assert!(nodes.iter().any(|n| n.name == "main" && n.distance == 2));
907    }
908
909    #[test]
910    fn test_has_path_syntax() {
911        let conn = setup_test_db().expect("Failed to setup test db");
912        let analytics = Analytics { conn };
913
914        // main -> run -> helper (path exists)
915        let result = analytics.has_path("main", "helper", 5);
916        assert!(result.is_ok(), "has_path query failed: {:?}", result.err());
917        assert!(result.unwrap(), "Expected path from main to helper");
918
919        // helper -> main (no path in reverse direction)
920        let result = analytics.has_path("helper", "main", 5);
921        assert!(result.is_ok(), "has_path query failed: {:?}", result.err());
922        assert!(!result.unwrap(), "Expected no path from helper to main");
923    }
924
925    #[test]
926    fn test_sql_injection_in_path_escaping() {
927        // Test that paths with special SQL characters are properly escaped
928        // This is a unit test for the escaping logic - the actual open() function
929        // requires a real SQLite file, so we test the escaping directly
930
931        // Test paths that would cause SQL injection if not escaped
932        let test_paths = [
933            "normal/path.db",
934            "path with spaces/file.db",
935            "path'with'quotes/file.db",
936            "path''with''double/file.db",
937            "path;DROP TABLE code;/file.db",
938            "path' OR '1'='1/file.db",
939        ];
940
941        for path in &test_paths {
942            // Simulate the escaping done in Analytics::open()
943            let escaped = path.replace('\'', "''");
944            let sql = format!("ATTACH '{}' AS code", escaped);
945
946            // The escaped SQL should have balanced quotes
947            let quote_count = sql.chars().filter(|c| *c == '\'').count();
948            assert_eq!(
949                quote_count % 2,
950                0,
951                "SQL for path '{}' has unbalanced quotes: {}",
952                path,
953                sql
954            );
955
956            // Single quotes in the path should be doubled
957            if path.contains('\'') {
958                assert!(
959                    escaped.contains("''"),
960                    "Path with quote should have doubled quotes: {}",
961                    escaped
962                );
963            }
964        }
965    }
966}