Skip to main content

codelens_engine/db/
ops.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, OptionalExtension, params};
3
4use super::{DirStats, FileRow, IndexDb, NewCall, NewImport, NewSymbol, SymbolRow, SymbolWithFile};
5
6/// Build FTS5 query: split into tokens, add prefix matching (*), join with OR.
7/// e.g. "run_service" → "run" * OR "service" *
8/// e.g. "ServiceManager" → "ServiceManager" *
9fn fts5_escape(query: &str) -> String {
10    let tokens: Vec<String> = query
11        .split(|c: char| c.is_whitespace() || c == '_' || c == '-')
12        .filter(|t| !t.is_empty())
13        .map(|token| {
14            let escaped = token.replace('"', "\"\"");
15            // FTS5 prefix query: token* matches any token starting with this string
16            format!("{escaped}*")
17        })
18        .collect();
19    if tokens.is_empty() {
20        let escaped = query.replace('"', "\"\"");
21        return format!("{escaped}*");
22    }
23    tokens.join(" OR ")
24}
25
26// ---- Transaction-compatible free functions ----
27// These accept &Connection so they work with both Connection and Transaction (via Deref).
28
29/// Returns the file row if it exists and is fresh (same mtime and hash).
30pub(crate) fn get_fresh_file(
31    conn: &Connection,
32    relative_path: &str,
33    mtime_ms: i64,
34    content_hash: &str,
35) -> Result<Option<FileRow>> {
36    conn.query_row(
37        "SELECT id, relative_path, mtime_ms, content_hash, size_bytes, language
38         FROM files WHERE relative_path = ?1 AND mtime_ms = ?2 AND content_hash = ?3",
39        params![relative_path, mtime_ms, content_hash],
40        |row| {
41            Ok(FileRow {
42                id: row.get(0)?,
43                relative_path: row.get(1)?,
44                mtime_ms: row.get(2)?,
45                content_hash: row.get(3)?,
46                size_bytes: row.get(4)?,
47                language: row.get(5)?,
48            })
49        },
50    )
51    .optional()
52    .context("get_fresh_file query failed")
53}
54
55/// Upsert a file record. Returns the file id. Deletes old symbols/imports on update.
56pub(crate) fn upsert_file(
57    conn: &Connection,
58    relative_path: &str,
59    mtime_ms: i64,
60    content_hash: &str,
61    size_bytes: i64,
62    language: Option<&str>,
63) -> Result<i64> {
64    let now = std::time::SystemTime::now()
65        .duration_since(std::time::UNIX_EPOCH)
66        .unwrap_or_default()
67        .as_millis() as i64;
68
69    let id: i64 = conn.query_row(
70        "INSERT INTO files (relative_path, mtime_ms, content_hash, size_bytes, language, indexed_at)
71         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
72         ON CONFLICT(relative_path) DO UPDATE SET
73            mtime_ms = excluded.mtime_ms,
74            content_hash = excluded.content_hash,
75            size_bytes = excluded.size_bytes,
76            language = excluded.language,
77            indexed_at = excluded.indexed_at
78         RETURNING id",
79        params![relative_path, mtime_ms, content_hash, size_bytes, language, now],
80        |row| row.get(0),
81    )?;
82
83    conn.execute("DELETE FROM symbols WHERE file_id = ?1", params![id])?;
84    conn.execute("DELETE FROM imports WHERE source_file_id = ?1", params![id])?;
85    conn.execute("DELETE FROM calls WHERE caller_file_id = ?1", params![id])?;
86
87    Ok(id)
88}
89
90/// Delete a file and its associated symbols/imports.
91pub(crate) fn delete_file(conn: &Connection, relative_path: &str) -> Result<()> {
92    conn.execute(
93        "DELETE FROM files WHERE relative_path = ?1",
94        params![relative_path],
95    )?;
96    Ok(())
97}
98
99/// Per-directory aggregate stats.
100pub(crate) fn dir_stats(conn: &Connection) -> Result<Vec<DirStats>> {
101    // Fetch per-file symbol counts, then aggregate in Rust for accurate dir extraction
102    let mut stmt = conn.prepare_cached(
103        "SELECT f.relative_path, COUNT(s.id) AS sym_count
104         FROM files f LEFT JOIN symbols s ON s.file_id = f.id
105         GROUP BY f.id",
106    )?;
107    let rows = stmt.query_map([], |row| {
108        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
109    })?;
110
111    let mut dir_map: std::collections::HashMap<String, (usize, usize)> =
112        std::collections::HashMap::new();
113    for row in rows {
114        let (path, sym_count) = row?;
115        let dir = match path.rfind('/') {
116            Some(pos) => &path[..=pos],
117            None => ".",
118        };
119        let entry = dir_map.entry(dir.to_owned()).or_insert((0, 0));
120        entry.0 += 1; // file count
121        entry.1 += sym_count; // symbol count
122    }
123
124    let mut result: Vec<DirStats> = dir_map
125        .into_iter()
126        .map(|(dir, (files, symbols))| DirStats {
127            dir,
128            files,
129            symbols,
130            imports_from_others: 0,
131        })
132        .collect();
133    result.sort_by(|a, b| b.symbols.cmp(&a.symbols));
134    Ok(result)
135}
136
137/// Return all indexed file paths.
138pub(crate) fn all_file_paths(conn: &Connection) -> Result<Vec<String>> {
139    let mut stmt = conn.prepare_cached("SELECT relative_path FROM files")?;
140    let rows = stmt.query_map([], |row| row.get(0))?;
141    let mut paths = Vec::new();
142    for row in rows {
143        paths.push(row?);
144    }
145    Ok(paths)
146}
147
148/// Return file paths that contain symbols of the given kinds (e.g. "class", "interface").
149pub(crate) fn files_with_symbol_kinds(conn: &Connection, kinds: &[&str]) -> Result<Vec<String>> {
150    if kinds.is_empty() {
151        return Ok(Vec::new());
152    }
153    let placeholders: String = kinds.iter().map(|_| "?").collect::<Vec<_>>().join(",");
154    let sql = format!(
155        "SELECT DISTINCT f.relative_path FROM files f \
156         JOIN symbols s ON s.file_id = f.id \
157         WHERE s.kind IN ({placeholders})"
158    );
159    let mut stmt = conn.prepare_cached(&sql)?;
160    let params: Vec<&dyn rusqlite::types::ToSql> = kinds
161        .iter()
162        .map(|k| k as &dyn rusqlite::types::ToSql)
163        .collect();
164    let rows = stmt.query_map(params.as_slice(), |row| row.get(0))?;
165    let mut paths = Vec::new();
166    for row in rows {
167        paths.push(row?);
168    }
169    Ok(paths)
170}
171
172/// Bulk insert symbols for a file. Returns the inserted symbol ids.
173pub(crate) fn insert_symbols(
174    conn: &Connection,
175    file_id: i64,
176    symbols: &[NewSymbol<'_>],
177) -> Result<Vec<i64>> {
178    let mut ids = Vec::with_capacity(symbols.len());
179    let mut stmt = conn.prepare_cached(
180        "INSERT INTO symbols (file_id, name, kind, line, column_num, start_byte, end_byte, signature, name_path, parent_id, end_line)
181         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
182    )?;
183    for sym in symbols {
184        stmt.execute(params![
185            file_id,
186            sym.name,
187            sym.kind,
188            sym.line,
189            sym.column_num,
190            sym.start_byte,
191            sym.end_byte,
192            sym.signature,
193            sym.name_path,
194            sym.parent_id,
195            sym.end_line,
196        ])?;
197        ids.push(conn.last_insert_rowid());
198    }
199    Ok(ids)
200}
201
202/// Bulk insert imports for a file.
203pub(crate) fn insert_imports(conn: &Connection, file_id: i64, imports: &[NewImport]) -> Result<()> {
204    let mut stmt = conn.prepare_cached(
205        "INSERT OR REPLACE INTO imports (source_file_id, target_path, raw_import)
206         VALUES (?1, ?2, ?3)",
207    )?;
208    for imp in imports {
209        stmt.execute(params![file_id, imp.target_path, imp.raw_import])?;
210    }
211    Ok(())
212}
213
214/// Bulk insert call edges for a file (clears old edges first).
215pub(crate) fn insert_calls(conn: &Connection, file_id: i64, calls: &[NewCall]) -> Result<()> {
216    conn.execute(
217        "DELETE FROM calls WHERE caller_file_id = ?1",
218        params![file_id],
219    )?;
220    let mut stmt = conn.prepare_cached(
221        "INSERT INTO calls (caller_file_id, caller_name, callee_name, line)
222         VALUES (?1, ?2, ?3, ?4)",
223    )?;
224    for call in calls {
225        stmt.execute(params![
226            file_id,
227            call.caller_name,
228            call.callee_name,
229            call.line
230        ])?;
231    }
232    Ok(())
233}
234
235// ---- IndexDb impl: CRUD operations ----
236
237impl IndexDb {
238    // ---- File operations (delegating to free functions) ----
239
240    /// Fast mtime-only freshness check. Avoids content hashing entirely.
241    pub fn get_fresh_file_by_mtime(
242        &self,
243        relative_path: &str,
244        mtime_ms: i64,
245    ) -> Result<Option<FileRow>> {
246        self.conn
247            .query_row(
248                "SELECT id, relative_path, mtime_ms, content_hash, size_bytes, language
249                 FROM files WHERE relative_path = ?1 AND mtime_ms = ?2",
250                params![relative_path, mtime_ms],
251                |row| {
252                    Ok(FileRow {
253                        id: row.get(0)?,
254                        relative_path: row.get(1)?,
255                        mtime_ms: row.get(2)?,
256                        content_hash: row.get(3)?,
257                        size_bytes: row.get(4)?,
258                        language: row.get(5)?,
259                    })
260                },
261            )
262            .optional()
263            .context("get_fresh_file_by_mtime query failed")
264    }
265
266    /// Returns the file row if it exists and is fresh (same mtime and hash).
267    pub fn get_fresh_file(
268        &self,
269        relative_path: &str,
270        mtime_ms: i64,
271        content_hash: &str,
272    ) -> Result<Option<FileRow>> {
273        get_fresh_file(&self.conn, relative_path, mtime_ms, content_hash)
274    }
275
276    /// Returns the file row by path (regardless of freshness).
277    pub fn get_file(&self, relative_path: &str) -> Result<Option<FileRow>> {
278        self.conn
279            .query_row(
280                "SELECT id, relative_path, mtime_ms, content_hash, size_bytes, language
281                 FROM files WHERE relative_path = ?1",
282                params![relative_path],
283                |row| {
284                    Ok(FileRow {
285                        id: row.get(0)?,
286                        relative_path: row.get(1)?,
287                        mtime_ms: row.get(2)?,
288                        content_hash: row.get(3)?,
289                        size_bytes: row.get(4)?,
290                        language: row.get(5)?,
291                    })
292                },
293            )
294            .optional()
295            .context("get_file query failed")
296    }
297
298    /// Upsert a file record. Returns the file id. Deletes old symbols/imports on update.
299    pub fn upsert_file(
300        &self,
301        relative_path: &str,
302        mtime_ms: i64,
303        content_hash: &str,
304        size_bytes: i64,
305        language: Option<&str>,
306    ) -> Result<i64> {
307        upsert_file(
308            &self.conn,
309            relative_path,
310            mtime_ms,
311            content_hash,
312            size_bytes,
313            language,
314        )
315    }
316
317    /// Delete a file and its associated symbols/imports.
318    pub fn delete_file(&self, relative_path: &str) -> Result<()> {
319        delete_file(&self.conn, relative_path)
320    }
321
322    /// Count indexed files.
323    pub fn file_count(&self) -> Result<usize> {
324        let count: i64 = self
325            .conn
326            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
327        Ok(count as usize)
328    }
329
330    /// Return all indexed file paths.
331    pub fn all_file_paths(&self) -> Result<Vec<String>> {
332        all_file_paths(&self.conn)
333    }
334
335    /// Return file paths containing symbols of given kinds.
336    pub fn files_with_symbol_kinds(&self, kinds: &[&str]) -> Result<Vec<String>> {
337        files_with_symbol_kinds(&self.conn, kinds)
338    }
339
340    pub fn dir_stats(&self) -> Result<Vec<DirStats>> {
341        dir_stats(&self.conn)
342    }
343
344    // ---- Symbol operations ----
345
346    /// Bulk insert symbols for a file. Returns the inserted symbol ids.
347    pub fn insert_symbols(&self, file_id: i64, symbols: &[NewSymbol<'_>]) -> Result<Vec<i64>> {
348        insert_symbols(&self.conn, file_id, symbols)
349    }
350
351    /// Query symbols by name (exact or substring match).
352    pub fn find_symbols_by_name(
353        &self,
354        name: &str,
355        file_path: Option<&str>,
356        exact: bool,
357        max_results: usize,
358    ) -> Result<Vec<SymbolRow>> {
359        let (sql, use_file_filter) = match (exact, file_path.is_some()) {
360            (true, true) => (
361                "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num, s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id, s.end_line
362                 FROM symbols s JOIN files f ON s.file_id = f.id
363                 WHERE s.name = ?1 AND f.relative_path = ?2
364                 LIMIT ?3",
365                true,
366            ),
367            (true, false) => (
368                "SELECT id, file_id, name, kind, line, column_num, start_byte, end_byte, signature, name_path, parent_id, end_line
369                 FROM symbols WHERE name = ?1
370                 LIMIT ?2",
371                false,
372            ),
373            (false, true) => (
374                "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num, s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id, s.end_line
375                 FROM symbols s JOIN files f ON s.file_id = f.id
376                 WHERE s.name LIKE '%' || ?1 || '%' AND f.relative_path = ?2
377                 ORDER BY LENGTH(s.name), s.name
378                 LIMIT ?3",
379                true,
380            ),
381            (false, false) => (
382                "SELECT id, file_id, name, kind, line, column_num, start_byte, end_byte, signature, name_path, parent_id, end_line
383                 FROM symbols WHERE name LIKE '%' || ?1 || '%'
384                 ORDER BY LENGTH(name), name
385                 LIMIT ?2",
386                false,
387            ),
388        };
389
390        let mut stmt = self.conn.prepare_cached(sql)?;
391        let mut rows = if use_file_filter {
392            stmt.query(params![name, file_path.unwrap_or(""), max_results as i64])?
393        } else {
394            stmt.query(params![name, max_results as i64])?
395        };
396
397        let mut results = Vec::new();
398        while let Some(row) = rows.next()? {
399            results.push(SymbolRow {
400                id: row.get(0)?,
401                file_id: row.get(1)?,
402                name: row.get(2)?,
403                kind: row.get(3)?,
404                line: row.get(4)?,
405                column_num: row.get(5)?,
406                start_byte: row.get(6)?,
407                end_byte: row.get(7)?,
408                signature: row.get(8)?,
409                name_path: row.get(9)?,
410                parent_id: row.get(10)?,
411                end_line: row.get(11)?,
412            });
413        }
414        Ok(results)
415    }
416
417    /// Query symbols by name with file path resolved via JOIN (no N+1).
418    /// Returns (SymbolRow, file_path) tuples.
419    pub fn find_symbols_with_path(
420        &self,
421        name: &str,
422        exact: bool,
423        max_results: usize,
424    ) -> Result<Vec<(SymbolRow, String)>> {
425        let sql = if exact {
426            "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num,
427                    s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id,
428                    s.end_line, f.relative_path
429             FROM symbols s JOIN files f ON s.file_id = f.id
430             WHERE s.name = ?1
431             LIMIT ?2"
432        } else {
433            "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num,
434                    s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id,
435                    s.end_line, f.relative_path
436             FROM symbols s JOIN files f ON s.file_id = f.id
437             WHERE s.name LIKE '%' || ?1 || '%'
438             LIMIT ?2"
439        };
440
441        let mut stmt = self.conn.prepare_cached(sql)?;
442        let mut rows = stmt.query(params![name, max_results as i64])?;
443        let mut results = Vec::new();
444        while let Some(row) = rows.next()? {
445            results.push((
446                SymbolRow {
447                    id: row.get(0)?,
448                    file_id: row.get(1)?,
449                    name: row.get(2)?,
450                    kind: row.get(3)?,
451                    line: row.get(4)?,
452                    column_num: row.get(5)?,
453                    start_byte: row.get(6)?,
454                    end_byte: row.get(7)?,
455                    signature: row.get(8)?,
456                    name_path: row.get(9)?,
457                    parent_id: row.get(10)?,
458                    end_line: row.get(11)?,
459                },
460                row.get::<_, String>(12)?,
461            ));
462        }
463        Ok(results)
464    }
465
466    /// Get all symbols for a file, ordered by start_byte.
467    pub fn get_file_symbols(&self, file_id: i64) -> Result<Vec<SymbolRow>> {
468        let mut stmt = self.conn.prepare_cached(
469            "SELECT id, file_id, name, kind, line, column_num, start_byte, end_byte, signature, name_path, parent_id, end_line
470             FROM symbols WHERE file_id = ?1 ORDER BY start_byte",
471        )?;
472        let rows = stmt.query_map(params![file_id], |row| {
473            Ok(SymbolRow {
474                id: row.get(0)?,
475                file_id: row.get(1)?,
476                name: row.get(2)?,
477                kind: row.get(3)?,
478                line: row.get(4)?,
479                column_num: row.get(5)?,
480                start_byte: row.get(6)?,
481                end_byte: row.get(7)?,
482                signature: row.get(8)?,
483                name_path: row.get(9)?,
484                parent_id: row.get(10)?,
485                end_line: row.get(11)?,
486            })
487        })?;
488        let mut results = Vec::new();
489        for row in rows {
490            results.push(row?);
491        }
492        Ok(results)
493    }
494
495    /// Full-text search symbols via FTS5 index. Returns (SymbolRow, file_path, rank).
496    /// Falls back to LIKE search if FTS5 table doesn't exist (pre-v4 DB).
497    pub fn search_symbols_fts(
498        &self,
499        query: &str,
500        max_results: usize,
501    ) -> Result<Vec<(SymbolRow, String, f64)>> {
502        // Check if FTS5 table exists
503        let fts_exists: bool = self
504            .conn
505            .query_row(
506                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
507                [],
508                |row| row.get::<_, i64>(0),
509            )
510            .map(|c| c > 0)
511            .unwrap_or(false);
512
513        if !fts_exists {
514            // Fallback: LIKE search with JOIN
515            return self
516                .find_symbols_with_path(query, false, max_results)
517                .map(|rows| rows.into_iter().map(|(r, p)| (r, p, 0.0)).collect());
518        }
519
520        // Lazy rebuild: rebuild FTS index if stale (symbols changed since last rebuild).
521        // Uses meta keys for count freshness + timestamp cooldown (30s) to avoid
522        // expensive COUNT(*) + rebuild on every search call.
523        let now_secs = std::time::SystemTime::now()
524            .duration_since(std::time::UNIX_EPOCH)
525            .map(|d| d.as_secs() as i64)
526            .unwrap_or(0);
527        let last_rebuild_ts: i64 = self
528            .conn
529            .query_row(
530                "SELECT value FROM meta WHERE key = 'fts_rebuild_ts'",
531                [],
532                |row| row.get::<_, String>(0),
533            )
534            .optional()?
535            .and_then(|v| v.parse::<i64>().ok())
536            .unwrap_or(0);
537
538        if now_secs - last_rebuild_ts > 30 {
539            let fts_fresh: bool = self
540                .conn
541                .query_row(
542                    "SELECT value FROM meta WHERE key = 'fts_symbol_count'",
543                    [],
544                    |row| row.get::<_, String>(0),
545                )
546                .optional()?
547                .and_then(|v| v.parse::<i64>().ok())
548                .map(|cached_count| {
549                    let current: i64 = self
550                        .conn
551                        .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
552                        .unwrap_or(0);
553                    cached_count == current
554                })
555                .unwrap_or(false);
556
557            if !fts_fresh {
558                let sym_count: i64 = self
559                    .conn
560                    .query_row("SELECT COUNT(*) FROM symbols", [], |row| row.get(0))
561                    .unwrap_or(0);
562                if sym_count > 0 {
563                    let _ = self
564                        .conn
565                        .execute_batch("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')");
566                    let _ = self.conn.execute(
567                        "INSERT OR REPLACE INTO meta (key, value) VALUES ('fts_symbol_count', ?1)",
568                        params![sym_count.to_string()],
569                    );
570                }
571                let _ = self.conn.execute(
572                    "INSERT OR REPLACE INTO meta (key, value) VALUES ('fts_rebuild_ts', ?1)",
573                    params![now_secs.to_string()],
574                );
575            }
576        }
577
578        // Escape FTS5 special chars and build query
579        let fts_query = fts5_escape(query);
580        let mut stmt = self.conn.prepare_cached(
581            "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num,
582                    s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id,
583                    s.end_line, f.relative_path, rank
584             FROM symbols_fts
585             JOIN symbols s ON symbols_fts.rowid = s.id
586             JOIN files f ON s.file_id = f.id
587             WHERE symbols_fts MATCH ?1
588             ORDER BY rank
589             LIMIT ?2",
590        )?;
591
592        let mut rows = stmt.query(params![fts_query, max_results as i64])?;
593        let mut results = Vec::new();
594        while let Some(row) = rows.next()? {
595            results.push((
596                SymbolRow {
597                    id: row.get(0)?,
598                    file_id: row.get(1)?,
599                    name: row.get(2)?,
600                    kind: row.get(3)?,
601                    line: row.get(4)?,
602                    column_num: row.get(5)?,
603                    start_byte: row.get(6)?,
604                    end_byte: row.get(7)?,
605                    signature: row.get(8)?,
606                    name_path: row.get(9)?,
607                    parent_id: row.get(10)?,
608                    end_line: row.get(11)?,
609                },
610                row.get::<_, String>(12)?,
611                row.get::<_, f64>(13)?,
612            ));
613        }
614        Ok(results)
615    }
616
617    /// Get all symbols for files under a directory prefix in a single JOIN query.
618    /// Returns (file_path, Vec<SymbolRow>) grouped by file. Eliminates N+1 queries.
619    pub fn get_symbols_for_directory(&self, prefix: &str) -> Result<Vec<(String, Vec<SymbolRow>)>> {
620        let pattern = if prefix.is_empty() || prefix == "." {
621            "%".to_owned()
622        } else {
623            format!("{prefix}%")
624        };
625        let mut stmt = self.conn.prepare_cached(
626            "SELECT s.id, s.file_id, s.name, s.kind, s.line, s.column_num,
627                    s.start_byte, s.end_byte, s.signature, s.name_path, s.parent_id,
628                    s.end_line, f.relative_path
629             FROM symbols s
630             JOIN files f ON s.file_id = f.id
631             WHERE f.relative_path LIKE ?1
632             ORDER BY s.file_id, s.start_byte",
633        )?;
634        let rows = stmt.query_map(params![pattern], |row| {
635            Ok((
636                SymbolRow {
637                    id: row.get(0)?,
638                    file_id: row.get(1)?,
639                    name: row.get(2)?,
640                    kind: row.get(3)?,
641                    line: row.get(4)?,
642                    column_num: row.get(5)?,
643                    start_byte: row.get(6)?,
644                    end_byte: row.get(7)?,
645                    signature: row.get(8)?,
646                    name_path: row.get(9)?,
647                    parent_id: row.get(10)?,
648                    end_line: row.get(11)?,
649                },
650                row.get::<_, String>(12)?,
651            ))
652        })?;
653
654        let mut groups: Vec<(String, Vec<SymbolRow>)> = Vec::new();
655        let mut current_path = String::new();
656        for row in rows {
657            let (sym, path) = row?;
658            if path != current_path {
659                current_path = path.clone();
660                groups.push((path, Vec::new()));
661            }
662            groups.last_mut().unwrap().1.push(sym);
663        }
664        Ok(groups)
665    }
666
667    /// Return all symbols as (name, kind, file_path, line, signature, name_path).
668    #[allow(clippy::type_complexity)]
669    pub fn all_symbol_names(&self) -> Result<Vec<(String, String, String, i64, String, String)>> {
670        let mut stmt = self.conn.prepare_cached(
671            "SELECT s.name, s.kind, f.relative_path, s.line, s.signature, s.name_path
672             FROM symbols s JOIN files f ON s.file_id = f.id",
673        )?;
674        let rows = stmt.query_map([], |row| {
675            Ok((
676                row.get::<_, String>(0)?,
677                row.get::<_, String>(1)?,
678                row.get::<_, String>(2)?,
679                row.get::<_, i64>(3)?,
680                row.get::<_, String>(4)?,
681                row.get::<_, String>(5)?,
682            ))
683        })?;
684        let mut results = Vec::new();
685        for row in rows {
686            results.push(row?);
687        }
688        Ok(results)
689    }
690
691    /// Get all symbols with byte offsets and file paths, ordered by file for batch processing.
692    pub fn all_symbols_with_bytes(&self) -> Result<Vec<SymbolWithFile>> {
693        let mut stmt = self.conn.prepare_cached(
694            "SELECT s.name, s.kind, f.relative_path, s.line, s.signature, s.name_path,
695                    s.start_byte, s.end_byte
696             FROM symbols s JOIN files f ON s.file_id = f.id
697             ORDER BY s.file_id, s.start_byte",
698        )?;
699        let rows = stmt.query_map([], |row| {
700            Ok(SymbolWithFile {
701                name: row.get(0)?,
702                kind: row.get(1)?,
703                file_path: row.get(2)?,
704                line: row.get(3)?,
705                signature: row.get(4)?,
706                name_path: row.get(5)?,
707                start_byte: row.get(6)?,
708                end_byte: row.get(7)?,
709            })
710        })?;
711        let mut results = Vec::new();
712        for row in rows {
713            results.push(row?);
714        }
715        Ok(results)
716    }
717
718    /// Stream all symbols with bytes via callback — avoids loading entire Vec into memory.
719    /// Symbols are ordered by file_path then start_byte (same as all_symbols_with_bytes).
720    pub fn for_each_symbol_with_bytes<F>(&self, mut callback: F) -> Result<usize>
721    where
722        F: FnMut(SymbolWithFile) -> Result<()>,
723    {
724        let mut stmt = self.conn.prepare_cached(
725            "SELECT s.name, s.kind, f.relative_path, s.line, s.signature, s.name_path,
726                    s.start_byte, s.end_byte
727             FROM symbols s JOIN files f ON s.file_id = f.id
728             ORDER BY s.file_id, s.start_byte",
729        )?;
730        let mut rows = stmt.query([])?;
731        let mut count = 0usize;
732        while let Some(row) = rows.next()? {
733            callback(SymbolWithFile {
734                name: row.get(0)?,
735                kind: row.get(1)?,
736                file_path: row.get(2)?,
737                line: row.get(3)?,
738                signature: row.get(4)?,
739                name_path: row.get(5)?,
740                start_byte: row.get(6)?,
741                end_byte: row.get(7)?,
742            })?;
743            count += 1;
744        }
745        Ok(count)
746    }
747
748    /// Stream symbols grouped by file path via callback — avoids loading the
749    /// entire symbol table into memory and gives deterministic file-wise order.
750    pub fn for_each_file_symbols_with_bytes<F>(&self, mut callback: F) -> Result<usize>
751    where
752        F: FnMut(String, Vec<SymbolWithFile>) -> Result<()>,
753    {
754        let mut stmt = self.conn.prepare_cached(
755            "SELECT s.name, s.kind, f.relative_path, s.line, s.signature, s.name_path,
756                    s.start_byte, s.end_byte
757             FROM symbols s JOIN files f ON s.file_id = f.id
758             ORDER BY f.relative_path, s.start_byte",
759        )?;
760        let mut rows = stmt.query([])?;
761        let mut count = 0usize;
762        let mut current_file: Option<String> = None;
763        let mut current_symbols: Vec<SymbolWithFile> = Vec::new();
764
765        while let Some(row) = rows.next()? {
766            let symbol = SymbolWithFile {
767                name: row.get(0)?,
768                kind: row.get(1)?,
769                file_path: row.get(2)?,
770                line: row.get(3)?,
771                signature: row.get(4)?,
772                name_path: row.get(5)?,
773                start_byte: row.get(6)?,
774                end_byte: row.get(7)?,
775            };
776
777            if current_file.as_deref() != Some(symbol.file_path.as_str())
778                && let Some(previous_file) = current_file.replace(symbol.file_path.clone())
779            {
780                callback(previous_file, std::mem::take(&mut current_symbols))?;
781            }
782
783            current_symbols.push(symbol);
784            count += 1;
785        }
786
787        if let Some(file_path) = current_file {
788            callback(file_path, current_symbols)?;
789        }
790
791        Ok(count)
792    }
793
794    /// Get symbols with bytes for specific files only (for incremental embedding).
795    pub fn symbols_for_files(&self, file_paths: &[&str]) -> Result<Vec<SymbolWithFile>> {
796        if file_paths.is_empty() {
797            return Ok(Vec::new());
798        }
799        let placeholders: Vec<String> = (1..=file_paths.len()).map(|i| format!("?{i}")).collect();
800        let sql = format!(
801            "SELECT s.name, s.kind, f.relative_path, s.line, s.signature, s.name_path,
802                    s.start_byte, s.end_byte
803             FROM symbols s JOIN files f ON s.file_id = f.id
804             WHERE f.relative_path IN ({})
805             ORDER BY s.file_id, s.start_byte",
806            placeholders.join(", ")
807        );
808        let mut stmt = self.conn.prepare(&sql)?;
809        let params: Vec<&dyn rusqlite::types::ToSql> = file_paths
810            .iter()
811            .map(|p| p as &dyn rusqlite::types::ToSql)
812            .collect();
813        let rows = stmt.query_map(params.as_slice(), |row| {
814            Ok(SymbolWithFile {
815                name: row.get(0)?,
816                kind: row.get(1)?,
817                file_path: row.get(2)?,
818                line: row.get(3)?,
819                signature: row.get(4)?,
820                name_path: row.get(5)?,
821                start_byte: row.get(6)?,
822                end_byte: row.get(7)?,
823            })
824        })?;
825        let mut results = Vec::new();
826        for row in rows {
827            results.push(row?);
828        }
829        Ok(results)
830    }
831
832    /// Get file path for a file_id.
833    pub fn get_file_path(&self, file_id: i64) -> Result<Option<String>> {
834        self.conn
835            .query_row(
836                "SELECT relative_path FROM files WHERE id = ?1",
837                params![file_id],
838                |row| row.get(0),
839            )
840            .optional()
841            .context("get_file_path query failed")
842    }
843
844    // ---- Import operations ----
845
846    /// Bulk insert imports for a file.
847    pub fn insert_imports(&self, file_id: i64, imports: &[NewImport]) -> Result<()> {
848        insert_imports(&self.conn, file_id, imports)
849    }
850
851    /// Get files that import the given file path (reverse dependencies).
852    pub fn get_importers(&self, target_path: &str) -> Result<Vec<String>> {
853        let mut stmt = self.conn.prepare_cached(
854            "SELECT f.relative_path FROM imports i
855             JOIN files f ON i.source_file_id = f.id
856             WHERE i.target_path = ?1
857             ORDER BY f.relative_path",
858        )?;
859        let rows = stmt.query_map(params![target_path], |row| row.get(0))?;
860        let mut results = Vec::new();
861        for row in rows {
862            results.push(row?);
863        }
864        Ok(results)
865    }
866
867    /// Get files that the given file imports (forward dependencies).
868    pub fn get_imports_of(&self, relative_path: &str) -> Result<Vec<String>> {
869        let mut stmt = self.conn.prepare_cached(
870            "SELECT i.target_path FROM imports i
871             JOIN files f ON i.source_file_id = f.id
872             WHERE f.relative_path = ?1
873             ORDER BY i.target_path",
874        )?;
875        let rows = stmt.query_map(params![relative_path], |row| row.get(0))?;
876        let mut results = Vec::new();
877        for row in rows {
878            results.push(row?);
879        }
880        Ok(results)
881    }
882
883    /// Build the full import graph from the database.
884    #[allow(clippy::type_complexity)]
885    pub fn build_import_graph(
886        &self,
887    ) -> Result<std::collections::HashMap<String, (Vec<String>, Vec<String>)>> {
888        let mut graph = std::collections::HashMap::new();
889
890        for path in self.all_file_paths()? {
891            graph.insert(path, (Vec::new(), Vec::new()));
892        }
893
894        let mut stmt = self.conn.prepare_cached(
895            "SELECT f.relative_path, i.target_path FROM imports i
896             JOIN files f ON i.source_file_id = f.id",
897        )?;
898        let rows = stmt.query_map([], |row| {
899            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
900        })?;
901        for row in rows {
902            let (source, target) = row?;
903            if let Some(entry) = graph.get_mut(&source) {
904                entry.0.push(target.clone());
905            }
906            if let Some(entry) = graph.get_mut(&target) {
907                entry.1.push(source.clone());
908            }
909        }
910
911        Ok(graph)
912    }
913
914    // ---- Call graph operations ----
915
916    /// Bulk insert call edges for a file (clears old edges first).
917    pub fn insert_calls(&self, file_id: i64, calls: &[NewCall]) -> Result<()> {
918        insert_calls(&self.conn, file_id, calls)
919    }
920
921    /// Find all callers of a function name (from DB cache).
922    pub fn get_callers_cached(
923        &self,
924        callee_name: &str,
925        max_results: usize,
926    ) -> Result<Vec<(String, String, i64)>> {
927        let mut stmt = self.conn.prepare_cached(
928            "SELECT f.relative_path, c.caller_name, c.line FROM calls c
929             JOIN files f ON c.caller_file_id = f.id
930             WHERE c.callee_name = ?1
931             ORDER BY f.relative_path, c.line
932             LIMIT ?2",
933        )?;
934        let mut rows = stmt.query(params![callee_name, max_results as i64])?;
935        let mut results = Vec::new();
936        while let Some(row) = rows.next()? {
937            results.push((row.get(0)?, row.get(1)?, row.get(2)?));
938        }
939        Ok(results)
940    }
941
942    /// Find all callees of a function name (from DB cache).
943    pub fn get_callees_cached(
944        &self,
945        caller_name: &str,
946        file_path: Option<&str>,
947        max_results: usize,
948    ) -> Result<Vec<(String, i64)>> {
949        let (sql, use_file) = match file_path {
950            Some(_) => (
951                "SELECT c.callee_name, c.line FROM calls c
952                 JOIN files f ON c.caller_file_id = f.id
953                 WHERE c.caller_name = ?1 AND f.relative_path = ?2
954                 ORDER BY c.line LIMIT ?3",
955                true,
956            ),
957            None => (
958                "SELECT c.callee_name, c.line FROM calls c
959                 WHERE c.caller_name = ?1
960                 ORDER BY c.line LIMIT ?2",
961                false,
962            ),
963        };
964        let mut stmt = self.conn.prepare_cached(sql)?;
965        let mut rows = if use_file {
966            stmt.query(params![
967                caller_name,
968                file_path.unwrap_or(""),
969                max_results as i64
970            ])?
971        } else {
972            stmt.query(params![caller_name, max_results as i64])?
973        };
974        let mut results = Vec::new();
975        while let Some(row) = rows.next()? {
976            results.push((row.get(0)?, row.get(1)?));
977        }
978        Ok(results)
979    }
980
981    /// Check if calls table has any data.
982    pub fn has_call_data(&self) -> Result<bool> {
983        let count: i64 = self
984            .conn
985            .query_row("SELECT COUNT(*) FROM calls", [], |row| row.get(0))?;
986        Ok(count > 0)
987    }
988
989    // ---- Index failure tracking ----
990
991    /// Record an indexing failure for a file. Updates retry_count on conflict.
992    pub fn record_index_failure(
993        &self,
994        file_path: &str,
995        error_type: &str,
996        error_message: &str,
997    ) -> Result<()> {
998        let now = std::time::SystemTime::now()
999            .duration_since(std::time::UNIX_EPOCH)
1000            .unwrap_or_default()
1001            .as_secs() as i64;
1002        self.conn.execute(
1003            "INSERT INTO index_failures (file_path, error_type, error_message, failed_at, retry_count)
1004             VALUES (?1, ?2, ?3, ?4, 1)
1005             ON CONFLICT(file_path) DO UPDATE SET
1006                error_type = excluded.error_type,
1007                error_message = excluded.error_message,
1008                failed_at = excluded.failed_at,
1009                retry_count = retry_count + 1",
1010            params![file_path, error_type, error_message, now],
1011        )?;
1012        Ok(())
1013    }
1014
1015    /// Clear a failure record when a file is successfully indexed.
1016    pub fn clear_index_failure(&self, file_path: &str) -> Result<()> {
1017        self.conn.execute(
1018            "DELETE FROM index_failures WHERE file_path = ?1",
1019            params![file_path],
1020        )?;
1021        Ok(())
1022    }
1023
1024    /// Invalidate FTS index cache so next search triggers a lazy rebuild.
1025    pub fn invalidate_fts(&self) -> Result<()> {
1026        self.conn
1027            .execute("DELETE FROM meta WHERE key = 'fts_symbol_count'", [])?;
1028        Ok(())
1029    }
1030
1031    /// Get the number of files with indexing failures.
1032    pub fn index_failure_count(&self) -> Result<usize> {
1033        let count: i64 = self
1034            .conn
1035            .query_row("SELECT COUNT(*) FROM index_failures", [], |row| row.get(0))?;
1036        Ok(count as usize)
1037    }
1038
1039    /// Remove failure records for files that no longer exist on disk.
1040    pub fn prune_missing_index_failures(&self, project_root: &std::path::Path) -> Result<usize> {
1041        let mut stmt = self
1042            .conn
1043            .prepare_cached("SELECT file_path FROM index_failures ORDER BY file_path")?;
1044        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1045        let mut missing = Vec::new();
1046        for row in rows {
1047            let relative_path = row?;
1048            if !project_root.join(&relative_path).is_file() {
1049                missing.push(relative_path);
1050            }
1051        }
1052        for relative_path in &missing {
1053            self.clear_index_failure(relative_path)?;
1054        }
1055        Ok(missing.len())
1056    }
1057
1058    /// Summarize unresolved index failures by recency and persistence.
1059    pub fn index_failure_summary(
1060        &self,
1061        recent_window_secs: i64,
1062    ) -> Result<crate::db::IndexFailureSummary> {
1063        let now = std::time::SystemTime::now()
1064            .duration_since(std::time::UNIX_EPOCH)
1065            .unwrap_or_default()
1066            .as_secs() as i64;
1067        let recent_cutoff = now.saturating_sub(recent_window_secs.max(0));
1068
1069        let total_failures: i64 =
1070            self.conn
1071                .query_row("SELECT COUNT(*) FROM index_failures", [], |row| row.get(0))?;
1072        let recent_failures: i64 = self.conn.query_row(
1073            "SELECT COUNT(*) FROM index_failures WHERE failed_at >= ?1",
1074            params![recent_cutoff],
1075            |row| row.get(0),
1076        )?;
1077        let persistent_failures: i64 = self.conn.query_row(
1078            "SELECT COUNT(*) FROM index_failures WHERE retry_count >= 3",
1079            [],
1080            |row| row.get(0),
1081        )?;
1082
1083        Ok(crate::db::IndexFailureSummary {
1084            total_failures: total_failures as usize,
1085            recent_failures: recent_failures as usize,
1086            stale_failures: total_failures.saturating_sub(recent_failures) as usize,
1087            persistent_failures: persistent_failures as usize,
1088        })
1089    }
1090
1091    /// Get files that have failed more than `min_retries` times.
1092    pub fn get_persistent_failures(&self, min_retries: i64) -> Result<Vec<(String, String, i64)>> {
1093        let mut stmt = self.conn.prepare_cached(
1094            "SELECT file_path, error_message, retry_count FROM index_failures WHERE retry_count >= ?1 ORDER BY retry_count DESC",
1095        )?;
1096        let mut rows = stmt.query(params![min_retries])?;
1097        let mut results = Vec::new();
1098        while let Some(row) = rows.next()? {
1099            results.push((row.get(0)?, row.get(1)?, row.get(2)?));
1100        }
1101        Ok(results)
1102    }
1103}