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