1use 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
18pub struct Analytics {
20 conn: Connection,
21}
22
23impl Analytics {
24 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 let conn = Connection::open_in_memory()?;
31
32 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 let analytics = Self { conn };
43 analytics.create_materialized_views()?;
44
45 Ok(analytics)
46 }
47
48 fn create_materialized_views(&self) -> Result<()> {
50 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 let index_root = root.display().to_string();
555 analytics.create_public_schema_v1(env!("CARGO_PKG_VERSION"), &index_root)?;
556
557 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 fn create_public_schema_v1(&self, ctx_version: &str, index_root: &str) -> Result<()> {
588 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 pub fn interrupt_handle(&self) -> Arc<InterruptHandle> {
671 self.conn.interrupt_handle()
672 }
673
674 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 !(n == 1 && s.column_name(0).map(|c| c == "Count").unwrap_or(false))
700 })
701 .unwrap_or(false);
702 Ok(produced)
703 }
704
705 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 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#[derive(Debug, Clone)]
752pub struct SqlColumn {
753 pub name: String,
754 pub type_name: String,
755}
756
757#[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
765fn 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
794fn 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 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 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 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 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 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 let escaped = path.replace('\'', "''");
944 let sql = format!("ATTACH '{}' AS code", escaped);
945
946 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 if path.contains('\'') {
958 assert!(
959 escaped.contains("''"),
960 "Path with quote should have doubled quotes: {}",
961 escaped
962 );
963 }
964 }
965 }
966}