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