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