Skip to main content

code_kb_core/
queries.rs

1use rusqlite::{Connection, Row, params};
2use std::collections::{HashMap, HashSet};
3use thiserror::Error;
4
5use crate::models::{
6    BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, StructuralFact,
7    Symbol, SymbolSearchResult, TestTarget, TypeFact,
8};
9
10#[derive(Debug, Error)]
11pub enum QueryError {
12    #[error("Database query error: {0}")]
13    Sqlite(#[from] rusqlite::Error),
14    #[error("Symbol '{0}' not found")]
15    SymbolNotFound(String),
16    #[error("Symbol '{0}' not found. Did you mean one of:\n{1}")]
17    SymbolNotFoundWithSuggestions(String, String),
18    #[error(
19        "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
20    )]
21    AmbiguousSymbol(String, usize, String),
22    #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
23    InvalidDirection(String),
24}
25
26fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
27    Ok(Symbol {
28        symbol_id: row.get("symbol_id")?,
29        file_id: row.get("file_id")?,
30        path: row.get::<_, String>("path")?.replace('\\', "/"),
31        language: row.get("language")?,
32        name: row.get("name")?,
33        kind: row.get("kind")?,
34        signature: row.get("signature")?,
35        doc_comment: row.get("doc_comment")?,
36        visibility: row.get("visibility")?,
37        parent_symbol_id: row.get("parent_symbol_id")?,
38        start_line: row.get::<_, i64>("start_line")? as usize,
39        start_column: row.get::<_, i64>("start_column")? as usize,
40        end_line: row.get::<_, i64>("end_line")? as usize,
41        end_column: row.get::<_, i64>("end_column")? as usize,
42        start_byte: row.get::<_, i64>("start_byte")? as usize,
43        end_byte: row.get::<_, i64>("end_byte")? as usize,
44        body_start_line: row
45            .get::<_, Option<i64>>("body_start_line")?
46            .map(|v| v as usize),
47        body_start_column: row
48            .get::<_, Option<i64>>("body_start_column")?
49            .map(|v| v as usize),
50        body_end_line: row
51            .get::<_, Option<i64>>("body_end_line")?
52            .map(|v| v as usize),
53        body_end_column: row
54            .get::<_, Option<i64>>("body_end_column")?
55            .map(|v| v as usize),
56        body_start_byte: row
57            .get::<_, Option<i64>>("body_start_byte")?
58            .map(|v| v as usize),
59        body_end_byte: row
60            .get::<_, Option<i64>>("body_end_byte")?
61            .map(|v| v as usize),
62        body_hash: row.get("body_hash")?,
63        semantic_group: row.get("semantic_group")?,
64        is_test: row.get::<_, i64>("is_test")? != 0,
65        test_container: row.get::<_, i64>("test_container")? != 0,
66    })
67}
68
69pub(crate) fn escape_like(value: &str) -> String {
70    value
71        .replace('\\', "\\\\")
72        .replace('%', "\\%")
73        .replace('_', "\\_")
74}
75
76/// Retrieve indexed files optionally scoped by path filter, pushed down to SQLite.
77pub fn load_scoped_files(
78    conn: &Connection,
79    path_filter: Option<&str>,
80) -> Result<Vec<FileFact>, QueryError> {
81    let norm = path_filter
82        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
83        .filter(|p| !p.is_empty());
84    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
85    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
86    let prefix_bs = norm_bs
87        .as_ref()
88        .map(|path| format!("{}\\\\%", escape_like(path)));
89
90    let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
91               FROM files
92               WHERE (:path IS NULL
93                  OR path = :path COLLATE NOCASE
94                  OR path = :path_bs COLLATE NOCASE
95                  OR path LIKE :path_prefix ESCAPE '\\'
96                  OR path LIKE :path_prefix_bs ESCAPE '\\')
97               ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
98
99    let mut stmt = conn.prepare(sql)?;
100    let files = stmt
101        .query_map(
102            rusqlite::named_params! {
103                ":path": norm.as_deref(),
104                ":path_bs": norm_bs.as_deref(),
105                ":path_prefix": prefix.as_deref(),
106                ":path_prefix_bs": prefix_bs.as_deref(),
107            },
108            |row| {
109                Ok(FileFact {
110                    file_id: row.get(0)?,
111                    path: row.get::<_, String>(1)?.replace('\\', "/"),
112                    language: row.get(2)?,
113                    content_hash: row.get(3)?,
114                    content_bytes: row.get(4)?,
115                    line_count: row.get(5)?,
116                    indexed_at: row.get(6)?,
117                })
118            },
119        )?
120        .collect::<Result<Vec<_>, _>>()?;
121
122    Ok(files)
123}
124
125/// Load up to `limit_per_file` symbols per file for scoped files, directly aggregated in SQLite.
126/// Files deeper than `depth` are filtered out in SQLite to keep memory strictly bounded.
127pub fn load_scoped_outline_symbols(
128    conn: &Connection,
129    path_filter: Option<&str>,
130    depth: usize,
131    limit_per_file: usize,
132) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
133    let norm = path_filter
134        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
135        .filter(|p| !p.is_empty());
136    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
137    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
138    let prefix_bs = norm_bs
139        .as_ref()
140        .map(|path| format!("{}\\\\%", escape_like(path)));
141
142    let max_slashes = match &norm {
143        None => {
144            if depth > 0 {
145                (depth - 1) as i64
146            } else {
147                0
148            }
149        }
150        Some(f) => {
151            let filter_slashes = f.chars().filter(|&c| c == '/').count();
152            (filter_slashes + depth) as i64
153        }
154    };
155
156    let sql = "
157        WITH bounded_files AS (
158            SELECT path FROM files
159            WHERE (:path IS NULL
160               OR path = :path COLLATE NOCASE
161               OR path = :path_bs COLLATE NOCASE
162               OR path LIKE :path_prefix ESCAPE '\\'
163               OR path LIKE :path_prefix_bs ESCAPE '\\')
164            ORDER BY path ASC
165            LIMIT 1000
166        ),
167        ranked AS (
168            SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
169                   s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
170                   s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
171                   s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
172                   s.is_test, s.test_container,
173                   ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
174            FROM symbols s
175            JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
176            WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
177              AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
178              AND s.parent_symbol_id IS NULL
179        )
180        SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
181               visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
182               start_byte, end_byte, body_start_line, body_start_column, body_end_line,
183               body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
184               is_test, test_container
185        FROM ranked
186        WHERE rn <= :limit
187        ORDER BY path ASC, start_line ASC
188    ";
189
190    let mut stmt = conn.prepare(sql)?;
191    let mut rows = stmt.query(rusqlite::named_params! {
192        ":path": norm.as_deref(),
193        ":path_bs": norm_bs.as_deref(),
194        ":path_prefix": prefix.as_deref(),
195        ":path_prefix_bs": prefix_bs.as_deref(),
196        ":max_slashes": max_slashes,
197        ":limit": limit_per_file as i64,
198    })?;
199
200    let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
201    while let Some(row) = rows.next()? {
202        let sym = map_symbol(row)?;
203        symbols_by_file
204            .entry(sym.path.clone())
205            .or_default()
206            .push(sym);
207    }
208
209    Ok(symbols_by_file)
210}
211
212/// Lookup single file metadata by path with slash-boundary matching.
213pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
214    let normalized = path.replace('\\', "/");
215    let backslash = path.replace('/', "\\");
216
217    // Check exact path match first, prioritizing exact case before case-insensitive fallback
218    let mut stmt = conn.prepare(
219        "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
220         FROM files
221         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
222         ORDER BY (path = ?1 OR path = ?2) DESC
223         LIMIT 1",
224    )?;
225
226    let mut rows = stmt.query(params![normalized, backslash])?;
227    if let Some(row) = rows.next()? {
228        Ok(Some(FileFact {
229            file_id: row.get(0)?,
230            path: row.get::<_, String>(1)?.replace('\\', "/"),
231            language: row.get(2)?,
232            content_hash: row.get(3)?,
233            content_bytes: row.get(4)?,
234            line_count: row.get(5)?,
235            indexed_at: row.get(6)?,
236        }))
237    } else {
238        Ok(None)
239    }
240}
241
242/// Load all symbols declared inside a specific file.
243pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
244    // Normalizing slashes for path matching
245    let normalized = file_path.replace('\\', "/");
246    let backslash = file_path.replace('/', "\\");
247
248    // Try exact case matching first to avoid conflating sibling files on case-sensitive filesystems
249    let mut stmt = conn.prepare(
250        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
251                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
252                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
253                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
254                is_test, test_container
255         FROM symbols
256         WHERE (path = ?1 OR path = ?2)
257         ORDER BY start_line ASC, start_column ASC",
258    )?;
259
260    let rows = stmt
261        .query_map(params![&normalized, &backslash], map_symbol)?
262        .collect::<Result<Vec<_>, _>>()?;
263
264    if !rows.is_empty() {
265        return Ok(rows);
266    }
267
268    // Fall back to case-insensitive match (for Windows or case-variant requests)
269    let mut stmt = conn.prepare(
270        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
271                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
272                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
273                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
274                is_test, test_container
275         FROM symbols
276         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
277         ORDER BY start_line ASC, start_column ASC",
278    )?;
279
280    let rows = stmt
281        .query_map(params![normalized, backslash], map_symbol)?
282        .collect::<Result<Vec<_>, _>>()?;
283
284    Ok(rows)
285}
286
287/// Normalizes common symbol kind aliases to their canonical database representation.
288pub fn normalize_kind(kind: &str) -> String {
289    let lower = kind.trim().to_lowercase();
290    match lower.as_str() {
291        "fn" | "func" | "function" => "function".to_string(),
292        "method" => "method".to_string(),
293        "struct" => "struct".to_string(),
294        "class" => "class".to_string(),
295        "enum" => "enum".to_string(),
296        "trait" => "trait".to_string(),
297        "interface" => "interface".to_string(),
298        "type" | "typedef" => "type".to_string(),
299        "mod" | "module" => "module".to_string(),
300        "const" | "constant" => "constant".to_string(),
301        "var" | "variable" => "variable".to_string(),
302        _ => lower,
303    }
304}
305
306/// Search symbols by name query, kind filter, and test flag.
307pub fn search_symbols(
308    conn: &Connection,
309    query: &str,
310    kind_filter: Option<&str>,
311    include_tests: bool,
312    limit: usize,
313) -> Result<Vec<Symbol>, QueryError> {
314    search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
315}
316
317/// Search symbols with optional path scoping filter.
318pub fn search_symbols_scoped(
319    conn: &Connection,
320    query: &str,
321    kind_filter: Option<&str>,
322    path_filter: Option<&str>,
323    include_tests: bool,
324    limit: usize,
325) -> Result<Vec<Symbol>, QueryError> {
326    // Try get_symbol_by_name first for qualified queries (e.g. McpServer::new, Class.method)
327    if (query.contains("::") || query.contains('.'))
328        && let Ok(Some(sym)) = get_symbol_by_name(conn, query, path_filter)
329    {
330        return Ok(vec![sym]);
331    }
332
333    let pattern = format!("%{}%", escape_like(query));
334    let normalized_path = path_filter.map(|p| {
335        p.replace('\\', "/")
336            .trim_start_matches("./")
337            .trim_matches('/')
338            .to_string()
339    });
340    let escaped_path = normalized_path.as_deref().map(escape_like);
341    let norm_kind = kind_filter.map(normalize_kind);
342
343    let mut sql = String::from(
344        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
345                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
346                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
347                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
348                is_test, test_container
349         FROM symbols
350         WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
351           AND (:kind IS NULL OR kind = :kind)
352           AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
353    );
354
355    if !include_tests {
356        sql.push_str(" AND is_test = 0 AND test_container = 0");
357    }
358
359    sql.push_str(
360        " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
361    );
362    sql.push_str(&limit.to_string());
363
364    let mut stmt = conn.prepare(&sql)?;
365
366    let path_val = normalized_path.as_deref();
367    let path_like = escaped_path.as_deref();
368    let kind_val = norm_kind.as_deref();
369    let rows = stmt
370        .query_map(
371            rusqlite::named_params! {
372                ":query": query,
373                ":pattern": pattern,
374                ":kind": kind_val,
375                ":path": path_val,
376                ":path_like": path_like,
377            },
378            map_symbol,
379        )?
380        .collect::<Result<Vec<_>, _>>()?;
381
382    Ok(rows)
383}
384
385/// Sanitizes a free-form user query into `(and_query, or_query)` formatted for SQLite FTS5.
386/// Each alphanumeric/underscore token is quoted and given a prefix wildcard: `"token"*`.
387pub fn sanitize_fts5_query(query: &str) -> (String, String) {
388    let words: Vec<String> = query
389        .split(|c: char| !c.is_alphanumeric() && c != '_')
390        .filter(|s| !s.is_empty())
391        .map(|s| format!("\"{s}\"*"))
392        .collect();
393
394    if words.is_empty() {
395        return (String::new(), String::new());
396    }
397
398    let and_query = words.join(" ");
399    let or_query = words.join(" OR ");
400    (and_query, or_query)
401}
402
403/// Conceptual full-text search with optional path scoping filter.
404pub fn fts_search_symbols_scoped(
405    conn: &Connection,
406    query: &str,
407    kind_filter: Option<&str>,
408    path_filter: Option<&str>,
409    include_tests: bool,
410    limit: usize,
411) -> Result<Vec<SymbolSearchResult>, QueryError> {
412    let (and_q, or_q) = sanitize_fts5_query(query);
413    if and_q.is_empty() {
414        return Ok(Vec::new());
415    }
416
417    let normalized_path = path_filter.map(|p| {
418        p.replace('\\', "/")
419            .trim_start_matches("./")
420            .trim_matches('/')
421            .to_string()
422    });
423    let norm_kind = kind_filter.map(normalize_kind);
424
425    let fts_exists: bool = conn
426        .query_row(
427            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
428            [],
429            |_| Ok(true),
430        )
431        .unwrap_or(false);
432
433    if !fts_exists {
434        let pattern = format!("%{}%", escape_like(query));
435        let escaped_path = normalized_path.as_deref().map(escape_like);
436        let mut sql = String::from(
437            "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
438                    visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
439                    start_byte, end_byte, body_start_line, body_start_column, body_end_line,
440                    body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
441                    is_test, test_container
442              FROM symbols
443              WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
444                AND (:kind IS NULL OR kind = :kind)
445                AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
446        );
447        if !include_tests {
448            sql.push_str(" AND is_test = 0 AND test_container = 0");
449        }
450        sql.push_str(
451            " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
452        );
453        sql.push_str(&limit.to_string());
454
455        let mut stmt = conn.prepare(&sql)?;
456        let path_val = normalized_path.as_deref();
457        let path_like = escaped_path.as_deref();
458        let kind_val = norm_kind.as_deref();
459        let rows = stmt
460            .query_map(
461                rusqlite::named_params! {
462                    ":query": query,
463                    ":pattern": pattern,
464                    ":kind": kind_val,
465                    ":path": path_val,
466                    ":path_like": path_like,
467                },
468                map_symbol,
469            )?
470            .collect::<Result<Vec<_>, _>>()?;
471
472        return Ok(rows
473            .into_iter()
474            .map(|s| SymbolSearchResult {
475                symbol: s,
476                score: 0.0,
477                snippet: None,
478            })
479            .collect());
480    }
481
482    let escaped_path = normalized_path.as_deref().map(escape_like);
483    let execute_search = |match_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
484        let mut sql = String::from(
485            "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
486                    s.visibility, s.parent_symbol_id, s.start_line, s.start_column, end_line, end_column,
487                    start_byte, end_byte, body_start_line, body_start_column, body_end_line,
488                    body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
489                    is_test, test_container,
490                    bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
491                    snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
492                    snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
493                    snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
494             FROM symbols_fts
495             CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
496             WHERE symbols_fts MATCH :match
497               AND (:kind IS NULL OR s.kind = :kind)
498               AND (:path IS NULL OR replace(s.path, '\\', '/') = :path COLLATE NOCASE OR replace(s.path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(s.path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
499        );
500
501        if !include_tests {
502            sql.push_str(" AND s.is_test = 0 AND s.test_container = 0");
503        }
504
505        sql.push_str(" ORDER BY (s.kind = 'import') ASC, (s.language IN ('markdown', 'yaml', 'toml', 'json', 'html', 'css', 'xml', 'ini', 'text')) ASC, rank_score ASC LIMIT ");
506        sql.push_str(&limit.to_string());
507
508        let mut stmt = conn.prepare(&sql)?;
509
510        let map_fn = |row: &Row| -> rusqlite::Result<SymbolSearchResult> {
511            let symbol = map_symbol(row)?;
512            let score: f64 = row.get("rank_score")?;
513            let doc_snip: Option<String> = row.get("doc_snippet").ok();
514            let sig_snip: Option<String> = row.get("sig_snippet").ok();
515            let name_snip: Option<String> = row.get("name_snippet").ok();
516
517            // Pick the snippet containing match highlight brackets
518            let snippet = if doc_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
519                doc_snip
520            } else if sig_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
521                sig_snip
522            } else if name_snip.as_ref().map(|s| s.contains('[')).unwrap_or(false) {
523                name_snip
524            } else {
525                doc_snip.or(sig_snip).or(name_snip)
526            };
527
528            Ok(SymbolSearchResult {
529                symbol,
530                score,
531                snippet,
532            })
533        };
534
535        let path_val = normalized_path.as_deref();
536        let path_like = escaped_path.as_deref();
537        let kind_val = norm_kind.as_deref();
538        let rows = stmt
539            .query_map(
540                rusqlite::named_params! {
541                    ":match": match_clause,
542                    ":kind": kind_val,
543                    ":path": path_val,
544                    ":path_like": path_like,
545                },
546                map_fn,
547            )?
548            .collect::<Result<Vec<_>, _>>()?;
549
550        Ok(rows)
551    };
552
553    let mut results = execute_search(&and_q)?;
554    if results.is_empty() && and_q != or_q {
555        results = execute_search(&or_q)?;
556    }
557
558    Ok(results)
559}
560
561/// Find tests related to a target symbol by caller relationships, naming pattern, or FTS matching.
562pub fn find_related_tests(
563    conn: &Connection,
564    target_symbol: &Symbol,
565    limit: usize,
566) -> Result<Vec<Symbol>, QueryError> {
567    if limit == 0 {
568        return Ok(Vec::new());
569    }
570
571    let mut tests = Vec::new();
572    let mut seen_ids = std::collections::HashSet::new();
573
574    // 1. Direct callers / references that are marked as test or located in test files
575    let callers_sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
576            s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
577            s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
578            s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
579            s.is_test, s.test_container
580     FROM symbols s
581     JOIN relationships r ON r.from_symbol_id = s.symbol_id
582     WHERE r.to_symbol_id = ?1 AND (s.is_test = 1 OR s.test_container = 1)
583     LIMIT ?2";
584
585    if let Ok(mut stmt) = conn.prepare(callers_sql)
586        && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
587    {
588        for row in rows.flatten() {
589            if seen_ids.insert(row.symbol_id.clone()) {
590                tests.push(row);
591                if tests.len() >= limit {
592                    return Ok(tests);
593                }
594            }
595        }
596    }
597
598    // 2. Name-matching tests in SQLite
599    let remaining = limit - tests.len();
600    let name_sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
601            s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
602            s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
603            s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
604            s.is_test, s.test_container
605     FROM symbols s
606     WHERE (s.is_test = 1 OR s.test_container = 1)
607       AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
608     ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
609     LIMIT ?2";
610
611    if let Ok(mut stmt) = conn.prepare(name_sql)
612        && let Ok(rows) = stmt.query_map(
613            params![target_symbol.name, (remaining * 2) as i64],
614            map_symbol,
615        )
616    {
617        for row in rows.flatten() {
618            if seen_ids.insert(row.symbol_id.clone()) {
619                tests.push(row);
620                if tests.len() >= limit {
621                    return Ok(tests);
622                }
623            }
624        }
625    }
626
627    // 3. FTS5 search restricted to tests
628    let remaining = limit - tests.len();
629    let fts_exists: bool = conn
630        .query_row(
631            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
632            [],
633            |_| Ok(true),
634        )
635        .unwrap_or(false);
636
637    if remaining > 0 && fts_exists {
638        let fts_sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
639                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
640                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
641                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
642                s.is_test, s.test_container
643         FROM symbols_fts
644         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
645         WHERE symbols_fts MATCH ?1 AND (s.is_test = 1 OR s.test_container = 1)
646         LIMIT ?2";
647
648        let (and_q, _or_q) = sanitize_fts5_query(&target_symbol.name);
649        if !and_q.is_empty()
650            && let Ok(mut stmt) = conn.prepare(fts_sql)
651            && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
652        {
653            for row in rows.flatten() {
654                if seen_ids.insert(row.symbol_id.clone()) {
655                    tests.push(row);
656                    if tests.len() >= limit {
657                        break;
658                    }
659                }
660            }
661        }
662    }
663
664    Ok(tests)
665}
666
667/// Find a specific symbol by name, with an optional path filter for disambiguation.
668pub fn get_symbol_by_name(
669    conn: &Connection,
670    name: &str,
671    path_filter: Option<&str>,
672) -> Result<Option<Symbol>, QueryError> {
673    get_symbol_by_name_internal(conn, name, path_filter, false)
674}
675
676/// Find a specific symbol by name, requiring exact path match (used for atomic edits).
677pub fn get_symbol_by_name_exact(
678    conn: &Connection,
679    name: &str,
680    exact_path: &str,
681) -> Result<Option<Symbol>, QueryError> {
682    get_symbol_by_name_internal(conn, name, Some(exact_path), true)
683}
684
685fn get_symbol_by_name_internal(
686    conn: &Connection,
687    name: &str,
688    path_filter: Option<&str>,
689    exact_path: bool,
690) -> Result<Option<Symbol>, QueryError> {
691    // Check if name is qualified like `Struct::method` or `Class.method`
692    let (parent_name, terminal_name) = if let Some(idx) = name.rfind("::") {
693        let parent = &name[..idx];
694        let term = &name[idx + 2..];
695        let immediate_parent = if let Some(p_idx) = parent.rfind("::") {
696            &parent[p_idx + 2..]
697        } else {
698            parent
699        };
700        (Some(immediate_parent), term)
701    } else if let Some(idx) = name.rfind('.') {
702        let parent = &name[..idx];
703        let term = &name[idx + 1..];
704        let immediate_parent = if let Some(p_idx) = parent.rfind('.') {
705            &parent[p_idx + 1..]
706        } else {
707            parent
708        };
709        (Some(immediate_parent), term)
710    } else {
711        (None, name)
712    };
713
714    let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
715                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
716                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
717                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
718                s.is_test, s.test_container
719         FROM symbols s
720         LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
721         WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
722           AND (:path IS NULL OR s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE OR (:exact = 0 AND (s.path LIKE '%/' || :path_like ESCAPE '\\' OR s.path LIKE '%\\\\' || :path_like_bs ESCAPE '\\')))
723         ORDER BY (s.kind != 'import') DESC,
724                  (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
725                  (s.name = :name) DESC,
726                  (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
727                  s.is_test ASC
728         LIMIT 25";
729
730    let mut stmt = conn.prepare(sql)?;
731    let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
732    let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
733    let path_like = normalized_path.as_deref().map(escape_like);
734    let path_like_bs = backslash_path.as_deref().map(escape_like);
735
736    let mut rows = stmt.query(rusqlite::named_params! {
737        ":name": name,
738        ":term": terminal_name,
739        ":parent": parent_name,
740        ":path": normalized_path.as_deref(),
741        ":path_bs": backslash_path.as_deref(),
742        ":path_like": path_like.as_deref(),
743        ":path_like_bs": path_like_bs.as_deref(),
744        ":exact": if exact_path { 1 } else { 0 },
745    })?;
746
747    let mut matches: Vec<Symbol> = Vec::new();
748    while let Some(row) = rows.next()? {
749        matches.push(map_symbol(row)?);
750    }
751
752    if matches.is_empty() {
753        return Ok(None);
754    }
755
756    if matches.len() == 1 {
757        return Ok(Some(matches.remove(0)));
758    }
759
760    // Exclude imports if non-import candidates exist
761    let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
762        matches.into_iter().filter(|s| s.kind != "import").collect()
763    } else {
764        matches
765    };
766
767    if candidates.len() == 1 {
768        return Ok(Some(candidates.into_iter().next().unwrap()));
769    }
770
771    // Check if there's an exact match on full name among candidates
772    let exact_name_matches: Vec<_> = candidates
773        .iter()
774        .filter(|s| s.name == name)
775        .cloned()
776        .collect();
777    if exact_name_matches.len() == 1 {
778        return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
779    }
780
781    let definition_candidates = if exact_name_matches.is_empty() {
782        &candidates
783    } else {
784        &exact_name_matches
785    };
786    let def_matches: Vec<_> = definition_candidates
787        .iter()
788        .filter(|s| {
789            matches!(
790                s.kind.as_str(),
791                "function"
792                    | "struct"
793                    | "class"
794                    | "trait"
795                    | "method"
796                    | "enum"
797                    | "interface"
798                    | "type"
799            )
800        })
801        .cloned()
802        .collect();
803    if def_matches.len() == 1 {
804        return Ok(Some(def_matches.into_iter().next().unwrap()));
805    }
806
807    let active_pool = if !def_matches.is_empty() {
808        def_matches
809    } else if !exact_name_matches.is_empty() {
810        exact_name_matches
811    } else {
812        candidates
813    };
814
815    // If path_filter was given and there's an exact path match
816    if let Some(ref p) = normalized_path {
817        let exact_path_matches: Vec<_> = active_pool
818            .iter()
819            .filter(|s| s.path == *p)
820            .cloned()
821            .collect();
822        if exact_path_matches.len() == 1 {
823            return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
824        }
825    }
826
827    if active_pool.len() == 1 {
828        return Ok(Some(active_pool.into_iter().next().unwrap()));
829    }
830
831    // Ambiguity detected
832    let mut candidate_list = String::new();
833    for s in &active_pool {
834        candidate_list.push_str(&format!(
835            "- {} `{}` in {}:{}\n",
836            s.kind, s.name, s.path, s.start_line
837        ));
838    }
839
840    Err(QueryError::AmbiguousSymbol(
841        name.to_string(),
842        active_pool.len(),
843        candidate_list,
844    ))
845}
846
847/// Find callers or callees of a symbol (filters unresolved external stdlib/runtime primitives by default).
848pub fn find_references(
849    conn: &Connection,
850    symbol_name: &str,
851    direction: &str,
852    limit: usize,
853) -> Result<Vec<ReferenceSite>, QueryError> {
854    find_references_ext(conn, symbol_name, direction, limit, false)
855}
856
857/// Find callers or callees with option to include external runtime/stdlib primitives.
858pub fn find_references_ext(
859    conn: &Connection,
860    symbol_name: &str,
861    direction: &str,
862    limit: usize,
863    include_external: bool,
864) -> Result<Vec<ReferenceSite>, QueryError> {
865    find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
866}
867
868/// Find callers or callees with optional file path disambiguation filter and external symbols toggle.
869pub fn find_references_scoped(
870    conn: &Connection,
871    symbol_name: &str,
872    direction: &str,
873    limit: usize,
874    include_external: bool,
875    path_filter: Option<&str>,
876) -> Result<Vec<ReferenceSite>, QueryError> {
877    if direction != "callers" && direction != "callees" {
878        return Err(QueryError::InvalidDirection(direction.to_string()));
879    }
880
881    match get_symbol_by_name(conn, symbol_name, path_filter)? {
882        Some(target) => find_references_internal(
883            conn,
884            &target.name,
885            direction,
886            limit,
887            Some(&target.symbol_id),
888            include_external,
889        ),
890        None => {
891            let suggestions = search_symbols_scoped(conn, symbol_name, None, path_filter, false, 3)
892                .unwrap_or_default();
893            if suggestions.is_empty() {
894                Err(QueryError::SymbolNotFound(symbol_name.to_string()))
895            } else {
896                let list = suggestions
897                    .into_iter()
898                    .map(|s| format!("  - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
899                    .collect::<Vec<_>>()
900                    .join("\n");
901                Err(QueryError::SymbolNotFoundWithSuggestions(
902                    symbol_name.to_string(),
903                    list,
904                ))
905            }
906        }
907    }
908}
909
910pub fn find_references_for_symbol(
911    conn: &Connection,
912    symbol_name: &str,
913    direction: &str,
914    limit: usize,
915    symbol_id: &str,
916) -> Result<Vec<ReferenceSite>, QueryError> {
917    find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
918}
919
920/// SQL expression ranking a candidate path against the call site `p.path`:
921/// 2 for the same file, 1 for the same directory, 0 otherwise.
922fn call_site_proximity(candidate_path: &str) -> String {
923    let normalized = format!("replace({candidate_path}, '\\', '/')");
924    let call_site = "replace(p.path, '\\', '/')";
925    format!(
926        "CASE WHEN {normalized} = {call_site} THEN 2
927              WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
928              ELSE 0 END"
929    )
930}
931
932/// SQL predicate that decides whether a pending call edge `p` (with caller `s_from`) points at
933/// the candidate definition `target` (whose parent symbol is joined as `parent`).
934fn pending_target_predicate(target: &str, parent: &str) -> String {
935    let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
936    let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
937    let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
938    let closer_rank = call_site_proximity("closer.path");
939    let target_rank = call_site_proximity(&format!("{target}.path"));
940    format!(
941        "(
942            (
943                {target}.parent_symbol_id IS NOT NULL
944                AND {parent}.name IS NOT NULL
945                AND (
946                    EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
947                    OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
948                        AND s_from.parent_symbol_id = {target}.parent_symbol_id)
949                    OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
950                    OR EXISTS (
951                        SELECT 1 FROM symbols receiver
952                        JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
953                        WHERE receiver.name = p.target_receiver
954                          AND receiver.path = p.path
955                          AND receiver_type.resolved_type = {parent}.name
956                    )
957                )
958                AND NOT EXISTS (
959                    SELECT 1 FROM {ns}
960                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
961                      AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
962                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
963                )
964            )
965            OR (
966                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
967                AND (p.target_receiver IS NULL OR p.target_receiver = '')
968                AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
969                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
970                    SELECT 1 FROM symbols closer
971                    WHERE closer.name = {target}.name
972                      AND closer.symbol_id != {target}.symbol_id
973                      AND closer.parent_symbol_id IS NULL
974                      AND closer.kind = {target}.kind
975                      AND {closer_rank} > {target_rank}
976                ))
977            )
978            OR (
979                {target}.parent_symbol_id IS NULL
980                AND EXISTS (
981                    SELECT 1 FROM {ns}
982                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
983                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
984                )
985            )
986        )"
987    )
988}
989
990fn has_table(conn: &Connection, name: &str) -> bool {
991    conn.query_row(
992        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
993        [name],
994        |_| Ok(true),
995    )
996    .unwrap_or(false)
997}
998
999fn has_pending_namespace_column(conn: &Connection) -> bool {
1000    let has_ns: bool = conn
1001        .query_row(
1002            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
1003            [],
1004            |_| Ok(true),
1005        )
1006        .unwrap_or(false);
1007    let has_display: bool = conn
1008        .query_row(
1009            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
1010            [],
1011            |_| Ok(true),
1012        )
1013        .unwrap_or(false);
1014    let has_receiver: bool = conn
1015        .query_row(
1016            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
1017            [],
1018            |_| Ok(true),
1019        )
1020        .unwrap_or(false);
1021    has_ns && has_display && has_receiver
1022}
1023
1024fn find_references_internal(
1025    conn: &Connection,
1026    symbol_name: &str,
1027    direction: &str,
1028    limit: usize,
1029    symbol_id: Option<&str>,
1030    include_external: bool,
1031) -> Result<Vec<ReferenceSite>, QueryError> {
1032    let mut results = Vec::new();
1033
1034    if direction == "callers" {
1035        // Find callers: references pointing to target symbol
1036        let mut stmt = conn.prepare(
1037            "SELECT s_from.name AS from_name,
1038                    r.from_symbol_id,
1039                    s_to.name AS to_name,
1040                    r.kind,
1041                    r.path,
1042                    r.start_line,
1043                    r.start_column
1044             FROM relationships r
1045             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1046             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1047             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
1048             LIMIT ?2",
1049        )?;
1050
1051        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1052            Ok(ReferenceSite {
1053                from_symbol_name: row.get(0)?,
1054                from_symbol_id: row.get(1)?,
1055                to_symbol_name: row.get(2)?,
1056                kind: row.get(3)?,
1057                path: row.get::<_, String>(4)?.replace('\\', "/"),
1058                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1059                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1060            })
1061        })?;
1062
1063        for r in rows {
1064            results.push(r?);
1065        }
1066
1067        // Also query pending_relationships for callers if results < limit
1068        if results.len() < limit {
1069            let remaining = limit - results.len();
1070            if has_pending_namespace_column(conn) {
1071                if let Some(sid) = symbol_id {
1072                    let mut pending_stmt = conn.prepare(
1073                        &format!("SELECT s_from.name AS from_name,
1074                                p.from_symbol_id,
1075                                p.target_terminal_name AS to_name,
1076                                p.kind,
1077                                p.path,
1078                                p.start_line,
1079                                p.start_column
1080                         FROM pending_relationships p
1081                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1082                         JOIN symbols s_target ON s_target.symbol_id = ?3
1083                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1084                          WHERE p.target_terminal_name = ?1
1085                            AND {pred}
1086                          LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
1087                    )?;
1088
1089                    let p_rows = pending_stmt.query_map(
1090                        params![symbol_name, remaining as i64, sid],
1091                        |row| {
1092                            Ok(ReferenceSite {
1093                                from_symbol_name: row.get(0)?,
1094                                from_symbol_id: row.get(1)?,
1095                                to_symbol_name: row.get(2)?,
1096                                kind: row.get(3)?,
1097                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1098                                start_line: Some(row.get::<_, i64>(5)? as usize),
1099                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1100                            })
1101                        },
1102                    )?;
1103                    for r in p_rows {
1104                        results.push(r?);
1105                    }
1106                } else {
1107                    let mut pending_stmt = conn.prepare(
1108                        "SELECT s_from.name AS from_name,
1109                                p.from_symbol_id,
1110                                p.target_terminal_name AS to_name,
1111                                p.kind,
1112                                p.path,
1113                                p.start_line,
1114                                p.start_column
1115                         FROM pending_relationships p
1116                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1117                         WHERE p.target_terminal_name = ?1
1118                           AND (
1119                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1120                               OR EXISTS (
1121                                   SELECT 1 FROM symbols s_any
1122                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
1123                                   WHERE s_any.name = p.target_terminal_name
1124                                     AND EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END) WHERE value = s_any_parent.name)
1125                               )
1126                           )
1127                         LIMIT ?2",
1128                    )?;
1129
1130                    let p_rows =
1131                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1132                            Ok(ReferenceSite {
1133                                from_symbol_name: row.get(0)?,
1134                                from_symbol_id: row.get(1)?,
1135                                to_symbol_name: row.get(2)?,
1136                                kind: row.get(3)?,
1137                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1138                                start_line: Some(row.get::<_, i64>(5)? as usize),
1139                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1140                            })
1141                        })?;
1142                    for r in p_rows {
1143                        results.push(r?);
1144                    }
1145                }
1146            } else {
1147                let is_nested = if let Some(sid) = symbol_id {
1148                    conn.query_row(
1149                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
1150                        params![sid],
1151                        |_| Ok(true),
1152                    )
1153                    .unwrap_or(false)
1154                } else {
1155                    false
1156                };
1157
1158                if !is_nested {
1159                    let mut pending_stmt = conn.prepare(
1160                        "SELECT s_from.name AS from_name,
1161                                p.from_symbol_id,
1162                                p.target_terminal_name AS to_name,
1163                                p.kind,
1164                                p.path,
1165                                p.start_line,
1166                                p.start_column
1167                         FROM pending_relationships p
1168                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1169                         WHERE p.target_terminal_name = ?1
1170                         LIMIT ?2",
1171                    )?;
1172
1173                    let p_rows =
1174                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1175                            Ok(ReferenceSite {
1176                                from_symbol_name: row.get(0)?,
1177                                from_symbol_id: row.get(1)?,
1178                                to_symbol_name: row.get(2)?,
1179                                kind: row.get(3)?,
1180                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1181                                start_line: Some(row.get::<_, i64>(5)? as usize),
1182                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1183                            })
1184                        })?;
1185
1186                    for r in p_rows {
1187                        results.push(r?);
1188                    }
1189                }
1190            }
1191        }
1192
1193        if results.len() < limit && has_table(conn, "identifiers") {
1194            let remaining = limit - results.len();
1195            let mut ident_stmt = conn.prepare(
1196                "SELECT COALESCE(s.name, ''),
1197                        COALESCE(i.containing_symbol_id, ''),
1198                        i.name,
1199                        i.kind,
1200                        i.path,
1201                        i.start_line,
1202                        i.start_column
1203                 FROM identifiers i
1204                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
1205                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
1206                   AND COALESCE(s.kind, '') != 'import'
1207                 ORDER BY i.path, i.start_line
1208                 LIMIT ?2",
1209            )?;
1210            let rows = ident_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1211                Ok(ReferenceSite {
1212                    from_symbol_name: row.get(0)?,
1213                    from_symbol_id: row.get(1)?,
1214                    to_symbol_name: row.get(2)?,
1215                    kind: row.get(3)?,
1216                    path: row.get::<_, String>(4)?.replace('\\', "/"),
1217                    start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1218                    start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1219                })
1220            })?;
1221            for r in rows {
1222                results.push(r?);
1223            }
1224        }
1225    } else {
1226        // Find callees: symbols called by target symbol
1227        let mut stmt = conn.prepare(
1228            "SELECT s_from.name AS from_name,
1229                    r.from_symbol_id,
1230                    s_to.name AS to_name,
1231                    r.kind,
1232                    r.path,
1233                    r.start_line,
1234                    r.start_column
1235             FROM relationships r
1236             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1237             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1238             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
1239             LIMIT ?2",
1240        )?;
1241
1242        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1243            Ok(ReferenceSite {
1244                from_symbol_name: row.get(0)?,
1245                from_symbol_id: row.get(1)?,
1246                to_symbol_name: row.get(2)?,
1247                kind: row.get(3)?,
1248                path: row.get::<_, String>(4)?.replace('\\', "/"),
1249                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1250                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1251            })
1252        })?;
1253
1254        for r in rows {
1255            results.push(r?);
1256        }
1257
1258        // Also query pending_relationships for callees
1259        if results.len() < limit {
1260            let remaining = limit - results.len();
1261            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
1262                let sql = if include_external {
1263                    String::from("SELECT DISTINCT s_from.name AS from_name,
1264                            p.from_symbol_id,
1265                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
1266                            p.kind,
1267                            p.path,
1268                            p.start_line,
1269                            p.start_column
1270                     FROM pending_relationships p
1271                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1272                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1273                     LIMIT ?2")
1274                } else {
1275                    format!("SELECT DISTINCT s_from.name AS from_name,
1276                            p.from_symbol_id,
1277                            p.target_terminal_name AS to_name,
1278                            p.kind,
1279                            p.path,
1280                            p.start_line,
1281                            p.start_column
1282                     FROM pending_relationships p
1283                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1284                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1285                       AND EXISTS (
1286                           SELECT 1 FROM symbols s_to
1287                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
1288                           WHERE s_to.name = p.target_terminal_name
1289                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1290                             AND {pred}
1291                       )
1292                     LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
1293                };
1294                let mut pending_stmt = conn.prepare(&sql)?;
1295                let rows = pending_stmt.query_map(
1296                    params![symbol_name, remaining as i64, symbol_id],
1297                    |row| {
1298                        Ok(ReferenceSite {
1299                            from_symbol_name: row.get(0)?,
1300                            from_symbol_id: row.get(1)?,
1301                            to_symbol_name: row.get(2)?,
1302                            kind: row.get(3)?,
1303                            path: row.get::<_, String>(4)?.replace('\\', "/"),
1304                            start_line: Some(row.get::<_, i64>(5)? as usize),
1305                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1306                        })
1307                    },
1308                )?;
1309                let mut out = Vec::new();
1310                for r in rows {
1311                    out.push(r?);
1312                }
1313                out
1314            } else {
1315                let sql = if include_external {
1316                    "SELECT DISTINCT s_from.name AS from_name,
1317                            p.from_symbol_id,
1318                            p.target_terminal_name AS to_name,
1319                            p.kind,
1320                            p.path,
1321                            p.start_line,
1322                            p.start_column
1323                     FROM pending_relationships p
1324                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1325                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1326                     LIMIT ?2"
1327                } else {
1328                    "SELECT DISTINCT s_from.name AS from_name,
1329                            p.from_symbol_id,
1330                            p.target_terminal_name AS to_name,
1331                            p.kind,
1332                            p.path,
1333                            p.start_line,
1334                            p.start_column
1335                     FROM pending_relationships p
1336                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1337                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1338                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
1339                     LIMIT ?2"
1340                };
1341                let mut pending_stmt = conn.prepare(sql)?;
1342
1343                let rows = pending_stmt.query_map(
1344                    params![symbol_name, remaining as i64, symbol_id],
1345                    |row| {
1346                        Ok(ReferenceSite {
1347                            from_symbol_name: row.get(0)?,
1348                            from_symbol_id: row.get(1)?,
1349                            to_symbol_name: row.get(2)?,
1350                            kind: row.get(3)?,
1351                            path: row.get::<_, String>(4)?.replace('\\', "/"),
1352                            start_line: Some(row.get::<_, i64>(5)? as usize),
1353                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1354                        })
1355                    },
1356                )?;
1357                let mut out = Vec::new();
1358                for r in rows {
1359                    out.push(r?);
1360                }
1361                out
1362            };
1363
1364            for r in p_rows {
1365                results.push(r);
1366            }
1367        }
1368    }
1369
1370    Ok(results)
1371}
1372
1373/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
1374/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
1375pub fn find_callee_signatures(
1376    conn: &Connection,
1377    symbol_name: &str,
1378    symbol_id: &str,
1379    limit: usize,
1380    include_external: bool,
1381) -> Result<Vec<String>, QueryError> {
1382    let mut stmt = conn.prepare(
1383        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1384         FROM relationships r
1385         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1386         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1387         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
1388         LIMIT ?3",
1389    )?;
1390
1391    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
1392        Ok((
1393            row.get::<_, String>(0)?,
1394            row.get::<_, Option<String>>(1)?,
1395            row.get::<_, String>(2)?.replace('\\', "/"),
1396            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1397            row.get::<_, String>(4)?,
1398        ))
1399    })?;
1400
1401    let mut signatures = Vec::new();
1402    let mut variants = Vec::new();
1403
1404    for r in rows.flatten() {
1405        let (name, sig_opt, path, line, kind) = r;
1406        let sig = sig_opt.unwrap_or(name);
1407        let entry = format!("{sig} ({path}:{line})");
1408        if kind == "variant" {
1409            if !variants.contains(&entry) {
1410                variants.push(entry);
1411            }
1412        } else if !signatures.contains(&entry) {
1413            signatures.push(entry);
1414        }
1415    }
1416
1417    if signatures.len() < limit {
1418        let remaining = (limit - signatures.len()) * 2;
1419        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
1420            if has_pending_namespace_column(conn) {
1421                let mut p_stmt = conn.prepare(
1422                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1423                 FROM pending_relationships p
1424                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1425                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1426                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1427                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1428                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1429                    AND {pred}
1430                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1431            )?;
1432
1433                let rows =
1434                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1435                        Ok((
1436                            row.get::<_, String>(0)?,
1437                            row.get::<_, Option<String>>(1)?,
1438                            row.get::<_, String>(2)?.replace('\\', "/"),
1439                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1440                            row.get::<_, String>(4)?,
1441                        ))
1442                    })?;
1443                rows.flatten().collect()
1444            } else {
1445                let mut p_stmt = conn.prepare(
1446                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1447                 FROM pending_relationships p
1448                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1449                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1450                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1451                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1452                 LIMIT ?3",
1453            )?;
1454
1455                let rows =
1456                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1457                        Ok((
1458                            row.get::<_, String>(0)?,
1459                            row.get::<_, Option<String>>(1)?,
1460                            row.get::<_, String>(2)?.replace('\\', "/"),
1461                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1462                            row.get::<_, String>(4)?,
1463                        ))
1464                    })?;
1465                rows.flatten().collect()
1466            };
1467
1468        for r in p_rows {
1469            let (name, sig_opt, path, line, kind) = r;
1470            let sig = sig_opt.unwrap_or(name);
1471            let entry = format!("{sig} ({path}:{line})");
1472            if kind == "variant" {
1473                if !variants.contains(&entry) {
1474                    variants.push(entry);
1475                }
1476            } else if !signatures.contains(&entry) {
1477                signatures.push(entry);
1478            }
1479        }
1480    }
1481
1482    if include_external && signatures.len() < limit {
1483        let remaining = (limit - signatures.len()) * 2;
1484        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
1485            let mut ext_stmt = conn.prepare(
1486                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
1487                 FROM pending_relationships p
1488                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1489                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1490                   AND NOT EXISTS (
1491                       SELECT 1 FROM symbols s_to
1492                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1493                       WHERE s_to.name = p.target_terminal_name
1494                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1495                         AND {pred}
1496                   )
1497                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1498            )?;
1499
1500            let rows =
1501                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1502                    Ok((
1503                        row.get::<_, String>(0)?,
1504                        row.get::<_, String>(1)?.replace('\\', "/"),
1505                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1506                    ))
1507                })?;
1508            rows.flatten().collect()
1509        } else {
1510            let mut ext_stmt = conn.prepare(
1511                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
1512                 FROM pending_relationships p
1513                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1514                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1515                   AND NOT EXISTS (
1516                       SELECT 1 FROM symbols s_to
1517                       WHERE s_to.name = p.target_terminal_name
1518                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1519                   )
1520                 LIMIT ?3",
1521            )?;
1522
1523            let rows =
1524                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1525                    Ok((
1526                        row.get::<_, String>(0)?,
1527                        row.get::<_, String>(1)?.replace('\\', "/"),
1528                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1529                    ))
1530                })?;
1531            rows.flatten().collect()
1532        };
1533
1534        for r in ext_rows {
1535            let (name, path, line) = r;
1536            let entry = format!("{name} ({path}:{line})");
1537            if !signatures.contains(&entry) {
1538                signatures.push(entry);
1539            }
1540        }
1541    }
1542
1543    for v in variants {
1544        if signatures.len() >= limit {
1545            break;
1546        }
1547        if !signatures.contains(&v) {
1548            signatures.push(v);
1549        }
1550    }
1551
1552    signatures.truncate(limit);
1553    Ok(signatures)
1554}
1555
1556/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
1557pub fn find_structural_facts_scoped(
1558    conn: &Connection,
1559    category: &str,
1560    path_filter: Option<&str>,
1561    limit: usize,
1562) -> Result<Vec<StructuralFact>, QueryError> {
1563    let norm_path = path_filter
1564        .map(|p| {
1565            p.replace('\\', "/")
1566                .trim_start_matches("./")
1567                .trim_matches('/')
1568                .to_string()
1569        })
1570        .filter(|p| !p.is_empty());
1571    let dir_prefix = norm_path
1572        .as_deref()
1573        .map(|p| format!("{}/%", escape_like(p)));
1574    let cat_pattern = format!("%{}%", escape_like(category));
1575
1576    let cat_lower = category.trim().to_ascii_lowercase();
1577    let cat_clause = match cat_lower.as_str() {
1578        "config" => {
1579            "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
1580        }
1581        "route" | "routes" => {
1582            "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
1583        }
1584        "query" | "queries" | "sql" => {
1585            "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
1586        }
1587        "model" | "models" => "sf.pattern_id LIKE '%.model%'",
1588        _ => {
1589            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
1590        }
1591    };
1592
1593    let sql = format!(
1594        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
1595                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
1596                sf.start_line, sf.end_line, sf.confidence
1597         FROM structural_facts sf
1598         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
1599         WHERE (:cat IS NOT NULL AND {cat_clause})
1600           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1601         ORDER BY sf.path ASC, sf.start_line ASC
1602         LIMIT :limit"
1603    );
1604
1605    let mut stmt = conn.prepare(&sql)?;
1606    let rows = stmt.query_map(
1607        rusqlite::named_params! {
1608            ":cat": cat_pattern,
1609            ":path": norm_path.as_deref(),
1610            ":dir_prefix": dir_prefix.as_deref(),
1611            ":limit": limit as i64,
1612        },
1613        |row| {
1614            Ok(StructuralFact {
1615                structural_fact_id: row.get(0)?,
1616                path: row.get::<_, String>(1)?.replace('\\', "/"),
1617                language: row.get(2)?,
1618                pattern_id: row.get(3)?,
1619                capture_name: row.get(4)?,
1620                node_kind: row.get(5)?,
1621                containing_symbol_name: row.get(6)?,
1622                start_line: row.get::<_, i64>(7)? as usize,
1623                end_line: row.get::<_, i64>(8)? as usize,
1624                confidence: row.get(9)?,
1625            })
1626        },
1627    )?;
1628
1629    let mut results = Vec::new();
1630    for r in rows {
1631        results.push(r?);
1632    }
1633    Ok(results)
1634}
1635
1636/// Find structural facts by category (e.g. route, query, model, config).
1637pub fn find_structural_facts(
1638    conn: &Connection,
1639    category: &str,
1640    limit: usize,
1641) -> Result<Vec<StructuralFact>, QueryError> {
1642    find_structural_facts_scoped(conn, category, None, limit)
1643}
1644
1645/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
1646pub fn find_literals_scoped(
1647    conn: &Connection,
1648    category: &str,
1649    path_filter: Option<&str>,
1650    limit: usize,
1651) -> Result<Vec<LiteralFact>, QueryError> {
1652    let norm_path = path_filter
1653        .map(|p| {
1654            p.replace('\\', "/")
1655                .trim_start_matches("./")
1656                .trim_matches('/')
1657                .to_string()
1658        })
1659        .filter(|p| !p.is_empty());
1660    let dir_prefix = norm_path
1661        .as_deref()
1662        .map(|p| format!("{}/%", escape_like(p)));
1663    let cat_pattern = format!("%{}%", escape_like(category));
1664
1665    let cat_lower = category.trim().to_ascii_lowercase();
1666    let cat_clause = match cat_lower.as_str() {
1667        "config" => {
1668            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
1669        }
1670        "route" | "routes" => "l.kind LIKE '%route%'",
1671        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
1672        "model" | "models" => "l.kind LIKE '%model%'",
1673        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
1674    };
1675
1676    let sql = format!(
1677        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
1678                l.start_line, s.name AS containing_symbol_name
1679         FROM literals l
1680         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
1681         WHERE (:cat IS NOT NULL AND {cat_clause})
1682           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1683         ORDER BY l.path ASC, l.start_line ASC
1684         LIMIT :limit"
1685    );
1686
1687    let mut stmt = conn.prepare(&sql)?;
1688    let rows = stmt.query_map(
1689        rusqlite::named_params! {
1690            ":cat": cat_pattern,
1691            ":path": norm_path.as_deref(),
1692            ":dir_prefix": dir_prefix.as_deref(),
1693            ":limit": limit as i64,
1694        },
1695        |row| {
1696            Ok(LiteralFact {
1697                literal_id: row.get(0)?,
1698                path: row.get::<_, String>(1)?.replace('\\', "/"),
1699                literal_text: row.get(2)?,
1700                kind: row.get(3)?,
1701                carrier: row.get(4)?,
1702                start_line: row.get::<_, i64>(5)? as usize,
1703                containing_symbol_name: row.get(6)?,
1704            })
1705        },
1706    )?;
1707
1708    let mut results = Vec::new();
1709    for r in rows {
1710        results.push(r?);
1711    }
1712    Ok(results)
1713}
1714
1715/// Find literals (endpoints, SQL queries, configs) matching category.
1716pub fn find_literals(
1717    conn: &Connection,
1718    category: &str,
1719    limit: usize,
1720) -> Result<Vec<LiteralFact>, QueryError> {
1721    find_literals_scoped(conn, category, None, limit)
1722}
1723
1724/// List available structural fact and literal categories with counts, optionally scoped by path.
1725pub fn list_structural_fact_categories_scoped(
1726    conn: &Connection,
1727    path_filter: Option<&str>,
1728) -> Result<Vec<(String, usize)>, QueryError> {
1729    let norm_path = path_filter
1730        .map(|p| {
1731            p.replace('\\', "/")
1732                .trim_start_matches("./")
1733                .trim_matches('/')
1734                .to_string()
1735        })
1736        .filter(|p| !p.is_empty());
1737    let dir_prefix = norm_path
1738        .as_deref()
1739        .map(|p| format!("{}/%", escape_like(p)));
1740
1741    let mut categories = Vec::new();
1742
1743    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
1744               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1745               GROUP BY pattern_id ORDER BY cnt DESC";
1746    let mut stmt = conn.prepare(sql)?;
1747    let rows = stmt.query_map(
1748        rusqlite::named_params! {
1749            ":path": norm_path.as_deref(),
1750            ":dir_prefix": dir_prefix.as_deref(),
1751        },
1752        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1753    )?;
1754    for r in rows {
1755        categories.push(r?);
1756    }
1757
1758    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
1759                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1760                   GROUP BY kind ORDER BY cnt DESC";
1761    let mut lit_stmt = conn.prepare(lit_sql)?;
1762    let lit_rows = lit_stmt.query_map(
1763        rusqlite::named_params! {
1764            ":path": norm_path.as_deref(),
1765            ":dir_prefix": dir_prefix.as_deref(),
1766        },
1767        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1768    )?;
1769    for r in lit_rows {
1770        categories.push(r?);
1771    }
1772
1773    Ok(categories)
1774}
1775
1776/// List all available structural fact and literal categories with counts.
1777pub fn list_structural_fact_categories(
1778    conn: &Connection,
1779) -> Result<Vec<(String, usize)>, QueryError> {
1780    list_structural_fact_categories_scoped(conn, None)
1781}
1782
1783/// Find type facts for a symbol.
1784pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
1785    let has_table: bool = conn
1786        .query_row(
1787            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
1788            [],
1789            |_| Ok(true),
1790        )
1791        .unwrap_or(false);
1792    if !has_table {
1793        return Ok(Vec::new());
1794    }
1795
1796    let mut stmt = conn.prepare(
1797        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
1798         FROM type_facts
1799         WHERE symbol_id = ?1",
1800    )?;
1801
1802    let rows = stmt.query_map(params![symbol_id], |row| {
1803        Ok(TypeFact {
1804            type_fact_id: row.get(0)?,
1805            symbol_id: row.get(1)?,
1806            language: row.get(2)?,
1807            resolved_type: row.get(3)?,
1808            generic_params: row.get(4)?,
1809        })
1810    })?;
1811
1812    let mut results = Vec::new();
1813    for r in rows {
1814        results.push(r?);
1815    }
1816    Ok(results)
1817}
1818
1819/// Helper to determine if a relative path looks like a test file across ecosystems.
1820pub fn is_test_path(path: &str) -> bool {
1821    let p = path.to_lowercase().replace('\\', "/");
1822    p.contains("/test/")
1823        || p.contains("/tests/")
1824        || p.contains("/__tests__/")
1825        || p.contains("_test.")
1826        || p.contains(".test.")
1827        || p.contains(".spec.")
1828        || p.ends_with("test.rs")
1829        || p.ends_with("tests.rs")
1830        || p.ends_with("tests.cs")
1831        || p.ends_with("test.go")
1832        || p.starts_with("test_")
1833}
1834
1835/// Compute blast radius and likely tests for given seed symbols or seed file paths.
1836/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
1837pub fn compute_blast_radius_scoped(
1838    conn: &Connection,
1839    seed_symbols: &[&str],
1840    symbol_path_filter: Option<&str>,
1841    seed_paths: &[&str],
1842    max_depth: usize,
1843    limit: usize,
1844) -> Result<BlastRadiusResult, QueryError> {
1845    let max_depth = max_depth.min(5);
1846    let resolved_seed_symbols = seed_symbols
1847        .iter()
1848        .map(|name| {
1849            get_symbol_by_name(conn, name, symbol_path_filter)?
1850                .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
1851        })
1852        .collect::<Result<Vec<_>, _>>()?;
1853    let mut seeds = Vec::new();
1854    let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
1855        for s in seed_symbols {
1856            seeds.push(s.to_string());
1857        }
1858        for p in seed_paths {
1859            seeds.push(p.to_string());
1860        }
1861        "mixed".to_string()
1862    } else if !seed_symbols.is_empty() {
1863        for s in seed_symbols {
1864            seeds.push(s.to_string());
1865        }
1866        "symbol".to_string()
1867    } else if !seed_paths.is_empty() {
1868        for p in seed_paths {
1869            seeds.push(p.to_string());
1870        }
1871        "file".to_string()
1872    } else {
1873        return Ok(BlastRadiusResult {
1874            seed_type: "none".to_string(),
1875            seeds: Vec::new(),
1876            likely_tests: Vec::new(),
1877            impacted_symbols: Vec::new(),
1878            traversal_ceiling_reached: false,
1879        });
1880    };
1881
1882    let mut where_clauses = Vec::new();
1883    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
1884
1885    if !resolved_seed_symbols.is_empty() {
1886        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
1887            .map(|i| format!("?{}", i))
1888            .collect();
1889        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
1890        for symbol in &resolved_seed_symbols {
1891            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
1892        }
1893    }
1894
1895    if !seed_paths.is_empty() {
1896        let mut path_conds = Vec::new();
1897        for p in seed_paths.iter() {
1898            let raw = p
1899                .replace('\\', "/")
1900                .trim_start_matches("./")
1901                .trim_matches('/')
1902                .to_string();
1903            let exact_idx = params_vec.len() + 1;
1904            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
1905            let dir_pattern = format!("{}/%", escape_like(&raw));
1906            let like_idx = params_vec.len() + 1;
1907            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
1908            path_conds.push(format!(
1909                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
1910            ));
1911        }
1912        where_clauses.push(format!("({})", path_conds.join(" OR ")));
1913    }
1914
1915    let seed_condition = where_clauses.join(" OR ");
1916    let max_depth_idx = params_vec.len() + 1;
1917    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
1918
1919    let mut traversal_ceiling_reached = false;
1920
1921    let has_relationships: bool = conn
1922        .query_row(
1923            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
1924            [],
1925            |_| Ok(true),
1926        )
1927        .unwrap_or(false);
1928
1929    let has_pending: bool = conn
1930        .query_row(
1931            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
1932            [],
1933            |_| Ok(true),
1934        )
1935        .unwrap_or(false);
1936
1937    let mut likely_tests = Vec::new();
1938    let mut impacted_symbols = Vec::new();
1939    let mut seen_test_keys = HashSet::new();
1940
1941    let mut recursive_branches = Vec::new();
1942
1943    if has_relationships {
1944        recursive_branches.push(format!(
1945            "SELECT r.from_symbol_id, iw.depth + 1
1946             FROM relationships r
1947             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
1948             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1949             WHERE iw.depth < ?{max_depth_idx}
1950               AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
1951        ));
1952    }
1953
1954    if has_pending {
1955        let (parent_join, ns_condition) = if conn
1956            .query_row(
1957                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
1958                [],
1959                |_| Ok(true),
1960            )
1961            .unwrap_or(false)
1962        {
1963            (
1964                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1965            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
1966                format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
1967            )
1968        } else {
1969            ("", String::new())
1970        };
1971
1972        recursive_branches.push(format!(
1973            "SELECT p.from_symbol_id, iw.depth + 1
1974             FROM pending_relationships p
1975             JOIN symbols s_target ON p.target_terminal_name = s_target.name
1976             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
1977             {parent_join}
1978             WHERE iw.depth < ?{max_depth_idx}
1979               AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
1980               {ns_condition}"
1981        ));
1982    }
1983
1984    if !recursive_branches.is_empty() {
1985        let recursive_sql = recursive_branches.join("\n UNION \n");
1986        let sql = format!(
1987            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
1988                SELECT symbol_id, 0
1989                FROM symbols
1990                WHERE ({seed_condition})
1991                  AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
1992
1993                UNION
1994
1995                {recursive_sql}
1996            )
1997            SELECT s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container, MIN(iw.depth) as min_depth
1998            FROM impact_walk iw
1999            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
2000            WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2001            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
2002            HAVING MIN(iw.depth) > 0
2003            ORDER BY min_depth ASC, s.path ASC, s.name ASC
2004            LIMIT 200"
2005        );
2006
2007        let mut stmt = conn.prepare(&sql)?;
2008        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
2009            .iter()
2010            .map(|v| v as &dyn rusqlite::ToSql)
2011            .collect();
2012
2013        let rows = stmt.query_map(param_refs.as_slice(), |row| {
2014            Ok((
2015                row.get::<_, String>(0)?,
2016                row.get::<_, String>(1)?,
2017                row.get::<_, String>(2)?,
2018                row.get::<_, String>(3)?,
2019                row.get::<_, i64>(4)? as usize,
2020                row.get::<_, bool>(5)?,
2021                row.get::<_, bool>(6)?,
2022                row.get::<_, i64>(7)? as usize,
2023            ))
2024        })?;
2025
2026        let mut row_count = 0;
2027        for r in rows {
2028            row_count += 1;
2029            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
2030            let path = raw_path.replace('\\', "/");
2031            let is_test_target = is_test || test_container || is_test_path(&path);
2032
2033            if is_test_target {
2034                let key = format!("{}:{}", path, line);
2035                if seen_test_keys.insert(key) {
2036                    likely_tests.push(TestTarget {
2037                        name,
2038                        path,
2039                        line,
2040                        reason: format!("transitive caller [depth {depth}]"),
2041                    });
2042                }
2043            } else {
2044                impacted_symbols.push(ImpactedSymbol {
2045                    name,
2046                    kind,
2047                    path,
2048                    line,
2049                    depth,
2050                });
2051            }
2052        }
2053        traversal_ceiling_reached = row_count >= 200;
2054    }
2055
2056    // 2. Discover stem-matched test files in the workspace
2057    let mut file_stems = Vec::new();
2058    for p in seed_paths {
2059        if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
2060            && stem.len() >= 3
2061            && !file_stems.contains(&stem.to_string())
2062        {
2063            file_stems.push(stem.to_string());
2064        }
2065    }
2066    for symbol in &resolved_seed_symbols {
2067        if let Some(stem) = std::path::Path::new(&symbol.path)
2068            .file_stem()
2069            .and_then(|s| s.to_str())
2070            && stem.len() >= 3
2071            && !file_stems.contains(&stem.to_string())
2072        {
2073            file_stems.push(stem.to_string());
2074        }
2075    }
2076
2077    let has_files: bool = conn
2078        .query_row(
2079            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
2080            [],
2081            |_| Ok(true),
2082        )
2083        .unwrap_or(false);
2084
2085    if has_files {
2086        let mut test_files_stmt = conn.prepare(
2087            "SELECT DISTINCT path FROM files WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\' LIMIT 10",
2088        )?;
2089        for stem in file_stems {
2090            let stem_pattern = format!("%{}%", escape_like(&stem));
2091            let t_rows =
2092                test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
2093            for p in t_rows.flatten() {
2094                let p = p.replace('\\', "/");
2095                let key = format!("{}:1", p);
2096                if seen_test_keys.insert(key) {
2097                    likely_tests.push(TestTarget {
2098                        name: p.clone(),
2099                        path: p,
2100                        line: 1,
2101                        reason: "stem-matched test file".to_string(),
2102                    });
2103                }
2104            }
2105        }
2106    }
2107
2108    // Truncate to limit
2109    if likely_tests.len() > limit {
2110        likely_tests.truncate(limit);
2111    }
2112    if impacted_symbols.len() > limit {
2113        impacted_symbols.truncate(limit);
2114    }
2115
2116    Ok(BlastRadiusResult {
2117        seed_type,
2118        seeds,
2119        likely_tests,
2120        impacted_symbols,
2121        traversal_ceiling_reached,
2122    })
2123}
2124
2125/// Compute blast radius and likely tests for given seed symbols or seed file paths.
2126pub fn compute_blast_radius(
2127    conn: &Connection,
2128    seed_symbols: &[&str],
2129    seed_paths: &[&str],
2130    max_depth: usize,
2131    limit: usize,
2132) -> Result<BlastRadiusResult, QueryError> {
2133    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138    use super::*;
2139    use crate::db::{ensure_fts_index, open_read_write};
2140
2141    #[test]
2142    fn test_sanitize_fts5_query() {
2143        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
2144        assert_eq!(and_q, "\"parse\"* \"tokens\"*");
2145        assert_eq!(or_q, "\"parse\"* OR \"tokens\"*");
2146
2147        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
2148        assert_eq!(and_q, "\"Option\"* \"T\"*");
2149        assert_eq!(or_q, "\"Option\"* OR \"T\"*");
2150
2151        let (and_q, or_q) = sanitize_fts5_query("   ");
2152        assert!(and_q.is_empty());
2153        assert!(or_q.is_empty());
2154    }
2155
2156    #[test]
2157    fn search_symbols_treats_like_wildcards_as_literals() {
2158        let dir = crate::safe_tempdir();
2159        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
2160        let conn = open_read_write(&db_path).unwrap();
2161        conn.execute_batch(
2162            "CREATE TABLE symbols (
2163                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2164                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2165                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2166                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2167                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2168                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2169                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2170            );
2171            INSERT INTO symbols VALUES (
2172                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
2173                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2174            );
2175            INSERT INTO symbols VALUES (
2176                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
2177                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2178            );
2179            INSERT INTO symbols VALUES (
2180                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
2181                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2182            );
2183            CREATE TABLE files (
2184                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
2185                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
2186            );
2187            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
2188            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
2189            );",
2190        )
2191        .unwrap();
2192
2193        assert_eq!(
2194            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
2195            "literal%name"
2196        );
2197        assert_eq!(
2198            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
2199            "literal_name"
2200        );
2201        assert_eq!(
2202            load_scoped_files(&conn, Some("src/literal_path"))
2203                .unwrap()
2204                .len(),
2205            1
2206        );
2207    }
2208
2209    #[test]
2210    fn find_references_for_symbol_limits_callees_by_symbol_id() {
2211        let dir = crate::safe_tempdir();
2212        let db_path = dir.path().join("find_references_for_symbol.db");
2213        let conn = open_read_write(&db_path).unwrap();
2214        conn.execute_batch(
2215            "CREATE TABLE symbols (
2216                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2217                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2218                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2219                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2220                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2221                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2222                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2223            );
2224            CREATE TABLE relationships (
2225                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2226                start_line INTEGER, start_column INTEGER
2227            );
2228            CREATE TABLE pending_relationships (
2229                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2230                start_line INTEGER, start_column INTEGER
2231            );
2232            INSERT INTO symbols VALUES
2233                ('wanted', 'f', 'a.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
2234                ('other', 'f', 'b.rs', 'rust', 'new', 'method', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
2235                ('wanted-callee', 'f', 'a.rs', 'rust', 'wanted_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0),
2236                ('other-callee', 'f', 'b.rs', 'rust', 'other_dep', 'function', NULL, NULL, NULL, NULL, 1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0);
2237            INSERT INTO relationships VALUES
2238                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
2239                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
2240        )
2241        .unwrap();
2242
2243        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
2244        assert_eq!(references.len(), 1);
2245        assert_eq!(references[0].to_symbol_name, "wanted_dep");
2246    }
2247
2248    #[test]
2249    fn test_fts_search_symbols_and_porter_stemming() {
2250        let dir = crate::safe_tempdir();
2251        let db_path = dir.path().join("fts_search_symbols.db");
2252        let conn = open_read_write(&db_path).unwrap();
2253
2254        conn.execute_batch(
2255            "CREATE TABLE symbols (
2256                symbol_id TEXT PRIMARY KEY,
2257                file_id TEXT,
2258                path TEXT,
2259                language TEXT,
2260                name TEXT,
2261                kind TEXT,
2262                signature TEXT,
2263                doc_comment TEXT,
2264                visibility TEXT,
2265                parent_symbol_id TEXT,
2266                start_line INTEGER,
2267                start_column INTEGER,
2268                end_line INTEGER,
2269                end_column INTEGER,
2270                start_byte INTEGER,
2271                end_byte INTEGER,
2272                body_start_line INTEGER,
2273                body_start_column INTEGER,
2274                body_end_line INTEGER,
2275                body_end_column INTEGER,
2276                body_start_byte INTEGER,
2277                body_end_byte INTEGER,
2278                body_hash TEXT,
2279                semantic_group TEXT,
2280                is_test INTEGER,
2281                test_container INTEGER
2282            );
2283            INSERT INTO symbols VALUES (
2284                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
2285                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
2286                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
2287            );
2288            INSERT INTO symbols VALUES (
2289                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
2290                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
2291                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
2292            );
2293            INSERT INTO symbols VALUES (
2294                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
2295                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
2296                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
2297            );
2298            INSERT INTO symbols VALUES (
2299                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
2300                'fn test_payment_flow()', 'Tests payment charge workflow',
2301                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
2302            );",
2303        )
2304        .unwrap();
2305
2306        ensure_fts_index(&conn).unwrap();
2307
2308        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
2309        let results =
2310            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
2311        assert_eq!(results.len(), 1);
2312        assert_eq!(results[0].symbol.name, "parse_tokens");
2313        assert!(results[0].snippet.is_some());
2314
2315        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
2316        let results =
2317            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
2318        assert_eq!(results.len(), 1);
2319        assert_eq!(results[0].symbol.name, "PaymentGateway");
2320
2321        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
2322        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
2323        assert_eq!(results.len(), 2);
2324        assert!(results.iter().all(|r| !r.symbol.is_test));
2325
2326        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
2327        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
2328        assert_eq!(results.len(), 3);
2329
2330        // 5. Fallback OR matching: multi-term where only some match
2331        let results =
2332            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
2333        assert_eq!(results.len(), 1);
2334        assert_eq!(results[0].symbol.name, "StripeClient");
2335    }
2336
2337    #[test]
2338    fn test_queries_nocase_and_path_normalization() {
2339        let conn = Connection::open_in_memory().unwrap();
2340        conn.execute_batch(
2341            "CREATE TABLE files (
2342                file_id TEXT PRIMARY KEY,
2343                path TEXT NOT NULL,
2344                language TEXT,
2345                content_hash TEXT,
2346                content_bytes INTEGER,
2347                line_count INTEGER,
2348                indexed_at INTEGER
2349            );
2350            CREATE TABLE symbols (
2351                symbol_id TEXT PRIMARY KEY,
2352                file_id TEXT,
2353                path TEXT NOT NULL,
2354                language TEXT,
2355                name TEXT,
2356                kind TEXT,
2357                signature TEXT,
2358                doc_comment TEXT,
2359                visibility TEXT,
2360                parent_symbol_id TEXT,
2361                start_line INTEGER,
2362                start_column INTEGER,
2363                end_line INTEGER,
2364                end_column INTEGER,
2365                start_byte INTEGER,
2366                end_byte INTEGER,
2367                body_start_line INTEGER,
2368                body_start_column INTEGER,
2369                body_end_line INTEGER,
2370                body_end_column INTEGER,
2371                body_start_byte INTEGER,
2372                body_end_byte INTEGER,
2373                body_hash TEXT,
2374                semantic_group TEXT,
2375                is_test INTEGER,
2376                test_container INTEGER
2377            );
2378            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
2379            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
2380            INSERT INTO symbols VALUES (
2381                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
2382                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2383                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
2384            );",
2385        )
2386        .unwrap();
2387
2388        // 1. get_file: query with uppercase, lowercase, and forward slashes
2389        let file = get_file(&conn, "SRC/PAYMENT.RS")
2390            .unwrap()
2391            .expect("File should be found");
2392        assert_eq!(
2393            file.path, "src/Payment.rs",
2394            "Path should be normalized to forward slashes"
2395        );
2396
2397        let file2 = get_file(&conn, "src/payment.rs")
2398            .unwrap()
2399            .expect("File should be found");
2400        assert_eq!(file2.path, "src/Payment.rs");
2401
2402        // 2. load_file_symbols: query with uppercase and forward slashes
2403        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
2404        assert_eq!(syms.len(), 1);
2405        assert_eq!(
2406            syms[0].path, "src/Payment.rs",
2407            "Symbol path should be normalized to forward slashes"
2408        );
2409
2410        // 3. get_symbol_by_name with path filter
2411        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
2412            .unwrap()
2413            .expect("Symbol should be found with case-insensitive path filter");
2414        assert_eq!(sym.path, "src/Payment.rs");
2415    }
2416
2417    #[test]
2418    fn test_exact_case_prioritized_over_nocase() {
2419        let conn = Connection::open_in_memory().unwrap();
2420        conn.execute_batch(
2421            "CREATE TABLE files (
2422                file_id TEXT PRIMARY KEY,
2423                path TEXT NOT NULL,
2424                language TEXT,
2425                content_hash TEXT,
2426                content_bytes INTEGER,
2427                line_count INTEGER,
2428                indexed_at TEXT
2429            );
2430            CREATE TABLE symbols (
2431                symbol_id TEXT PRIMARY KEY,
2432                file_id TEXT,
2433                path TEXT NOT NULL,
2434                language TEXT,
2435                name TEXT NOT NULL,
2436                kind TEXT NOT NULL,
2437                signature TEXT,
2438                doc_comment TEXT,
2439                visibility TEXT,
2440                parent_symbol_id TEXT,
2441                start_line INTEGER,
2442                start_column INTEGER,
2443                end_line INTEGER,
2444                end_column INTEGER,
2445                start_byte INTEGER,
2446                end_byte INTEGER,
2447                body_start_line INTEGER,
2448                body_start_column INTEGER,
2449                body_end_line INTEGER,
2450                body_end_column INTEGER,
2451                body_start_byte INTEGER,
2452                body_end_byte INTEGER,
2453                body_hash TEXT,
2454                semantic_group TEXT,
2455                is_test INTEGER,
2456                test_container INTEGER
2457            );
2458            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
2459            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
2460            INSERT INTO symbols VALUES (
2461                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
2462                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2463                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
2464            );
2465            INSERT INTO symbols VALUES (
2466                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
2467                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2468                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
2469            );",
2470        )
2471        .unwrap();
2472
2473        // Exact match should return exact file, not conflate with sibling differing only by case
2474        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
2475        assert_eq!(f_lower.path, "src/payment.rs");
2476        assert_eq!(f_lower.file_id, "f2");
2477
2478        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
2479        assert_eq!(f_upper.path, "src/Payment.rs");
2480        assert_eq!(f_upper.file_id, "f1");
2481
2482        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
2483        assert_eq!(syms_lower.len(), 1);
2484        assert_eq!(syms_lower[0].file_id, "f2");
2485
2486        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
2487        assert_eq!(syms_upper.len(), 1);
2488        assert_eq!(syms_upper[0].file_id, "f1");
2489    }
2490
2491    #[test]
2492    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
2493        let dir = crate::safe_tempdir();
2494        let db_path = dir.path().join("conservative_resolution.db");
2495        let conn = open_read_write(&db_path).unwrap();
2496
2497        conn.execute_batch(
2498            "CREATE TABLE symbols (
2499                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2500                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2501                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2502                start_column INTEGER, end_line INTEGER, end_column INTEGER,
2503                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2504                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2505                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2506                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2507            );
2508            CREATE TABLE relationships (
2509                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2510                start_line INTEGER, start_column INTEGER
2511            );
2512            CREATE TABLE pending_relationships (
2513                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2514                start_line INTEGER, start_column INTEGER,
2515                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2516            );
2517            CREATE TABLE type_facts (
2518                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2519            );
2520            -- Workspace struct Workspace and method Workspace::new
2521            INSERT INTO symbols VALUES
2522                ('s_ws', 'f1', 'src/workspace.rs', 'rust', 'Workspace', 'struct', 'pub struct Workspace', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'struct', 0, 0),
2523                ('s_ws_new', 'f1', 'src/workspace.rs', 'rust', 'new', 'method', 'pub fn new() -> Workspace', NULL, 'pub', 's_ws', 2, 4, 4, 5, 20, 50, 2, 4, 4, 5, 20, 50, 'h1', 'method', 0, 0),
2524                ('s_caller', 'f2', 'src/caller.rs', 'rust', 'my_func', 'function', 'pub fn my_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);
2525
2526            -- my_func calls Vec::new() (external namespace 'Vec')
2527            INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES
2528                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
2529        )
2530        .unwrap();
2531
2532        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
2533        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2534        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
2535
2536        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
2537        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
2538
2539        // Caller references for Workspace::new should NOT list my_func
2540        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
2541        assert!(
2542            callers.is_empty(),
2543            "Expected 0 callers for Workspace::new, got: {:?}",
2544            callers
2545        );
2546
2547        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
2548        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2549        assert!(
2550            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
2551            "my_func should not be impacted before calling Workspace::new: {:?}",
2552            blast.impacted_symbols
2553        );
2554
2555        // Now add a call to Workspace::new()
2556        conn.execute(
2557            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_caller', 'new', 'calls', 'src/caller.rs', 5, 8, NULL, '[\"Workspace\"]', 'Workspace::new')",
2558            [],
2559        )
2560        .unwrap();
2561
2562        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2563        assert_eq!(
2564            sigs2.len(),
2565            1,
2566            "Expected 1 signature for Workspace::new, got: {:?}",
2567            sigs2
2568        );
2569        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
2570
2571        // Blast radius for Workspace::new should now include my_func
2572        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2573        assert!(
2574            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
2575            "my_func should be impacted after calling Workspace::new: {:?}",
2576            blast2.impacted_symbols
2577        );
2578
2579        // Add a bare call to new() from an unrelated caller s_other
2580        conn.execute(
2581            "INSERT INTO symbols VALUES
2582                ('s_other', 'f3', 'src/other.rs', 'rust', 'other_func', 'function', 'pub fn other_func()', NULL, 'pub', NULL, 1, 0, 10, 0, 0, 100, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'function', 0, 0);",
2583            [],
2584        )
2585        .unwrap();
2586        conn.execute(
2587            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_other', 'new', 'calls', 'src/other.rs', 2, 8, NULL, NULL, 'new')",
2588            [],
2589        )
2590        .unwrap();
2591
2592        // Bare call from unrelated function should NOT resolve to Workspace::new
2593        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
2594        assert!(
2595            sigs_other.is_empty(),
2596            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
2597            sigs_other
2598        );
2599
2600        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
2601        conn.execute(
2602            "INSERT INTO symbols VALUES
2603                ('s_ws_helper', 'f1', 'src/workspace.rs', 'rust', 'helper', 'method', 'pub fn helper()', NULL, 'pub', 's_ws', 5, 4, 7, 5, 60, 90, 5, 4, 7, 5, 60, 90, 'h2', 'method', 0, 0);",
2604            [],
2605        )
2606        .unwrap();
2607        conn.execute(
2608            "INSERT INTO pending_relationships (from_symbol_id, target_terminal_name, kind, path, start_line, start_column, target_receiver, target_namespace_json, target_display_name) VALUES ('s_ws_helper', 'new', 'calls', 'src/workspace.rs', 6, 8, NULL, NULL, 'new')",
2609            [],
2610        )
2611        .unwrap();
2612
2613        let sigs_sibling =
2614            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
2615        assert_eq!(
2616            sigs_sibling.len(),
2617            1,
2618            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
2619            sigs_sibling
2620        );
2621
2622        // With include_external: true, external calls should be returned
2623        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
2624        assert!(
2625            ext_sigs.iter().any(|s| s.contains("Vec")),
2626            "include_external: true should include external Vec::new: {:?}",
2627            ext_sigs
2628        );
2629    }
2630
2631    #[test]
2632    fn test_find_structural_facts_and_literals_scoped() {
2633        let dir = crate::safe_tempdir();
2634        let db_path = dir.path().join("facts_test.db");
2635        let conn = open_read_write(&db_path).unwrap();
2636        conn.execute_batch(
2637            "CREATE TABLE symbols (
2638                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2639                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2640                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2641                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2642                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2643                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2644                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2645            );
2646            CREATE TABLE structural_facts (
2647                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2648                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
2649                start_line INTEGER, end_line INTEGER, confidence REAL
2650            );
2651            CREATE TABLE literals (
2652                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2653                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
2654                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2655                start_byte INTEGER, end_byte INTEGER
2656            );
2657            INSERT INTO structural_facts VALUES
2658                ('sf_toml', 'f1', 'Cargo.toml', 'toml', 'toml.key_value.v1', 'package.name', 'table', NULL, 1, 2, 1.0),
2659                ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'http.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0),
2660                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0),
2661                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0),
2662                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0);
2663            INSERT INTO literals VALUES
2664                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
2665                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
2666                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
2667                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
2668        )
2669        .unwrap();
2670
2671        // 1. "config" alias
2672        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
2673        assert_eq!(facts_config.len(), 1);
2674        assert_eq!(facts_config[0].pattern_id, "toml.key_value.v1");
2675        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
2676        assert_eq!(lits_config.len(), 1);
2677        assert_eq!(lits_config[0].kind, "toml_key");
2678
2679        // 2. "route" and "routes" aliases
2680        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
2681        assert_eq!(facts_route.len(), 1);
2682        assert_eq!(facts_route[0].pattern_id, "http.route.v1");
2683        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
2684        assert_eq!(facts_routes.len(), 1);
2685        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
2686        assert_eq!(lits_route.len(), 1);
2687        assert_eq!(lits_route[0].kind, "http_route");
2688
2689        // 3. "query", "queries", "sql" aliases
2690        for q in &["query", "queries", "sql"] {
2691            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
2692            assert_eq!(facts.len(), 1, "Failed for {}", q);
2693            assert_eq!(facts[0].pattern_id, "db.sql.select");
2694            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
2695            assert_eq!(lits.len(), 1, "Failed for {}", q);
2696            assert_eq!(lits[0].kind, "sql_query");
2697        }
2698
2699        // 4. "model" and "models" aliases
2700        for m in &["model", "models"] {
2701            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
2702            assert_eq!(facts.len(), 1, "Failed for {}", m);
2703            assert_eq!(facts[0].pattern_id, "orm.model.entity");
2704            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
2705            assert_eq!(lits.len(), 1, "Failed for {}", m);
2706            assert_eq!(lits[0].kind, "model_table");
2707        }
2708
2709        // 5. Custom / unknown category
2710        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
2711        assert_eq!(facts_custom.len(), 1);
2712        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
2713
2714        // 6. Path filter: exact file match
2715        let facts_exact =
2716            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
2717        assert_eq!(facts_exact.len(), 1);
2718        let facts_miss =
2719            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
2720        assert_eq!(facts_miss.len(), 0);
2721
2722        // 7. Path filter: directory prefix
2723        let facts_dir =
2724            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
2725        assert_eq!(facts_dir.len(), 1);
2726        let facts_dir_miss =
2727            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
2728        assert_eq!(facts_dir_miss.len(), 0);
2729
2730        // 8. Delegating find_structural_facts and find_literals
2731        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
2732        assert_eq!(f_del.len(), 1);
2733        let l_del = find_literals(&conn, "config", 10).unwrap();
2734        assert_eq!(l_del.len(), 1);
2735    }
2736}