1use std::path::Path;
10
11use duckdb::{params, Connection, Result};
12
13use super::{CallGraphNode, ComplexityResult, FileStats, ImpactNode};
14use crate::index::{CTX_DIR, DB_FILE};
15
16pub struct Analytics {
18 conn: Connection,
19}
20
21impl Analytics {
22 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 let conn = Connection::open_in_memory()?;
29
30 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 let analytics = Self { conn };
41 analytics.create_materialized_views()?;
42
43 Ok(analytics)
44 }
45
46 fn create_materialized_views(&self) -> Result<()> {
48 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 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 let escaped = path.replace('\'', "''");
647 let sql = format!("ATTACH '{}' AS code", escaped);
648
649 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 if path.contains('\'') {
661 assert!(
662 escaped.contains("''"),
663 "Path with quote should have doubled quotes: {}",
664 escaped
665 );
666 }
667 }
668 }
669}