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 {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1076                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
1077                )
1078            )
1079            OR (
1080                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1081                AND (p.target_receiver IS NULL OR p.target_receiver = '')
1082                AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
1083                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
1084                    SELECT 1 FROM symbols closer
1085                    WHERE closer.name = {target}.name
1086                      AND closer.symbol_id != {target}.symbol_id
1087                      AND closer.parent_symbol_id IS NULL
1088                      AND closer.kind = {target}.kind
1089                      AND {closer_rank} > {target_rank}
1090                ))
1091            )
1092            OR (
1093                {target}.parent_symbol_id IS NULL
1094                AND EXISTS (
1095                    SELECT 1 FROM {ns}
1096                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
1097                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
1098                )
1099            )
1100        )"
1101    )
1102}
1103
1104/// SQL predicate excluding rows julie marked as documentation, or the always-true `1 = 1` when
1105/// the column is absent, because a bare `1` in ORDER BY means the first result column in SQLite.
1106fn not_documentation(conn: &Connection, alias: &str) -> String {
1107    let has_content_type: bool = conn
1108        .query_row(
1109            "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
1110            [],
1111            |_| Ok(true),
1112        )
1113        .unwrap_or(false);
1114    if has_content_type {
1115        format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
1116    } else {
1117        "1 = 1".to_string()
1118    }
1119}
1120
1121fn has_table(conn: &Connection, name: &str) -> bool {
1122    conn.query_row(
1123        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
1124        [name],
1125        |_| Ok(true),
1126    )
1127    .unwrap_or(false)
1128}
1129
1130fn has_pending_namespace_column(conn: &Connection) -> bool {
1131    let has_ns: bool = conn
1132        .query_row(
1133            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
1134            [],
1135            |_| Ok(true),
1136        )
1137        .unwrap_or(false);
1138    let has_display: bool = conn
1139        .query_row(
1140            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
1141            [],
1142            |_| Ok(true),
1143        )
1144        .unwrap_or(false);
1145    let has_receiver: bool = conn
1146        .query_row(
1147            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
1148            [],
1149            |_| Ok(true),
1150        )
1151        .unwrap_or(false);
1152    has_ns && has_display && has_receiver
1153}
1154
1155fn find_references_internal(
1156    conn: &Connection,
1157    symbol_name: &str,
1158    direction: &str,
1159    limit: usize,
1160    symbol_id: Option<&str>,
1161    include_external: bool,
1162) -> Result<Vec<ReferenceSite>, QueryError> {
1163    let mut results = Vec::new();
1164
1165    if direction == "callers" {
1166        // Find callers: references pointing to target symbol
1167        let mut stmt = conn.prepare(
1168            "SELECT s_from.name AS from_name,
1169                    r.from_symbol_id,
1170                    s_to.name AS to_name,
1171                    r.kind,
1172                    r.path,
1173                    r.start_line,
1174                    r.start_column
1175             FROM relationships r
1176             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1177             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1178             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
1179             LIMIT ?2",
1180        )?;
1181
1182        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1183            Ok(ReferenceSite {
1184                from_symbol_name: row.get(0)?,
1185                from_symbol_id: row.get(1)?,
1186                to_symbol_name: row.get(2)?,
1187                kind: row.get(3)?,
1188                path: row.get::<_, String>(4)?.replace('\\', "/"),
1189                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1190                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1191            })
1192        })?;
1193
1194        for r in rows {
1195            results.push(r?);
1196        }
1197
1198        // Also query pending_relationships for callers if results < limit
1199        if results.len() < limit {
1200            let remaining = limit - results.len();
1201            if has_pending_namespace_column(conn) {
1202                if let Some(sid) = symbol_id {
1203                    let mut pending_stmt = conn.prepare(
1204                        &format!("SELECT s_from.name AS from_name,
1205                                p.from_symbol_id,
1206                                p.target_terminal_name AS to_name,
1207                                p.kind,
1208                                p.path,
1209                                p.start_line,
1210                                p.start_column
1211                         FROM pending_relationships p
1212                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1213                         JOIN symbols s_target ON s_target.symbol_id = ?3
1214                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1215                          WHERE p.target_terminal_name = ?1
1216                            AND {pred}
1217                          LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
1218                    )?;
1219
1220                    let p_rows = pending_stmt.query_map(
1221                        params![symbol_name, remaining as i64, sid],
1222                        |row| {
1223                            Ok(ReferenceSite {
1224                                from_symbol_name: row.get(0)?,
1225                                from_symbol_id: row.get(1)?,
1226                                to_symbol_name: row.get(2)?,
1227                                kind: row.get(3)?,
1228                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1229                                start_line: Some(row.get::<_, i64>(5)? as usize),
1230                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1231                            })
1232                        },
1233                    )?;
1234                    for r in p_rows {
1235                        results.push(r?);
1236                    }
1237                } else {
1238                    let mut pending_stmt = conn.prepare(
1239                        "SELECT s_from.name AS from_name,
1240                                p.from_symbol_id,
1241                                p.target_terminal_name AS to_name,
1242                                p.kind,
1243                                p.path,
1244                                p.start_line,
1245                                p.start_column
1246                         FROM pending_relationships p
1247                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1248                         WHERE p.target_terminal_name = ?1
1249                           AND (
1250                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
1251                               OR EXISTS (
1252                                   SELECT 1 FROM symbols s_any
1253                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
1254                                   WHERE s_any.name = p.target_terminal_name
1255                                     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)
1256                               )
1257                           )
1258                         LIMIT ?2",
1259                    )?;
1260
1261                    let p_rows =
1262                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1263                            Ok(ReferenceSite {
1264                                from_symbol_name: row.get(0)?,
1265                                from_symbol_id: row.get(1)?,
1266                                to_symbol_name: row.get(2)?,
1267                                kind: row.get(3)?,
1268                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1269                                start_line: Some(row.get::<_, i64>(5)? as usize),
1270                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1271                            })
1272                        })?;
1273                    for r in p_rows {
1274                        results.push(r?);
1275                    }
1276                }
1277            } else {
1278                let is_nested = if let Some(sid) = symbol_id {
1279                    conn.query_row(
1280                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
1281                        params![sid],
1282                        |_| Ok(true),
1283                    )
1284                    .unwrap_or(false)
1285                } else {
1286                    false
1287                };
1288
1289                if !is_nested {
1290                    let mut pending_stmt = conn.prepare(
1291                        "SELECT s_from.name AS from_name,
1292                                p.from_symbol_id,
1293                                p.target_terminal_name AS to_name,
1294                                p.kind,
1295                                p.path,
1296                                p.start_line,
1297                                p.start_column
1298                         FROM pending_relationships p
1299                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1300                         WHERE p.target_terminal_name = ?1
1301                         LIMIT ?2",
1302                    )?;
1303
1304                    let p_rows =
1305                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1306                            Ok(ReferenceSite {
1307                                from_symbol_name: row.get(0)?,
1308                                from_symbol_id: row.get(1)?,
1309                                to_symbol_name: row.get(2)?,
1310                                kind: row.get(3)?,
1311                                path: row.get::<_, String>(4)?.replace('\\', "/"),
1312                                start_line: Some(row.get::<_, i64>(5)? as usize),
1313                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1314                            })
1315                        })?;
1316
1317                    for r in p_rows {
1318                        results.push(r?);
1319                    }
1320                }
1321            }
1322        }
1323
1324        if results.len() < limit && has_table(conn, "identifiers") {
1325            let remaining = limit - results.len();
1326            let mut ident_stmt = conn.prepare(
1327                "SELECT COALESCE(s.name, ''),
1328                        COALESCE(i.containing_symbol_id, ''),
1329                        i.name,
1330                        i.kind,
1331                        i.path,
1332                        i.start_line,
1333                        i.start_column
1334                 FROM identifiers i
1335                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
1336                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
1337                   AND COALESCE(s.kind, '') != 'import'
1338                 ORDER BY i.path, i.start_line
1339                 LIMIT ?2",
1340            )?;
1341            let rows = ident_stmt.query_map(params![symbol_name, remaining as i64], |row| {
1342                Ok(ReferenceSite {
1343                    from_symbol_name: row.get(0)?,
1344                    from_symbol_id: row.get(1)?,
1345                    to_symbol_name: row.get(2)?,
1346                    kind: row.get(3)?,
1347                    path: row.get::<_, String>(4)?.replace('\\', "/"),
1348                    start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1349                    start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1350                })
1351            })?;
1352            for r in rows {
1353                results.push(r?);
1354            }
1355        }
1356    } else {
1357        // Find callees: symbols called by target symbol
1358        let mut stmt = conn.prepare(
1359            "SELECT s_from.name AS from_name,
1360                    r.from_symbol_id,
1361                    s_to.name AS to_name,
1362                    r.kind,
1363                    r.path,
1364                    r.start_line,
1365                    r.start_column
1366             FROM relationships r
1367             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1368             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1369             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
1370             LIMIT ?2",
1371        )?;
1372
1373        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
1374            Ok(ReferenceSite {
1375                from_symbol_name: row.get(0)?,
1376                from_symbol_id: row.get(1)?,
1377                to_symbol_name: row.get(2)?,
1378                kind: row.get(3)?,
1379                path: row.get::<_, String>(4)?.replace('\\', "/"),
1380                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
1381                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1382            })
1383        })?;
1384
1385        for r in rows {
1386            results.push(r?);
1387        }
1388
1389        // Also query pending_relationships for callees
1390        if results.len() < limit {
1391            let remaining = limit - results.len();
1392            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
1393                let sql = if include_external {
1394                    String::from("SELECT DISTINCT s_from.name AS from_name,
1395                            p.from_symbol_id,
1396                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
1397                            p.kind,
1398                            p.path,
1399                            p.start_line,
1400                            p.start_column
1401                     FROM pending_relationships p
1402                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1403                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1404                     LIMIT ?2")
1405                } else {
1406                    format!("SELECT DISTINCT s_from.name AS from_name,
1407                            p.from_symbol_id,
1408                            p.target_terminal_name AS to_name,
1409                            p.kind,
1410                            p.path,
1411                            p.start_line,
1412                            p.start_column
1413                     FROM pending_relationships p
1414                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1415                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1416                       AND EXISTS (
1417                           SELECT 1 FROM symbols s_to
1418                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
1419                           WHERE s_to.name = p.target_terminal_name
1420                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1421                             AND {pred}
1422                       )
1423                     LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
1424                };
1425                let mut pending_stmt = conn.prepare(&sql)?;
1426                let rows = pending_stmt.query_map(
1427                    params![symbol_name, remaining as i64, symbol_id],
1428                    |row| {
1429                        Ok(ReferenceSite {
1430                            from_symbol_name: row.get(0)?,
1431                            from_symbol_id: row.get(1)?,
1432                            to_symbol_name: row.get(2)?,
1433                            kind: row.get(3)?,
1434                            path: row.get::<_, String>(4)?.replace('\\', "/"),
1435                            start_line: Some(row.get::<_, i64>(5)? as usize),
1436                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1437                        })
1438                    },
1439                )?;
1440                let mut out = Vec::new();
1441                for r in rows {
1442                    out.push(r?);
1443                }
1444                out
1445            } else {
1446                let sql = if include_external {
1447                    "SELECT DISTINCT s_from.name AS from_name,
1448                            p.from_symbol_id,
1449                            p.target_terminal_name AS to_name,
1450                            p.kind,
1451                            p.path,
1452                            p.start_line,
1453                            p.start_column
1454                     FROM pending_relationships p
1455                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1456                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1457                     LIMIT ?2"
1458                } else {
1459                    "SELECT DISTINCT s_from.name AS from_name,
1460                            p.from_symbol_id,
1461                            p.target_terminal_name AS to_name,
1462                            p.kind,
1463                            p.path,
1464                            p.start_line,
1465                            p.start_column
1466                     FROM pending_relationships p
1467                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1468                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
1469                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
1470                     LIMIT ?2"
1471                };
1472                let mut pending_stmt = conn.prepare(sql)?;
1473
1474                let rows = pending_stmt.query_map(
1475                    params![symbol_name, remaining as i64, symbol_id],
1476                    |row| {
1477                        Ok(ReferenceSite {
1478                            from_symbol_name: row.get(0)?,
1479                            from_symbol_id: row.get(1)?,
1480                            to_symbol_name: row.get(2)?,
1481                            kind: row.get(3)?,
1482                            path: row.get::<_, String>(4)?.replace('\\', "/"),
1483                            start_line: Some(row.get::<_, i64>(5)? as usize),
1484                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
1485                        })
1486                    },
1487                )?;
1488                let mut out = Vec::new();
1489                for r in rows {
1490                    out.push(r?);
1491                }
1492                out
1493            };
1494
1495            for r in p_rows {
1496                results.push(r);
1497            }
1498        }
1499    }
1500
1501    Ok(results)
1502}
1503
1504/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
1505/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
1506pub fn find_callee_signatures(
1507    conn: &Connection,
1508    symbol_name: &str,
1509    symbol_id: &str,
1510    limit: usize,
1511    include_external: bool,
1512) -> Result<Vec<String>, QueryError> {
1513    let mut stmt = conn.prepare(
1514        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1515         FROM relationships r
1516         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
1517         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
1518         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
1519         LIMIT ?3",
1520    )?;
1521
1522    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
1523        Ok((
1524            row.get::<_, String>(0)?,
1525            row.get::<_, Option<String>>(1)?,
1526            row.get::<_, String>(2)?.replace('\\', "/"),
1527            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1528            row.get::<_, String>(4)?,
1529        ))
1530    })?;
1531
1532    let mut signatures = Vec::new();
1533    let mut variants = Vec::new();
1534
1535    for r in rows.flatten() {
1536        let (name, sig_opt, path, line, kind) = r;
1537        let sig = sig_opt.unwrap_or(name);
1538        let entry = format!("{sig} ({path}:{line})");
1539        if kind == "variant" {
1540            if !variants.contains(&entry) {
1541                variants.push(entry);
1542            }
1543        } else if !signatures.contains(&entry) {
1544            signatures.push(entry);
1545        }
1546    }
1547
1548    if signatures.len() < limit {
1549        let remaining = (limit - signatures.len()) * 2;
1550        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
1551            if has_pending_namespace_column(conn) {
1552                let mut p_stmt = conn.prepare(
1553                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1554                 FROM pending_relationships p
1555                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1556                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1557                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1558                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1559                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1560                    AND {pred}
1561                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1562            )?;
1563
1564                let rows =
1565                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1566                        Ok((
1567                            row.get::<_, String>(0)?,
1568                            row.get::<_, Option<String>>(1)?,
1569                            row.get::<_, String>(2)?.replace('\\', "/"),
1570                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1571                            row.get::<_, String>(4)?,
1572                        ))
1573                    })?;
1574                rows.flatten().collect()
1575            } else {
1576                let mut p_stmt = conn.prepare(
1577                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
1578                 FROM pending_relationships p
1579                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1580                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
1581                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1582                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1583                 LIMIT ?3",
1584            )?;
1585
1586                let rows =
1587                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1588                        Ok((
1589                            row.get::<_, String>(0)?,
1590                            row.get::<_, Option<String>>(1)?,
1591                            row.get::<_, String>(2)?.replace('\\', "/"),
1592                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
1593                            row.get::<_, String>(4)?,
1594                        ))
1595                    })?;
1596                rows.flatten().collect()
1597            };
1598
1599        for r in p_rows {
1600            let (name, sig_opt, path, line, kind) = r;
1601            let sig = sig_opt.unwrap_or(name);
1602            let entry = format!("{sig} ({path}:{line})");
1603            if kind == "variant" {
1604                if !variants.contains(&entry) {
1605                    variants.push(entry);
1606                }
1607            } else if !signatures.contains(&entry) {
1608                signatures.push(entry);
1609            }
1610        }
1611    }
1612
1613    if include_external && signatures.len() < limit {
1614        let remaining = (limit - signatures.len()) * 2;
1615        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
1616            let mut ext_stmt = conn.prepare(
1617                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
1618                 FROM pending_relationships p
1619                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1620                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1621                   AND NOT EXISTS (
1622                       SELECT 1 FROM symbols s_to
1623                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
1624                       WHERE s_to.name = p.target_terminal_name
1625                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1626                         AND {pred}
1627                   )
1628                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
1629            )?;
1630
1631            let rows =
1632                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1633                    Ok((
1634                        row.get::<_, String>(0)?,
1635                        row.get::<_, String>(1)?.replace('\\', "/"),
1636                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1637                    ))
1638                })?;
1639            rows.flatten().collect()
1640        } else {
1641            let mut ext_stmt = conn.prepare(
1642                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
1643                 FROM pending_relationships p
1644                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
1645                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
1646                   AND NOT EXISTS (
1647                       SELECT 1 FROM symbols s_to
1648                       WHERE s_to.name = p.target_terminal_name
1649                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
1650                   )
1651                 LIMIT ?3",
1652            )?;
1653
1654            let rows =
1655                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
1656                    Ok((
1657                        row.get::<_, String>(0)?,
1658                        row.get::<_, String>(1)?.replace('\\', "/"),
1659                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
1660                    ))
1661                })?;
1662            rows.flatten().collect()
1663        };
1664
1665        for r in ext_rows {
1666            let (name, path, line) = r;
1667            let entry = format!("{name} ({path}:{line})");
1668            if !signatures.contains(&entry) {
1669                signatures.push(entry);
1670            }
1671        }
1672    }
1673
1674    for v in variants {
1675        if signatures.len() >= limit {
1676            break;
1677        }
1678        if !signatures.contains(&v) {
1679            signatures.push(v);
1680        }
1681    }
1682
1683    signatures.truncate(limit);
1684    Ok(signatures)
1685}
1686
1687/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
1688pub fn find_structural_facts_scoped(
1689    conn: &Connection,
1690    category: &str,
1691    path_filter: Option<&str>,
1692    limit: usize,
1693) -> Result<Vec<StructuralFact>, QueryError> {
1694    let norm_path = path_filter
1695        .map(|p| {
1696            p.replace('\\', "/")
1697                .trim_start_matches("./")
1698                .trim_matches('/')
1699                .to_string()
1700        })
1701        .filter(|p| !p.is_empty());
1702    let dir_prefix = norm_path
1703        .as_deref()
1704        .map(|p| format!("{}/%", escape_like(p)));
1705    let cat_pattern = format!("%{}%", escape_like(category));
1706
1707    let cat_lower = category.trim().to_ascii_lowercase();
1708    let cat_clause = match cat_lower.as_str() {
1709        "config" => {
1710            "(sf.pattern_id LIKE '%.key_value.%' OR sf.pattern_id LIKE '%config%' OR sf.capture_name LIKE '%config%' OR sf.node_kind LIKE '%config%')"
1711        }
1712        "route" | "routes" => {
1713            "(sf.pattern_id LIKE '%.route%' OR sf.pattern_id LIKE '%route%' OR sf.capture_name LIKE '%route%')"
1714        }
1715        "query" | "queries" | "sql" => {
1716            "(sf.pattern_id LIKE '%.sql.%' OR sf.pattern_id LIKE '%query%')"
1717        }
1718        "model" | "models" => "sf.pattern_id LIKE '%.model%'",
1719        _ => {
1720            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
1721        }
1722    };
1723
1724    let sql = format!(
1725        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
1726                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
1727                sf.start_line, sf.end_line, sf.confidence,
1728                COALESCE(
1729                    CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
1730                         THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
1731                         ELSE json_extract(sf.metadata_json, '$.key_path') END,
1732                    json_extract(sf.metadata_json, '$.key'),
1733                    json_extract(sf.metadata_json, '$.normalized_route_template')
1734                ) AS display_key
1735         FROM structural_facts sf
1736         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
1737         WHERE (:cat IS NOT NULL AND {cat_clause})
1738           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1739         ORDER BY sf.path ASC, sf.start_line ASC
1740         LIMIT :limit"
1741    );
1742
1743    let mut stmt = conn.prepare(&sql)?;
1744    let rows = stmt.query_map(
1745        rusqlite::named_params! {
1746            ":cat": cat_pattern,
1747            ":path": norm_path.as_deref(),
1748            ":dir_prefix": dir_prefix.as_deref(),
1749            ":limit": limit as i64,
1750        },
1751        |row| {
1752            Ok(StructuralFact {
1753                structural_fact_id: row.get(0)?,
1754                path: row.get::<_, String>(1)?.replace('\\', "/"),
1755                language: row.get(2)?,
1756                pattern_id: row.get(3)?,
1757                capture_name: row.get(4)?,
1758                node_kind: row.get(5)?,
1759                key: row.get(10)?,
1760                containing_symbol_name: row.get(6)?,
1761                start_line: row.get::<_, i64>(7)? as usize,
1762                end_line: row.get::<_, i64>(8)? as usize,
1763                confidence: row.get(9)?,
1764            })
1765        },
1766    )?;
1767
1768    let mut results = Vec::new();
1769    for r in rows {
1770        results.push(r?);
1771    }
1772    Ok(results)
1773}
1774
1775/// Find structural facts by category (e.g. route, query, model, config).
1776pub fn find_structural_facts(
1777    conn: &Connection,
1778    category: &str,
1779    limit: usize,
1780) -> Result<Vec<StructuralFact>, QueryError> {
1781    find_structural_facts_scoped(conn, category, None, limit)
1782}
1783
1784/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
1785pub fn find_literals_scoped(
1786    conn: &Connection,
1787    category: &str,
1788    path_filter: Option<&str>,
1789    limit: usize,
1790) -> Result<Vec<LiteralFact>, QueryError> {
1791    let norm_path = path_filter
1792        .map(|p| {
1793            p.replace('\\', "/")
1794                .trim_start_matches("./")
1795                .trim_matches('/')
1796                .to_string()
1797        })
1798        .filter(|p| !p.is_empty());
1799    let dir_prefix = norm_path
1800        .as_deref()
1801        .map(|p| format!("{}/%", escape_like(p)));
1802    let cat_pattern = format!("%{}%", escape_like(category));
1803
1804    let cat_lower = category.trim().to_ascii_lowercase();
1805    let cat_clause = match cat_lower.as_str() {
1806        "config" => {
1807            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
1808        }
1809        "route" | "routes" => "l.kind LIKE '%route%'",
1810        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
1811        "model" | "models" => "l.kind LIKE '%model%'",
1812        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
1813    };
1814
1815    let sql = format!(
1816        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
1817                l.start_line, s.name AS containing_symbol_name
1818         FROM literals l
1819         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
1820         WHERE (:cat IS NOT NULL AND {cat_clause})
1821           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1822         ORDER BY l.path ASC, l.start_line ASC
1823         LIMIT :limit"
1824    );
1825
1826    let mut stmt = conn.prepare(&sql)?;
1827    let rows = stmt.query_map(
1828        rusqlite::named_params! {
1829            ":cat": cat_pattern,
1830            ":path": norm_path.as_deref(),
1831            ":dir_prefix": dir_prefix.as_deref(),
1832            ":limit": limit as i64,
1833        },
1834        |row| {
1835            Ok(LiteralFact {
1836                literal_id: row.get(0)?,
1837                path: row.get::<_, String>(1)?.replace('\\', "/"),
1838                literal_text: row.get(2)?,
1839                kind: row.get(3)?,
1840                carrier: row.get(4)?,
1841                start_line: row.get::<_, i64>(5)? as usize,
1842                containing_symbol_name: row.get(6)?,
1843            })
1844        },
1845    )?;
1846
1847    let mut results = Vec::new();
1848    for r in rows {
1849        results.push(r?);
1850    }
1851    Ok(results)
1852}
1853
1854/// Find literals (endpoints, SQL queries, configs) matching category.
1855pub fn find_literals(
1856    conn: &Connection,
1857    category: &str,
1858    limit: usize,
1859) -> Result<Vec<LiteralFact>, QueryError> {
1860    find_literals_scoped(conn, category, None, limit)
1861}
1862
1863/// List available structural fact and literal categories with counts, optionally scoped by path.
1864pub fn list_structural_fact_categories_scoped(
1865    conn: &Connection,
1866    path_filter: Option<&str>,
1867) -> Result<Vec<(String, usize)>, QueryError> {
1868    let norm_path = path_filter
1869        .map(|p| {
1870            p.replace('\\', "/")
1871                .trim_start_matches("./")
1872                .trim_matches('/')
1873                .to_string()
1874        })
1875        .filter(|p| !p.is_empty());
1876    let dir_prefix = norm_path
1877        .as_deref()
1878        .map(|p| format!("{}/%", escape_like(p)));
1879
1880    let mut categories = Vec::new();
1881
1882    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
1883               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1884               GROUP BY pattern_id ORDER BY cnt DESC";
1885    let mut stmt = conn.prepare(sql)?;
1886    let rows = stmt.query_map(
1887        rusqlite::named_params! {
1888            ":path": norm_path.as_deref(),
1889            ":dir_prefix": dir_prefix.as_deref(),
1890        },
1891        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1892    )?;
1893    for r in rows {
1894        categories.push(r?);
1895    }
1896
1897    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
1898                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
1899                   GROUP BY kind ORDER BY cnt DESC";
1900    let mut lit_stmt = conn.prepare(lit_sql)?;
1901    let lit_rows = lit_stmt.query_map(
1902        rusqlite::named_params! {
1903            ":path": norm_path.as_deref(),
1904            ":dir_prefix": dir_prefix.as_deref(),
1905        },
1906        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
1907    )?;
1908    for r in lit_rows {
1909        categories.push(r?);
1910    }
1911
1912    Ok(categories)
1913}
1914
1915/// List all available structural fact and literal categories with counts.
1916pub fn list_structural_fact_categories(
1917    conn: &Connection,
1918) -> Result<Vec<(String, usize)>, QueryError> {
1919    list_structural_fact_categories_scoped(conn, None)
1920}
1921
1922/// Find type facts for a symbol.
1923pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
1924    let has_table: bool = conn
1925        .query_row(
1926            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
1927            [],
1928            |_| Ok(true),
1929        )
1930        .unwrap_or(false);
1931    if !has_table {
1932        return Ok(Vec::new());
1933    }
1934
1935    let mut stmt = conn.prepare(
1936        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
1937         FROM type_facts
1938         WHERE symbol_id = ?1",
1939    )?;
1940
1941    let rows = stmt.query_map(params![symbol_id], |row| {
1942        Ok(TypeFact {
1943            type_fact_id: row.get(0)?,
1944            symbol_id: row.get(1)?,
1945            language: row.get(2)?,
1946            resolved_type: row.get(3)?,
1947            generic_params: row.get(4)?,
1948        })
1949    })?;
1950
1951    let mut results = Vec::new();
1952    for r in rows {
1953        results.push(r?);
1954    }
1955    Ok(results)
1956}
1957
1958/// Helper to determine if a relative path looks like a test file across ecosystems.
1959pub fn is_test_path(path: &str) -> bool {
1960    let p = path.to_lowercase().replace('\\', "/");
1961    p.contains("/test/")
1962        || p.contains("/tests/")
1963        || p.contains("/__tests__/")
1964        || p.contains("_test.")
1965        || p.contains(".test.")
1966        || p.contains(".spec.")
1967        || p.ends_with("test.rs")
1968        || p.ends_with("tests.rs")
1969        || p.ends_with("tests.cs")
1970        || p.ends_with("test.go")
1971        || p.starts_with("test_")
1972}
1973
1974/// Compute blast radius and likely tests for given seed symbols or seed file paths.
1975/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
1976pub fn compute_blast_radius_scoped(
1977    conn: &Connection,
1978    seed_symbols: &[&str],
1979    symbol_path_filter: Option<&str>,
1980    seed_paths: &[&str],
1981    max_depth: usize,
1982    limit: usize,
1983) -> Result<BlastRadiusResult, QueryError> {
1984    let max_depth = max_depth.min(5);
1985    let resolved_seed_symbols = seed_symbols
1986        .iter()
1987        .map(|name| {
1988            get_symbol_by_name(conn, name, symbol_path_filter)?
1989                .ok_or_else(|| QueryError::SymbolNotFound((*name).to_string()))
1990        })
1991        .collect::<Result<Vec<_>, _>>()?;
1992    let mut seeds = Vec::new();
1993    let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
1994        for s in seed_symbols {
1995            seeds.push(s.to_string());
1996        }
1997        for p in seed_paths {
1998            seeds.push(p.to_string());
1999        }
2000        "mixed".to_string()
2001    } else if !seed_symbols.is_empty() {
2002        for s in seed_symbols {
2003            seeds.push(s.to_string());
2004        }
2005        "symbol".to_string()
2006    } else if !seed_paths.is_empty() {
2007        for p in seed_paths {
2008            seeds.push(p.to_string());
2009        }
2010        "file".to_string()
2011    } else {
2012        return Ok(BlastRadiusResult {
2013            seed_type: "none".to_string(),
2014            seeds: Vec::new(),
2015            likely_tests: Vec::new(),
2016            impacted_symbols: Vec::new(),
2017            traversal_ceiling_reached: false,
2018        });
2019    };
2020
2021    let mut where_clauses = Vec::new();
2022    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
2023
2024    if !resolved_seed_symbols.is_empty() {
2025        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
2026            .map(|i| format!("?{}", i))
2027            .collect();
2028        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
2029        for symbol in &resolved_seed_symbols {
2030            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
2031        }
2032    }
2033
2034    if !seed_paths.is_empty() {
2035        let mut path_conds = Vec::new();
2036        for p in seed_paths.iter() {
2037            let raw = p
2038                .replace('\\', "/")
2039                .trim_start_matches("./")
2040                .trim_matches('/')
2041                .to_string();
2042            let exact_idx = params_vec.len() + 1;
2043            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
2044            let dir_pattern = format!("{}/%", escape_like(&raw));
2045            let like_idx = params_vec.len() + 1;
2046            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
2047            path_conds.push(format!(
2048                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
2049            ));
2050        }
2051        where_clauses.push(format!("({})", path_conds.join(" OR ")));
2052    }
2053
2054    let seed_condition = where_clauses.join(" OR ");
2055    let max_depth_idx = params_vec.len() + 1;
2056    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
2057
2058    let mut traversal_ceiling_reached = false;
2059
2060    let has_relationships: bool = conn
2061        .query_row(
2062            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
2063            [],
2064            |_| Ok(true),
2065        )
2066        .unwrap_or(false);
2067
2068    let has_pending: bool = conn
2069        .query_row(
2070            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
2071            [],
2072            |_| Ok(true),
2073        )
2074        .unwrap_or(false);
2075
2076    let mut likely_tests = Vec::new();
2077    let mut impacted_symbols = Vec::new();
2078    let mut seen_test_keys = HashSet::new();
2079
2080    let mut recursive_branches = Vec::new();
2081
2082    if has_relationships {
2083        recursive_branches.push(format!(
2084            "SELECT r.from_symbol_id, iw.depth + 1
2085             FROM relationships r
2086             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
2087             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2088             WHERE iw.depth < ?{max_depth_idx}
2089               AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
2090        ));
2091    }
2092
2093    if has_pending {
2094        let (parent_join, ns_condition) = if conn
2095            .query_row(
2096                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
2097                [],
2098                |_| Ok(true),
2099            )
2100            .unwrap_or(false)
2101        {
2102            (
2103                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2104            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
2105                format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
2106            )
2107        } else {
2108            ("", String::new())
2109        };
2110
2111        recursive_branches.push(format!(
2112            "SELECT p.from_symbol_id, iw.depth + 1
2113             FROM pending_relationships p
2114             JOIN symbols s_target ON p.target_terminal_name = s_target.name
2115             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
2116             {parent_join}
2117             WHERE iw.depth < ?{max_depth_idx}
2118               AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2119               {ns_condition}"
2120        ));
2121    }
2122
2123    if !recursive_branches.is_empty() {
2124        let recursive_sql = recursive_branches.join("\n UNION \n");
2125        let not_documentation = not_documentation(conn, "s");
2126        let sql = format!(
2127            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
2128                SELECT symbol_id, 0
2129                FROM symbols
2130                WHERE ({seed_condition})
2131                  AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2132
2133                UNION
2134
2135                {recursive_sql}
2136            )
2137            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
2138            FROM impact_walk iw
2139            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
2140            WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
2141              AND {not_documentation}
2142            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
2143            HAVING MIN(iw.depth) > 0
2144            ORDER BY min_depth ASC, s.path ASC, s.name ASC
2145            LIMIT 200"
2146        );
2147
2148        let mut stmt = conn.prepare(&sql)?;
2149        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
2150            .iter()
2151            .map(|v| v as &dyn rusqlite::ToSql)
2152            .collect();
2153
2154        let rows = stmt.query_map(param_refs.as_slice(), |row| {
2155            Ok((
2156                row.get::<_, String>(0)?,
2157                row.get::<_, String>(1)?,
2158                row.get::<_, String>(2)?,
2159                row.get::<_, String>(3)?,
2160                row.get::<_, i64>(4)? as usize,
2161                row.get::<_, bool>(5)?,
2162                row.get::<_, bool>(6)?,
2163                row.get::<_, i64>(7)? as usize,
2164            ))
2165        })?;
2166
2167        let mut row_count = 0;
2168        for r in rows {
2169            row_count += 1;
2170            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
2171            let path = raw_path.replace('\\', "/");
2172            let is_test_target = is_test || test_container || is_test_path(&path);
2173
2174            if is_test_target {
2175                let key = format!("{}:{}", path, line);
2176                if seen_test_keys.insert(key) {
2177                    likely_tests.push(TestTarget {
2178                        name,
2179                        path,
2180                        line,
2181                        reason: format!("transitive caller [depth {depth}]"),
2182                    });
2183                }
2184            } else {
2185                impacted_symbols.push(ImpactedSymbol {
2186                    name,
2187                    kind,
2188                    path,
2189                    line,
2190                    depth,
2191                });
2192            }
2193        }
2194        traversal_ceiling_reached = row_count >= 200;
2195    }
2196
2197    // 2. Discover stem-matched test files in the workspace
2198    let mut file_stems = Vec::new();
2199    for p in seed_paths {
2200        if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
2201            && stem.len() >= 3
2202            && !file_stems.contains(&stem.to_string())
2203        {
2204            file_stems.push(stem.to_string());
2205        }
2206    }
2207    for symbol in &resolved_seed_symbols {
2208        if let Some(stem) = std::path::Path::new(&symbol.path)
2209            .file_stem()
2210            .and_then(|s| s.to_str())
2211            && stem.len() >= 3
2212            && !file_stems.contains(&stem.to_string())
2213        {
2214            file_stems.push(stem.to_string());
2215        }
2216    }
2217
2218    let has_files: bool = conn
2219        .query_row(
2220            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
2221            [],
2222            |_| Ok(true),
2223        )
2224        .unwrap_or(false);
2225
2226    if has_files {
2227        let doc_file = format!(
2228            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
2229            not_documentation(conn, "d")
2230        );
2231        let mut test_files_stmt = conn.prepare(&format!(
2232            "SELECT DISTINCT path FROM files
2233             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
2234               AND NOT {doc_file}
2235             LIMIT 10"
2236        ))?;
2237        for stem in file_stems {
2238            let stem_pattern = format!("%{}%", escape_like(&stem));
2239            let t_rows =
2240                test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
2241            for p in t_rows.flatten() {
2242                let p = p.replace('\\', "/");
2243                let key = format!("{}:1", p);
2244                if seen_test_keys.insert(key) {
2245                    likely_tests.push(TestTarget {
2246                        name: p.clone(),
2247                        path: p,
2248                        line: 1,
2249                        reason: "stem-matched test file".to_string(),
2250                    });
2251                }
2252            }
2253        }
2254    }
2255
2256    // Truncate to limit
2257    if likely_tests.len() > limit {
2258        likely_tests.truncate(limit);
2259    }
2260    if impacted_symbols.len() > limit {
2261        impacted_symbols.truncate(limit);
2262    }
2263
2264    Ok(BlastRadiusResult {
2265        seed_type,
2266        seeds,
2267        likely_tests,
2268        impacted_symbols,
2269        traversal_ceiling_reached,
2270    })
2271}
2272
2273/// Compute blast radius and likely tests for given seed symbols or seed file paths.
2274pub fn compute_blast_radius(
2275    conn: &Connection,
2276    seed_symbols: &[&str],
2277    seed_paths: &[&str],
2278    max_depth: usize,
2279    limit: usize,
2280) -> Result<BlastRadiusResult, QueryError> {
2281    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
2282}
2283
2284#[cfg(test)]
2285mod tests {
2286    use super::*;
2287    use crate::db::{ensure_fts_index, open_read_write};
2288
2289    #[test]
2290    fn count_parse_diagnostics_counts_rows_for_one_file() {
2291        let dir = crate::safe_tempdir();
2292        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
2293
2294        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
2295
2296        conn.execute_batch(
2297            "CREATE TABLE parse_diagnostics (
2298                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
2299            );
2300            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
2301            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
2302            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
2303        )
2304        .unwrap();
2305
2306        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
2307        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
2308        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
2309    }
2310
2311    #[test]
2312    fn test_sanitize_fts5_query() {
2313        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
2314        assert_eq!(and_q, "\"parse\"* \"tokens\"*");
2315        assert_eq!(or_q, "\"parse\"* OR \"tokens\"*");
2316
2317        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
2318        assert_eq!(and_q, "\"Option\"* \"T\"*");
2319        assert_eq!(or_q, "\"Option\"* OR \"T\"*");
2320
2321        let (and_q, or_q) = sanitize_fts5_query("   ");
2322        assert!(and_q.is_empty());
2323        assert!(or_q.is_empty());
2324    }
2325
2326    #[test]
2327    fn search_symbols_treats_like_wildcards_as_literals() {
2328        let dir = crate::safe_tempdir();
2329        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
2330        let conn = open_read_write(&db_path).unwrap();
2331        conn.execute_batch(
2332            "CREATE TABLE symbols (
2333                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2334                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2335                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2336                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2337                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2338                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2339                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2340            );
2341            INSERT INTO symbols VALUES (
2342                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
2343                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2344            );
2345            INSERT INTO symbols VALUES (
2346                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
2347                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2348            );
2349            INSERT INTO symbols VALUES (
2350                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
2351                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
2352            );
2353            CREATE TABLE files (
2354                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
2355                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
2356            );
2357            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
2358            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
2359            );",
2360        )
2361        .unwrap();
2362
2363        assert_eq!(
2364            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
2365            "literal%name"
2366        );
2367        assert_eq!(
2368            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
2369            "literal_name"
2370        );
2371        assert_eq!(
2372            load_scoped_files(&conn, Some("src/literal_path"))
2373                .unwrap()
2374                .len(),
2375            1
2376        );
2377    }
2378
2379    #[test]
2380    fn find_references_for_symbol_limits_callees_by_symbol_id() {
2381        let dir = crate::safe_tempdir();
2382        let db_path = dir.path().join("find_references_for_symbol.db");
2383        let conn = open_read_write(&db_path).unwrap();
2384        conn.execute_batch(
2385            "CREATE TABLE symbols (
2386                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2387                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2388                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2389                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2390                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2391                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2392                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2393            );
2394            CREATE TABLE relationships (
2395                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2396                start_line INTEGER, start_column INTEGER
2397            );
2398            CREATE TABLE pending_relationships (
2399                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2400                start_line INTEGER, start_column INTEGER
2401            );
2402            INSERT INTO symbols VALUES
2403                ('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),
2404                ('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),
2405                ('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),
2406                ('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);
2407            INSERT INTO relationships VALUES
2408                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
2409                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
2410        )
2411        .unwrap();
2412
2413        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
2414        assert_eq!(references.len(), 1);
2415        assert_eq!(references[0].to_symbol_name, "wanted_dep");
2416    }
2417
2418    #[test]
2419    fn test_fts_search_symbols_and_porter_stemming() {
2420        let dir = crate::safe_tempdir();
2421        let db_path = dir.path().join("fts_search_symbols.db");
2422        let conn = open_read_write(&db_path).unwrap();
2423
2424        conn.execute_batch(
2425            "CREATE TABLE symbols (
2426                symbol_id TEXT PRIMARY KEY,
2427                file_id TEXT,
2428                path TEXT,
2429                language TEXT,
2430                name TEXT,
2431                kind TEXT,
2432                signature TEXT,
2433                doc_comment TEXT,
2434                visibility TEXT,
2435                parent_symbol_id TEXT,
2436                start_line INTEGER,
2437                start_column INTEGER,
2438                end_line INTEGER,
2439                end_column INTEGER,
2440                start_byte INTEGER,
2441                end_byte INTEGER,
2442                body_start_line INTEGER,
2443                body_start_column INTEGER,
2444                body_end_line INTEGER,
2445                body_end_column INTEGER,
2446                body_start_byte INTEGER,
2447                body_end_byte INTEGER,
2448                body_hash TEXT,
2449                semantic_group TEXT,
2450                is_test INTEGER,
2451                test_container INTEGER
2452            );
2453            INSERT INTO symbols VALUES (
2454                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
2455                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
2456                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
2457            );
2458            INSERT INTO symbols VALUES (
2459                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
2460                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
2461                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
2462            );
2463            INSERT INTO symbols VALUES (
2464                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
2465                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
2466                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
2467            );
2468            INSERT INTO symbols VALUES (
2469                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
2470                'fn test_payment_flow()', 'Tests payment charge workflow',
2471                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
2472            );",
2473        )
2474        .unwrap();
2475
2476        ensure_fts_index(&conn).unwrap();
2477
2478        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
2479        let results =
2480            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
2481        assert_eq!(results.len(), 1);
2482        assert_eq!(results[0].symbol.name, "parse_tokens");
2483        assert!(results[0].snippet.is_some());
2484
2485        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
2486        let results =
2487            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
2488        assert_eq!(results.len(), 1);
2489        assert_eq!(results[0].symbol.name, "PaymentGateway");
2490
2491        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
2492        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
2493        assert_eq!(results.len(), 2);
2494        assert!(results.iter().all(|r| !r.symbol.is_test));
2495
2496        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
2497        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
2498        assert_eq!(results.len(), 3);
2499
2500        // 5. Fallback OR matching: multi-term where only some match
2501        let results =
2502            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
2503        assert_eq!(results.len(), 1);
2504        assert_eq!(results[0].symbol.name, "StripeClient");
2505    }
2506
2507    #[test]
2508    fn find_related_tests_returns_each_test_once_under_the_limit() {
2509        let dir = crate::safe_tempdir();
2510        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
2511        conn.execute_batch(
2512            "CREATE TABLE symbols (
2513                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2514                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2515                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2516                start_column INTEGER, end_line INTEGER, end_column INTEGER,
2517                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2518                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2519                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2520                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2521            );
2522            CREATE TABLE relationships (
2523                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2524                start_line INTEGER, start_column INTEGER
2525            );
2526            CREATE TABLE pending_relationships (
2527                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2528                start_line INTEGER, start_column INTEGER,
2529                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2530            );
2531            CREATE TABLE type_facts (
2532                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2533            );
2534            INSERT INTO symbols VALUES
2535                ('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),
2536                ('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),
2537                ('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);
2538            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
2539                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
2540                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
2541                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
2542                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
2543                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
2544                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
2545        )
2546        .unwrap();
2547        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
2548
2549        let tests = find_related_tests(&conn, &target, 5).unwrap();
2550
2551        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2552        names.sort();
2553        assert_eq!(names, vec!["first_case", "second_case"]);
2554    }
2555
2556    #[test]
2557    fn documentation_rows_rank_after_code_in_search() {
2558        let dir = crate::safe_tempdir();
2559        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
2560        conn.execute_batch(
2561            "CREATE TABLE symbols (
2562                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
2563                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2564                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2565                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2566                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2567                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2568                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
2569            );
2570            INSERT INTO symbols VALUES
2571                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
2572                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
2573                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
2574                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
2575                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
2576                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
2577        )
2578        .unwrap();
2579        ensure_fts_index(&conn).unwrap();
2580
2581        let results =
2582            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
2583                .unwrap();
2584
2585        assert_eq!(results.len(), 2);
2586        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
2587        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
2588    }
2589
2590    #[test]
2591    fn test_queries_nocase_and_path_normalization() {
2592        let conn = Connection::open_in_memory().unwrap();
2593        conn.execute_batch(
2594            "CREATE TABLE files (
2595                file_id TEXT PRIMARY KEY,
2596                path TEXT NOT NULL,
2597                language TEXT,
2598                content_hash TEXT,
2599                content_bytes INTEGER,
2600                line_count INTEGER,
2601                indexed_at INTEGER
2602            );
2603            CREATE TABLE symbols (
2604                symbol_id TEXT PRIMARY KEY,
2605                file_id TEXT,
2606                path TEXT NOT NULL,
2607                language TEXT,
2608                name TEXT,
2609                kind TEXT,
2610                signature TEXT,
2611                doc_comment TEXT,
2612                visibility TEXT,
2613                parent_symbol_id TEXT,
2614                start_line INTEGER,
2615                start_column INTEGER,
2616                end_line INTEGER,
2617                end_column INTEGER,
2618                start_byte INTEGER,
2619                end_byte INTEGER,
2620                body_start_line INTEGER,
2621                body_start_column INTEGER,
2622                body_end_line INTEGER,
2623                body_end_column INTEGER,
2624                body_start_byte INTEGER,
2625                body_end_byte INTEGER,
2626                body_hash TEXT,
2627                semantic_group TEXT,
2628                is_test INTEGER,
2629                test_container INTEGER
2630            );
2631            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
2632            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
2633            INSERT INTO symbols VALUES (
2634                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
2635                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2636                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
2637            );",
2638        )
2639        .unwrap();
2640
2641        // 1. get_file: query with uppercase, lowercase, and forward slashes
2642        let file = get_file(&conn, "SRC/PAYMENT.RS")
2643            .unwrap()
2644            .expect("File should be found");
2645        assert_eq!(
2646            file.path, "src/Payment.rs",
2647            "Path should be normalized to forward slashes"
2648        );
2649
2650        let file2 = get_file(&conn, "src/payment.rs")
2651            .unwrap()
2652            .expect("File should be found");
2653        assert_eq!(file2.path, "src/Payment.rs");
2654
2655        // 2. load_file_symbols: query with uppercase and forward slashes
2656        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
2657        assert_eq!(syms.len(), 1);
2658        assert_eq!(
2659            syms[0].path, "src/Payment.rs",
2660            "Symbol path should be normalized to forward slashes"
2661        );
2662
2663        // 3. get_symbol_by_name with path filter
2664        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
2665            .unwrap()
2666            .expect("Symbol should be found with case-insensitive path filter");
2667        assert_eq!(sym.path, "src/Payment.rs");
2668    }
2669
2670    #[test]
2671    fn test_exact_case_prioritized_over_nocase() {
2672        let conn = Connection::open_in_memory().unwrap();
2673        conn.execute_batch(
2674            "CREATE TABLE files (
2675                file_id TEXT PRIMARY KEY,
2676                path TEXT NOT NULL,
2677                language TEXT,
2678                content_hash TEXT,
2679                content_bytes INTEGER,
2680                line_count INTEGER,
2681                indexed_at TEXT
2682            );
2683            CREATE TABLE symbols (
2684                symbol_id TEXT PRIMARY KEY,
2685                file_id TEXT,
2686                path TEXT NOT NULL,
2687                language TEXT,
2688                name TEXT NOT NULL,
2689                kind TEXT NOT NULL,
2690                signature TEXT,
2691                doc_comment TEXT,
2692                visibility TEXT,
2693                parent_symbol_id TEXT,
2694                start_line INTEGER,
2695                start_column INTEGER,
2696                end_line INTEGER,
2697                end_column INTEGER,
2698                start_byte INTEGER,
2699                end_byte INTEGER,
2700                body_start_line INTEGER,
2701                body_start_column INTEGER,
2702                body_end_line INTEGER,
2703                body_end_column INTEGER,
2704                body_start_byte INTEGER,
2705                body_end_byte INTEGER,
2706                body_hash TEXT,
2707                semantic_group TEXT,
2708                is_test INTEGER,
2709                test_container INTEGER
2710            );
2711            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
2712            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
2713            INSERT INTO symbols VALUES (
2714                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
2715                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2716                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
2717            );
2718            INSERT INTO symbols VALUES (
2719                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
2720                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
2721                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
2722            );",
2723        )
2724        .unwrap();
2725
2726        // Exact match should return exact file, not conflate with sibling differing only by case
2727        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
2728        assert_eq!(f_lower.path, "src/payment.rs");
2729        assert_eq!(f_lower.file_id, "f2");
2730
2731        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
2732        assert_eq!(f_upper.path, "src/Payment.rs");
2733        assert_eq!(f_upper.file_id, "f1");
2734
2735        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
2736        assert_eq!(syms_lower.len(), 1);
2737        assert_eq!(syms_lower[0].file_id, "f2");
2738
2739        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
2740        assert_eq!(syms_upper.len(), 1);
2741        assert_eq!(syms_upper[0].file_id, "f1");
2742    }
2743
2744    #[test]
2745    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
2746        let dir = crate::safe_tempdir();
2747        let db_path = dir.path().join("conservative_resolution.db");
2748        let conn = open_read_write(&db_path).unwrap();
2749
2750        conn.execute_batch(
2751            "CREATE TABLE symbols (
2752                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
2753                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
2754                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
2755                start_column INTEGER, end_line INTEGER, end_column INTEGER,
2756                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2757                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2758                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2759                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2760            );
2761            CREATE TABLE relationships (
2762                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
2763                start_line INTEGER, start_column INTEGER
2764            );
2765            CREATE TABLE pending_relationships (
2766                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
2767                start_line INTEGER, start_column INTEGER,
2768                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
2769            );
2770            CREATE TABLE type_facts (
2771                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
2772            );
2773            -- Workspace struct Workspace and method Workspace::new
2774            INSERT INTO symbols VALUES
2775                ('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),
2776                ('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),
2777                ('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);
2778
2779            -- my_func calls Vec::new() (external namespace 'Vec')
2780            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
2781                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
2782        )
2783        .unwrap();
2784
2785        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
2786        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2787        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
2788
2789        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
2790        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
2791
2792        // Caller references for Workspace::new should NOT list my_func
2793        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
2794        assert!(
2795            callers.is_empty(),
2796            "Expected 0 callers for Workspace::new, got: {:?}",
2797            callers
2798        );
2799
2800        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
2801        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2802        assert!(
2803            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
2804            "my_func should not be impacted before calling Workspace::new: {:?}",
2805            blast.impacted_symbols
2806        );
2807
2808        // Now add a call to Workspace::new()
2809        conn.execute(
2810            "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')",
2811            [],
2812        )
2813        .unwrap();
2814
2815        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
2816        assert_eq!(
2817            sigs2.len(),
2818            1,
2819            "Expected 1 signature for Workspace::new, got: {:?}",
2820            sigs2
2821        );
2822        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
2823
2824        // Blast radius for Workspace::new should now include my_func
2825        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
2826        assert!(
2827            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
2828            "my_func should be impacted after calling Workspace::new: {:?}",
2829            blast2.impacted_symbols
2830        );
2831
2832        // Add a bare call to new() from an unrelated caller s_other
2833        conn.execute(
2834            "INSERT INTO symbols VALUES
2835                ('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);",
2836            [],
2837        )
2838        .unwrap();
2839        conn.execute(
2840            "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')",
2841            [],
2842        )
2843        .unwrap();
2844
2845        // Bare call from unrelated function should NOT resolve to Workspace::new
2846        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
2847        assert!(
2848            sigs_other.is_empty(),
2849            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
2850            sigs_other
2851        );
2852
2853        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
2854        conn.execute(
2855            "INSERT INTO symbols VALUES
2856                ('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);",
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_ws_helper', 'new', 'calls', 'src/workspace.rs', 6, 8, NULL, NULL, 'new')",
2862            [],
2863        )
2864        .unwrap();
2865
2866        let sigs_sibling =
2867            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
2868        assert_eq!(
2869            sigs_sibling.len(),
2870            1,
2871            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
2872            sigs_sibling
2873        );
2874
2875        // With include_external: true, external calls should be returned
2876        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
2877        assert!(
2878            ext_sigs.iter().any(|s| s.contains("Vec")),
2879            "include_external: true should include external Vec::new: {:?}",
2880            ext_sigs
2881        );
2882    }
2883
2884    #[test]
2885    fn test_find_structural_facts_and_literals_scoped() {
2886        let dir = crate::safe_tempdir();
2887        let db_path = dir.path().join("facts_test.db");
2888        let conn = open_read_write(&db_path).unwrap();
2889        conn.execute_batch(
2890            "CREATE TABLE symbols (
2891                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
2892                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
2893                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2894                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
2895                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
2896                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
2897                semantic_group TEXT, is_test INTEGER, test_container INTEGER
2898            );
2899            CREATE TABLE structural_facts (
2900                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2901                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
2902                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
2903            );
2904            CREATE TABLE literals (
2905                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
2906                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
2907                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
2908                start_byte INTEGER, end_byte INTEGER
2909            );
2910            INSERT INTO structural_facts VALUES
2911                ('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\"}'),
2912                ('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\"}'),
2913                ('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\"}'),
2914                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'db.sql.select', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
2915                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'orm.model.entity', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
2916                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
2917            INSERT INTO literals VALUES
2918                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
2919                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
2920                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
2921                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
2922        )
2923        .unwrap();
2924
2925        // 1. "config" alias
2926        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
2927        assert_eq!(facts_config.len(), 2);
2928        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
2929        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
2930        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
2931        assert_eq!(
2932            facts_config[1].key.as_deref(),
2933            Some("mcp_servers.code-kb.command")
2934        );
2935        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
2936        assert_eq!(lits_config.len(), 1);
2937        assert_eq!(lits_config[0].kind, "toml_key");
2938
2939        // 2. "route" and "routes" aliases
2940        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
2941        assert_eq!(facts_route.len(), 1);
2942        assert_eq!(facts_route[0].pattern_id, "http.route.v1");
2943        assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
2944        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
2945        assert_eq!(facts_routes.len(), 1);
2946        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
2947        assert_eq!(lits_route.len(), 1);
2948        assert_eq!(lits_route[0].kind, "http_route");
2949
2950        // 3. "query", "queries", "sql" aliases
2951        for q in &["query", "queries", "sql"] {
2952            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
2953            assert_eq!(facts.len(), 1, "Failed for {}", q);
2954            assert_eq!(facts[0].pattern_id, "db.sql.select");
2955            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
2956            assert_eq!(lits.len(), 1, "Failed for {}", q);
2957            assert_eq!(lits[0].kind, "sql_query");
2958        }
2959
2960        // 4. "model" and "models" aliases
2961        for m in &["model", "models"] {
2962            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
2963            assert_eq!(facts.len(), 1, "Failed for {}", m);
2964            assert_eq!(facts[0].pattern_id, "orm.model.entity");
2965            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
2966            assert_eq!(lits.len(), 1, "Failed for {}", m);
2967            assert_eq!(lits[0].kind, "model_table");
2968        }
2969
2970        // 5. Custom / unknown category
2971        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
2972        assert_eq!(facts_custom.len(), 1);
2973        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
2974        assert_eq!(facts_custom[0].key, None);
2975
2976        // 6. Path filter: exact file match
2977        let facts_exact =
2978            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
2979        assert_eq!(facts_exact.len(), 1);
2980        let facts_miss =
2981            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
2982        assert_eq!(facts_miss.len(), 0);
2983
2984        // 7. Path filter: directory prefix
2985        let facts_dir =
2986            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
2987        assert_eq!(facts_dir.len(), 1);
2988        let facts_dir_miss =
2989            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
2990        assert_eq!(facts_dir_miss.len(), 0);
2991
2992        // 8. Delegating find_structural_facts and find_literals
2993        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
2994        assert_eq!(f_del.len(), 2);
2995        let l_del = find_literals(&conn, "config", 10).unwrap();
2996        assert_eq!(l_del.len(), 1);
2997    }
2998
2999    fn local_variable_fixture() -> Connection {
3000        let conn = Connection::open_in_memory().unwrap();
3001        conn.execute_batch(
3002            "CREATE TABLE symbols (
3003                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3004                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
3005                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
3006                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
3007                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
3008                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
3009                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
3010            );
3011            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
3012                                 parent_symbol_id, start_line, start_column, end_line, end_column,
3013                                 start_byte, end_byte, is_test, test_container)
3014            VALUES
3015                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
3016                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
3017                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3018                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
3019                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
3020                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
3021                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3022                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
3023                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3024                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
3025                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
3026                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
3027                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
3028                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
3029        )
3030        .unwrap();
3031        conn
3032    }
3033
3034    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
3035        let mut stmt = conn
3036            .prepare(
3037                "SELECT s.symbol_id FROM symbols_fts f
3038                 JOIN symbols s ON s.rowid = f.rowid
3039                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
3040            )
3041            .unwrap();
3042        let mut ids = stmt
3043            .query_map(params![query], |row| row.get::<_, String>(0))
3044            .unwrap()
3045            .collect::<Result<Vec<_>, _>>()
3046            .unwrap();
3047        ids.sort();
3048        ids
3049    }
3050
3051    #[test]
3052    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
3053        let conn = local_variable_fixture();
3054        conn.execute_batch(
3055            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
3056                name, signature, doc_comment,
3057                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
3058            );
3059            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
3060            SELECT rowid, name, signature, doc_comment FROM symbols;",
3061        )
3062        .unwrap();
3063
3064        ensure_fts_index(&conn).unwrap();
3065
3066        assert_eq!(
3067            matched_symbol_ids(&conn, "sqlite"),
3068            vec!["field", "func", "global", "pool"]
3069        );
3070    }
3071
3072    #[test]
3073    fn lookup_excludes_locals_and_parameters() {
3074        let conn = local_variable_fixture();
3075
3076        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
3077            .unwrap()
3078            .into_iter()
3079            .map(|s| s.symbol_id)
3080            .collect();
3081
3082        assert!(!ids.contains(&"local".to_string()));
3083        assert!(!ids.contains(&"nested".to_string()));
3084        assert!(ids.contains(&"field".to_string()));
3085        assert!(ids.contains(&"global".to_string()));
3086    }
3087
3088    #[test]
3089    fn search_excludes_locals_and_parameters() {
3090        let conn = local_variable_fixture();
3091        ensure_fts_index(&conn).unwrap();
3092
3093        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
3094            .unwrap()
3095            .into_iter()
3096            .map(|r| r.symbol.symbol_id)
3097            .collect();
3098
3099        assert!(!ids.contains(&"local".to_string()));
3100        assert!(ids.contains(&"func".to_string()));
3101    }
3102
3103    #[test]
3104    fn variable_kind_search_keeps_full_text_matching() {
3105        let conn = local_variable_fixture();
3106        ensure_fts_index(&conn).unwrap();
3107
3108        let ids: Vec<String> = fts_search_symbols_scoped(
3109            &conn,
3110            "sqlite connection",
3111            Some("variable"),
3112            None,
3113            false,
3114            10,
3115        )
3116        .unwrap()
3117        .into_iter()
3118        .map(|r| r.symbol.symbol_id)
3119        .collect();
3120
3121        assert!(ids.contains(&"global".to_string()));
3122        assert!(ids.contains(&"field".to_string()));
3123    }
3124
3125    #[test]
3126    fn qualified_lookup_returns_the_named_local_variable() {
3127        let conn = local_variable_fixture();
3128
3129        let ids: Vec<String> =
3130            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
3131                .unwrap()
3132                .into_iter()
3133                .map(|s| s.symbol_id)
3134                .collect();
3135
3136        assert_eq!(ids, vec!["local".to_string()]);
3137    }
3138
3139    #[test]
3140    fn variable_kind_filter_returns_locals_and_parameters() {
3141        let conn = local_variable_fixture();
3142        ensure_fts_index(&conn).unwrap();
3143
3144        let lookup_ids: Vec<String> =
3145            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3146                .unwrap()
3147                .into_iter()
3148                .map(|s| s.symbol_id)
3149                .collect();
3150        assert!(lookup_ids.contains(&"local".to_string()));
3151        assert!(lookup_ids.contains(&"nested".to_string()));
3152
3153        let search_ids: Vec<String> =
3154            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
3155                .unwrap()
3156                .into_iter()
3157                .map(|r| r.symbol.symbol_id)
3158                .collect();
3159        assert!(search_ids.contains(&"local".to_string()));
3160    }
3161}