Skip to main content

code_kb_core/
queries.rs

1use rusqlite::{Connection, Row, ToSql, params};
2use rust_stemmers::{Algorithm, Stemmer};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6use crate::db::local_variable_predicate;
7use crate::models::{
8    BlastRadiusResult, FileFact, ImpactedSymbol, LiteralFact, ReferenceSite, SearchExplain,
9    StructuralFact, Symbol, SymbolSearchResult, TestTarget, TypeFact,
10};
11
12#[derive(Debug, Error)]
13pub enum QueryError {
14    #[error("Database query error: {0}")]
15    Sqlite(#[from] rusqlite::Error),
16    #[error("Symbol '{name}' not found in {workspace}. {hint}")]
17    SymbolNotFound {
18        name: String,
19        workspace: String,
20        hint: String,
21    },
22    #[error(
23        "Ambiguous symbol '{0}': found {1} matching candidates. Specify file_path or qualified name to disambiguate:\n{2}"
24    )]
25    AmbiguousSymbol(String, usize, String),
26    #[error("Invalid direction '{0}': must be 'callers' or 'callees'")]
27    InvalidDirection(String),
28    #[error("Result limit must be between 0 and {MAX_RESULT_LIMIT}, got {0}")]
29    InvalidResultLimit(usize),
30}
31
32pub const MAX_RESULT_LIMIT: usize = 200;
33
34pub fn validate_result_limit(limit: usize) -> Result<(), QueryError> {
35    if limit > MAX_RESULT_LIMIT {
36        return Err(QueryError::InvalidResultLimit(limit));
37    }
38    Ok(())
39}
40
41fn map_symbol(row: &Row) -> rusqlite::Result<Symbol> {
42    Ok(Symbol {
43        symbol_id: row.get("symbol_id")?,
44        file_id: row.get("file_id")?,
45        path: row.get::<_, String>("path")?.replace('\\', "/"),
46        language: row.get("language")?,
47        name: row.get("name")?,
48        kind: row.get("kind")?,
49        signature: row.get("signature")?,
50        doc_comment: row.get("doc_comment")?,
51        visibility: row.get("visibility")?,
52        parent_symbol_id: row.get("parent_symbol_id")?,
53        start_line: row.get::<_, i64>("start_line")? as usize,
54        start_column: row.get::<_, i64>("start_column")? as usize,
55        end_line: row.get::<_, i64>("end_line")? as usize,
56        end_column: row.get::<_, i64>("end_column")? as usize,
57        start_byte: row.get::<_, i64>("start_byte")? as usize,
58        end_byte: row.get::<_, i64>("end_byte")? as usize,
59        body_start_line: row
60            .get::<_, Option<i64>>("body_start_line")?
61            .map(|v| v as usize),
62        body_start_column: row
63            .get::<_, Option<i64>>("body_start_column")?
64            .map(|v| v as usize),
65        body_end_line: row
66            .get::<_, Option<i64>>("body_end_line")?
67            .map(|v| v as usize),
68        body_end_column: row
69            .get::<_, Option<i64>>("body_end_column")?
70            .map(|v| v as usize),
71        body_start_byte: row
72            .get::<_, Option<i64>>("body_start_byte")?
73            .map(|v| v as usize),
74        body_end_byte: row
75            .get::<_, Option<i64>>("body_end_byte")?
76            .map(|v| v as usize),
77        body_hash: row.get("body_hash")?,
78        semantic_group: row.get("semantic_group")?,
79        is_test: row.get::<_, i64>("is_test")? != 0,
80        test_container: row.get::<_, i64>("test_container")? != 0,
81    })
82}
83
84pub(crate) fn escape_like(value: &str) -> String {
85    value
86        .replace('\\', "\\\\")
87        .replace('%', "\\%")
88        .replace('_', "\\_")
89}
90
91/// Retrieve indexed files optionally scoped by path filter, pushed down to SQLite.
92pub fn load_scoped_files(
93    conn: &Connection,
94    path_filter: Option<&str>,
95) -> Result<Vec<FileFact>, QueryError> {
96    let norm = path_filter
97        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
98        .filter(|p| !p.is_empty());
99    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
100    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
101    let prefix_bs = norm_bs
102        .as_ref()
103        .map(|path| format!("{}\\\\%", escape_like(path)));
104
105    let sql = "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
106               FROM files
107               WHERE (:path IS NULL
108                  OR path = :path COLLATE NOCASE
109                  OR path = :path_bs COLLATE NOCASE
110                  OR path LIKE :path_prefix ESCAPE '\\'
111                  OR path LIKE :path_prefix_bs ESCAPE '\\')
112               ORDER BY (:path IS NOT NULL AND (path = :path OR path = :path_bs)) DESC, path ASC";
113
114    let mut stmt = conn.prepare(sql)?;
115    let files = stmt
116        .query_map(
117            rusqlite::named_params! {
118                ":path": norm.as_deref(),
119                ":path_bs": norm_bs.as_deref(),
120                ":path_prefix": prefix.as_deref(),
121                ":path_prefix_bs": prefix_bs.as_deref(),
122            },
123            |row| {
124                Ok(FileFact {
125                    file_id: row.get(0)?,
126                    path: row.get::<_, String>(1)?.replace('\\', "/"),
127                    language: row.get(2)?,
128                    content_hash: row.get(3)?,
129                    content_bytes: row.get(4)?,
130                    line_count: row.get(5)?,
131                    indexed_at: row.get(6)?,
132                })
133            },
134        )?
135        .collect::<Result<Vec<_>, _>>()?;
136
137    Ok(files)
138}
139
140/// Load up to `limit_per_file` symbols per file for scoped files, directly aggregated in SQLite.
141/// Files deeper than `depth` are filtered out in SQLite to keep memory strictly bounded.
142pub fn load_scoped_outline_symbols(
143    conn: &Connection,
144    path_filter: Option<&str>,
145    depth: usize,
146    limit_per_file: usize,
147) -> Result<HashMap<String, Vec<Symbol>>, QueryError> {
148    let norm = path_filter
149        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
150        .filter(|p| !p.is_empty());
151    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
152    let prefix = norm.as_ref().map(|path| format!("{}/%", escape_like(path)));
153    let prefix_bs = norm_bs
154        .as_ref()
155        .map(|path| format!("{}\\\\%", escape_like(path)));
156
157    let max_slashes = match &norm {
158        None => {
159            if depth > 0 {
160                (depth - 1) as i64
161            } else {
162                0
163            }
164        }
165        Some(f) => {
166            let filter_slashes = f.chars().filter(|&c| c == '/').count();
167            (filter_slashes + depth) as i64
168        }
169    };
170
171    let sql = "
172        WITH bounded_files AS (
173            SELECT path FROM files
174            WHERE (:path IS NULL
175               OR path = :path COLLATE NOCASE
176               OR path = :path_bs COLLATE NOCASE
177               OR path LIKE :path_prefix ESCAPE '\\'
178               OR path LIKE :path_prefix_bs ESCAPE '\\')
179            ORDER BY path ASC
180            LIMIT 1000
181        ),
182        ranked AS (
183            SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
184                   s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
185                   s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
186                   s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
187                   s.is_test, s.test_container,
188                   ROW_NUMBER() OVER (PARTITION BY s.path ORDER BY s.start_line ASC) as rn
189            FROM symbols s
190            JOIN bounded_files bf ON (s.path = bf.path COLLATE NOCASE OR replace(s.path, '\\', '/') = replace(bf.path, '\\', '/') COLLATE NOCASE)
191            WHERE (length(s.path) - length(replace(replace(s.path, '/', ''), '\\', '')) <= :max_slashes)
192              AND s.kind IN ('function', 'method', 'struct', 'enum', 'trait', 'class', 'interface', 'type')
193              AND s.parent_symbol_id IS NULL
194        )
195        SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
196               visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
197               start_byte, end_byte, body_start_line, body_start_column, body_end_line,
198               body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
199               is_test, test_container
200        FROM ranked
201        WHERE rn <= :limit
202        ORDER BY path ASC, start_line ASC
203    ";
204
205    let mut stmt = conn.prepare(sql)?;
206    let mut rows = stmt.query(rusqlite::named_params! {
207        ":path": norm.as_deref(),
208        ":path_bs": norm_bs.as_deref(),
209        ":path_prefix": prefix.as_deref(),
210        ":path_prefix_bs": prefix_bs.as_deref(),
211        ":max_slashes": max_slashes,
212        ":limit": limit_per_file as i64,
213    })?;
214
215    let mut symbols_by_file: HashMap<String, Vec<Symbol>> = HashMap::new();
216    while let Some(row) = rows.next()? {
217        let sym = map_symbol(row)?;
218        symbols_by_file
219            .entry(sym.path.clone())
220            .or_default()
221            .push(sym);
222    }
223
224    Ok(symbols_by_file)
225}
226
227/// The largest number of files one answer is measured against.
228pub const BASELINE_PATH_CAP: usize = 20;
229
230/// Sums the indexed byte size of up to `BASELINE_PATH_CAP` distinct paths.
231/// Returns 0 when no path is indexed, so telemetry never fails a tool call.
232pub fn file_sizes_for_paths(conn: &Connection, paths: &[String]) -> usize {
233    let mut seen = std::collections::HashSet::new();
234    let distinct: Vec<&String> = paths
235        .iter()
236        .filter(|path| seen.insert(path.as_str()))
237        .take(BASELINE_PATH_CAP)
238        .collect();
239    if distinct.is_empty() {
240        return 0;
241    }
242
243    let placeholders = vec!["?"; distinct.len()].join(", ");
244    let sql =
245        format!("SELECT COALESCE(SUM(content_bytes), 0) FROM files WHERE path IN ({placeholders})");
246    conn.query_row(&sql, rusqlite::params_from_iter(distinct), |row| {
247        row.get::<_, i64>(0)
248    })
249    .map(|bytes| bytes.max(0) as usize)
250    .unwrap_or(0)
251}
252
253/// Lookup single file metadata by path with slash-boundary matching.
254pub fn get_file(conn: &Connection, path: &str) -> Result<Option<FileFact>, QueryError> {
255    let normalized = path.replace('\\', "/");
256    let backslash = path.replace('/', "\\");
257
258    // Check exact path match first, prioritizing exact case before case-insensitive fallback
259    let mut stmt = conn.prepare(
260        "SELECT file_id, path, language, content_hash, content_bytes, line_count, indexed_at
261         FROM files
262         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
263         ORDER BY (path = ?1 OR path = ?2) DESC
264         LIMIT 1",
265    )?;
266
267    let mut rows = stmt.query(params![normalized, backslash])?;
268    if let Some(row) = rows.next()? {
269        Ok(Some(FileFact {
270            file_id: row.get(0)?,
271            path: row.get::<_, String>(1)?.replace('\\', "/"),
272            language: row.get(2)?,
273            content_hash: row.get(3)?,
274            content_bytes: row.get(4)?,
275            line_count: row.get(5)?,
276            indexed_at: row.get(6)?,
277        }))
278    } else {
279        Ok(None)
280    }
281}
282
283/// Count parse diagnostics recorded for a file, returning 0 when the index has none.
284pub fn count_parse_diagnostics(conn: &Connection, path: &str) -> usize {
285    conn.query_row(
286        "SELECT COUNT(*) FROM parse_diagnostics
287         WHERE path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE",
288        params![path.replace('\\', "/"), path.replace('/', "\\")],
289        |row| row.get::<_, i64>(0),
290    )
291    .map(|count| count as usize)
292    .unwrap_or(0)
293}
294
295/// Count files julie could not parse under a path, returning 0 when the index has none.
296pub fn count_unsupported_files(conn: &Connection, path_filter: Option<&str>) -> usize {
297    let norm = path_filter
298        .map(|p| p.replace('\\', "/").trim_matches('/').to_string())
299        .filter(|p| !p.is_empty());
300    let norm_bs = norm.as_ref().map(|p| p.replace('/', "\\"));
301    let prefix = norm.as_ref().map(|p| format!("{}/%", escape_like(p)));
302    let prefix_bs = norm_bs.as_ref().map(|p| format!("{}\\\\%", escape_like(p)));
303
304    conn.query_row(
305        "SELECT COUNT(*) FROM files
306         WHERE status = 'unsupported'
307           AND (:path IS NULL
308             OR path = :path COLLATE NOCASE
309             OR path = :path_bs COLLATE NOCASE
310             OR path LIKE :path_prefix ESCAPE '\\'
311             OR path LIKE :path_prefix_bs ESCAPE '\\')",
312        rusqlite::named_params! {
313            ":path": norm.as_deref(),
314            ":path_bs": norm_bs.as_deref(),
315            ":path_prefix": prefix.as_deref(),
316            ":path_prefix_bs": prefix_bs.as_deref(),
317        },
318        |row| row.get::<_, i64>(0),
319    )
320    .map(|count| count as usize)
321    .unwrap_or(0)
322}
323
324/// Load all symbols declared inside a specific file.
325pub fn load_file_symbols(conn: &Connection, file_path: &str) -> Result<Vec<Symbol>, QueryError> {
326    // Normalizing slashes for path matching
327    let normalized = file_path.replace('\\', "/");
328    let backslash = file_path.replace('/', "\\");
329
330    // Try exact case matching first to avoid conflating sibling files on case-sensitive filesystems
331    let mut stmt = conn.prepare(
332        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
333                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
334                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
335                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
336                is_test, test_container
337         FROM symbols
338         WHERE (path = ?1 OR path = ?2)
339         ORDER BY start_line ASC, start_column ASC",
340    )?;
341
342    let rows = stmt
343        .query_map(params![&normalized, &backslash], map_symbol)?
344        .collect::<Result<Vec<_>, _>>()?;
345
346    if !rows.is_empty() {
347        return Ok(rows);
348    }
349
350    // Fall back to case-insensitive match (for Windows or case-variant requests)
351    let mut stmt = conn.prepare(
352        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
353                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
354                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
355                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
356                is_test, test_container
357         FROM symbols
358         WHERE (path = ?1 COLLATE NOCASE OR path = ?2 COLLATE NOCASE)
359         ORDER BY start_line ASC, start_column ASC",
360    )?;
361
362    let rows = stmt
363        .query_map(params![normalized, backslash], map_symbol)?
364        .collect::<Result<Vec<_>, _>>()?;
365
366    Ok(rows)
367}
368
369/// Normalizes common symbol kind aliases to their canonical database representation.
370pub fn normalize_kind(kind: &str) -> String {
371    let lower = kind.trim().to_lowercase();
372    match lower.as_str() {
373        "fn" | "func" | "function" => "function".to_string(),
374        "method" => "method".to_string(),
375        "struct" => "struct".to_string(),
376        "class" => "class".to_string(),
377        "enum" => "enum".to_string(),
378        "trait" => "trait".to_string(),
379        "interface" => "interface".to_string(),
380        "type" | "typedef" => "type".to_string(),
381        "mod" | "module" => "module".to_string(),
382        "const" | "constant" => "constant".to_string(),
383        "var" | "variable" => "variable".to_string(),
384        _ => lower,
385    }
386}
387
388/// Search symbols by name query, kind filter, and test flag.
389pub fn search_symbols(
390    conn: &Connection,
391    query: &str,
392    kind_filter: Option<&str>,
393    include_tests: bool,
394    limit: usize,
395) -> Result<Vec<Symbol>, QueryError> {
396    search_symbols_scoped(conn, query, kind_filter, None, include_tests, limit)
397}
398
399/// Search symbols with optional path scoping filter. Locals and parameters are left out unless
400/// the caller passes `kind = "variable"` or names one explicitly as a qualified name such as
401/// `open_conn::conn`. With `kind = "variable"` they match by name only, because they are not in
402/// the full-text index.
403pub fn search_symbols_scoped(
404    conn: &Connection,
405    query: &str,
406    kind_filter: Option<&str>,
407    path_filter: Option<&str>,
408    include_tests: bool,
409    limit: usize,
410) -> Result<Vec<Symbol>, QueryError> {
411    validate_result_limit(limit)?;
412    if limit == 0 {
413        return Ok(Vec::new());
414    }
415    let norm_kind = kind_filter.map(normalize_kind);
416    if (query.contains("::") || query.contains('.'))
417        && let Some(sym) = get_symbol_by_name(conn, query, path_filter)?
418    {
419        let kind_matches = norm_kind.as_deref().is_none_or(|kind| sym.kind == kind);
420        return Ok(if kind_matches { vec![sym] } else { Vec::new() });
421    }
422
423    let pattern = format!("%{}%", escape_like(query));
424    let normalized_path = path_filter.map(|p| {
425        p.replace('\\', "/")
426            .trim_start_matches("./")
427            .trim_matches('/')
428            .to_string()
429    });
430    let escaped_path = normalized_path.as_deref().map(escape_like);
431
432    let mut sql = String::from(
433        "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
434                visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
435                start_byte, end_byte, body_start_line, body_start_column, body_end_line,
436                body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
437                is_test, test_container
438         FROM symbols s
439         WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
440           AND (:kind IS NULL OR kind = :kind)
441           AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
442    );
443
444    if norm_kind.as_deref() != Some("variable") {
445        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
446    }
447
448    if !include_tests {
449        sql.push_str(&format!(
450            " AND (name = :query OR (is_test = 0 AND test_container = 0 AND NOT {}))",
451            test_path_predicate("s")
452        ));
453    }
454
455    sql.push_str(
456        " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
457    );
458    sql.push_str(&limit.to_string());
459
460    let mut stmt = conn.prepare(&sql)?;
461
462    let path_val = normalized_path.as_deref();
463    let path_like = escaped_path.as_deref();
464    let kind_val = norm_kind.as_deref();
465    let rows = stmt
466        .query_map(
467            rusqlite::named_params! {
468                ":query": query,
469                ":pattern": pattern,
470                ":kind": kind_val,
471                ":path": path_val,
472                ":path_like": path_like,
473            },
474            map_symbol,
475        )?
476        .collect::<Result<Vec<_>, _>>()?;
477
478    Ok(rows)
479}
480
481/// Sanitizes a free-form user query into `(and_query, or_query)` formatted for SQLite FTS5.
482/// Each alphanumeric/underscore token is quoted and given a prefix wildcard: `"token"*`.
483/// Identifiers are split at case boundaries too (`parseHTTPResponse` -> `parse HTTP Response`),
484/// so a camelCase query finds a snake_case symbol. Each split identifier keeps its unsplit form
485/// as an alternative inside its own required term, so a camelCase symbol, which FTS5 indexes as
486/// one token, still matches. A query of two or three words also tries their concatenation.
487/// English stop words are dropped unless the whole query is stop words, and tokens under three
488/// characters get no prefix wildcard.
489pub fn sanitize_fts5_query(query: &str) -> (String, String) {
490    let raw_words = query_words(query);
491    let split: Vec<(Vec<&str>, Option<&str>)> = raw_words
492        .iter()
493        .map(|raw| {
494            let parts = split_identifier(raw);
495            let whole = (parts.len() > 1).then_some(*raw);
496            (parts, whole)
497        })
498        .collect();
499    let any_content = split
500        .iter()
501        .any(|(parts, _)| parts.iter().any(|p| !is_stop_word(p)));
502
503    let mut and_groups: Vec<String> = Vec::new();
504    let mut or_terms: Vec<String> = Vec::new();
505    for (parts, whole) in split {
506        let parts: Vec<String> = parts
507            .into_iter()
508            .filter(|p| !any_content || !is_stop_word(p))
509            .map(fts5_term)
510            .collect();
511        let whole = whole.map(fts5_term);
512        let group = match (parts.is_empty(), whole.as_deref()) {
513            (true, None) => continue,
514            (true, Some(w)) => w.to_string(),
515            (false, None) => parts.join(" "),
516            (false, Some(w)) => format!("(({}) OR {w})", parts.join(" ")),
517        };
518        and_groups.push(group);
519        or_terms.extend(parts);
520        or_terms.extend(whole);
521    }
522
523    if and_groups.is_empty() {
524        return (String::new(), String::new());
525    }
526
527    let mut and_query = and_groups.join(" AND ");
528    if (2..=3).contains(&raw_words.len()) {
529        let all: String = raw_words.concat();
530        if all.len() <= 64 {
531            let all = fts5_term(&all);
532            and_query = format!("({and_query}) OR {all}");
533            or_terms.push(all);
534        }
535    }
536    (and_query, or_terms.join(" OR "))
537}
538
539const STOP_WORDS: &[&str] = &[
540    "a", "about", "after", "again", "all", "already", "also", "always", "an", "and", "another",
541    "any", "are", "as", "at", "be", "because", "been", "before", "being", "between", "both", "but",
542    "by", "can", "could", "did", "do", "does", "each", "either", "else", "ever", "every", "for",
543    "from", "had", "has", "have", "here", "how", "if", "in", "instead", "is", "it", "its",
544    "itself", "just", "many", "may", "might", "more", "most", "much", "must", "neither", "never",
545    "no", "nor", "not", "of", "on", "once", "one", "only", "or", "other", "our", "per", "rather",
546    "same", "should", "since", "so", "some", "still", "such", "than", "that", "the", "their",
547    "them", "then", "there", "these", "they", "this", "those", "through", "to", "too", "two",
548    "until", "very", "via", "was", "we", "were", "what", "when", "where", "whether", "which",
549    "while", "who", "whom", "why", "will", "with", "within", "without", "would", "yet", "you",
550    "your",
551];
552
553fn is_stop_word(word: &str) -> bool {
554    STOP_WORDS.contains(&word.to_ascii_lowercase().as_str())
555}
556
557/// Splits a query at every character that is not alphanumeric or `_`.
558fn query_words(query: &str) -> Vec<&str> {
559    query
560        .split(|c: char| !c.is_alphanumeric() && c != '_')
561        .filter(|s| !s.is_empty())
562        .collect()
563}
564
565/// Lowercase terms of three or more characters for the trigram name index: every query word
566/// and every identifier part of it (`collapse_name` -> `collapse_name`, `collapse`, `name`),
567/// deduplicated. Stop words are dropped unless every term is a stop word. Empty when no term
568/// qualifies.
569fn trigram_name_terms(query: &str) -> Vec<String> {
570    let mut terms: Vec<String> = Vec::new();
571    for word in query_words(query) {
572        for term in std::iter::once(word).chain(split_identifier(word)) {
573            let lower = term.to_lowercase();
574            if lower.chars().count() >= 3 && !terms.contains(&lower) {
575                terms.push(lower);
576            }
577        }
578    }
579    let any_content = terms.iter().any(|t| !is_stop_word(t));
580    terms.retain(|t| !any_content || !is_stop_word(t));
581    terms
582}
583
584/// Quotes one token for FTS5 with a prefix wildcard, except for tokens under three characters.
585fn fts5_term(token: &str) -> String {
586    if token.chars().count() < 3 {
587        format!("\"{token}\"")
588    } else {
589        format!("\"{token}\"*")
590    }
591}
592
593/// FTS5 query for a symbol name as typed: no case splitting, no stop words.
594/// `isReady` -> `"isReady"*`, so related-test lookup stays as strict as the name.
595fn name_prefix_query(name: &str) -> String {
596    name.split(|c: char| !c.is_alphanumeric() && c != '_')
597        .filter(|s| !s.is_empty())
598        .map(|s| format!("\"{s}\"*"))
599        .collect::<Vec<_>>()
600        .join(" ")
601}
602
603/// Splits one identifier into words at `_`, digit runs, and case boundaries.
604/// `parseHTTPResponse2` -> `["parse", "HTTP", "Response", "2"]`.
605fn split_identifier(word: &str) -> Vec<&str> {
606    let mut out = Vec::new();
607    split_identifier_into(word, &mut out);
608    out
609}
610
611/// `split_identifier` that appends to a caller-owned vector, so tokenizing a whole doc
612/// comment costs one allocation instead of one per word.
613fn split_identifier_into<'a>(word: &'a str, out: &mut Vec<&'a str>) {
614    let mut chars = word.char_indices().peekable();
615    let Some((_, first)) = chars.next() else {
616        return;
617    };
618    let mut prev = char_class(first);
619    let mut start = 0;
620    while let Some((idx, c)) = chars.next() {
621        let cur = char_class(c);
622        let next = chars.peek().map_or(OTHER, |(_, n)| char_class(*n));
623        if identifier_boundary(prev, cur, next) {
624            push_piece(out, &word[start..idx]);
625            start = idx;
626        }
627        prev = cur;
628    }
629    push_piece(out, &word[start..]);
630}
631
632const OTHER: u8 = 0;
633const UNDERSCORE: u8 = 1;
634const UPPER: u8 = 2;
635const LOWER: u8 = 3;
636const DIGIT: u8 = 4;
637
638fn char_class(c: char) -> u8 {
639    if c == '_' {
640        UNDERSCORE
641    } else if c.is_uppercase() {
642        UPPER
643    } else if c.is_lowercase() {
644        LOWER
645    } else if c.is_ascii_digit() {
646        DIGIT
647    } else {
648        OTHER
649    }
650}
651
652fn byte_class(b: u8) -> u8 {
653    match b {
654        b'_' => UNDERSCORE,
655        b'A'..=b'Z' => UPPER,
656        b'a'..=b'z' => LOWER,
657        b'0'..=b'9' => DIGIT,
658        _ => OTHER,
659    }
660}
661
662/// The identifier split rule over character classes: `_` on either side, lower/digit to
663/// upper, the last upper of an acronym before a lower (`HTTPResponse`), and digit runs.
664fn identifier_boundary(prev: u8, cur: u8, next: u8) -> bool {
665    cur == UNDERSCORE
666        || prev == UNDERSCORE
667        || (cur == UPPER && (prev == LOWER || prev == DIGIT))
668        || (cur == UPPER && prev == UPPER && next == LOWER)
669        || ((cur == DIGIT) != (prev == DIGIT))
670}
671
672fn push_piece<'a>(out: &mut Vec<&'a str>, piece: &'a str) {
673    if !piece.is_empty() && piece != "_" {
674        out.push(piece);
675    }
676}
677
678/// Tokens of a signature or doc comment: the text split at non-word characters, each word
679/// split like an identifier. ASCII text is walked byte by byte in one pass; other text takes
680/// the char path with the same boundary rule.
681fn text_tokens_into<'a>(text: &'a str, out: &mut Vec<&'a str>) {
682    if !text.is_ascii() {
683        for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
684            split_identifier_into(word, out);
685        }
686        return;
687    }
688    let bytes = text.as_bytes();
689    let mut start: Option<usize> = None;
690    let mut prev = OTHER;
691    for (i, &b) in bytes.iter().enumerate() {
692        let cur = byte_class(b);
693        if cur == OTHER {
694            if let Some(s) = start.take() {
695                push_piece(out, &text[s..i]);
696            }
697            continue;
698        }
699        match start {
700            None => start = Some(i),
701            Some(s) => {
702                let next = bytes.get(i + 1).map_or(OTHER, |n| byte_class(*n));
703                if identifier_boundary(prev, cur, next) {
704                    push_piece(out, &text[s..i]);
705                    start = Some(i);
706                }
707            }
708        }
709        prev = cur;
710    }
711    if let Some(s) = start {
712        push_piece(out, &text[s..]);
713    }
714}
715
716/// One admitted search row with the recall branches that reached it.
717/// `result.score` is the word-branch BM25 for word rows and `0.0` otherwise until the
718/// rerank replaces it.
719pub(crate) struct Candidate {
720    pub result: SymbolSearchResult,
721    pub bm25: Option<f64>,
722    pub exact_name: bool,
723    pub word_match: bool,
724    pub name_match: bool,
725    pub name_terms: Vec<String>,
726    pub documentation: bool,
727}
728
729fn candidate_columns(conn: &Connection) -> String {
730    format!(
731        "s.rowid AS row_id, s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind,
732                s.signature, s.doc_comment, s.visibility, s.parent_symbol_id, s.start_line,
733                s.start_column, s.end_line, s.end_column, s.start_byte, s.end_byte,
734                s.body_start_line, s.body_start_column, s.body_end_line, s.body_end_column,
735                s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group, s.is_test,
736                s.test_container,
737                (s.language IN ({doc_langs}) OR NOT ({not_doc})) AS documentation",
738        doc_langs = documentation_language_list(),
739        not_doc = not_documentation(conn, "s")
740    )
741}
742
743fn candidate_filters(searching_variables: bool, include_tests: bool) -> String {
744    let mut sql = String::from(
745        " AND (:kind IS NULL OR s.kind = :kind)
746          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 '\\')",
747    );
748    if !searching_variables {
749        sql.push_str(&format!(" AND NOT {}", local_variable_predicate("s")));
750    }
751    if !include_tests {
752        // Unary `+` keeps the planner off the test-flag indexes: without ANALYZE statistics it
753        // would otherwise prefer them over the name index and walk nearly every row.
754        sql.push_str(" AND +s.is_test = 0 AND +s.test_container = 0");
755        sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
756    }
757    sql
758}
759
760/// Runs the word, trigram-name, and exact-name branches with the same filters and merges
761/// them by `rowid`. The word branch admits the rows that match every query word first and
762/// then fills its cap with rows that match any word, so one full match never hides a
763/// better partial one. Word BM25 exists only for word rows and never orders across branches.
764/// A `variable` kind filter adds locals and parameters by name, because the FTS tables
765/// exclude them.
766pub(crate) fn collect_search_candidates(
767    conn: &Connection,
768    query: &str,
769    kind_filter: Option<&str>,
770    path_filter: Option<&str>,
771    include_tests: bool,
772    limit: usize,
773) -> Result<Vec<Candidate>, QueryError> {
774    let (and_q, or_q) = sanitize_fts5_query(query);
775    let terms = trigram_name_terms(query);
776    let normalized_path = path_filter.map(|p| {
777        p.replace('\\', "/")
778            .trim_start_matches("./")
779            .trim_matches('/')
780            .to_string()
781    });
782    let escaped_path = normalized_path.as_deref().map(escape_like);
783    let norm_kind = kind_filter.map(normalize_kind);
784    let searching_variables = norm_kind.as_deref() == Some("variable");
785    let path_val = normalized_path.as_deref();
786    let path_like = escaped_path.as_deref();
787    let kind_val = norm_kind.as_deref();
788    let columns = candidate_columns(conn);
789    let filters = candidate_filters(searching_variables, include_tests);
790    let word_cap = (limit * 4).clamp(40, 160);
791    let name_cap = (limit * 2).clamp(20, 40);
792
793    let new_candidate = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
794        let symbol = map_symbol(row)?;
795        let lower_name = symbol.name.to_lowercase();
796        let name_terms = terms
797            .iter()
798            .filter(|t| lower_name.contains(t.as_str()))
799            .cloned()
800            .collect();
801        let candidate = Candidate {
802            result: SymbolSearchResult {
803                symbol,
804                score: 0.0,
805                snippet: None,
806                explain: None,
807            },
808            bm25: None,
809            exact_name: false,
810            word_match: false,
811            name_match: false,
812            name_terms,
813            documentation: row.get::<_, Option<i64>>("documentation")? == Some(1),
814        };
815        Ok((row.get("row_id")?, candidate))
816    };
817
818    let mut candidates: Vec<Candidate> = Vec::new();
819    let mut by_rowid: HashMap<i64, usize> = HashMap::new();
820    let mut admit = |rowid: i64, incoming: Candidate| match by_rowid.get(&rowid).copied() {
821        Some(i) => {
822            let existing = &mut candidates[i];
823            existing.exact_name |= incoming.exact_name;
824            existing.word_match |= incoming.word_match;
825            existing.name_match |= incoming.name_match;
826            if incoming.bm25.is_some() && existing.bm25.is_none() {
827                existing.bm25 = incoming.bm25;
828                existing.result = incoming.result;
829            }
830        }
831        None => {
832            by_rowid.insert(rowid, candidates.len());
833            candidates.push(incoming);
834        }
835    };
836
837    let has_trigram = has_table(conn, "symbol_names_tri");
838    let exact_query = query.trim();
839    let exact_phrase = format!("\"{}\"", exact_query.replace('"', "\"\""));
840    let exact_via_trigram = has_trigram && exact_query.chars().count() >= 3;
841    let exact_sql = if exact_via_trigram {
842        format!(
843            "SELECT {columns} FROM symbol_names_tri
844             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
845             WHERE symbol_names_tri MATCH :exact AND length(s.name) = length(:query) {filters}
846             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
847        )
848    } else {
849        format!(
850            "SELECT {columns} FROM symbols s WHERE s.name = :query {filters}
851             ORDER BY s.path ASC, s.start_line ASC LIMIT {MAX_RESULT_LIMIT}"
852        )
853    };
854    let mut exact_params: Vec<(&str, &dyn ToSql)> = vec![
855        (":query", &exact_query),
856        (":kind", &kind_val),
857        (":path", &path_val),
858        (":path_like", &path_like),
859    ];
860    if exact_via_trigram {
861        exact_params.push((":exact", &exact_phrase));
862    }
863    let exact_rows = conn
864        .prepare(&exact_sql)?
865        .query_map(exact_params.as_slice(), new_candidate)?
866        .collect::<Result<Vec<_>, _>>()?;
867    for (rowid, mut candidate) in exact_rows {
868        candidate.exact_name = true;
869        admit(rowid, candidate);
870    }
871
872    if searching_variables {
873        let pattern = format!("%{}%", escape_like(exact_query));
874        let local_sql = format!(
875            "SELECT {columns} FROM symbols s
876             WHERE {local} AND (s.name = :query OR s.name LIKE :pattern ESCAPE '\\') {filters}
877             ORDER BY (s.name = :query) DESC, length(s.name) ASC, s.path ASC LIMIT {limit}",
878            local = local_variable_predicate("s")
879        );
880        let local_rows = conn
881            .prepare(&local_sql)?
882            .query_map(
883                rusqlite::named_params! {
884                    ":query": exact_query,
885                    ":pattern": pattern,
886                    ":kind": kind_val,
887                    ":path": path_val,
888                    ":path_like": path_like,
889                },
890                new_candidate,
891            )?
892            .collect::<Result<Vec<_>, _>>()?;
893        for (rowid, mut candidate) in local_rows {
894            candidate.exact_name = candidate.result.symbol.name == exact_query;
895            candidate.name_match = true;
896            admit(rowid, candidate);
897        }
898    }
899
900    let word_sql = format!(
901        "SELECT {columns},
902                bm25(symbols_fts, 10.0, 5.0, 1.0) AS rank_score,
903                snippet(symbols_fts, 2, '[', ']', '...', 12) AS doc_snippet,
904                snippet(symbols_fts, 1, '[', ']', '...', 12) AS sig_snippet,
905                snippet(symbols_fts, 0, '[', ']', '...', 12) AS name_snippet
906         FROM symbols_fts
907         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
908         WHERE symbols_fts MATCH :match {filters}
909         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 {word_cap}",
910        doc_langs = documentation_language_list(),
911        not_doc = not_documentation(conn, "s")
912    );
913    let word_rows = |match_clause: &str| -> Result<Vec<(i64, Candidate)>, QueryError> {
914        let map_fn = |row: &Row| -> rusqlite::Result<(i64, Candidate)> {
915            let (rowid, mut candidate) = new_candidate(row)?;
916            let score: f64 = row.get("rank_score")?;
917            let doc_snip: Option<String> = row.get("doc_snippet").ok();
918            let sig_snip: Option<String> = row.get("sig_snippet").ok();
919            let name_snip: Option<String> = row.get("name_snippet").ok();
920            let highlighted = |s: &Option<String>| s.as_ref().is_some_and(|s| s.contains('['));
921            candidate.result.snippet = if highlighted(&doc_snip) {
922                doc_snip
923            } else if highlighted(&sig_snip) {
924                sig_snip
925            } else if highlighted(&name_snip) {
926                name_snip
927            } else {
928                doc_snip.or(sig_snip).or(name_snip)
929            };
930            candidate.result.score = score;
931            candidate.bm25 = Some(score);
932            candidate.word_match = true;
933            Ok((rowid, candidate))
934        };
935        Ok(conn
936            .prepare(&word_sql)?
937            .query_map(
938                rusqlite::named_params! {
939                    ":match": match_clause,
940                    ":query": query.trim(),
941                    ":kind": kind_val,
942                    ":path": path_val,
943                    ":path_like": path_like,
944                },
945                map_fn,
946            )?
947            .collect::<Result<Vec<_>, _>>()?)
948    };
949    if !and_q.is_empty() {
950        let and_rows = word_rows(&and_q)?;
951        let mut word_admitted: HashSet<i64> = and_rows.iter().map(|(rowid, _)| *rowid).collect();
952        for (rowid, candidate) in and_rows {
953            admit(rowid, candidate);
954        }
955        if and_q != or_q {
956            for (rowid, candidate) in word_rows(&or_q)? {
957                if word_admitted.len() >= word_cap && !word_admitted.contains(&rowid) {
958                    break;
959                }
960                word_admitted.insert(rowid);
961                admit(rowid, candidate);
962            }
963        }
964    }
965
966    if !terms.is_empty() && has_trigram {
967        let match_clause = terms
968            .iter()
969            .map(|t| format!("\"{t}\""))
970            .collect::<Vec<_>>()
971            .join(" OR ");
972        let name_sql = format!(
973            "SELECT {columns} FROM symbol_names_tri
974             CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
975             WHERE symbol_names_tri MATCH :match {filters}
976             ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT {name_cap}"
977        );
978        let name_rows = conn
979            .prepare(&name_sql)?
980            .query_map(
981                rusqlite::named_params! {
982                    ":match": match_clause,
983                    ":kind": kind_val,
984                    ":path": path_val,
985                    ":path_like": path_like,
986                },
987                new_candidate,
988            )?
989            .collect::<Result<Vec<_>, _>>()?;
990        for (rowid, mut candidate) in name_rows {
991            candidate.name_match = true;
992            admit(rowid, candidate);
993        }
994    }
995
996    Ok(candidates)
997}
998
999/// Conceptual full-text search with optional path scoping filter.
1000pub fn fts_search_symbols_scoped(
1001    conn: &Connection,
1002    query: &str,
1003    kind_filter: Option<&str>,
1004    path_filter: Option<&str>,
1005    include_tests: bool,
1006    limit: usize,
1007) -> Result<Vec<SymbolSearchResult>, QueryError> {
1008    fts_search_symbols_explained(
1009        conn,
1010        query,
1011        kind_filter,
1012        path_filter,
1013        include_tests,
1014        limit,
1015        false,
1016    )
1017}
1018
1019/// Conceptual full-text search that also attaches the rerank breakdown to every row when
1020/// `explain` is true. Without it, `explain` stays `None` on every row.
1021pub fn fts_search_symbols_explained(
1022    conn: &Connection,
1023    query: &str,
1024    kind_filter: Option<&str>,
1025    path_filter: Option<&str>,
1026    include_tests: bool,
1027    limit: usize,
1028    explain: bool,
1029) -> Result<Vec<SymbolSearchResult>, QueryError> {
1030    validate_result_limit(limit)?;
1031    if limit == 0 {
1032        return Ok(Vec::new());
1033    }
1034    let (and_q, _) = sanitize_fts5_query(query);
1035    if and_q.is_empty() {
1036        return Ok(Vec::new());
1037    }
1038
1039    let normalized_path = path_filter.map(|p| {
1040        p.replace('\\', "/")
1041            .trim_start_matches("./")
1042            .trim_matches('/')
1043            .to_string()
1044    });
1045    let norm_kind = kind_filter.map(normalize_kind);
1046    let escaped_path = normalized_path.as_deref().map(escape_like);
1047    let searching_variables = norm_kind.as_deref() == Some("variable");
1048
1049    let name_search = |local_clause: &str| -> Result<Vec<SymbolSearchResult>, QueryError> {
1050        let pattern = format!("%{}%", escape_like(query));
1051        let mut sql = String::from(
1052            "SELECT symbol_id, file_id, path, language, name, kind, signature, doc_comment,
1053                    visibility, parent_symbol_id, start_line, start_column, end_line, end_column,
1054                    start_byte, end_byte, body_start_line, body_start_column, body_end_line,
1055                    body_end_column, body_start_byte, body_end_byte, body_hash, semantic_group,
1056                    is_test, test_container
1057              FROM symbols s
1058              WHERE (name = :query OR name LIKE :pattern ESCAPE '\\')
1059                AND (:kind IS NULL OR kind = :kind)
1060                AND (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :path_like || '/%' ESCAPE '\\' OR replace(path, '\\', '/') LIKE '%/' || :path_like ESCAPE '\\')",
1061        );
1062        sql.push_str(local_clause);
1063        if !include_tests {
1064            sql.push_str(" AND is_test = 0 AND test_container = 0");
1065            sql.push_str(&format!(" AND NOT {}", test_path_predicate("s")));
1066        }
1067        sql.push_str(
1068            " ORDER BY (name = :query) DESC, (kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC, length(name) ASC, path ASC LIMIT ",
1069        );
1070        sql.push_str(&limit.to_string());
1071
1072        let mut stmt = conn.prepare(&sql)?;
1073        let path_val = normalized_path.as_deref();
1074        let path_like = escaped_path.as_deref();
1075        let kind_val = norm_kind.as_deref();
1076        let rows = stmt
1077            .query_map(
1078                rusqlite::named_params! {
1079                    ":query": query,
1080                    ":pattern": pattern,
1081                    ":kind": kind_val,
1082                    ":path": path_val,
1083                    ":path_like": path_like,
1084                },
1085                map_symbol,
1086            )?
1087            .collect::<Result<Vec<_>, _>>()?;
1088
1089        Ok(rows
1090            .into_iter()
1091            .map(|s| SymbolSearchResult {
1092                symbol: s,
1093                score: 0.0,
1094                snippet: None,
1095                explain: None,
1096            })
1097            .collect())
1098    };
1099
1100    if !has_table(conn, "symbols_fts") {
1101        let local_clause = if searching_variables {
1102            String::new()
1103        } else {
1104            format!(" AND NOT {}", local_variable_predicate("s"))
1105        };
1106        return name_search(&local_clause);
1107    }
1108
1109    let candidates =
1110        collect_search_candidates(conn, query, kind_filter, path_filter, include_tests, limit)?;
1111    let candidate_count = candidates.len();
1112    let started = std::time::Instant::now();
1113    let idf = idf_weights(conn, &rerank_words(query));
1114    let ranked = rerank_with(candidates, query, include_tests, Some(&idf));
1115    let rerank_us = started.elapsed().as_micros();
1116    Ok(ranked
1117        .into_iter()
1118        .take(limit)
1119        .map(|(mut result, mut breakdown)| {
1120            if explain {
1121                breakdown.candidates = candidate_count;
1122                breakdown.rerank_us = rerank_us;
1123                result.explain = Some(breakdown);
1124            }
1125            result
1126        })
1127        .collect())
1128}
1129
1130const W_NAME_WHOLE: f64 = 100.0;
1131const W_NAME_ALL_WORDS: f64 = 60.0;
1132const W_KIND_DEFINITION: f64 = 4.0;
1133const W_KIND_MEMBER: f64 = 0.0;
1134const W_KIND_IMPORT: f64 = -50.0;
1135const W_PATH_ROLE: f64 = -10.0;
1136const W_DOCUMENTATION_ROW: f64 = -200.0;
1137const W_TEST_INTENT: f64 = 5.0;
1138const W_TERMS: f64 = 52.0;
1139const MAX_TERM_CREDIT: f64 = 3.0;
1140const TEXT_CREDIT: f64 = 1.0;
1141const TEXT_HEAD_BYTES: usize = 400;
1142
1143/// Symbol kinds that define a body: the kinds a touched-symbol or ranking rule prefers over locals.
1144pub const DEFINITION_KINDS: &[&str] = &[
1145    "function",
1146    "method",
1147    "class",
1148    "struct",
1149    "trait",
1150    "interface",
1151    "enum",
1152    "type",
1153];
1154const MEMBER_KINDS: &[&str] = &["enum_member", "field", "property", "constant", "variable"];
1155const DEMOTED_PATH_SEGMENTS: &[&str] = &["scripts", "examples", "benchmarks", "fixtures", "vendor"];
1156const TEST_INTENT_WORDS: &[&str] = &["test", "tests", "spec", "specs"];
1157
1158struct QueryWord {
1159    word: String,
1160    stem: String,
1161}
1162
1163/// Per-word hits of one candidate: which query words its name, signature, and capped doc
1164/// cover. The name carries a match strength per word, the others a plain hit.
1165struct Hits {
1166    name: Vec<u8>,
1167    signature: Vec<bool>,
1168    doc: Vec<bool>,
1169}
1170
1171/// Symbols a query word may be counted in before it is called common: the count walks the
1172/// word's postings, so the cap bounds the cost of a word held by most of a million symbols.
1173const DF_CAP: usize = 20_000;
1174
1175/// Global rarity of each lowercase rerank term: `ln(1 + N / (df + 1))`, where `df` is the
1176/// number of indexed symbols holding the term (capped at `DF_CAP`) and `N` an upper bound on
1177/// the index size. The count is an FTS5 `MATCH`, so the term is stemmed by the index's own
1178/// tokenizer and `news` finds the rows indexed as `new`.
1179fn idf_weights(conn: &Connection, words: &[String]) -> Vec<f64> {
1180    let n = conn
1181        .query_row("SELECT max(rowid) FROM symbols", [], |r| {
1182            r.get::<_, Option<i64>>(0)
1183        })
1184        .ok()
1185        .flatten()
1186        .unwrap_or(0) as f64;
1187    let mut count = conn
1188        .prepare(&format!(
1189            "SELECT count(*) FROM (SELECT rowid FROM symbols_fts WHERE symbols_fts MATCH ?1 LIMIT {DF_CAP})"
1190        ))
1191        .ok();
1192    words
1193        .iter()
1194        .map(|word| {
1195            let df = count
1196                .as_mut()
1197                .and_then(|stmt| document_frequency(stmt, &format!("\"{word}\"")))
1198                .unwrap_or(0);
1199            (1.0 + n / (df as f64 + 1.0)).ln()
1200        })
1201        .collect()
1202}
1203
1204fn document_frequency(stmt: &mut rusqlite::Statement<'_>, term: &str) -> Option<i64> {
1205    stmt.query_row([term], |r| r.get(0)).ok()
1206}
1207
1208/// The field that credits each query term and the credit it is worth: a name whole token 3,
1209/// a name stem 2, a signature or doc hit `TEXT_CREDIT`, a name substring 1, nothing 0.
1210fn term_credits(hits: &Hits, words: &[QueryWord]) -> Vec<(String, String, f64)> {
1211    words
1212        .iter()
1213        .enumerate()
1214        .map(|(i, w)| {
1215            let (field, credit) = match hits.name[i] {
1216                3 => ("name", 3.0),
1217                2 => ("name", 2.0),
1218                _ if hits.signature[i] => ("signature", TEXT_CREDIT),
1219                _ if hits.doc[i] => ("doc", TEXT_CREDIT),
1220                1 => ("name", 1.0),
1221                _ => ("none", 0.0),
1222            };
1223            (w.word.clone(), field.to_string(), credit)
1224        })
1225        .collect()
1226}
1227
1228fn term_score(terms: &[(String, String, f64)], weights: &[f64]) -> f64 {
1229    let total: f64 = weights.iter().sum();
1230    if total == 0.0 {
1231        return 0.0;
1232    }
1233    let credited: f64 = terms
1234        .iter()
1235        .zip(weights)
1236        .map(|((_, _, credit), weight)| credit * weight)
1237        .sum();
1238    W_TERMS * credited / (MAX_TERM_CREDIT * total)
1239}
1240
1241/// Lowercase query words for the rerank: every `query_words` token is split like an
1242/// identifier, and stop words are dropped only when a content word remains.
1243fn rerank_words(query: &str) -> Vec<String> {
1244    let words: Vec<String> = query_words(query)
1245        .into_iter()
1246        .flat_map(split_identifier)
1247        .map(str::to_lowercase)
1248        .collect();
1249    let any_content = words.iter().any(|w| !is_stop_word(w));
1250    words
1251        .into_iter()
1252        .filter(|w| !any_content || !is_stop_word(w))
1253        .collect()
1254}
1255
1256fn collapse(text: &str) -> String {
1257    text.chars()
1258        .filter(|c| c.is_alphanumeric())
1259        .flat_map(char::to_lowercase)
1260        .collect()
1261}
1262
1263fn head_bytes(text: &str, bytes: usize) -> &str {
1264    let mut end = bytes.min(text.len());
1265    while !text.is_char_boundary(end) {
1266        end -= 1;
1267    }
1268    &text[..end]
1269}
1270
1271fn token_run_equals(tokens: &[String], word: &str) -> bool {
1272    (0..tokens.len()).any(|start| {
1273        let mut joined = String::new();
1274        for token in &tokens[start..] {
1275            joined.push_str(token);
1276            if joined.len() >= word.len() {
1277                return joined == word;
1278            }
1279        }
1280        false
1281    })
1282}
1283
1284/// Match strength of a name per query word: `3` when a run of its tokens equals the word,
1285/// `2` when a token stem equals the word stem, `1` when the collapsed name contains the word,
1286/// `0` when nothing matches.
1287fn name_hits(name: &str, words: &[QueryWord], stemmer: &Stemmer) -> Vec<u8> {
1288    let tokens: Vec<String> = split_identifier(name)
1289        .into_iter()
1290        .map(str::to_lowercase)
1291        .collect();
1292    let stems: Vec<String> = tokens
1293        .iter()
1294        .map(|t| stemmer.stem(t).into_owned())
1295        .collect();
1296    let collapsed = collapse(name);
1297    words
1298        .iter()
1299        .map(|w| {
1300            if token_run_equals(&tokens, &w.word) {
1301                3
1302            } else if stems.contains(&w.stem) {
1303                2
1304            } else if w.word.chars().count() >= 3 && collapsed.contains(&w.word) {
1305                1
1306            } else {
1307                0
1308            }
1309        })
1310        .collect()
1311}
1312
1313/// True when `token` lowercased starts with `prefix` (or equals it when `exact`).
1314/// `prefix` is already lowercase.
1315fn lowercase_prefix_match(token: &str, prefix: &str, exact: bool) -> bool {
1316    if token.is_ascii() && prefix.is_ascii() {
1317        let Some(head) = token.as_bytes().get(..prefix.len()) else {
1318            return false;
1319        };
1320        return head.eq_ignore_ascii_case(prefix.as_bytes())
1321            && (!exact || token.len() == prefix.len());
1322    }
1323    let mut lower = token.chars().flat_map(char::to_lowercase);
1324    for expected in prefix.chars() {
1325        if lower.next() != Some(expected) {
1326            return false;
1327        }
1328    }
1329    !exact || lower.next().is_none()
1330}
1331
1332/// Token-level coverage of a signature or doc: a word is covered when some token equals it,
1333/// or starts with its stem or with the word itself (three or more characters), so `stemming`
1334/// covers `stemmer` and `stems` but not `system`.
1335fn text_hits<'a>(
1336    text: Option<&'a str>,
1337    words: &[QueryWord],
1338    tokens: &mut Vec<&'a str>,
1339) -> Vec<bool> {
1340    tokens.clear();
1341    text_tokens_into(text.unwrap_or(""), tokens);
1342    words
1343        .iter()
1344        .map(|w| {
1345            let stem_prefix = w.stem.chars().count() >= 3;
1346            let exact_word = w.word.chars().count() < 3;
1347            tokens.iter().any(|t| {
1348                lowercase_prefix_match(t, &w.word, exact_word)
1349                    || (stem_prefix && lowercase_prefix_match(t, &w.stem, false))
1350            })
1351        })
1352        .collect()
1353}
1354
1355fn kind_prior(kind: &str) -> f64 {
1356    let kind = normalize_kind(kind);
1357    match kind.as_str() {
1358        "import" => W_KIND_IMPORT,
1359        k if DEFINITION_KINDS.contains(&k) => W_KIND_DEFINITION,
1360        k if MEMBER_KINDS.contains(&k) => W_KIND_MEMBER,
1361        _ => 0.0,
1362    }
1363}
1364
1365fn path_role(path: &str, words: &[QueryWord], stemmer: &Stemmer) -> f64 {
1366    let Some(segment) = path.split(['/', '\\']).find(|seg| {
1367        DEMOTED_PATH_SEGMENTS
1368            .iter()
1369            .any(|d| d.eq_ignore_ascii_case(seg))
1370    }) else {
1371        return 0.0;
1372    };
1373    let segment = segment.to_lowercase();
1374    let segment_stem = stemmer.stem(&segment);
1375    let named = words.iter().any(|w| {
1376        w.word == segment || w.word == segment_stem || w.stem == segment || w.stem == segment_stem
1377    });
1378    if named { 0.0 } else { W_PATH_ROLE }
1379}
1380
1381fn bracket_longest_term(name: &str, terms: &[String]) -> String {
1382    let lower = name.to_lowercase();
1383    if lower.len() != name.len() {
1384        return name.to_string();
1385    }
1386    let mut best: Option<(usize, usize)> = None;
1387    for term in terms {
1388        if let Some(start) = lower.find(term.as_str()) {
1389            let end = start + term.len();
1390            let longer = best.is_none_or(|(s, e)| end - start > e - s);
1391            if longer && name.is_char_boundary(start) && name.is_char_boundary(end) {
1392                best = Some((start, end));
1393            }
1394        }
1395    }
1396    match best {
1397        Some((start, end)) => {
1398            format!("{}[{}]{}", &name[..start], &name[start..end], &name[end..])
1399        }
1400        None => name.to_string(),
1401    }
1402}
1403
1404fn branch_snippet(candidate: &Candidate) -> Option<String> {
1405    let name = &candidate.result.symbol.name;
1406    if candidate.word_match {
1407        candidate.result.snippet.clone()
1408    } else if candidate.exact_name {
1409        Some(name.clone())
1410    } else {
1411        Some(bracket_longest_term(name, &candidate.name_terms))
1412    }
1413}
1414
1415/// Scores every admitted candidate and returns them best first. Each query term is credited
1416/// once from its strongest field and weighted by `idf`, the term's rarity across the index;
1417/// a weight vector of the wrong length, or none, makes every term count the same. Ties fall
1418/// to name strength, then word BM25 (rows without one last), then name length, path, name.
1419fn rerank_with(
1420    candidates: Vec<Candidate>,
1421    query: &str,
1422    include_tests: bool,
1423    idf: Option<&[f64]>,
1424) -> Vec<(SymbolSearchResult, SearchExplain)> {
1425    let stemmer = Stemmer::create(Algorithm::English);
1426    let words: Vec<QueryWord> = rerank_words(query)
1427        .into_iter()
1428        .map(|word| QueryWord {
1429            stem: stemmer.stem(&word).into_owned(),
1430            word,
1431        })
1432        .collect();
1433    let collapsed_query = collapse(query);
1434    let test_intent = include_tests
1435        && words
1436            .iter()
1437            .any(|w| TEST_INTENT_WORDS.contains(&w.word.as_str()));
1438
1439    let mut tokens: Vec<&str> = Vec::new();
1440    let hits: Vec<Hits> = candidates
1441        .iter()
1442        .map(|candidate| {
1443            let symbol = &candidate.result.symbol;
1444            Hits {
1445                name: name_hits(&symbol.name, &words, &stemmer),
1446                signature: text_hits(
1447                    symbol
1448                        .signature
1449                        .as_deref()
1450                        .map(|signature| head_bytes(signature, TEXT_HEAD_BYTES)),
1451                    &words,
1452                    &mut tokens,
1453                ),
1454                doc: text_hits(
1455                    symbol
1456                        .doc_comment
1457                        .as_deref()
1458                        .map(|doc| head_bytes(doc, TEXT_HEAD_BYTES)),
1459                    &words,
1460                    &mut tokens,
1461                ),
1462            }
1463        })
1464        .collect();
1465    let term_weights: Vec<f64> = match idf {
1466        Some(weights) if weights.len() == words.len() => weights.to_vec(),
1467        _ => vec![1.0; words.len()],
1468    };
1469    let word_weights: Vec<(String, f64)> = words
1470        .iter()
1471        .zip(&term_weights)
1472        .map(|(w, weight)| (w.word.clone(), *weight))
1473        .collect();
1474
1475    let mut scored: Vec<(SymbolSearchResult, SearchExplain)> = candidates
1476        .into_iter()
1477        .zip(hits)
1478        .map(|(candidate, hits)| {
1479            let symbol = &candidate.result.symbol;
1480            let name_strength: u32 = hits.name.iter().map(|s| u32::from(*s)).sum();
1481            let tier = if !collapsed_query.is_empty() && collapse(&symbol.name) == collapsed_query {
1482                "whole"
1483            } else if !hits.name.is_empty() && hits.name.iter().all(|s| *s >= 2) {
1484                "all"
1485            } else if hits.name.iter().any(|s| *s > 0) {
1486                "partial"
1487            } else {
1488                "none"
1489            };
1490            let terms = term_credits(&hits, &words);
1491            let explain = SearchExplain {
1492                bm25: candidate.bm25,
1493                branches: [
1494                    (candidate.exact_name, "exact"),
1495                    (candidate.word_match, "word"),
1496                    (candidate.name_match, "name"),
1497                ]
1498                .into_iter()
1499                .filter(|(hit, _)| *hit)
1500                .map(|(_, branch)| branch.to_string())
1501                .collect(),
1502                name_tier: tier.to_string(),
1503                name_strength,
1504                term_score: term_score(&terms, &term_weights),
1505                name_bonus: match tier {
1506                    "whole"
1507                        if DEFINITION_KINDS.contains(&normalize_kind(&symbol.kind).as_str()) =>
1508                    {
1509                        W_NAME_WHOLE
1510                    }
1511                    "whole" | "all" => W_NAME_ALL_WORDS,
1512                    _ => 0.0,
1513                },
1514                kind_prior: kind_prior(&symbol.kind),
1515                path_role: path_role(&symbol.path, &words, &stemmer),
1516                documentation: if candidate.documentation {
1517                    W_DOCUMENTATION_ROW
1518                } else {
1519                    0.0
1520                },
1521                test_intent: if test_intent && (symbol.is_test || symbol.test_container) {
1522                    W_TEST_INTENT
1523                } else {
1524                    0.0
1525                },
1526                terms,
1527                word_weights: word_weights.clone(),
1528                candidates: 0,
1529                rerank_us: 0,
1530            };
1531            let score = explain.term_score
1532                + explain.name_bonus
1533                + explain.kind_prior
1534                + explain.path_role
1535                + explain.documentation
1536                + explain.test_intent;
1537            let snippet = branch_snippet(&candidate);
1538            let mut result = candidate.result;
1539            result.score = score;
1540            result.snippet = snippet;
1541            (result, explain)
1542        })
1543        .collect();
1544
1545    scored.sort_by(|(a, ea), (b, eb)| {
1546        b.score
1547            .total_cmp(&a.score)
1548            .then_with(|| eb.name_strength.cmp(&ea.name_strength))
1549            .then_with(|| {
1550                a.symbol
1551                    .name
1552                    .starts_with('_')
1553                    .cmp(&b.symbol.name.starts_with('_'))
1554            })
1555            .then_with(|| ea.bm25.is_none().cmp(&eb.bm25.is_none()))
1556            .then_with(|| ea.bm25.unwrap_or(0.0).total_cmp(&eb.bm25.unwrap_or(0.0)))
1557            .then_with(|| a.symbol.name.len().cmp(&b.symbol.name.len()))
1558            .then_with(|| a.symbol.path.cmp(&b.symbol.path))
1559            .then_with(|| a.symbol.name.cmp(&b.symbol.name))
1560    });
1561    scored
1562}
1563
1564/// Find tests related to a target symbol by caller relationships, naming pattern, or FTS matching.
1565pub fn find_related_tests(
1566    conn: &Connection,
1567    target_symbol: &Symbol,
1568    limit: usize,
1569) -> Result<Vec<Symbol>, QueryError> {
1570    if limit == 0 {
1571        return Ok(Vec::new());
1572    }
1573
1574    const COLUMNS: &str = "s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1575            s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1576            s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1577            s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1578            s.is_test, s.test_container";
1579    const IS_TEST: &str = "(s.is_test = 1 OR s.test_container = 1)";
1580    let not_documentation = not_documentation(conn, "s");
1581
1582    let mut tests = Vec::new();
1583    let mut seen_ids = std::collections::HashSet::new();
1584
1585    let callers_sql = format!(
1586        "SELECT {COLUMNS}
1587     FROM symbols s
1588     JOIN relationships r ON r.from_symbol_id = s.symbol_id
1589     WHERE r.to_symbol_id = ?1 AND {IS_TEST} AND {not_documentation}
1590     LIMIT ?2"
1591    );
1592
1593    if let Ok(mut stmt) = conn.prepare(&callers_sql)
1594        && let Ok(rows) = stmt.query_map(params![target_symbol.symbol_id, limit as i64], map_symbol)
1595    {
1596        for row in rows.flatten() {
1597            if seen_ids.insert(row.symbol_id.clone()) {
1598                tests.push(row);
1599                if tests.len() >= limit {
1600                    return Ok(tests);
1601                }
1602            }
1603        }
1604    }
1605
1606    // julie resolves call edges inside one file only; every cross-file caller is a pending edge
1607    let remaining = limit - tests.len();
1608    if remaining > 0 && has_pending_namespace_column(conn) {
1609        let pending_sql = format!(
1610            "SELECT DISTINCT {COLUMNS}
1611     FROM pending_relationships p
1612     JOIN symbols s ON p.from_symbol_id = s.symbol_id
1613     JOIN symbols s_from ON s_from.symbol_id = s.symbol_id
1614     JOIN symbols s_target ON s_target.symbol_id = ?1
1615     LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
1616     WHERE p.target_terminal_name = s_target.name
1617       AND {IS_TEST}
1618       AND {not_documentation}
1619       AND {pred}
1620     LIMIT ?2",
1621            pred = pending_target_predicate("s_target", "s_target_parent")
1622        );
1623
1624        if let Ok(mut stmt) = conn.prepare(&pending_sql)
1625            && let Ok(rows) = stmt.query_map(
1626                params![target_symbol.symbol_id, remaining as i64],
1627                map_symbol,
1628            )
1629        {
1630            for row in rows.flatten() {
1631                if seen_ids.insert(row.symbol_id.clone()) {
1632                    tests.push(row);
1633                    if tests.len() >= limit {
1634                        return Ok(tests);
1635                    }
1636                }
1637            }
1638        }
1639    }
1640
1641    let remaining = limit - tests.len();
1642    let name_sql = format!(
1643        "SELECT {COLUMNS}
1644     FROM symbols s
1645     WHERE {IS_TEST}
1646       AND {not_documentation}
1647       AND (s.name LIKE '%' || ?1 || '%' OR s.signature LIKE '%' || ?1 || '%')
1648     ORDER BY (s.name LIKE '%' || ?1 || '%') DESC
1649     LIMIT ?2"
1650    );
1651
1652    if let Ok(mut stmt) = conn.prepare(&name_sql)
1653        && let Ok(rows) = stmt.query_map(
1654            params![target_symbol.name, (remaining * 2) as i64],
1655            map_symbol,
1656        )
1657    {
1658        for row in rows.flatten() {
1659            if seen_ids.insert(row.symbol_id.clone()) {
1660                tests.push(row);
1661                if tests.len() >= limit {
1662                    return Ok(tests);
1663                }
1664            }
1665        }
1666    }
1667
1668    let remaining = limit - tests.len();
1669    let fts_exists: bool = conn
1670        .query_row(
1671            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='symbols_fts'",
1672            [],
1673            |_| Ok(true),
1674        )
1675        .unwrap_or(false);
1676
1677    if remaining > 0 && fts_exists {
1678        let fts_sql = format!(
1679            "SELECT {COLUMNS}
1680         FROM symbols_fts
1681         CROSS JOIN symbols s ON s.rowid = symbols_fts.rowid
1682         WHERE symbols_fts MATCH ?1 AND {IS_TEST} AND {not_documentation}
1683         LIMIT ?2"
1684        );
1685
1686        let and_q = name_prefix_query(&target_symbol.name);
1687        if !and_q.is_empty()
1688            && let Ok(mut stmt) = conn.prepare(&fts_sql)
1689            && let Ok(rows) = stmt.query_map(params![and_q, (remaining * 2) as i64], map_symbol)
1690        {
1691            for row in rows.flatten() {
1692                if seen_ids.insert(row.symbol_id.clone()) {
1693                    tests.push(row);
1694                    if tests.len() >= limit {
1695                        break;
1696                    }
1697                }
1698            }
1699        }
1700    }
1701
1702    Ok(tests)
1703}
1704
1705/// Find a specific symbol by name, with an optional path filter for disambiguation.
1706pub fn get_symbol_by_name(
1707    conn: &Connection,
1708    name: &str,
1709    path_filter: Option<&str>,
1710) -> Result<Option<Symbol>, QueryError> {
1711    get_symbol_by_name_internal(conn, name, path_filter, false)
1712}
1713
1714/// Find a specific symbol by name, requiring exact path match (used for atomic edits).
1715pub fn get_symbol_by_name_exact(
1716    conn: &Connection,
1717    name: &str,
1718    exact_path: &str,
1719) -> Result<Option<Symbol>, QueryError> {
1720    get_symbol_by_name_internal(conn, name, Some(exact_path), true)
1721}
1722
1723/// Split `Outer::Inner::run` or `Outer.Inner.run` into its ancestor segments, outermost first, and the terminal name.
1724fn split_qualified_name(name: &str) -> (Vec<&str>, &str) {
1725    let separator = if name.contains("::") {
1726        "::"
1727    } else if name.contains('.') {
1728        "."
1729    } else {
1730        return (Vec::new(), name);
1731    };
1732    let mut segments: Vec<&str> = name.split(separator).collect();
1733    let terminal = segments.pop().unwrap_or(name);
1734    (segments, terminal)
1735}
1736
1737/// Names of the parents of `symbol_id`, innermost first.
1738fn ancestor_names(conn: &Connection, symbol_id: &str) -> Result<Vec<String>, QueryError> {
1739    let mut stmt = conn.prepare(
1740        "SELECT p.symbol_id, p.name FROM symbols s
1741         JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1742         WHERE s.symbol_id = ?1",
1743    )?;
1744    let mut names = Vec::new();
1745    let mut current = symbol_id.to_string();
1746    for _ in 0..32 {
1747        let mut rows = stmt.query(params![current])?;
1748        let Some(row) = rows.next()? else { break };
1749        let parent_id: String = row.get(0)?;
1750        let parent_name: String = row.get(1)?;
1751        names.push(parent_name);
1752        current = parent_id;
1753    }
1754    Ok(names)
1755}
1756
1757/// A qualified name with several ancestor segments is filtered in Rust after the SQL, so the SQL
1758/// must not truncate the candidate set the way the 25-row cap does for plain and one-segment names.
1759const ANCESTOR_WALK_ROW_CAP: i64 = 2000;
1760
1761fn chain_contains(chain: &[String], wanted: &[&str]) -> bool {
1762    let mut remaining = chain.iter();
1763    wanted
1764        .iter()
1765        .all(|segment| remaining.any(|name| name == segment))
1766}
1767
1768fn get_symbol_by_name_internal(
1769    conn: &Connection,
1770    name: &str,
1771    path_filter: Option<&str>,
1772    exact_path: bool,
1773) -> Result<Option<Symbol>, QueryError> {
1774    let (ancestor_segments, terminal_name) = split_qualified_name(name);
1775    let parent_name = ancestor_segments.last().copied();
1776
1777    let sql = "SELECT s.symbol_id, s.file_id, s.path, s.language, s.name, s.kind, s.signature, s.doc_comment,
1778                s.visibility, s.parent_symbol_id, s.start_line, s.start_column, s.end_line, s.end_column,
1779                s.start_byte, s.end_byte, s.body_start_line, s.body_start_column, s.body_end_line,
1780                s.body_end_column, s.body_start_byte, s.body_end_byte, s.body_hash, s.semantic_group,
1781                s.is_test, s.test_container
1782         FROM symbols s
1783         LEFT JOIN symbols p ON s.parent_symbol_id = p.symbol_id
1784         WHERE (s.name = :name OR (s.name = :term AND (:parent IS NULL OR p.name = :parent)))
1785           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 '\\' OR s.path LIKE :path_like || '/%' ESCAPE '\\' OR s.path LIKE :path_like_bs || '\\\\%' ESCAPE '\\')))
1786         ORDER BY (s.kind != 'import') DESC,
1787                  (s.kind IN ('function', 'struct', 'class', 'trait', 'method', 'enum', 'interface', 'type')) DESC,
1788                  (s.name = :name) DESC,
1789                  (:path IS NOT NULL AND (s.path = :path COLLATE NOCASE OR s.path = :path_bs COLLATE NOCASE)) DESC,
1790                  s.is_test ASC
1791         LIMIT :limit";
1792
1793    let row_cap: i64 = if ancestor_segments.len() > 1 {
1794        ANCESTOR_WALK_ROW_CAP
1795    } else {
1796        25
1797    };
1798    let mut stmt = conn.prepare(sql)?;
1799    let normalized_path = path_filter.map(|p| p.replace('\\', "/").trim_matches('/').to_string());
1800    let backslash_path = normalized_path.as_deref().map(|p| p.replace('/', "\\"));
1801    let path_like = normalized_path.as_deref().map(escape_like);
1802    let path_like_bs = backslash_path.as_deref().map(escape_like);
1803
1804    let mut rows = stmt.query(rusqlite::named_params! {
1805        ":name": name,
1806        ":term": terminal_name,
1807        ":parent": parent_name,
1808        ":path": normalized_path.as_deref(),
1809        ":path_bs": backslash_path.as_deref(),
1810        ":path_like": path_like.as_deref(),
1811        ":path_like_bs": path_like_bs.as_deref(),
1812        ":exact": if exact_path { 1 } else { 0 },
1813        ":limit": row_cap,
1814    })?;
1815
1816    let mut matches: Vec<Symbol> = Vec::new();
1817    while let Some(row) = rows.next()? {
1818        matches.push(map_symbol(row)?);
1819    }
1820    drop(rows);
1821
1822    if ancestor_segments.len() > 1 {
1823        let required: Vec<&str> = ancestor_segments.iter().rev().copied().collect();
1824        let mut kept = Vec::with_capacity(matches.len());
1825        for symbol in matches {
1826            if symbol.name == name
1827                || chain_contains(&ancestor_names(conn, &symbol.symbol_id)?, &required)
1828            {
1829                kept.push(symbol);
1830            }
1831        }
1832        matches = kept;
1833    }
1834
1835    if matches.is_empty() {
1836        return Ok(None);
1837    }
1838
1839    if matches.len() == 1 {
1840        return Ok(Some(matches.remove(0)));
1841    }
1842
1843    // Exclude imports if non-import candidates exist
1844    let candidates: Vec<Symbol> = if matches.iter().any(|s| s.kind != "import") {
1845        matches.into_iter().filter(|s| s.kind != "import").collect()
1846    } else {
1847        matches
1848    };
1849
1850    if candidates.len() == 1 {
1851        return Ok(Some(candidates.into_iter().next().unwrap()));
1852    }
1853
1854    // Check if there's an exact match on full name among candidates
1855    let exact_name_matches: Vec<_> = candidates
1856        .iter()
1857        .filter(|s| s.name == name)
1858        .cloned()
1859        .collect();
1860    if exact_name_matches.len() == 1 {
1861        return Ok(Some(exact_name_matches.into_iter().next().unwrap()));
1862    }
1863
1864    let definition_candidates = if exact_name_matches.is_empty() {
1865        &candidates
1866    } else {
1867        &exact_name_matches
1868    };
1869    let def_matches: Vec<_> = definition_candidates
1870        .iter()
1871        .filter(|s| {
1872            matches!(
1873                s.kind.as_str(),
1874                "function"
1875                    | "struct"
1876                    | "class"
1877                    | "trait"
1878                    | "method"
1879                    | "enum"
1880                    | "interface"
1881                    | "type"
1882            )
1883        })
1884        .cloned()
1885        .collect();
1886    if def_matches.len() == 1 {
1887        return Ok(Some(def_matches.into_iter().next().unwrap()));
1888    }
1889
1890    let active_pool = if !def_matches.is_empty() {
1891        def_matches
1892    } else if !exact_name_matches.is_empty() {
1893        exact_name_matches
1894    } else {
1895        candidates
1896    };
1897
1898    // If path_filter was given and there's an exact path match
1899    if let Some(ref p) = normalized_path {
1900        let exact_path_matches: Vec<_> = active_pool
1901            .iter()
1902            .filter(|s| s.path == *p)
1903            .cloned()
1904            .collect();
1905        if exact_path_matches.len() == 1 {
1906            return Ok(Some(exact_path_matches.into_iter().next().unwrap()));
1907        }
1908    }
1909
1910    if active_pool.len() == 1 {
1911        return Ok(Some(active_pool.into_iter().next().unwrap()));
1912    }
1913
1914    // Ambiguity detected
1915    let mut candidate_list = String::new();
1916    for s in &active_pool {
1917        candidate_list.push_str(&format!(
1918            "- {} `{}` in {}:{}\n",
1919            s.kind, s.name, s.path, s.start_line
1920        ));
1921    }
1922
1923    Err(QueryError::AmbiguousSymbol(
1924        name.to_string(),
1925        active_pool.len(),
1926        candidate_list,
1927    ))
1928}
1929
1930/// The name of the repository this index was built for, for not-found messages.
1931pub fn workspace_name(conn: &Connection) -> String {
1932    conn.query_row(
1933        "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
1934        [],
1935        |row| row.get::<_, String>(0),
1936    )
1937    .ok()
1938    .map(|root| root.replace('\\', "/"))
1939    .and_then(|root| {
1940        root.trim_end_matches('/')
1941            .rsplit('/')
1942            .next()
1943            .filter(|name| !name.is_empty())
1944            .map(str::to_string)
1945    })
1946    .unwrap_or_else(|| "this workspace".to_string())
1947}
1948
1949fn edit_distance(left: &str, right: &str) -> usize {
1950    let left: Vec<char> = left.chars().collect();
1951    let right: Vec<char> = right.chars().collect();
1952    let mut previous: Vec<usize> = (0..=right.len()).collect();
1953    let mut current = vec![0usize; right.len() + 1];
1954    for (i, a) in left.iter().enumerate() {
1955        current[0] = i + 1;
1956        for (j, b) in right.iter().enumerate() {
1957            let substitution = previous[j] + usize::from(a != b);
1958            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
1959        }
1960        std::mem::swap(&mut previous, &mut current);
1961    }
1962    previous[right.len()]
1963}
1964
1965/// Names within a small edit distance of `name`, recalled through the name trigram index.
1966fn near_names_by_trigram(
1967    conn: &Connection,
1968    name: &str,
1969    path_filter: Option<&str>,
1970) -> Result<Vec<Symbol>, QueryError> {
1971    if !has_table(conn, "symbol_names_tri") {
1972        return Ok(Vec::new());
1973    }
1974    let lower = name.to_lowercase();
1975    let chars: Vec<char> = lower.chars().collect();
1976    let chunks: Vec<String> = chars
1977        .windows(3)
1978        .map(|window| window.iter().collect::<String>())
1979        .filter(|chunk| chunk.chars().all(|c| c.is_alphanumeric() || c == '_'))
1980        .collect();
1981    if chunks.is_empty() {
1982        return Ok(Vec::new());
1983    }
1984    let match_clause = chunks
1985        .iter()
1986        .map(|chunk| format!("\"{chunk}\""))
1987        .collect::<Vec<_>>()
1988        .join(" OR ");
1989
1990    let normalized_path = path_filter.map(|p| {
1991        p.replace('\\', "/")
1992            .trim_start_matches("./")
1993            .trim_matches('/')
1994            .to_string()
1995    });
1996    let escaped_path = normalized_path.as_deref().map(escape_like);
1997    let kind_val: Option<&str> = None;
1998    let columns = candidate_columns(conn);
1999    let filters = candidate_filters(false, false);
2000    let sql = format!(
2001        "SELECT {columns} FROM symbol_names_tri
2002         CROSS JOIN symbols s ON s.rowid = symbol_names_tri.rowid
2003         WHERE symbol_names_tri MATCH :match {filters}
2004         ORDER BY bm25(symbol_names_tri) ASC, length(s.name) ASC, s.path ASC LIMIT 20"
2005    );
2006    let rows = conn
2007        .prepare(&sql)?
2008        .query_map(
2009            rusqlite::named_params! {
2010                ":match": match_clause,
2011                ":kind": kind_val,
2012                ":path": normalized_path.as_deref(),
2013                ":path_like": escaped_path.as_deref(),
2014            },
2015            map_symbol,
2016        )?
2017        .collect::<Result<Vec<_>, _>>()?;
2018
2019    let budget = (chars.len() / 4).max(2);
2020    let mut scored: Vec<(usize, Symbol)> = rows
2021        .into_iter()
2022        .map(|symbol| (edit_distance(&lower, &symbol.name.to_lowercase()), symbol))
2023        .filter(|(distance, _)| *distance <= budget)
2024        .collect();
2025    scored.sort_by_key(|(distance, symbol)| (*distance, symbol.name.chars().count()));
2026    Ok(scored
2027        .into_iter()
2028        .map(|(_, symbol)| symbol)
2029        .take(3)
2030        .collect())
2031}
2032
2033/// Up to three indexed symbols whose names are close to `name`. Never fails.
2034pub fn suggest_symbol_names(
2035    conn: &Connection,
2036    name: &str,
2037    path_filter: Option<&str>,
2038) -> Vec<Symbol> {
2039    let (ancestors, terminal) = split_qualified_name(name);
2040    let mut found =
2041        search_symbols_scoped(conn, terminal, None, path_filter, false, 3).unwrap_or_default();
2042    if found.is_empty() && terminal.chars().count() >= 4 {
2043        found = near_names_by_trigram(conn, terminal, path_filter).unwrap_or_default();
2044    }
2045    if let Some(parent) = ancestors.last().copied() {
2046        let mut ranked: Vec<(bool, Symbol)> = found
2047            .into_iter()
2048            .map(|symbol| {
2049                let shares_parent = ancestor_names(conn, &symbol.symbol_id)
2050                    .unwrap_or_default()
2051                    .first()
2052                    .is_some_and(|found_parent| found_parent == parent);
2053                (!shares_parent, symbol)
2054            })
2055            .collect();
2056        ranked.sort_by_key(|(demoted, _)| *demoted);
2057        found = ranked.into_iter().map(|(_, symbol)| symbol).collect();
2058    }
2059    found.truncate(3);
2060    found
2061}
2062
2063/// Up to three indexed file paths close to `rel_path`. Never fails.
2064pub fn suggest_file_paths(conn: &Connection, rel_path: &str) -> Vec<String> {
2065    let wanted = rel_path.replace('\\', "/");
2066    let wanted = wanted.trim_start_matches("./").trim_matches('/');
2067    let basename = wanted.rsplit('/').next().unwrap_or(wanted).to_lowercase();
2068    if basename.is_empty() {
2069        return Vec::new();
2070    }
2071    let stem = basename.split('.').next().unwrap_or(&basename).to_string();
2072    let segments: Vec<&str> = wanted.split('/').collect();
2073    let tail = if segments.len() >= 2 {
2074        segments[segments.len() - 2..].join("/").to_lowercase()
2075    } else {
2076        basename.clone()
2077    };
2078
2079    let path_expr = "replace(files.path, '\\', '/')";
2080    let file_name = format!(
2081        "lower(replace({path_expr}, rtrim({path_expr}, replace({path_expr}, '/', '')), ''))"
2082    );
2083    let rules = [
2084        (format!("{file_name} = :value"), basename.clone()),
2085        (
2086            format!("{file_name} LIKE :value ESCAPE '\\'"),
2087            format!("{}%", escape_like(&stem)),
2088        ),
2089        (
2090            format!("lower({path_expr}) LIKE :value ESCAPE '\\'"),
2091            format!("%{}", escape_like(&tail)),
2092        ),
2093    ];
2094
2095    for (predicate, value) in rules {
2096        let sql = format!(
2097            "SELECT {path_expr} FROM files WHERE {predicate}
2098             ORDER BY length(files.path) ASC, files.path ASC LIMIT 3"
2099        );
2100        let found: Vec<String> = conn
2101            .prepare(&sql)
2102            .and_then(|mut stmt| {
2103                stmt.query_map(rusqlite::named_params! { ":value": value }, |row| {
2104                    row.get::<_, String>(0)
2105                })?
2106                .collect()
2107            })
2108            .unwrap_or_default();
2109        if !found.is_empty() {
2110            return found;
2111        }
2112    }
2113    Vec::new()
2114}
2115
2116/// The workspace name and the recovery hint for a symbol that is not indexed.
2117pub fn symbol_not_found_parts(
2118    conn: &Connection,
2119    name: &str,
2120    path_filter: Option<&str>,
2121) -> (String, String) {
2122    let candidates = suggest_symbol_names(conn, name, path_filter);
2123    let hint = if candidates.is_empty() {
2124        "No similar name is indexed; check the workspace and spelling.".to_string()
2125    } else {
2126        let list = candidates
2127            .iter()
2128            .map(|s| format!("  - {} `{}` ({}:{})", s.kind, s.name, s.path, s.start_line))
2129            .collect::<Vec<_>>()
2130            .join("\n");
2131        format!("Did you mean one of:\n{list}")
2132    };
2133    (workspace_name(conn), hint)
2134}
2135
2136/// The workspace name and the recovery hint for a file that is not indexed.
2137pub fn file_not_found_parts(conn: &Connection, rel_path: &str) -> (String, String) {
2138    let candidates = suggest_file_paths(conn, rel_path);
2139    let hint = if candidates.is_empty() {
2140        "No similar path is indexed; check the workspace and spelling.".to_string()
2141    } else {
2142        let list = candidates
2143            .iter()
2144            .map(|path| format!("  - {path}"))
2145            .collect::<Vec<_>>()
2146            .join("\n");
2147        format!("Did you mean one of:\n{list}")
2148    };
2149    (workspace_name(conn), hint)
2150}
2151
2152/// Find callers or callees of a symbol (filters unresolved external stdlib/runtime primitives by default).
2153pub fn find_references(
2154    conn: &Connection,
2155    symbol_name: &str,
2156    direction: &str,
2157    limit: usize,
2158) -> Result<Vec<ReferenceSite>, QueryError> {
2159    find_references_ext(conn, symbol_name, direction, limit, false)
2160}
2161
2162/// Find callers or callees with option to include external runtime/stdlib primitives.
2163pub fn find_references_ext(
2164    conn: &Connection,
2165    symbol_name: &str,
2166    direction: &str,
2167    limit: usize,
2168    include_external: bool,
2169) -> Result<Vec<ReferenceSite>, QueryError> {
2170    find_references_scoped(conn, symbol_name, direction, limit, include_external, None)
2171}
2172
2173/// Find callers or callees with optional file path disambiguation filter and external symbols toggle.
2174pub fn find_references_scoped(
2175    conn: &Connection,
2176    symbol_name: &str,
2177    direction: &str,
2178    limit: usize,
2179    include_external: bool,
2180    path_filter: Option<&str>,
2181) -> Result<Vec<ReferenceSite>, QueryError> {
2182    validate_result_limit(limit)?;
2183    if direction != "callers" && direction != "callees" {
2184        return Err(QueryError::InvalidDirection(direction.to_string()));
2185    }
2186
2187    match get_symbol_by_name(conn, symbol_name, path_filter)? {
2188        Some(target) => find_references_internal(
2189            conn,
2190            &target.name,
2191            direction,
2192            limit,
2193            Some(&target.symbol_id),
2194            include_external,
2195        ),
2196        None => {
2197            let (workspace, hint) = symbol_not_found_parts(conn, symbol_name, path_filter);
2198            Err(QueryError::SymbolNotFound {
2199                name: symbol_name.to_string(),
2200                workspace,
2201                hint,
2202            })
2203        }
2204    }
2205}
2206
2207pub fn find_references_for_symbol(
2208    conn: &Connection,
2209    symbol_name: &str,
2210    direction: &str,
2211    limit: usize,
2212    symbol_id: &str,
2213) -> Result<Vec<ReferenceSite>, QueryError> {
2214    find_references_internal(conn, symbol_name, direction, limit, Some(symbol_id), false)
2215}
2216
2217/// SQL expression ranking a candidate path against the call site `p.path`:
2218/// 2 for the same file, 1 for the same directory, 0 otherwise.
2219fn call_site_proximity(candidate_path: &str) -> String {
2220    let normalized = format!("replace({candidate_path}, '\\', '/')");
2221    let call_site = "replace(p.path, '\\', '/')";
2222    format!(
2223        "CASE WHEN {normalized} = {call_site} THEN 2
2224              WHEN rtrim({normalized}, replace({normalized}, '/', '')) = rtrim({call_site}, replace({call_site}, '/', '')) THEN 1
2225              ELSE 0 END"
2226    )
2227}
2228
2229/// SQL predicate that decides whether a pending call edge `p` (with caller `s_from`) points at
2230/// the candidate definition `target` (whose parent symbol is joined as `parent`).
2231fn pending_target_predicate(target: &str, parent: &str) -> String {
2232    let ns = "json_each(CASE WHEN json_valid(p.target_namespace_json) THEN p.target_namespace_json ELSE '[]' END)";
2233    let target_path = format!("('/' || replace({target}.path, '\\', '/'))");
2234    let like_value = "replace(replace(replace(value, '\\', '\\\\'), '%', '\\%'), '_', '\\_')";
2235    let closer_rank = call_site_proximity("closer.path");
2236    let target_rank = call_site_proximity(&format!("{target}.path"));
2237    format!(
2238        "(
2239            (
2240                {target}.parent_symbol_id IS NOT NULL
2241                AND {parent}.name IS NOT NULL
2242                AND (
2243                    EXISTS (SELECT 1 FROM {ns} WHERE value = {parent}.name)
2244                    OR (EXISTS (SELECT 1 FROM {ns} WHERE value = 'Self')
2245                        AND s_from.parent_symbol_id = {target}.parent_symbol_id)
2246                    OR (p.target_receiver IS NOT NULL AND p.target_receiver != '' AND {parent}.name = p.target_receiver)
2247                    OR EXISTS (
2248                        SELECT 1 FROM symbols receiver
2249                        JOIN type_facts receiver_type ON receiver_type.symbol_id = receiver.symbol_id
2250                        WHERE receiver.name = p.target_receiver
2251                          AND receiver.path = p.path
2252                          AND receiver_type.resolved_type = {parent}.name
2253                    )
2254                )
2255                AND NOT EXISTS (
2256                    SELECT 1 FROM {ns}
2257                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super', 'self', 'Self', {parent}.name)
2258                      AND NOT EXISTS (
2259                          WITH RECURSIVE ancestor(symbol_id, depth) AS (
2260                              SELECT {target}.parent_symbol_id, 0
2261                              UNION ALL
2262                              SELECT s.parent_symbol_id, ancestor.depth + 1
2263                              FROM symbols s JOIN ancestor ON s.symbol_id = ancestor.symbol_id
2264                              WHERE s.parent_symbol_id IS NOT NULL AND ancestor.depth < 32
2265                          )
2266                          SELECT 1 FROM ancestor JOIN symbols a ON a.symbol_id = ancestor.symbol_id
2267                          WHERE a.name = value
2268                      )
2269                      AND {target_path} NOT LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2270                      AND {target_path} NOT LIKE '%/' || {like_value} || '/%' ESCAPE '\\'
2271                )
2272            )
2273            OR (
2274                (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2275                AND (p.target_receiver IS NULL OR p.target_receiver = '')
2276                AND ({target}.parent_symbol_id IS NULL OR s_from.parent_symbol_id = {target}.parent_symbol_id)
2277                AND ({target}.parent_symbol_id IS NOT NULL OR NOT EXISTS (
2278                    SELECT 1 FROM symbols closer
2279                    WHERE closer.name = {target}.name
2280                      AND closer.symbol_id != {target}.symbol_id
2281                      AND closer.parent_symbol_id IS NULL
2282                      AND closer.kind = {target}.kind
2283                      AND {closer_rank} > {target_rank}
2284                ))
2285            )
2286            OR (
2287                {target}.parent_symbol_id IS NULL
2288                AND EXISTS (
2289                    SELECT 1 FROM {ns}
2290                    WHERE value NOT IN ('std', 'core', 'alloc', 'crate', 'super')
2291                      AND {target_path} LIKE '%/' || {like_value} || '.%' ESCAPE '\\'
2292                )
2293            )
2294        )"
2295    )
2296}
2297
2298/// SQL predicate excluding rows julie marked as documentation, or the always-true `1 = 1` when
2299/// the column is absent, because a bare `1` in ORDER BY means the first result column in SQLite.
2300const DOCUMENTATION_LANGUAGES: &[&str] = &[
2301    "markdown", "yaml", "toml", "json", "html", "css", "xml", "ini", "text",
2302];
2303
2304fn documentation_language_list() -> String {
2305    DOCUMENTATION_LANGUAGES
2306        .iter()
2307        .map(|l| format!("'{l}'"))
2308        .collect::<Vec<_>>()
2309        .join(", ")
2310}
2311
2312fn not_documentation(conn: &Connection, alias: &str) -> String {
2313    let has_content_type: bool = conn
2314        .query_row(
2315            "SELECT 1 FROM pragma_table_info('symbols') WHERE name = 'content_type'",
2316            [],
2317            |_| Ok(true),
2318        )
2319        .unwrap_or(false);
2320    if has_content_type {
2321        format!("({alias}.content_type IS NULL OR {alias}.content_type != 'documentation')")
2322    } else {
2323        "1 = 1".to_string()
2324    }
2325}
2326
2327pub(crate) fn has_table(conn: &Connection, name: &str) -> bool {
2328    conn.query_row(
2329        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
2330        [name],
2331        |_| Ok(true),
2332    )
2333    .unwrap_or(false)
2334}
2335
2336fn has_pending_namespace_column(conn: &Connection) -> bool {
2337    let has_ns: bool = conn
2338        .query_row(
2339            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_namespace_json'",
2340            [],
2341            |_| Ok(true),
2342        )
2343        .unwrap_or(false);
2344    let has_display: bool = conn
2345        .query_row(
2346            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_display_name'",
2347            [],
2348            |_| Ok(true),
2349        )
2350        .unwrap_or(false);
2351    let has_receiver: bool = conn
2352        .query_row(
2353            "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name = 'target_receiver'",
2354            [],
2355            |_| Ok(true),
2356        )
2357        .unwrap_or(false);
2358    has_ns && has_display && has_receiver
2359}
2360
2361fn find_references_internal(
2362    conn: &Connection,
2363    symbol_name: &str,
2364    direction: &str,
2365    limit: usize,
2366    symbol_id: Option<&str>,
2367    include_external: bool,
2368) -> Result<Vec<ReferenceSite>, QueryError> {
2369    let mut results = Vec::new();
2370
2371    if direction == "callers" {
2372        // Find callers: references pointing to target symbol
2373        let mut stmt = conn.prepare(
2374            "SELECT s_from.name AS from_name,
2375                    r.from_symbol_id,
2376                    s_to.name AS to_name,
2377                    r.kind,
2378                    r.path,
2379                    r.start_line,
2380                    r.start_column
2381             FROM relationships r
2382             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2383             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2384             WHERE s_to.name = ?1 AND (?3 IS NULL OR r.to_symbol_id = ?3)
2385             LIMIT ?2",
2386        )?;
2387
2388        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2389            Ok(ReferenceSite {
2390                from_symbol_name: row.get(0)?,
2391                from_symbol_id: row.get(1)?,
2392                to_symbol_name: row.get(2)?,
2393                kind: row.get(3)?,
2394                path: row.get::<_, String>(4)?.replace('\\', "/"),
2395                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2396                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2397            })
2398        })?;
2399
2400        for r in rows {
2401            results.push(r?);
2402        }
2403
2404        // Also query pending_relationships for callers if results < limit
2405        if results.len() < limit {
2406            let remaining = limit - results.len();
2407            if has_pending_namespace_column(conn) {
2408                if let Some(sid) = symbol_id {
2409                    let mut pending_stmt = conn.prepare(
2410                        &format!("SELECT s_from.name AS from_name,
2411                                p.from_symbol_id,
2412                                p.target_terminal_name AS to_name,
2413                                p.kind,
2414                                p.path,
2415                                p.start_line,
2416                                p.start_column
2417                         FROM pending_relationships p
2418                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2419                         JOIN symbols s_target ON s_target.symbol_id = ?3
2420                         LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
2421                          WHERE p.target_terminal_name = ?1
2422                            AND {pred}
2423                          LIMIT ?2", pred = pending_target_predicate("s_target", "s_target_parent")),
2424                    )?;
2425
2426                    let p_rows = pending_stmt.query_map(
2427                        params![symbol_name, remaining as i64, sid],
2428                        |row| {
2429                            Ok(ReferenceSite {
2430                                from_symbol_name: row.get(0)?,
2431                                from_symbol_id: row.get(1)?,
2432                                to_symbol_name: row.get(2)?,
2433                                kind: row.get(3)?,
2434                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2435                                start_line: Some(row.get::<_, i64>(5)? as usize),
2436                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2437                            })
2438                        },
2439                    )?;
2440                    for r in p_rows {
2441                        results.push(r?);
2442                    }
2443                } else {
2444                    let mut pending_stmt = conn.prepare(
2445                        "SELECT s_from.name AS from_name,
2446                                p.from_symbol_id,
2447                                p.target_terminal_name AS to_name,
2448                                p.kind,
2449                                p.path,
2450                                p.start_line,
2451                                p.start_column
2452                         FROM pending_relationships p
2453                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2454                         WHERE p.target_terminal_name = ?1
2455                           AND (
2456                               (p.target_namespace_json IS NULL OR p.target_namespace_json = '[]')
2457                               OR EXISTS (
2458                                   SELECT 1 FROM symbols s_any
2459                                   JOIN symbols s_any_parent ON s_any.parent_symbol_id = s_any_parent.symbol_id
2460                                   WHERE s_any.name = p.target_terminal_name
2461                                     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)
2462                               )
2463                           )
2464                         LIMIT ?2",
2465                    )?;
2466
2467                    let p_rows =
2468                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2469                            Ok(ReferenceSite {
2470                                from_symbol_name: row.get(0)?,
2471                                from_symbol_id: row.get(1)?,
2472                                to_symbol_name: row.get(2)?,
2473                                kind: row.get(3)?,
2474                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2475                                start_line: Some(row.get::<_, i64>(5)? as usize),
2476                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2477                            })
2478                        })?;
2479                    for r in p_rows {
2480                        results.push(r?);
2481                    }
2482                }
2483            } else {
2484                let is_nested = if let Some(sid) = symbol_id {
2485                    conn.query_row(
2486                        "SELECT 1 FROM symbols WHERE symbol_id = ?1 AND parent_symbol_id IS NOT NULL",
2487                        params![sid],
2488                        |_| Ok(true),
2489                    )
2490                    .unwrap_or(false)
2491                } else {
2492                    false
2493                };
2494
2495                if !is_nested {
2496                    let mut pending_stmt = conn.prepare(
2497                        "SELECT s_from.name AS from_name,
2498                                p.from_symbol_id,
2499                                p.target_terminal_name AS to_name,
2500                                p.kind,
2501                                p.path,
2502                                p.start_line,
2503                                p.start_column
2504                         FROM pending_relationships p
2505                         JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2506                         WHERE p.target_terminal_name = ?1
2507                         LIMIT ?2",
2508                    )?;
2509
2510                    let p_rows =
2511                        pending_stmt.query_map(params![symbol_name, remaining as i64], |row| {
2512                            Ok(ReferenceSite {
2513                                from_symbol_name: row.get(0)?,
2514                                from_symbol_id: row.get(1)?,
2515                                to_symbol_name: row.get(2)?,
2516                                kind: row.get(3)?,
2517                                path: row.get::<_, String>(4)?.replace('\\', "/"),
2518                                start_line: Some(row.get::<_, i64>(5)? as usize),
2519                                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2520                            })
2521                        })?;
2522
2523                    for r in p_rows {
2524                        results.push(r?);
2525                    }
2526                }
2527            }
2528        }
2529
2530        if results.len() < limit && has_table(conn, "identifiers") {
2531            let remaining = limit - results.len();
2532            let mut ident_stmt = conn.prepare(
2533                "SELECT COALESCE(s.name, ''),
2534                        COALESCE(i.containing_symbol_id, ''),
2535                        i.name,
2536                        i.kind,
2537                        i.path,
2538                        i.start_line,
2539                        i.start_column
2540                 FROM identifiers i
2541                 LEFT JOIN symbols s ON i.containing_symbol_id = s.symbol_id
2542                 WHERE i.name = ?1 AND i.kind IN ('type_usage', 'member_access')
2543                   AND COALESCE(s.kind, '') != 'import'
2544                   AND (?3 IS NULL OR NOT EXISTS (
2545                       SELECT 1 FROM symbols owner
2546                       JOIN symbols member ON member.parent_symbol_id = owner.symbol_id
2547                       WHERE owner.name = CASE WHEN json_valid(i.metadata_json) THEN json_extract(i.metadata_json, '$.receiver') END
2548                         AND member.name = i.name
2549                         AND owner.name IS NOT (SELECT parent.name FROM symbols target
2550                                                JOIN symbols parent ON parent.symbol_id = target.parent_symbol_id
2551                                                WHERE target.symbol_id = ?3)
2552                   ))
2553                 ORDER BY i.path, i.start_line
2554                 LIMIT ?2",
2555            )?;
2556            let rows =
2557                ident_stmt.query_map(params![symbol_name, remaining as i64, symbol_id], |row| {
2558                    Ok(ReferenceSite {
2559                        from_symbol_name: row.get(0)?,
2560                        from_symbol_id: row.get(1)?,
2561                        to_symbol_name: row.get(2)?,
2562                        kind: row.get(3)?,
2563                        path: row.get::<_, String>(4)?.replace('\\', "/"),
2564                        start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2565                        start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2566                    })
2567                })?;
2568            for r in rows {
2569                results.push(r?);
2570            }
2571        }
2572    } else {
2573        // Find callees: symbols called by target symbol
2574        let mut stmt = conn.prepare(
2575            "SELECT s_from.name AS from_name,
2576                    r.from_symbol_id,
2577                    s_to.name AS to_name,
2578                    r.kind,
2579                    r.path,
2580                    r.start_line,
2581                    r.start_column
2582             FROM relationships r
2583             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2584             JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2585             WHERE s_from.name = ?1 AND (?3 IS NULL OR r.from_symbol_id = ?3)
2586             LIMIT ?2",
2587        )?;
2588
2589        let rows = stmt.query_map(params![symbol_name, limit as i64, symbol_id], |row| {
2590            Ok(ReferenceSite {
2591                from_symbol_name: row.get(0)?,
2592                from_symbol_id: row.get(1)?,
2593                to_symbol_name: row.get(2)?,
2594                kind: row.get(3)?,
2595                path: row.get::<_, String>(4)?.replace('\\', "/"),
2596                start_line: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
2597                start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2598            })
2599        })?;
2600
2601        for r in rows {
2602            results.push(r?);
2603        }
2604
2605        // Also query pending_relationships for callees
2606        if results.len() < limit {
2607            let remaining = limit - results.len();
2608            let p_rows: Vec<ReferenceSite> = if has_pending_namespace_column(conn) {
2609                let sql = if include_external {
2610                    String::from("SELECT DISTINCT s_from.name AS from_name,
2611                            p.from_symbol_id,
2612                            COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name) AS to_name,
2613                            p.kind,
2614                            p.path,
2615                            p.start_line,
2616                            p.start_column
2617                     FROM pending_relationships p
2618                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2619                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2620                     LIMIT ?2")
2621                } else {
2622                    format!("SELECT DISTINCT s_from.name AS from_name,
2623                            p.from_symbol_id,
2624                            p.target_terminal_name AS to_name,
2625                            p.kind,
2626                            p.path,
2627                            p.start_line,
2628                            p.start_column
2629                     FROM pending_relationships p
2630                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2631                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2632                       AND EXISTS (
2633                           SELECT 1 FROM symbols s_to
2634                           LEFT JOIN symbols s_to_parent ON s_to.parent_symbol_id = s_to_parent.symbol_id
2635                           WHERE s_to.name = p.target_terminal_name
2636                             AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2637                             AND {pred}
2638                       )
2639                     LIMIT ?2", pred = pending_target_predicate("s_to", "s_to_parent"))
2640                };
2641                let mut pending_stmt = conn.prepare(&sql)?;
2642                let rows = pending_stmt.query_map(
2643                    params![symbol_name, remaining as i64, symbol_id],
2644                    |row| {
2645                        Ok(ReferenceSite {
2646                            from_symbol_name: row.get(0)?,
2647                            from_symbol_id: row.get(1)?,
2648                            to_symbol_name: row.get(2)?,
2649                            kind: row.get(3)?,
2650                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2651                            start_line: Some(row.get::<_, i64>(5)? as usize),
2652                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2653                        })
2654                    },
2655                )?;
2656                let mut out = Vec::new();
2657                for r in rows {
2658                    out.push(r?);
2659                }
2660                out
2661            } else {
2662                let sql = if include_external {
2663                    "SELECT DISTINCT s_from.name AS from_name,
2664                            p.from_symbol_id,
2665                            p.target_terminal_name AS to_name,
2666                            p.kind,
2667                            p.path,
2668                            p.start_line,
2669                            p.start_column
2670                     FROM pending_relationships p
2671                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2672                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2673                     LIMIT ?2"
2674                } else {
2675                    "SELECT DISTINCT s_from.name AS from_name,
2676                            p.from_symbol_id,
2677                            p.target_terminal_name AS to_name,
2678                            p.kind,
2679                            p.path,
2680                            p.start_line,
2681                            p.start_column
2682                     FROM pending_relationships p
2683                     JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2684                     WHERE s_from.name = ?1 AND (?3 IS NULL OR p.from_symbol_id = ?3)
2685                       AND EXISTS (SELECT 1 FROM symbols s_to WHERE s_to.name = p.target_terminal_name)
2686                     LIMIT ?2"
2687                };
2688                let mut pending_stmt = conn.prepare(sql)?;
2689
2690                let rows = pending_stmt.query_map(
2691                    params![symbol_name, remaining as i64, symbol_id],
2692                    |row| {
2693                        Ok(ReferenceSite {
2694                            from_symbol_name: row.get(0)?,
2695                            from_symbol_id: row.get(1)?,
2696                            to_symbol_name: row.get(2)?,
2697                            kind: row.get(3)?,
2698                            path: row.get::<_, String>(4)?.replace('\\', "/"),
2699                            start_line: Some(row.get::<_, i64>(5)? as usize),
2700                            start_column: row.get::<_, Option<i64>>(6)?.map(|v| v as usize),
2701                        })
2702                    },
2703                )?;
2704                let mut out = Vec::new();
2705                for r in rows {
2706                    out.push(r?);
2707                }
2708                out
2709            };
2710
2711            for r in p_rows {
2712                results.push(r);
2713            }
2714        }
2715    }
2716
2717    Ok(results)
2718}
2719
2720/// Resolve callee signatures directly in a single joined query, avoiding N+1 queries
2721/// and preserving ambiguous methods across types. Prioritizes functions/methods over enum variants.
2722pub fn find_callee_signatures(
2723    conn: &Connection,
2724    symbol_name: &str,
2725    symbol_id: &str,
2726    limit: usize,
2727    include_external: bool,
2728) -> Result<Vec<String>, QueryError> {
2729    let mut stmt = conn.prepare(
2730        "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2731         FROM relationships r
2732         JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
2733         JOIN symbols s_to ON r.to_symbol_id = s_to.symbol_id
2734         WHERE s_from.name = ?1 AND r.from_symbol_id = ?2
2735         LIMIT ?3",
2736    )?;
2737
2738    let rows = stmt.query_map(params![symbol_name, symbol_id, (limit * 2) as i64], |row| {
2739        Ok((
2740            row.get::<_, String>(0)?,
2741            row.get::<_, Option<String>>(1)?,
2742            row.get::<_, String>(2)?.replace('\\', "/"),
2743            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2744            row.get::<_, String>(4)?,
2745        ))
2746    })?;
2747
2748    let mut signatures = Vec::new();
2749    let mut variants = Vec::new();
2750
2751    for r in rows.flatten() {
2752        let (name, sig_opt, path, line, kind) = r;
2753        let sig = sig_opt.unwrap_or(name);
2754        let entry = format!("{sig} ({path}:{line})");
2755        if kind == "variant" {
2756            if !variants.contains(&entry) {
2757                variants.push(entry);
2758            }
2759        } else if !signatures.contains(&entry) {
2760            signatures.push(entry);
2761        }
2762    }
2763
2764    if signatures.len() < limit {
2765        let remaining = (limit - signatures.len()) * 2;
2766        let p_rows: Vec<(String, Option<String>, String, usize, String)> =
2767            if has_pending_namespace_column(conn) {
2768                let mut p_stmt = conn.prepare(
2769                &format!("SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2770                 FROM pending_relationships p
2771                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2772                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2773                 LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2774                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2775                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2776                    AND {pred}
2777                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2778            )?;
2779
2780                let rows =
2781                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2782                        Ok((
2783                            row.get::<_, String>(0)?,
2784                            row.get::<_, Option<String>>(1)?,
2785                            row.get::<_, String>(2)?.replace('\\', "/"),
2786                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2787                            row.get::<_, String>(4)?,
2788                        ))
2789                    })?;
2790                rows.flatten().collect()
2791            } else {
2792                let mut p_stmt = conn.prepare(
2793                "SELECT DISTINCT s_to.name, s_to.signature, s_to.path, s_to.start_line, s_to.kind
2794                 FROM pending_relationships p
2795                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2796                 JOIN symbols s_to ON s_to.name = p.target_terminal_name
2797                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2798                   AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2799                 LIMIT ?3",
2800            )?;
2801
2802                let rows =
2803                    p_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2804                        Ok((
2805                            row.get::<_, String>(0)?,
2806                            row.get::<_, Option<String>>(1)?,
2807                            row.get::<_, String>(2)?.replace('\\', "/"),
2808                            row.get::<_, Option<i64>>(3)?.unwrap_or(1) as usize,
2809                            row.get::<_, String>(4)?,
2810                        ))
2811                    })?;
2812                rows.flatten().collect()
2813            };
2814
2815        for r in p_rows {
2816            let (name, sig_opt, path, line, kind) = r;
2817            let sig = sig_opt.unwrap_or(name);
2818            let entry = format!("{sig} ({path}:{line})");
2819            if kind == "variant" {
2820                if !variants.contains(&entry) {
2821                    variants.push(entry);
2822                }
2823            } else if !signatures.contains(&entry) {
2824                signatures.push(entry);
2825            }
2826        }
2827    }
2828
2829    if include_external && signatures.len() < limit {
2830        let remaining = (limit - signatures.len()) * 2;
2831        let ext_rows: Vec<(String, String, usize)> = if has_pending_namespace_column(conn) {
2832            let mut ext_stmt = conn.prepare(
2833                &format!("SELECT DISTINCT COALESCE(NULLIF(p.target_display_name, ''), p.target_terminal_name), p.path, p.start_line
2834                 FROM pending_relationships p
2835                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2836                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2837                   AND NOT EXISTS (
2838                       SELECT 1 FROM symbols s_to
2839                       LEFT JOIN symbols s_parent ON s_to.parent_symbol_id = s_parent.symbol_id
2840                       WHERE s_to.name = p.target_terminal_name
2841                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2842                         AND {pred}
2843                   )
2844                 LIMIT ?3", pred = pending_target_predicate("s_to", "s_parent")),
2845            )?;
2846
2847            let rows =
2848                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2849                    Ok((
2850                        row.get::<_, String>(0)?,
2851                        row.get::<_, String>(1)?.replace('\\', "/"),
2852                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2853                    ))
2854                })?;
2855            rows.flatten().collect()
2856        } else {
2857            let mut ext_stmt = conn.prepare(
2858                "SELECT DISTINCT p.target_terminal_name, p.path, p.start_line
2859                 FROM pending_relationships p
2860                 JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id
2861                 WHERE s_from.name = ?1 AND p.from_symbol_id = ?2
2862                   AND NOT EXISTS (
2863                       SELECT 1 FROM symbols s_to
2864                       WHERE s_to.name = p.target_terminal_name
2865                         AND s_to.kind NOT IN ('import', 'variable', 'parameter', 'field', 'property', 'module', 'namespace')
2866                   )
2867                 LIMIT ?3",
2868            )?;
2869
2870            let rows =
2871                ext_stmt.query_map(params![symbol_name, symbol_id, remaining as i64], |row| {
2872                    Ok((
2873                        row.get::<_, String>(0)?,
2874                        row.get::<_, String>(1)?.replace('\\', "/"),
2875                        row.get::<_, Option<i64>>(2)?.unwrap_or(1) as usize,
2876                    ))
2877                })?;
2878            rows.flatten().collect()
2879        };
2880
2881        for r in ext_rows {
2882            let (name, path, line) = r;
2883            let entry = format!("{name} ({path}:{line})");
2884            if !signatures.contains(&entry) {
2885                signatures.push(entry);
2886            }
2887        }
2888    }
2889
2890    for v in variants {
2891        if signatures.len() >= limit {
2892            break;
2893        }
2894        if !signatures.contains(&v) {
2895            signatures.push(v);
2896        }
2897    }
2898
2899    signatures.truncate(limit);
2900    Ok(signatures)
2901}
2902
2903const SQL_FAMILIES: &[&str] = &["sql."];
2904
2905const ROUTE_FAMILIES: &[&str] = &[
2906    ".route.",
2907    ".attribute_route.",
2908    ".scope_route.",
2909    ".file_route.",
2910    ".route_handler.",
2911    ".route_reference.",
2912    ".route_group.",
2913    ".route_prefix.",
2914    ".resource_route.",
2915    ".router_mount.",
2916    ".include_router.",
2917    ".server_route.",
2918    ".route_definition.",
2919];
2920
2921const CONFIG_FAMILIES: &[&str] = &["toml.", "yaml.", "json.", "env."];
2922
2923const MODEL_FAMILIES: &[&str] = &[
2924    "sql.table_definition.v1",
2925    "sql.column_definition.v1",
2926    "sql.constraint.v1",
2927    "sql.foreign_key.v1",
2928    "sql.index_definition.v1",
2929    "json.schema.v1",
2930];
2931
2932/// Category aliases mapped to the pattern-id families they name.
2933///
2934/// A rule that starts with a dot matches any pattern id that contains it, a rule that ends with a
2935/// dot matches any pattern id that starts with it, and any other rule matches one exact id.
2936pub const CATEGORY_ALIASES: &[(&str, &[&str])] = &[
2937    ("sql", SQL_FAMILIES),
2938    ("query", SQL_FAMILIES),
2939    ("queries", SQL_FAMILIES),
2940    ("route", ROUTE_FAMILIES),
2941    ("routes", ROUTE_FAMILIES),
2942    ("config", CONFIG_FAMILIES),
2943    ("model", MODEL_FAMILIES),
2944    ("models", MODEL_FAMILIES),
2945];
2946
2947fn category_families(category: &str) -> Option<&'static [&'static str]> {
2948    let wanted = category.trim().to_ascii_lowercase();
2949    CATEGORY_ALIASES
2950        .iter()
2951        .find(|(alias, _)| *alias == wanted)
2952        .map(|(_, families)| *families)
2953}
2954
2955fn matches_family(pattern_id: &str, rule: &str) -> bool {
2956    if rule.starts_with('.') {
2957        pattern_id.contains(rule)
2958    } else if rule.ends_with('.') {
2959        pattern_id.starts_with(rule)
2960    } else {
2961        pattern_id == rule
2962    }
2963}
2964
2965fn family_clause(column: &str, families: &[&str]) -> String {
2966    let alternatives: Vec<String> = families
2967        .iter()
2968        .map(|rule| {
2969            let escaped = escape_like(rule);
2970            if rule.starts_with('.') {
2971                format!("{column} LIKE '%{escaped}%' ESCAPE '\\'")
2972            } else if rule.ends_with('.') {
2973                format!("{column} LIKE '{escaped}%' ESCAPE '\\'")
2974            } else {
2975                format!("{column} = '{rule}'")
2976            }
2977        })
2978        .collect();
2979    format!("({})", alternatives.join(" OR "))
2980}
2981
2982/// True when the category text names one of the aliases in `CATEGORY_ALIASES`.
2983pub fn is_category_alias(category: &str) -> bool {
2984    category_families(category).is_some()
2985}
2986
2987/// Counts the patterns and facts each category alias reaches, given a category listing.
2988///
2989/// Only one alias per family list is reported, and an alias with no facts is left out.
2990pub fn alias_fact_counts(categories: &[(String, usize)]) -> Vec<(&'static str, usize, usize)> {
2991    let mut reported: Vec<&[&str]> = Vec::new();
2992    let mut counts = Vec::new();
2993    for (alias, families) in CATEGORY_ALIASES {
2994        if reported.contains(families) {
2995            continue;
2996        }
2997        reported.push(families);
2998        let mut patterns = 0;
2999        let mut facts = 0;
3000        for (pattern_id, count) in categories {
3001            if families.iter().any(|rule| matches_family(pattern_id, rule)) {
3002                patterns += 1;
3003                facts += count;
3004            }
3005        }
3006        if facts > 0 {
3007            counts.push((*alias, patterns, facts));
3008        }
3009    }
3010    counts
3011}
3012
3013/// Find structural facts by category (e.g. route, query, model, config), optionally scoped by path.
3014pub fn find_structural_facts_scoped(
3015    conn: &Connection,
3016    category: &str,
3017    path_filter: Option<&str>,
3018    limit: usize,
3019) -> Result<Vec<StructuralFact>, QueryError> {
3020    validate_result_limit(limit)?;
3021    let norm_path = path_filter
3022        .map(|p| {
3023            p.replace('\\', "/")
3024                .trim_start_matches("./")
3025                .trim_matches('/')
3026                .to_string()
3027        })
3028        .filter(|p| !p.is_empty());
3029    let dir_prefix = norm_path
3030        .as_deref()
3031        .map(|p| format!("{}/%", escape_like(p)));
3032    let cat_pattern = format!("%{}%", escape_like(category));
3033
3034    let cat_clause = match category_families(category) {
3035        Some(families) => family_clause("sf.pattern_id", families),
3036        None => {
3037            "(sf.pattern_id LIKE :cat ESCAPE '\\' OR sf.capture_name LIKE :cat ESCAPE '\\' OR sf.node_kind LIKE :cat ESCAPE '\\')"
3038                .to_string()
3039        }
3040    };
3041
3042    let sql = format!(
3043        "SELECT sf.structural_fact_id, sf.path, sf.language, sf.pattern_id,
3044                sf.capture_name, sf.node_kind, s.name AS containing_symbol_name,
3045                sf.start_line, sf.end_line, sf.confidence,
3046                COALESCE(
3047                    CASE WHEN json_extract(sf.metadata_json, '$.key_path') LIKE '$.%'
3048                         THEN substr(json_extract(sf.metadata_json, '$.key_path'), 3)
3049                         ELSE json_extract(sf.metadata_json, '$.key_path') END,
3050                    json_extract(sf.metadata_json, '$.key'),
3051                    json_extract(sf.metadata_json, '$.normalized_route_template')
3052                ) AS display_key
3053         FROM structural_facts sf
3054         LEFT JOIN symbols s ON sf.containing_symbol_id = s.symbol_id
3055         WHERE (:cat IS NOT NULL AND {cat_clause})
3056           AND (:path IS NULL OR replace(sf.path, '\\', '/') = :path COLLATE NOCASE OR replace(sf.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3057         ORDER BY sf.path ASC, sf.start_line ASC
3058         LIMIT :limit"
3059    );
3060
3061    let mut stmt = conn.prepare(&sql)?;
3062    let rows = stmt.query_map(
3063        rusqlite::named_params! {
3064            ":cat": cat_pattern,
3065            ":path": norm_path.as_deref(),
3066            ":dir_prefix": dir_prefix.as_deref(),
3067            ":limit": limit as i64,
3068        },
3069        |row| {
3070            Ok(StructuralFact {
3071                structural_fact_id: row.get(0)?,
3072                path: row.get::<_, String>(1)?.replace('\\', "/"),
3073                language: row.get(2)?,
3074                pattern_id: row.get(3)?,
3075                capture_name: row.get(4)?,
3076                node_kind: row.get(5)?,
3077                key: row.get(10)?,
3078                containing_symbol_name: row.get(6)?,
3079                start_line: row.get::<_, i64>(7)? as usize,
3080                end_line: row.get::<_, i64>(8)? as usize,
3081                confidence: row.get(9)?,
3082            })
3083        },
3084    )?;
3085
3086    let mut results = Vec::new();
3087    for r in rows {
3088        results.push(r?);
3089    }
3090    Ok(results)
3091}
3092
3093/// Find structural facts by category (e.g. route, query, model, config).
3094pub fn find_structural_facts(
3095    conn: &Connection,
3096    category: &str,
3097    limit: usize,
3098) -> Result<Vec<StructuralFact>, QueryError> {
3099    find_structural_facts_scoped(conn, category, None, limit)
3100}
3101
3102/// Find literals (endpoints, SQL queries, configs) matching category, optionally scoped by path.
3103pub fn find_literals_scoped(
3104    conn: &Connection,
3105    category: &str,
3106    path_filter: Option<&str>,
3107    limit: usize,
3108) -> Result<Vec<LiteralFact>, QueryError> {
3109    validate_result_limit(limit)?;
3110    let norm_path = path_filter
3111        .map(|p| {
3112            p.replace('\\', "/")
3113                .trim_start_matches("./")
3114                .trim_matches('/')
3115                .to_string()
3116        })
3117        .filter(|p| !p.is_empty());
3118    let dir_prefix = norm_path
3119        .as_deref()
3120        .map(|p| format!("{}/%", escape_like(p)));
3121    let cat_pattern = format!("%{}%", escape_like(category));
3122
3123    let cat_lower = category.trim().to_ascii_lowercase();
3124    let cat_clause = match cat_lower.as_str() {
3125        "config" => {
3126            "(l.kind LIKE '%config%' OR l.kind LIKE '%toml%' OR l.kind LIKE '%json%' OR l.kind LIKE '%yaml%')"
3127        }
3128        "route" | "routes" => "l.kind LIKE '%route%'",
3129        "query" | "queries" | "sql" => "(l.kind LIKE '%sql%' OR l.kind LIKE '%query%')",
3130        "model" | "models" => "l.kind LIKE '%model%'",
3131        _ => "(l.kind LIKE :cat ESCAPE '\\' OR l.literal_text LIKE :cat ESCAPE '\\')",
3132    };
3133
3134    let sql = format!(
3135        "SELECT l.literal_id, l.path, l.literal_text, l.kind, l.carrier,
3136                l.start_line, s.name AS containing_symbol_name
3137         FROM literals l
3138         LEFT JOIN symbols s ON l.containing_symbol_id = s.symbol_id
3139         WHERE (:cat IS NOT NULL AND {cat_clause})
3140           AND (:path IS NULL OR replace(l.path, '\\', '/') = :path COLLATE NOCASE OR replace(l.path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3141         ORDER BY l.path ASC, l.start_line ASC
3142         LIMIT :limit"
3143    );
3144
3145    let mut stmt = conn.prepare(&sql)?;
3146    let rows = stmt.query_map(
3147        rusqlite::named_params! {
3148            ":cat": cat_pattern,
3149            ":path": norm_path.as_deref(),
3150            ":dir_prefix": dir_prefix.as_deref(),
3151            ":limit": limit as i64,
3152        },
3153        |row| {
3154            Ok(LiteralFact {
3155                literal_id: row.get(0)?,
3156                path: row.get::<_, String>(1)?.replace('\\', "/"),
3157                literal_text: row.get(2)?,
3158                kind: row.get(3)?,
3159                carrier: row.get(4)?,
3160                start_line: row.get::<_, i64>(5)? as usize,
3161                containing_symbol_name: row.get(6)?,
3162            })
3163        },
3164    )?;
3165
3166    let mut results = Vec::new();
3167    for r in rows {
3168        results.push(r?);
3169    }
3170    Ok(results)
3171}
3172
3173/// Find literals (endpoints, SQL queries, configs) matching category.
3174pub fn find_literals(
3175    conn: &Connection,
3176    category: &str,
3177    limit: usize,
3178) -> Result<Vec<LiteralFact>, QueryError> {
3179    find_literals_scoped(conn, category, None, limit)
3180}
3181
3182/// List available structural fact and literal categories with counts, optionally scoped by path.
3183pub fn list_structural_fact_categories_scoped(
3184    conn: &Connection,
3185    path_filter: Option<&str>,
3186) -> Result<Vec<(String, usize)>, QueryError> {
3187    let norm_path = path_filter
3188        .map(|p| {
3189            p.replace('\\', "/")
3190                .trim_start_matches("./")
3191                .trim_matches('/')
3192                .to_string()
3193        })
3194        .filter(|p| !p.is_empty());
3195    let dir_prefix = norm_path
3196        .as_deref()
3197        .map(|p| format!("{}/%", escape_like(p)));
3198
3199    let mut categories = Vec::new();
3200
3201    let sql = "SELECT pattern_id, COUNT(*) AS cnt FROM structural_facts
3202               WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3203               GROUP BY pattern_id ORDER BY cnt DESC";
3204    let mut stmt = conn.prepare(sql)?;
3205    let rows = stmt.query_map(
3206        rusqlite::named_params! {
3207            ":path": norm_path.as_deref(),
3208            ":dir_prefix": dir_prefix.as_deref(),
3209        },
3210        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3211    )?;
3212    for r in rows {
3213        categories.push(r?);
3214    }
3215
3216    let lit_sql = "SELECT kind, COUNT(*) AS cnt FROM literals
3217                   WHERE (:path IS NULL OR replace(path, '\\', '/') = :path COLLATE NOCASE OR replace(path, '\\', '/') LIKE :dir_prefix ESCAPE '\\')
3218                   GROUP BY kind ORDER BY cnt DESC";
3219    let mut lit_stmt = conn.prepare(lit_sql)?;
3220    let lit_rows = lit_stmt.query_map(
3221        rusqlite::named_params! {
3222            ":path": norm_path.as_deref(),
3223            ":dir_prefix": dir_prefix.as_deref(),
3224        },
3225        |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)),
3226    )?;
3227    for r in lit_rows {
3228        categories.push(r?);
3229    }
3230
3231    Ok(categories)
3232}
3233
3234/// List all available structural fact and literal categories with counts.
3235pub fn list_structural_fact_categories(
3236    conn: &Connection,
3237) -> Result<Vec<(String, usize)>, QueryError> {
3238    list_structural_fact_categories_scoped(conn, None)
3239}
3240
3241/// Find type facts for a symbol.
3242pub fn find_type_facts(conn: &Connection, symbol_id: &str) -> Result<Vec<TypeFact>, QueryError> {
3243    let has_table: bool = conn
3244        .query_row(
3245            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='type_facts'",
3246            [],
3247            |_| Ok(true),
3248        )
3249        .unwrap_or(false);
3250    if !has_table {
3251        return Ok(Vec::new());
3252    }
3253
3254    let mut stmt = conn.prepare(
3255        "SELECT type_fact_id, symbol_id, language, resolved_type, generic_params_json
3256         FROM type_facts
3257         WHERE symbol_id = ?1",
3258    )?;
3259
3260    let rows = stmt.query_map(params![symbol_id], |row| {
3261        Ok(TypeFact {
3262            type_fact_id: row.get(0)?,
3263            symbol_id: row.get(1)?,
3264            language: row.get(2)?,
3265            resolved_type: row.get(3)?,
3266            generic_params: row.get(4)?,
3267        })
3268    })?;
3269
3270    let mut results = Vec::new();
3271    for r in rows {
3272        results.push(r?);
3273    }
3274    Ok(results)
3275}
3276
3277/// True when a repository-relative path looks like a test file. Directory rules and file-name
3278/// rules are kept apart: a `test`, `tests`, or `__tests__` directory anywhere including the
3279/// repository root, or a file name that starts with `test_` in Python or Ruby, contains `_test.`,
3280/// `.test.`, or `.spec.`, is exactly `test.rs` or `tests.rs`, or ends with the C# `Tests.cs`
3281/// (case-sensitive, so `Contests.cs` is a production file).
3282pub fn is_test_path(path: &str) -> bool {
3283    let p = path.replace('\\', "/");
3284    let cut = p.rfind('/').map_or(0, |i| i + 1);
3285    let directories = format!("/{}/", p[..cut].to_lowercase());
3286    let file_name = &p[cut..];
3287    let lower_name = file_name.to_lowercase();
3288    directories.contains("/test/")
3289        || directories.contains("/tests/")
3290        || directories.contains("/__tests__/")
3291        || (lower_name.starts_with("test_")
3292            && (lower_name.ends_with(".py") || lower_name.ends_with(".rb")))
3293        || lower_name.contains("_test.")
3294        || lower_name.contains(".test.")
3295        || lower_name.contains(".spec.")
3296        || lower_name == "test.rs"
3297        || lower_name == "tests.rs"
3298        || file_name.ends_with("Tests.cs")
3299}
3300
3301/// SQL boolean over `alias.path` that mirrors [`is_test_path`] rule for rule.
3302///
3303/// `test_path_rule_and_its_sql_mirror_agree_on_every_path` runs both forms over one path list so
3304/// the two cannot drift apart.
3305///
3306/// Every rule needs the word `test` or `spec` in the path, so a cheap substring test guards the
3307/// rules and lets most rows skip the path split. Without the guard the split costs about seven
3308/// times more over a half-million rows.
3309pub(crate) fn test_path_predicate(alias: &str) -> String {
3310    let guard = format!("(lower({alias}.path) LIKE '%test%' OR lower({alias}.path) LIKE '%spec%')");
3311    let p = format!("replace({alias}.path, '\\', '/')");
3312    let directories = format!("'/' || lower(rtrim({p}, replace({p}, '/', ''))) || '/'");
3313    let file_name = format!("replace({p}, rtrim({p}, replace({p}, '/', '')), '')");
3314    let lower_name = format!("lower({file_name})");
3315    let like = |subject: &String, pattern: &str| format!("{subject} LIKE '{pattern}' ESCAPE '\\'");
3316    let clauses = [
3317        like(&directories, "%/test/%"),
3318        like(&directories, "%/tests/%"),
3319        like(&directories, "%/\\_\\_tests\\_\\_/%"),
3320        like(&lower_name, "test\\_%.py"),
3321        like(&lower_name, "test\\_%.rb"),
3322        like(&lower_name, "%\\_test.%"),
3323        like(&lower_name, "%.test.%"),
3324        like(&lower_name, "%.spec.%"),
3325        format!("{lower_name} = 'test.rs'"),
3326        format!("{lower_name} = 'tests.rs'"),
3327        format!("{file_name} GLOB '*Tests.cs'"),
3328    ]
3329    .join(" OR ");
3330    format!("({guard} AND ({clauses}))")
3331}
3332
3333/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3334/// Recursively walks reverse reachability (transitive callers) up to `max_depth` in SQLite.
3335pub fn compute_blast_radius_scoped(
3336    conn: &Connection,
3337    seed_symbols: &[&str],
3338    symbol_path_filter: Option<&str>,
3339    seed_paths: &[&str],
3340    max_depth: usize,
3341    limit: usize,
3342) -> Result<BlastRadiusResult, QueryError> {
3343    validate_result_limit(limit)?;
3344    let max_depth = max_depth.min(5);
3345    let resolved_seed_symbols = seed_symbols
3346        .iter()
3347        .map(|name| {
3348            get_symbol_by_name(conn, name, symbol_path_filter)?.ok_or_else(|| {
3349                let (workspace, hint) = symbol_not_found_parts(conn, name, symbol_path_filter);
3350                QueryError::SymbolNotFound {
3351                    name: (*name).to_string(),
3352                    workspace,
3353                    hint,
3354                }
3355            })
3356        })
3357        .collect::<Result<Vec<_>, _>>()?;
3358    let mut seeds = Vec::new();
3359    let seed_type = if !seed_symbols.is_empty() && !seed_paths.is_empty() {
3360        for s in seed_symbols {
3361            seeds.push(s.to_string());
3362        }
3363        for p in seed_paths {
3364            seeds.push(p.to_string());
3365        }
3366        "mixed".to_string()
3367    } else if !seed_symbols.is_empty() {
3368        for s in seed_symbols {
3369            seeds.push(s.to_string());
3370        }
3371        "symbol".to_string()
3372    } else if !seed_paths.is_empty() {
3373        for p in seed_paths {
3374            seeds.push(p.to_string());
3375        }
3376        "file".to_string()
3377    } else {
3378        return Ok(BlastRadiusResult {
3379            seed_type: "none".to_string(),
3380            seeds: Vec::new(),
3381            likely_tests: Vec::new(),
3382            impacted_symbols: Vec::new(),
3383            traversal_ceiling_reached: false,
3384        });
3385    };
3386
3387    let mut where_clauses = Vec::new();
3388    let mut params_vec: Vec<rusqlite::types::Value> = Vec::new();
3389
3390    if !resolved_seed_symbols.is_empty() {
3391        let placeholders: Vec<String> = (1..=resolved_seed_symbols.len())
3392            .map(|i| format!("?{}", i))
3393            .collect();
3394        where_clauses.push(format!("symbol_id IN ({})", placeholders.join(", ")));
3395        for symbol in &resolved_seed_symbols {
3396            params_vec.push(rusqlite::types::Value::Text(symbol.symbol_id.clone()));
3397        }
3398    }
3399
3400    if !seed_paths.is_empty() {
3401        let mut path_conds = Vec::new();
3402        for p in seed_paths.iter() {
3403            let raw = p
3404                .replace('\\', "/")
3405                .trim_start_matches("./")
3406                .trim_matches('/')
3407                .to_string();
3408            let exact_idx = params_vec.len() + 1;
3409            params_vec.push(rusqlite::types::Value::Text(raw.clone()));
3410            let dir_pattern = format!("{}/%", escape_like(&raw));
3411            let like_idx = params_vec.len() + 1;
3412            params_vec.push(rusqlite::types::Value::Text(dir_pattern));
3413            path_conds.push(format!(
3414                "replace(path, '\\', '/') = ?{exact_idx} COLLATE NOCASE OR replace(path, '\\', '/') LIKE ?{like_idx} ESCAPE '\\'"
3415            ));
3416        }
3417        where_clauses.push(format!("({})", path_conds.join(" OR ")));
3418    }
3419
3420    let seed_condition = where_clauses.join(" OR ");
3421    let max_depth_idx = params_vec.len() + 1;
3422    params_vec.push(rusqlite::types::Value::Integer(max_depth as i64));
3423
3424    let mut traversal_ceiling_reached = false;
3425
3426    let has_relationships: bool = conn
3427        .query_row(
3428            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='relationships'",
3429            [],
3430            |_| Ok(true),
3431        )
3432        .unwrap_or(false);
3433
3434    let has_pending: bool = conn
3435        .query_row(
3436            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='pending_relationships'",
3437            [],
3438            |_| Ok(true),
3439        )
3440        .unwrap_or(false);
3441
3442    let mut likely_tests = Vec::new();
3443    let mut impacted_symbols = Vec::new();
3444    let mut seen_test_keys = HashSet::new();
3445
3446    let mut recursive_branches = Vec::new();
3447
3448    if has_relationships {
3449        recursive_branches.push(format!(
3450            "SELECT r.from_symbol_id, iw.depth + 1
3451             FROM relationships r
3452             JOIN impact_walk iw ON r.to_symbol_id = iw.symbol_id
3453             JOIN symbols s_from ON r.from_symbol_id = s_from.symbol_id
3454             WHERE iw.depth < ?{max_depth_idx}
3455               AND s_from.kind NOT IN ('import','variable','parameter','field','property','module','namespace')"
3456        ));
3457    }
3458
3459    if has_pending {
3460        let (parent_join, ns_condition) = if conn
3461            .query_row(
3462                "SELECT 1 FROM pragma_table_info('pending_relationships') WHERE name='target_namespace_json'",
3463                [],
3464                |_| Ok(true),
3465            )
3466            .unwrap_or(false)
3467        {
3468            (
3469                "LEFT JOIN symbols s_target_parent ON s_target.parent_symbol_id = s_target_parent.symbol_id
3470            LEFT JOIN symbols s_from ON p.from_symbol_id = s_from.symbol_id",
3471                format!("AND {pred}", pred = pending_target_predicate("s_target", "s_target_parent")),
3472            )
3473        } else {
3474            ("", String::new())
3475        };
3476
3477        recursive_branches.push(format!(
3478            "SELECT p.from_symbol_id, iw.depth + 1
3479             FROM pending_relationships p
3480             JOIN symbols s_target ON p.target_terminal_name = s_target.name
3481             JOIN impact_walk iw ON s_target.symbol_id = iw.symbol_id
3482             {parent_join}
3483             WHERE iw.depth < ?{max_depth_idx}
3484               AND s_target.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3485               {ns_condition}"
3486        ));
3487    }
3488
3489    if !recursive_branches.is_empty() {
3490        let recursive_sql = recursive_branches.join("\n UNION \n");
3491        let not_documentation = not_documentation(conn, "s");
3492        let sql = format!(
3493            "WITH RECURSIVE impact_walk(symbol_id, depth) AS (
3494                SELECT symbol_id, 0
3495                FROM symbols
3496                WHERE ({seed_condition})
3497                  AND kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3498
3499                UNION
3500
3501                {recursive_sql}
3502            )
3503            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
3504            FROM impact_walk iw
3505            CROSS JOIN symbols s ON iw.symbol_id = s.symbol_id
3506            WHERE s.kind NOT IN ('import','variable','parameter','field','property','module','namespace')
3507              AND {not_documentation}
3508            GROUP BY s.symbol_id, s.name, s.kind, s.path, s.start_line, s.is_test, s.test_container
3509            HAVING MIN(iw.depth) > 0
3510            ORDER BY min_depth ASC, s.path ASC, s.name ASC
3511            LIMIT 200"
3512        );
3513
3514        let mut stmt = conn.prepare(&sql)?;
3515        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec
3516            .iter()
3517            .map(|v| v as &dyn rusqlite::ToSql)
3518            .collect();
3519
3520        let rows = stmt.query_map(param_refs.as_slice(), |row| {
3521            Ok((
3522                row.get::<_, String>(0)?,
3523                row.get::<_, String>(1)?,
3524                row.get::<_, String>(2)?,
3525                row.get::<_, String>(3)?,
3526                row.get::<_, i64>(4)? as usize,
3527                row.get::<_, bool>(5)?,
3528                row.get::<_, bool>(6)?,
3529                row.get::<_, i64>(7)? as usize,
3530            ))
3531        })?;
3532
3533        let mut row_count = 0;
3534        for r in rows {
3535            row_count += 1;
3536            let (_sym_id, name, kind, raw_path, line, is_test, test_container, depth) = r?;
3537            let path = raw_path.replace('\\', "/");
3538            let is_test_target = is_test || test_container || is_test_path(&path);
3539
3540            if is_test_target {
3541                let key = format!("{}:{}", path, line);
3542                if seen_test_keys.insert(key) {
3543                    likely_tests.push(TestTarget {
3544                        name,
3545                        path,
3546                        line,
3547                        reason: format!("transitive caller [depth {depth}]"),
3548                    });
3549                }
3550            } else {
3551                impacted_symbols.push(ImpactedSymbol {
3552                    name,
3553                    kind,
3554                    path,
3555                    line,
3556                    depth,
3557                });
3558            }
3559        }
3560        traversal_ceiling_reached = row_count >= 200;
3561    }
3562
3563    // 2. Discover stem-matched test files in the workspace
3564    let mut file_stems = Vec::new();
3565    for p in seed_paths {
3566        if let Some(stem) = std::path::Path::new(p).file_stem().and_then(|s| s.to_str())
3567            && stem.len() >= 3
3568            && !file_stems.contains(&stem.to_string())
3569        {
3570            file_stems.push(stem.to_string());
3571        }
3572    }
3573    for symbol in &resolved_seed_symbols {
3574        if let Some(stem) = std::path::Path::new(&symbol.path)
3575            .file_stem()
3576            .and_then(|s| s.to_str())
3577            && stem.len() >= 3
3578            && !file_stems.contains(&stem.to_string())
3579        {
3580            file_stems.push(stem.to_string());
3581        }
3582    }
3583
3584    let has_files: bool = conn
3585        .query_row(
3586            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='files'",
3587            [],
3588            |_| Ok(true),
3589        )
3590        .unwrap_or(false);
3591
3592    if has_files {
3593        let doc_file = format!(
3594            "EXISTS (SELECT 1 FROM symbols d WHERE d.path = files.path AND NOT {})",
3595            not_documentation(conn, "d")
3596        );
3597        let mut test_files_stmt = conn.prepare(&format!(
3598            "SELECT DISTINCT path FROM files
3599             WHERE (path LIKE '%test%' OR path LIKE '%spec%') AND path LIKE ?1 ESCAPE '\\'
3600               AND NOT {doc_file}
3601             LIMIT 10"
3602        ))?;
3603        for stem in file_stems {
3604            let stem_pattern = format!("%{}%", escape_like(&stem));
3605            let t_rows =
3606                test_files_stmt.query_map([stem_pattern], |row| row.get::<_, String>(0))?;
3607            for p in t_rows.flatten() {
3608                let p = p.replace('\\', "/");
3609                let key = format!("{}:1", p);
3610                if seen_test_keys.insert(key) {
3611                    likely_tests.push(TestTarget {
3612                        name: p.clone(),
3613                        path: p,
3614                        line: 1,
3615                        reason: "stem-matched test file".to_string(),
3616                    });
3617                }
3618            }
3619        }
3620    }
3621
3622    // Truncate to limit
3623    if likely_tests.len() > limit {
3624        likely_tests.truncate(limit);
3625    }
3626    if impacted_symbols.len() > limit {
3627        impacted_symbols.truncate(limit);
3628    }
3629
3630    Ok(BlastRadiusResult {
3631        seed_type,
3632        seeds,
3633        likely_tests,
3634        impacted_symbols,
3635        traversal_ceiling_reached,
3636    })
3637}
3638
3639/// Compute blast radius and likely tests for given seed symbols or seed file paths.
3640pub fn compute_blast_radius(
3641    conn: &Connection,
3642    seed_symbols: &[&str],
3643    seed_paths: &[&str],
3644    max_depth: usize,
3645    limit: usize,
3646) -> Result<BlastRadiusResult, QueryError> {
3647    compute_blast_radius_scoped(conn, seed_symbols, None, seed_paths, max_depth, limit)
3648}
3649
3650#[cfg(test)]
3651mod tests {
3652    #[test]
3653    fn result_limit_rejects_values_above_the_shared_ceiling() {
3654        assert!(validate_result_limit(MAX_RESULT_LIMIT).is_ok());
3655        assert!(matches!(
3656            validate_result_limit(usize::MAX),
3657            Err(QueryError::InvalidResultLimit(usize::MAX))
3658        ));
3659    }
3660
3661    #[test]
3662    fn find_references_rejects_an_unbounded_limit_before_sql_execution() {
3663        let conn = Connection::open_in_memory().unwrap();
3664
3665        assert!(matches!(
3666            find_references_scoped(&conn, "target", "callers", usize::MAX, false, None),
3667            Err(QueryError::InvalidResultLimit(usize::MAX))
3668        ));
3669    }
3670
3671    use super::*;
3672    use crate::db::{ensure_fts_index, open_read_write};
3673
3674    #[test]
3675    fn count_parse_diagnostics_counts_rows_for_one_file() {
3676        let dir = crate::safe_tempdir();
3677        let conn = open_read_write(&dir.path().join("parse_diagnostics.db")).unwrap();
3678
3679        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 0);
3680
3681        conn.execute_batch(
3682            "CREATE TABLE parse_diagnostics (
3683                diagnostic_id TEXT, file_id TEXT, path TEXT, language TEXT, kind TEXT
3684            );
3685            INSERT INTO parse_diagnostics VALUES ('d1', 'f1', 'src/lib.rs', 'rust', 'error');
3686            INSERT INTO parse_diagnostics VALUES ('d2', 'f1', 'src/lib.rs', 'rust', 'error');
3687            INSERT INTO parse_diagnostics VALUES ('d3', 'f2', 'src/other.rs', 'rust', 'error');",
3688        )
3689        .unwrap();
3690
3691        assert_eq!(count_parse_diagnostics(&conn, "src/lib.rs"), 2);
3692        assert_eq!(count_parse_diagnostics(&conn, "src\\lib.rs"), 2);
3693        assert_eq!(count_parse_diagnostics(&conn, "src/clean.rs"), 0);
3694    }
3695
3696    #[test]
3697    fn test_sanitize_fts5_query() {
3698        let (and_q, or_q) = sanitize_fts5_query("parse tokens");
3699        assert_eq!(and_q, "(\"parse\"* AND \"tokens\"*) OR \"parsetokens\"*");
3700        assert_eq!(or_q, "\"parse\"* OR \"tokens\"* OR \"parsetokens\"*");
3701
3702        let (and_q, or_q) = sanitize_fts5_query("  Option<T>  ");
3703        assert_eq!(and_q, "(\"Option\"* AND \"T\") OR \"OptionT\"*");
3704        assert_eq!(or_q, "\"Option\"* OR \"T\" OR \"OptionT\"*");
3705
3706        let (and_q, or_q) = sanitize_fts5_query("   ");
3707        assert!(and_q.is_empty());
3708        assert!(or_q.is_empty());
3709    }
3710
3711    #[test]
3712    fn sanitize_splits_case_boundaries_and_drops_stop_words() {
3713        let (and_q, or_q) = sanitize_fts5_query("ValidateSyntax");
3714        assert_eq!(
3715            and_q,
3716            "((\"Validate\"* \"Syntax\"*) OR \"ValidateSyntax\"*)"
3717        );
3718        assert_eq!(or_q, "\"Validate\"* OR \"Syntax\"* OR \"ValidateSyntax\"*");
3719
3720        let (and_q, _) = sanitize_fts5_query("find tests related to a symbol");
3721        assert_eq!(
3722            and_q,
3723            "\"find\"* AND \"tests\"* AND \"related\"* AND \"symbol\"*"
3724        );
3725
3726        let (and_q, or_q) = sanitize_fts5_query("parseHTTPResponse2");
3727        assert_eq!(
3728            and_q,
3729            "((\"parse\"* \"HTTP\"* \"Response\"* \"2\") OR \"parseHTTPResponse2\"*)"
3730        );
3731        assert!(or_q.ends_with("OR \"parseHTTPResponse2\"*"));
3732
3733        let (and_q, _) = sanitize_fts5_query("validate_syntax");
3734        assert_eq!(
3735            and_q,
3736            "((\"validate\"* \"syntax\"*) OR \"validate_syntax\"*)"
3737        );
3738
3739        let (and_q, _) = sanitize_fts5_query("isReady");
3740        assert_eq!(and_q, "((\"Ready\"*) OR \"isReady\"*)");
3741
3742        let (and_q, _) = sanitize_fts5_query("before");
3743        assert_eq!(and_q, "\"before\"*");
3744
3745        let (and_q, _) = sanitize_fts5_query("fooBar quux");
3746        assert_eq!(
3747            and_q,
3748            "(((\"foo\"* \"Bar\"*) OR \"fooBar\"*) AND \"quux\"*) OR \"fooBarquux\"*"
3749        );
3750
3751        let (and_q, _) = sanitize_fts5_query("the for a");
3752        assert_eq!(and_q, "(\"the\"* AND \"for\"* AND \"a\") OR \"thefora\"*");
3753    }
3754
3755    fn search_fixture(rows: &str) -> Connection {
3756        let conn = Connection::open_in_memory().unwrap();
3757        conn.execute_batch(&format!(
3758            "CREATE TABLE symbols (
3759                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
3760                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
3761                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
3762                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
3763                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
3764                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
3765                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
3766            );
3767            INSERT INTO symbols VALUES {rows};"
3768        ))
3769        .unwrap();
3770        ensure_fts_index(&conn).unwrap();
3771        conn
3772    }
3773
3774    fn code_row(id: &str, path: &str, language: &str, name: &str, doc: &str) -> String {
3775        format!(
3776            "('{id}', 'f_{id}', '{path}', '{language}', '{name}', 'function', 'fn {name}()', '{doc}', 'pub', NULL,
3777              10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_{id}', NULL, 0, 0, 'code')"
3778        )
3779    }
3780
3781    fn doc_row(id: &str, name: &str, doc: &str) -> String {
3782        format!(
3783            "('{id}', 'f_{id}', 'docs/{id}.md', 'markdown', '{name}', 'module', '{name}', '{doc}', NULL, NULL,
3784              3, 0, 3, 1, 10, 40, NULL, NULL, NULL, NULL, NULL, NULL, 'h_{id}', NULL, 0, 0, 'documentation')"
3785        )
3786    }
3787
3788    fn search_names(conn: &Connection, query: &str) -> Vec<String> {
3789        fts_search_symbols_scoped(conn, query, None, None, false, 10)
3790            .unwrap()
3791            .into_iter()
3792            .map(|r| r.symbol.name)
3793            .collect()
3794    }
3795
3796    const TEST_PATH_CASES: &[(&str, bool)] = &[
3797        ("tests/foo.py", true),
3798        ("tests/x.py", true),
3799        ("tests/tools/test_web.py", true),
3800        ("src/tests/x.rs", true),
3801        ("__tests__/a.ts", true),
3802        ("a/__tests__/b.ts", true),
3803        ("test/x.java", true),
3804        ("src/test/Helper.java", true),
3805        ("src/test_utils.py", true),
3806        ("lib/test_helper.rb", true),
3807        ("test_config.py", true),
3808        ("pkg/test_data/x.json", false),
3809        ("src/test_detection.rs", false),
3810        ("crates/julie-index/src/analysis/test_quality.rs", false),
3811        ("x/foo_test.go", true),
3812        ("x/foo.test.ts", true),
3813        ("x/foo.spec.js", true),
3814        ("src/lib_test.rs", true),
3815        ("src/test.rs", true),
3816        ("tests.rs", true),
3817        ("src/tests.rs", true),
3818        ("Foo.Tests.cs", true),
3819        ("x/FooTests.cs", true),
3820        ("src/FooTests.cs", true),
3821        ("src/Foo.Tests.cs", true),
3822        ("tests/Foo.cs", true),
3823        ("x/parser.spec.ts", true),
3824        ("test_x.py", true),
3825        ("test.rs", true),
3826        ("tests\\x.py", true),
3827        ("src/protocol.spec.v1/parser.rs", false),
3828        ("pkg/test_support/runtime.py", false),
3829        ("src/Contests.cs", false),
3830        ("spec/x.rb", false),
3831        ("crates/x/src/impact/likely_tests.rs", false),
3832        ("x/foo_tests.rs", false),
3833        ("src/latest.rs", false),
3834        ("x/latest.go", false),
3835        ("x/manifest.rs", false),
3836        ("src/attest.rs", false),
3837        ("contest/x.py", false),
3838        ("src/testing.rs", false),
3839        ("src/main.rs", false),
3840        ("pkg/service.go", false),
3841    ];
3842
3843    #[test]
3844    fn test_path_rule_and_its_sql_mirror_agree_on_every_path() {
3845        let conn = Connection::open_in_memory().unwrap();
3846        let sql = format!(
3847            "SELECT {} FROM (SELECT :path AS path) s",
3848            test_path_predicate("s")
3849        );
3850        let mut stmt = conn.prepare(&sql).unwrap();
3851        for (path, expected) in TEST_PATH_CASES {
3852            assert_eq!(is_test_path(path), *expected, "rust rule: {path}");
3853            let from_sql: bool = stmt
3854                .query_row(rusqlite::named_params! { ":path": path }, |row| row.get(0))
3855                .unwrap();
3856            assert_eq!(from_sql, *expected, "sql mirror: {path}");
3857        }
3858    }
3859
3860    #[test]
3861    fn unflagged_test_file_rows_are_hidden_unless_tests_are_included() {
3862        let conn = search_fixture(
3863            &[
3864                code_row(
3865                    "a",
3866                    "src/parser.rs",
3867                    "rust",
3868                    "parse_sidecar",
3869                    "Parse a sidecar.",
3870                ),
3871                code_row(
3872                    "b",
3873                    "src/tests/helpers.py",
3874                    "python",
3875                    "parse_sidecar_fixture",
3876                    "Parse a sidecar.",
3877                ),
3878            ]
3879            .join(", "),
3880        );
3881
3882        let default_search: Vec<String> =
3883            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, false, 10)
3884                .unwrap()
3885                .into_iter()
3886                .map(|r| r.symbol.name)
3887                .collect();
3888        assert_eq!(default_search, vec!["parse_sidecar".to_string()]);
3889
3890        let with_tests: Vec<String> =
3891            fts_search_symbols_scoped(&conn, "parse sidecar", None, None, true, 10)
3892                .unwrap()
3893                .into_iter()
3894                .map(|r| r.symbol.name)
3895                .collect();
3896        assert!(with_tests.contains(&"parse_sidecar_fixture".to_string()));
3897
3898        let default_lookup: Vec<String> =
3899            search_symbols_scoped(&conn, "parse_sidecar", None, None, false, 10)
3900                .unwrap()
3901                .into_iter()
3902                .map(|s| s.name)
3903                .collect();
3904        assert_eq!(default_lookup, vec!["parse_sidecar".to_string()]);
3905
3906        let lookup_with_tests: Vec<String> =
3907            search_symbols_scoped(&conn, "parse_sidecar", None, None, true, 10)
3908                .unwrap()
3909                .into_iter()
3910                .map(|s| s.name)
3911                .collect();
3912        assert!(lookup_with_tests.contains(&"parse_sidecar_fixture".to_string()));
3913    }
3914
3915    #[test]
3916    fn qualified_lookup_in_a_test_file_returns_the_named_row() {
3917        let conn = search_fixture(
3918            &[
3919                "('c', 'f_c', 'src/tests/helpers.py', 'python', 'Helpers', 'class', 'class Helpers', '', 'pub', NULL,
3920                  1, 0, 9, 1, 0, 90, 1, 0, 9, 1, 5, 88, 'h_c', NULL, 0, 0, 'code')".to_string(),
3921                "('d', 'f_d', 'src/tests/helpers.py', 'python', 'load_fixture', 'method', 'def load_fixture()', '', 'pub', 'c',
3922                  10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'h_d', NULL, 0, 0, 'code')".to_string(),
3923            ]
3924            .join(", "),
3925        );
3926
3927        assert_eq!(
3928            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, false, 10)
3929                .unwrap()
3930                .len(),
3931            1
3932        );
3933        assert_eq!(
3934            search_symbols_scoped(&conn, "Helpers.load_fixture", None, None, true, 10)
3935                .unwrap()
3936                .len(),
3937            1
3938        );
3939        assert!(
3940            search_symbols_scoped(&conn, "load_fix", None, None, false, 10)
3941                .unwrap()
3942                .is_empty()
3943        );
3944    }
3945
3946    #[test]
3947    fn concept_query_prefers_partial_code_match_over_full_doc_match() {
3948        let conn = search_fixture(
3949            &[
3950                doc_row(
3951                    "d1",
3952                    "Safety guarantees",
3953                    "Pre-flight syntax validation runs before the edit touches disk",
3954                ),
3955                doc_row(
3956                    "d2",
3957                    "Audit",
3958                    "The syntax validation before an edit is the invariant",
3959                ),
3960                code_row(
3961                    "c1",
3962                    "src/syntax.rs",
3963                    "rust",
3964                    "validate_syntax",
3965                    "Validate the syntax of a file",
3966                ),
3967                code_row(
3968                    "c2",
3969                    "src/edit.rs",
3970                    "rust",
3971                    "replace_symbol_body",
3972                    "Atomic edit with validation",
3973                ),
3974            ]
3975            .join(","),
3976        );
3977
3978        let names = search_names(&conn, "syntax validation before edit");
3979
3980        assert_eq!(names[0], "validate_syntax");
3981        assert!(names.contains(&"replace_symbol_body".to_string()));
3982        assert!(names.contains(&"Safety guarantees".to_string()));
3983    }
3984
3985    #[test]
3986    fn camel_case_query_finds_snake_case_symbol_and_vice_versa() {
3987        let conn = search_fixture(
3988            &[
3989                code_row("c1", "src/syntax.rs", "rust", "validate_syntax", ""),
3990                code_row("c2", "src/syntax.ts", "typescript", "validateSyntax", ""),
3991            ]
3992            .join(","),
3993        );
3994
3995        let mut camel = search_names(&conn, "ValidateSyntax");
3996        camel.sort();
3997        assert_eq!(camel, vec!["validateSyntax", "validate_syntax"]);
3998        let mut words = search_names(&conn, "validate syntax");
3999        words.sort();
4000        assert_eq!(words, vec!["validateSyntax", "validate_syntax"]);
4001    }
4002
4003    #[test]
4004    fn stop_word_prefixed_camel_case_symbol_is_still_found() {
4005        let conn = search_fixture(
4006            &[
4007                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4008                code_row("c2", "src/hooks.rs", "rust", "before", ""),
4009                code_row(
4010                    "c3",
4011                    "src/x.rs",
4012                    "rust",
4013                    "fooBar",
4014                    "has fooBar but not the other word",
4015                ),
4016            ]
4017            .join(","),
4018        );
4019
4020        assert_eq!(search_names(&conn, "isReady"), vec!["isReady"]);
4021        assert_eq!(search_names(&conn, "before"), vec!["before"]);
4022    }
4023
4024    #[test]
4025    fn related_tests_use_the_name_as_typed_without_splitting() {
4026        let conn = search_fixture(
4027            &[
4028                code_row("c1", "src/state.ts", "typescript", "isReady", ""),
4029                "('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(),
4030                "('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(),
4031            ]
4032            .join(","),
4033        );
4034        let target = get_symbol_by_name(&conn, "isReady", None).unwrap().unwrap();
4035
4036        let names: Vec<String> = find_related_tests(&conn, &target, 5)
4037            .unwrap()
4038            .into_iter()
4039            .map(|t| t.name)
4040            .collect();
4041
4042        assert_eq!(names, vec!["isReady_reports_true"]);
4043    }
4044
4045    #[test]
4046    fn exact_name_ranks_before_longer_names_with_the_same_tokens() {
4047        let conn = search_fixture(
4048            &[
4049                code_row(
4050                    "c1",
4051                    "src/queries.rs",
4052                    "rust",
4053                    "fts_search_symbols_scoped",
4054                    "search symbols scoped with fts",
4055                ),
4056                code_row("c2", "src/queries.rs", "rust", "search_symbols_scoped", ""),
4057            ]
4058            .join(","),
4059        );
4060
4061        assert_eq!(
4062            search_names(&conn, "search_symbols_scoped")[0],
4063            "search_symbols_scoped"
4064        );
4065    }
4066
4067    fn sidecar_fixture() -> Connection {
4068        search_fixture(
4069            &[
4070                code_row("c1", "src/sidecar.rs", "rust", "parseSha256Sidecar", ""),
4071                code_row(
4072                    "c2",
4073                    "src/sidecar.rs",
4074                    "rust",
4075                    "parse_sidecar_file",
4076                    "parse the sha256 sidecar file",
4077                ),
4078            ]
4079            .join(","),
4080        )
4081    }
4082
4083    fn candidate<'a>(candidates: &'a [Candidate], name: &str) -> &'a Candidate {
4084        candidates
4085            .iter()
4086            .find(|c| c.result.symbol.name == name)
4087            .unwrap_or_else(|| panic!("{name} is not a candidate"))
4088    }
4089
4090    #[test]
4091    fn name_substring_admits_a_symbol_the_word_branch_cannot_reach() {
4092        let conn = sidecar_fixture();
4093
4094        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4095
4096        let target = candidate(&candidates, "parseSha256Sidecar");
4097        assert!(target.name_match);
4098        assert!(!target.word_match);
4099        assert!(!target.exact_name);
4100        assert!(target.name_terms.contains(&"sha256".to_string()));
4101    }
4102
4103    #[test]
4104    fn a_row_matching_every_word_does_not_hide_a_row_matching_some() {
4105        let conn = search_fixture(
4106            &[
4107                code_row(
4108                    "c1",
4109                    "examples/demo.rs",
4110                    "rust",
4111                    "demo",
4112                    "restore offline state",
4113                ),
4114                code_row(
4115                    "c2",
4116                    "src/replay.rs",
4117                    "rust",
4118                    "replay",
4119                    "restore offline records",
4120                ),
4121            ]
4122            .join(","),
4123        );
4124
4125        let candidates =
4126            collect_search_candidates(&conn, "restore offline state", None, None, false, 10)
4127                .unwrap();
4128
4129        assert!(candidate(&candidates, "demo").word_match);
4130        assert!(candidate(&candidates, "replay").word_match);
4131        assert_eq!(search_names(&conn, "restore offline state")[0], "replay");
4132    }
4133
4134    #[test]
4135    fn name_branch_admits_the_target_when_word_matches_exceed_the_cap() {
4136        let mut rows: Vec<String> = (1..=170)
4137            .map(|i| {
4138                code_row(
4139                    &format!("h{i:03}"),
4140                    "src/sidecar.rs",
4141                    "rust",
4142                    &format!("sidecar_helper_{i:03}"),
4143                    "parse sidecar file",
4144                )
4145            })
4146            .collect();
4147        rows.push(code_row(
4148            "c1",
4149            "src/sidecar.rs",
4150            "rust",
4151            "parseSha256Sidecar",
4152            "",
4153        ));
4154        let conn = search_fixture(&rows.join(","));
4155
4156        let candidates = collect_search_candidates(
4157            &conn,
4158            "parse the sha256 sidecar file",
4159            None,
4160            None,
4161            false,
4162            40,
4163        )
4164        .unwrap();
4165
4166        assert!(candidate(&candidates, "parseSha256Sidecar").name_match);
4167        assert_eq!(candidates.iter().filter(|c| c.word_match).count(), 160);
4168    }
4169
4170    #[test]
4171    fn the_or_pass_fills_the_word_cap_but_never_exceeds_it() {
4172        let mut rows: Vec<String> = (1..=20)
4173            .map(|i| {
4174                code_row(
4175                    &format!("a{i:02}"),
4176                    "src/a.rs",
4177                    "rust",
4178                    &format!("both_{i:02}"),
4179                    "restore offline",
4180                )
4181            })
4182            .collect();
4183        rows.extend((1..=50).map(|i| {
4184            code_row(
4185                &format!("p{i:02}"),
4186                "src/p.rs",
4187                "rust",
4188                &format!("partial_{i:02}"),
4189                "restore records",
4190            )
4191        }));
4192        let conn = search_fixture(&rows.join(","));
4193
4194        let candidates =
4195            collect_search_candidates(&conn, "restore offline", None, None, false, 10).unwrap();
4196
4197        let word_rows: Vec<&Candidate> = candidates.iter().filter(|c| c.word_match).collect();
4198        assert_eq!(word_rows.len(), 40);
4199        assert_eq!(
4200            word_rows
4201                .iter()
4202                .filter(|c| c.result.symbol.name.starts_with("both_"))
4203                .count(),
4204            20
4205        );
4206    }
4207
4208    #[test]
4209    fn exact_name_is_admitted_regardless_of_case() {
4210        let conn = search_fixture(&code_row("c1", "src/q.rs", "rust", "xyzzy_q", ""));
4211
4212        let candidates =
4213            collect_search_candidates(&conn, "XYZZY_Q", None, None, false, 10).unwrap();
4214        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4215
4216        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4217        let candidates =
4218            collect_search_candidates(&conn, "xyzzy_q", None, None, false, 10).unwrap();
4219        assert!(candidate(&candidates, "xyzzy_q").exact_name);
4220    }
4221
4222    #[test]
4223    fn exact_name_with_a_quote_is_admitted_through_the_trigram_index() {
4224        let conn = search_fixture(&code_row(
4225            "c1",
4226            "src/say.js",
4227            "javascript",
4228            "say \"hi\"",
4229            "",
4230        ));
4231
4232        let candidates =
4233            collect_search_candidates(&conn, "say \"hi\"", None, None, false, 10).unwrap();
4234
4235        let target = candidate(&candidates, "say \"hi\"");
4236        assert!(target.exact_name && target.name_match);
4237    }
4238
4239    #[test]
4240    fn a_row_matched_by_every_branch_is_one_candidate_with_all_flags() {
4241        let conn = search_fixture(
4242            &[
4243                code_row("c1", "src/a.rs", "rust", "sidecar", ""),
4244                code_row("c2", "src/b.rs", "rust", "sidecar_helper", ""),
4245            ]
4246            .join(","),
4247        );
4248
4249        let candidates =
4250            collect_search_candidates(&conn, "sidecar", None, None, false, 10).unwrap();
4251
4252        assert_eq!(candidates.len(), 2);
4253        let target = candidate(&candidates, "sidecar");
4254        assert!(target.exact_name && target.word_match && target.name_match);
4255        assert!(target.bm25.is_some());
4256        let helper = candidate(&candidates, "sidecar_helper");
4257        assert!(!helper.exact_name && helper.word_match && helper.name_match);
4258    }
4259
4260    #[test]
4261    fn an_index_without_the_trigram_table_returns_word_rows_only() {
4262        let conn = sidecar_fixture();
4263        conn.execute_batch("DROP TABLE symbol_names_tri").unwrap();
4264
4265        let candidates = collect_search_candidates(&conn, "sha256", None, None, false, 10).unwrap();
4266
4267        let names: Vec<&str> = candidates
4268            .iter()
4269            .map(|c| c.result.symbol.name.as_str())
4270            .collect();
4271        assert_eq!(names, vec!["parse_sidecar_file"]);
4272        assert!(candidates.iter().all(|c| c.word_match && !c.name_match));
4273        assert_eq!(search_names(&conn, "sha256"), vec!["parse_sidecar_file"]);
4274    }
4275
4276    #[test]
4277    fn words_under_three_characters_skip_the_name_branch() {
4278        let conn = search_fixture(
4279            &[
4280                code_row("c1", "src/a.rs", "rust", "ab", ""),
4281                code_row("c2", "src/b.rs", "rust", "cab", ""),
4282            ]
4283            .join(","),
4284        );
4285
4286        let candidates = collect_search_candidates(&conn, "ab", None, None, false, 10).unwrap();
4287
4288        assert!(candidates.iter().all(|c| !c.name_match));
4289        assert!(candidate(&candidates, "ab").exact_name);
4290    }
4291
4292    #[test]
4293    fn trigram_terms_include_the_identifier_parts_of_each_word() {
4294        assert_eq!(
4295            trigram_name_terms("collapse_name"),
4296            vec!["collapse_name", "collapse", "name"]
4297        );
4298        assert_eq!(
4299            trigram_name_terms("parse the sha256 sidecar"),
4300            vec!["parse", "sha256", "sha", "256", "sidecar"]
4301        );
4302        assert_eq!(trigram_name_terms("isReady"), vec!["isready", "ready"]);
4303        assert_eq!(trigram_name_terms("the before"), vec!["the", "before"]);
4304        assert!(trigram_name_terms("ab").is_empty());
4305    }
4306
4307    #[test]
4308    fn snake_case_query_admits_a_pascal_case_name_through_the_name_branch() {
4309        let conn = search_fixture(
4310            &[
4311                code_row("c1", "src/collapse.rs", "rust", "CollapseName", ""),
4312                code_row("c2", "src/other.rs", "rust", "name_collapsed", ""),
4313            ]
4314            .join(","),
4315        );
4316
4317        let candidates =
4318            collect_search_candidates(&conn, "collapse_name", None, None, false, 10).unwrap();
4319
4320        let target = candidate(&candidates, "CollapseName");
4321        assert!(target.name_match);
4322        assert_eq!(target.name_terms, vec!["collapse", "name"]);
4323        assert_eq!(search_names(&conn, "collapse_name")[0], "CollapseName");
4324    }
4325
4326    fn plain_candidate(name: &str, kind: &str, path: &str) -> Candidate {
4327        Candidate {
4328            result: SymbolSearchResult {
4329                symbol: Symbol {
4330                    symbol_id: format!("{path}:{name}"),
4331                    file_id: "f".into(),
4332                    path: path.into(),
4333                    language: "rust".into(),
4334                    name: name.into(),
4335                    kind: kind.into(),
4336                    signature: None,
4337                    doc_comment: None,
4338                    visibility: None,
4339                    parent_symbol_id: None,
4340                    start_line: 1,
4341                    start_column: 0,
4342                    end_line: 1,
4343                    end_column: 0,
4344                    start_byte: 0,
4345                    end_byte: 0,
4346                    body_start_line: None,
4347                    body_start_column: None,
4348                    body_end_line: None,
4349                    body_end_column: None,
4350                    body_start_byte: None,
4351                    body_end_byte: None,
4352                    body_hash: None,
4353                    semantic_group: None,
4354                    is_test: false,
4355                    test_container: false,
4356                },
4357                score: 0.0,
4358                snippet: None,
4359                explain: None,
4360            },
4361            bm25: None,
4362            exact_name: false,
4363            word_match: false,
4364            name_match: false,
4365            name_terms: Vec::new(),
4366            documentation: false,
4367        }
4368    }
4369
4370    fn function(name: &str) -> Candidate {
4371        plain_candidate(name, "function", "src/lib.rs")
4372    }
4373
4374    fn ranked(candidates: Vec<Candidate>, query: &str) -> Vec<(SymbolSearchResult, SearchExplain)> {
4375        rerank_with(candidates, query, false, None)
4376    }
4377
4378    fn ranked_names(candidates: Vec<Candidate>, query: &str) -> Vec<String> {
4379        ranked(candidates, query)
4380            .into_iter()
4381            .map(|(r, _)| r.symbol.name)
4382            .collect()
4383    }
4384
4385    fn documented(name: &str, signature: Option<&str>, doc: &str) -> Candidate {
4386        let mut candidate = function(name);
4387        candidate.result.symbol.signature = signature.map(str::to_string);
4388        candidate.result.symbol.doc_comment = Some(doc.into());
4389        candidate
4390    }
4391
4392    fn strip_ansi_case() -> Vec<Candidate> {
4393        vec![
4394            documented("strip_ansi", None, "Remove ANSI escape sequences"),
4395            documented(
4396                "_strip_code_fences",
4397                Some("def _strip_code_fences(text: str)"),
4398                "The first fenced code block's body, or the stripped text",
4399            ),
4400        ]
4401    }
4402
4403    #[test]
4404    fn rerank_words_split_identifiers_and_drop_stop_words_only_beside_content_words() {
4405        assert_eq!(
4406            rerank_words("parse the sha256 sidecar file"),
4407            vec!["parse", "sha", "256", "sidecar", "file"]
4408        );
4409        assert_eq!(
4410            rerank_words("parse_sha256_sidecar"),
4411            vec!["parse", "sha", "256", "sidecar"]
4412        );
4413        assert_eq!(
4414            rerank_words("ParseHTTPResponse"),
4415            vec!["parse", "http", "response"]
4416        );
4417        assert_eq!(rerank_words("is_ok"), vec!["ok"]);
4418        assert_eq!(rerank_words("the before"), vec!["the", "before"]);
4419    }
4420
4421    #[test]
4422    fn stop_words_cover_english_function_words_but_not_identifier_directions() {
4423        for word in ["was", "whether", "another"] {
4424            assert!(is_stop_word(word), "{word} must be a stop word");
4425        }
4426        for word in ["down", "into", "run"] {
4427            assert!(!is_stop_word(word), "{word} must stay a content word");
4428        }
4429        assert_eq!(rerank_words("what was the file"), vec!["file"]);
4430    }
4431
4432    #[test]
4433    fn a_public_name_sorts_before_its_private_twin_at_an_equal_score() {
4434        let mut private = function("_create_skill");
4435        private.bm25 = Some(-9.0);
4436        let mut public = function("create_skill");
4437        public.bm25 = Some(-1.0);
4438
4439        let rows = ranked(vec![private, public], "create skill");
4440
4441        assert_eq!(rows[0].0.score, rows[1].0.score);
4442        assert_eq!(rows[0].1.name_strength, rows[1].1.name_strength);
4443        assert_eq!(
4444            rows.iter()
4445                .map(|(r, _)| r.symbol.name.as_str())
4446                .collect::<Vec<_>>(),
4447            vec!["create_skill", "_create_skill"]
4448        );
4449    }
4450
4451    #[test]
4452    fn a_whole_name_constant_yields_to_a_function_that_holds_the_word_with_context() {
4453        let rows = ranked(
4454            vec![
4455                plain_candidate("Glob", "constant", "src/glob.rs"),
4456                function("matches_glob_pattern"),
4457            ],
4458            "glob",
4459        );
4460
4461        assert_eq!(rows[0].0.symbol.name, "matches_glob_pattern");
4462        assert_eq!(rows[1].1.name_tier, "whole");
4463        assert_eq!(rows[1].1.name_bonus, W_NAME_ALL_WORDS);
4464    }
4465
4466    #[test]
4467    fn name_tiers_are_whole_then_all_words_then_partial_then_none() {
4468        let rows = ranked(
4469            vec![
4470                function("validate_everything"),
4471                function("validate_syntax_now"),
4472                function("validate_syntax"),
4473                function("unrelated"),
4474            ],
4475            "validate syntax",
4476        );
4477        let tiers: Vec<(&str, &str, f64)> = rows
4478            .iter()
4479            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4480            .collect();
4481
4482        assert_eq!(
4483            tiers,
4484            vec![
4485                ("validate_syntax", "whole", W_NAME_WHOLE),
4486                ("validate_syntax_now", "all", W_NAME_ALL_WORDS),
4487                ("validate_everything", "partial", 0.0),
4488                ("unrelated", "none", 0.0),
4489            ]
4490        );
4491        assert_eq!(rows[0].0.score, W_NAME_WHOLE + W_TERMS + W_KIND_DEFINITION);
4492        assert_eq!(
4493            rows[1].0.score,
4494            W_NAME_ALL_WORDS + W_TERMS + W_KIND_DEFINITION
4495        );
4496        assert_eq!(rows[2].0.score, W_TERMS / 2.0 + W_KIND_DEFINITION);
4497        assert_eq!(rows[3].0.score, W_KIND_DEFINITION);
4498    }
4499
4500    #[test]
4501    fn distinct_scoring_prefers_three_terms_covered_once_over_two_terms_repeated() {
4502        assert_eq!(
4503            ranked_names(strip_ansi_case(), "strip ansi escape codes"),
4504            vec!["strip_ansi", "_strip_code_fences"]
4505        );
4506    }
4507
4508    #[test]
4509    fn distinct_scoring_denies_the_all_words_bonus_to_a_substring_only_name() {
4510        let candidates = vec![
4511            function("execute_julie_extract"),
4512            documented("slice_bytes", None, "cut a byte range"),
4513        ];
4514
4515        let rows = ranked(candidates, "cut");
4516        let bonuses: Vec<(&str, &str, f64)> = rows
4517            .iter()
4518            .map(|(r, e)| (r.symbol.name.as_str(), e.name_tier.as_str(), e.name_bonus))
4519            .collect();
4520
4521        assert_eq!(
4522            bonuses,
4523            vec![
4524                ("execute_julie_extract", "partial", 0.0),
4525                ("slice_bytes", "none", 0.0),
4526            ]
4527        );
4528        assert_eq!(rows[0].0.score, rows[1].0.score);
4529    }
4530
4531    #[test]
4532    fn distinct_scoring_keeps_the_whole_name_and_all_words_tiers_in_order() {
4533        let candidates = vec![
4534            function("validate_everything"),
4535            function("validate_syntax_now"),
4536            function("validate_syntax"),
4537            function("unrelated"),
4538        ];
4539
4540        assert_eq!(
4541            ranked_names(candidates, "validate syntax"),
4542            vec![
4543                "validate_syntax",
4544                "validate_syntax_now",
4545                "validate_everything",
4546                "unrelated"
4547            ]
4548        );
4549    }
4550
4551    #[test]
4552    fn idf_weights_rank_a_term_in_one_row_above_a_term_in_most_rows() {
4553        let mut rows: Vec<String> = (0..10)
4554            .map(|i| {
4555                code_row(
4556                    &format!("s{i}"),
4557                    &format!("src/f{i}.rs"),
4558                    "rust",
4559                    &format!("search_{i}"),
4560                    "searches the index",
4561                )
4562            })
4563            .collect();
4564        rows.push(code_row(
4565            "rare",
4566            "src/rare.rs",
4567            "rust",
4568            "sanitize_input",
4569            "sanitize the input",
4570        ));
4571        let conn = search_fixture(&rows.join(", "));
4572        let terms = vec!["sanitize".to_string(), "search".to_string()];
4573
4574        let weights = idf_weights(&conn, &terms);
4575        assert!(weights[0] > weights[1]);
4576    }
4577
4578    #[test]
4579    fn idf_weights_count_a_term_the_way_the_index_tokenizer_stems_it() {
4580        let conn = search_fixture(&code_row(
4581            "n",
4582            "src/news.rs",
4583            "rust",
4584            "fetch_news",
4585            "fetch the news feed",
4586        ));
4587        let terms = vec!["news".to_string(), "unseen".to_string()];
4588
4589        let weights = idf_weights(&conn, &terms);
4590
4591        assert!(weights[0] < weights[1]);
4592    }
4593
4594    #[test]
4595    fn a_signature_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4596        let crediting_field = |padding: usize| {
4597            let mut row = function("handler");
4598            row.result.symbol.signature = Some(format!(
4599                "fn handler({}sidecar: u8)",
4600                "a: u8, ".repeat(padding)
4601            ));
4602            ranked(vec![row], "sidecar")[0].1.terms[0].1.clone()
4603        };
4604
4605        assert_eq!(crediting_field(4), "signature");
4606        assert_eq!(crediting_field(80), "none");
4607    }
4608
4609    #[test]
4610    fn explain_terms_name_the_crediting_field_of_every_query_term() {
4611        let rows = ranked(strip_ansi_case(), "strip ansi escape codes");
4612        let terms: Vec<(&str, &str, f64)> = rows[0]
4613            .1
4614            .terms
4615            .iter()
4616            .map(|(term, field, credit)| (term.as_str(), field.as_str(), *credit))
4617            .collect();
4618
4619        assert_eq!(rows[0].0.symbol.name, "strip_ansi");
4620        assert_eq!(
4621            terms,
4622            vec![
4623                ("strip", "name", 3.0),
4624                ("ansi", "name", 3.0),
4625                ("escape", "doc", TEXT_CREDIT),
4626                ("codes", "none", 0.0),
4627            ]
4628        );
4629    }
4630
4631    #[test]
4632    fn name_coverage_accepts_token_runs_substrings_and_stems() {
4633        let strengths = |name: &str, query: &str| {
4634            let stemmer = Stemmer::create(Algorithm::English);
4635            let words: Vec<QueryWord> = rerank_words(query)
4636                .into_iter()
4637                .map(|word| QueryWord {
4638                    stem: stemmer.stem(&word).into_owned(),
4639                    word,
4640                })
4641                .collect();
4642            name_hits(name, &words, &stemmer)
4643        };
4644
4645        assert_eq!(strengths("parseSha256Sidecar", "sha 256"), vec![3, 3]);
4646        assert_eq!(strengths("parseSha256Sidecar", "sha256"), vec![3, 3]);
4647        assert_eq!(strengths("parseSha256Sidecar", "esha"), vec![1]);
4648        assert_eq!(strengths("validate_syntax", "validation"), vec![2]);
4649        assert_eq!(strengths("is_ok", "ok"), vec![3]);
4650        assert_eq!(strengths("isReady", "is"), vec![3]);
4651        assert_eq!(strengths("größe_berechnen", "größe"), vec![3]);
4652        assert_eq!(
4653            strengths("parseSha256Sidecar", "sidecar checksum"),
4654            vec![3, 0]
4655        );
4656        assert_eq!(
4657            strengths("parseSha256Sidecar", "checksum digest"),
4658            vec![0, 0]
4659        );
4660    }
4661
4662    #[test]
4663    fn a_rarer_term_moves_the_score_more_than_a_common_one() {
4664        let weights = [4.0, 1.0];
4665        let rows = rerank_with(
4666            vec![function("rare_helper"), function("common_helper")],
4667            "rare common",
4668            false,
4669            Some(&weights),
4670        );
4671
4672        assert_eq!(
4673            rows[0].1.word_weights,
4674            vec![("rare".to_string(), 4.0), ("common".to_string(), 1.0)]
4675        );
4676        assert_eq!(rows[0].0.symbol.name, "rare_helper");
4677        assert_eq!(rows[0].0.score, W_TERMS * 4.0 / 5.0 + W_KIND_DEFINITION);
4678        assert_eq!(rows[1].0.score, W_TERMS * 1.0 / 5.0 + W_KIND_DEFINITION);
4679    }
4680
4681    #[test]
4682    fn any_name_hit_outranks_a_zero_coverage_definition_for_long_queries() {
4683        let rows = ranked(
4684            vec![
4685                function("render_mode"),
4686                plain_candidate("retry_count", "constant", "src/scan.rs"),
4687            ],
4688            "how many times a failed download is tried again retry limit",
4689        );
4690
4691        assert_eq!(rows[0].0.symbol.name, "retry_count");
4692        assert_eq!(rows[0].1.name_tier, "partial");
4693        assert!(rows[0].0.score > rows[1].0.score);
4694        assert_eq!(rows[1].0.score, W_KIND_DEFINITION);
4695    }
4696
4697    #[test]
4698    fn a_doc_hit_past_the_head_byte_cap_does_not_credit_its_term() {
4699        let mut row = function("load");
4700        row.result.symbol.signature = Some("fn load(config: &Config) -> Loaded".into());
4701        row.result.symbol.doc_comment = Some(format!("{}settings", "é".repeat(200)));
4702        let (result, explain) = ranked(vec![row], "config settings").remove(0);
4703
4704        assert_eq!(
4705            explain.terms,
4706            vec![
4707                ("config".into(), "signature".into(), TEXT_CREDIT),
4708                ("settings".into(), "none".into(), 0.0),
4709            ]
4710        );
4711        assert_eq!(result.score, explain.term_score + W_KIND_DEFINITION);
4712    }
4713
4714    #[test]
4715    fn text_coverage_matches_whole_tokens_by_word_or_stem_prefix() {
4716        let crediting_field =
4717            |row: Candidate, query: &str| ranked(vec![row], query).remove(0).1.terms[0].1.clone();
4718        let doc_field = |doc: &str, query: &str| {
4719            let mut row = function("row");
4720            row.result.symbol.doc_comment = Some(doc.into());
4721            crediting_field(row, query)
4722        };
4723        let sig_field = |signature: &str, query: &str| {
4724            let mut row = function("row");
4725            row.result.symbol.signature = Some(signature.into());
4726            crediting_field(row, query)
4727        };
4728
4729        assert_eq!(doc_field("The system runs.", "stemming"), "none");
4730        assert_eq!(doc_field("The stemmer runs.", "stemming"), "doc");
4731        assert_eq!(doc_field("Compares stems.", "stemming"), "doc");
4732        assert_eq!(doc_field("An important port.", "porter"), "none");
4733        assert_eq!(
4734            sig_field("fn sha256sum(data: &[u8]) -> String", "sha256"),
4735            "signature"
4736        );
4737        assert_eq!(sig_field("fn is_ok()", "ok"), "signature");
4738        assert_eq!(sig_field("fn okay()", "ok"), "none");
4739        assert_eq!(
4740            sig_field("fn parseSha256Sidecar(text)", "sidecar"),
4741            "signature"
4742        );
4743    }
4744
4745    #[test]
4746    fn text_tokens_split_like_query_words_then_identifiers() {
4747        fn two_pass(text: &str) -> Vec<&str> {
4748            query_words(text)
4749                .into_iter()
4750                .flat_map(split_identifier)
4751                .collect()
4752        }
4753        fn one_pass(text: &str) -> Vec<&str> {
4754            let mut out = Vec::new();
4755            text_tokens_into(text, &mut out);
4756            out
4757        }
4758        let ascii = "fn parseHTTPResponse2(raw: &str, _id: u8) -> Vec<&str> // sha256_sum";
4759        let unicode = "Berechnet die Größe: größe_berechnen(pfad) -> ÜberGroß2x";
4760
4761        assert_eq!(one_pass(ascii), two_pass(ascii));
4762        assert_eq!(
4763            one_pass(ascii),
4764            vec![
4765                "fn", "parse", "HTTP", "Response", "2", "raw", "str", "id", "u", "8", "Vec", "str",
4766                "sha", "256", "sum",
4767            ]
4768        );
4769        assert_eq!(one_pass(unicode), two_pass(unicode));
4770        assert!(one_pass("").is_empty());
4771        assert!(one_pass("_ __ ...").is_empty());
4772    }
4773
4774    #[test]
4775    fn a_doc_credits_a_term_by_its_stem() {
4776        let mut row = function("check");
4777        row.result.symbol.doc_comment = Some("Validates the input.".into());
4778        let explain = ranked(vec![row], "validation").remove(0).1;
4779
4780        assert_eq!(
4781            explain.terms,
4782            vec![("validation".into(), "doc".into(), TEXT_CREDIT)]
4783        );
4784    }
4785
4786    #[test]
4787    fn kind_prior_orders_definitions_over_members_over_imports() {
4788        let rows = ranked(
4789            vec![
4790                plain_candidate("Scan", "import", "src/a.rs"),
4791                plain_candidate("Scan", "enum_member", "src/b.rs"),
4792                plain_candidate("Scan", "function", "src/c.rs"),
4793            ],
4794            "scan",
4795        );
4796        let order: Vec<(&str, f64)> = rows
4797            .iter()
4798            .map(|(r, e)| (r.symbol.path.as_str(), e.kind_prior))
4799            .collect();
4800
4801        assert_eq!(
4802            order,
4803            vec![
4804                ("src/c.rs", W_KIND_DEFINITION),
4805                ("src/b.rs", W_KIND_MEMBER),
4806                ("src/a.rs", W_KIND_IMPORT),
4807            ]
4808        );
4809    }
4810
4811    #[test]
4812    fn a_partial_name_match_on_a_member_beats_the_kind_prior_of_a_function() {
4813        let names = ranked_names(
4814            vec![
4815                function("RenderMode"),
4816                plain_candidate("MaxRetryCount", "constant", "pkg/scan.go"),
4817            ],
4818            "retry download limit timeout",
4819        );
4820
4821        assert_eq!(names[0], "MaxRetryCount");
4822    }
4823
4824    #[test]
4825    fn path_role_demotes_role_directories_unless_the_query_names_them() {
4826        let rows = |query: &str| {
4827            ranked(
4828                vec![
4829                    plain_candidate("verifyChecksum", "function", "scripts/launcher.ts"),
4830                    plain_candidate("verify_checksum", "function", "src/archive.rs"),
4831                ],
4832                query,
4833            )
4834        };
4835
4836        let plain = rows("verify checksum");
4837        assert_eq!(plain[0].0.symbol.path, "src/archive.rs");
4838        assert_eq!(plain[1].1.path_role, W_PATH_ROLE);
4839
4840        let named = rows("launcher script verify checksum");
4841        assert!(named.iter().all(|(_, e)| e.path_role == 0.0));
4842
4843        let only_launcher = rows("launcher verify checksum");
4844        assert_eq!(only_launcher[0].0.symbol.path, "src/archive.rs");
4845        assert_eq!(only_launcher[1].1.path_role, W_PATH_ROLE);
4846
4847        let windows = ranked(
4848            vec![plain_candidate(
4849                "verifyChecksum",
4850                "function",
4851                "scripts\\launcher.ts",
4852            )],
4853            "verify checksum",
4854        );
4855        assert_eq!(windows[0].1.path_role, W_PATH_ROLE);
4856    }
4857
4858    #[test]
4859    fn documentation_rows_sort_after_every_code_row() {
4860        let mut heading = plain_candidate("Verify checksum", "heading", "README.md");
4861        heading.documentation = true;
4862        heading.result.symbol.language = "markdown".into();
4863        heading.result.symbol.signature = Some("Verify checksum".into());
4864        heading.result.symbol.doc_comment = Some("Verify the checksum of the archive.".into());
4865        let rows = ranked(
4866            vec![
4867                heading,
4868                plain_candidate("unrelated", "variable", "src/a.rs"),
4869            ],
4870            "verify checksum",
4871        );
4872
4873        assert_eq!(rows[0].0.symbol.name, "unrelated");
4874        assert_eq!(rows[1].1.documentation, W_DOCUMENTATION_ROW);
4875        assert_eq!(rows[1].1.name_tier, "whole");
4876        assert!(rows[1].0.score < 0.0);
4877    }
4878
4879    #[test]
4880    fn test_intent_boosts_test_rows_only_when_tests_are_included_and_named() {
4881        let rows = |query: &str, include_tests: bool| {
4882            let mut test_row = plain_candidate("payment_flow", "function", "tests/payment.rs");
4883            test_row.result.symbol.is_test = true;
4884            let plain_row = plain_candidate("payment_flow", "function", "src/payment.rs");
4885            rerank_with(vec![plain_row, test_row], query, include_tests, None)
4886        };
4887
4888        let boosted = rows("payment flow tests", true);
4889        assert_eq!(boosted[0].0.symbol.path, "tests/payment.rs");
4890        assert_eq!(boosted[0].1.test_intent, W_TEST_INTENT);
4891        assert_eq!(boosted[1].1.test_intent, 0.0);
4892
4893        assert!(
4894            rows("payment flow tests", false)
4895                .iter()
4896                .all(|(_, e)| e.test_intent == 0.0)
4897        );
4898        assert!(
4899            rows("payment flow", true)
4900                .iter()
4901                .all(|(_, e)| e.test_intent == 0.0)
4902        );
4903    }
4904
4905    #[test]
4906    fn ties_break_by_bm25_then_name_length_then_path() {
4907        let mut word_row = plain_candidate("payment", "function", "src/z.rs");
4908        word_row.word_match = true;
4909        word_row.bm25 = Some(-4.0);
4910        let mut weaker_word_row = plain_candidate("payment", "function", "src/a.rs");
4911        weaker_word_row.word_match = true;
4912        weaker_word_row.bm25 = Some(-2.0);
4913        let mut name_only = plain_candidate("payment", "function", "src/b.rs");
4914        name_only.name_match = true;
4915        let rows = ranked(
4916            vec![
4917                plain_candidate("payment", "function", "src/y.rs"),
4918                name_only,
4919                weaker_word_row,
4920                word_row,
4921            ],
4922            "payment",
4923        );
4924        let paths: Vec<&str> = rows.iter().map(|(r, _)| r.symbol.path.as_str()).collect();
4925
4926        assert_eq!(paths, vec!["src/z.rs", "src/a.rs", "src/b.rs", "src/y.rs"]);
4927
4928        let by_length = ranked_names(
4929            vec![
4930                function("payment_gateway_client"),
4931                function("payment_gateway"),
4932            ],
4933            "gateway",
4934        );
4935        assert_eq!(by_length, vec!["payment_gateway", "payment_gateway_client"]);
4936    }
4937
4938    #[test]
4939    fn a_whole_token_name_outranks_a_substring_name_with_better_bm25() {
4940        let mut token = function("csr");
4941        token.bm25 = Some(-1.0);
4942        let mut substring = function("action_csrf_token");
4943        substring.bm25 = Some(-5.0);
4944
4945        let rows = ranked(vec![substring, token], "csr adjacency");
4946
4947        assert!(rows[0].0.score > rows[1].0.score);
4948        assert_eq!(rows[0].0.symbol.name, "csr");
4949    }
4950
4951    #[test]
4952    fn an_acronym_token_outranks_a_name_that_only_contains_it() {
4953        let mut token = function("http_client");
4954        token.bm25 = Some(-1.0);
4955        let mut substring = function("shttpd_config");
4956        substring.bm25 = Some(-5.0);
4957
4958        let rows = ranked(vec![substring, token], "http");
4959
4960        assert!(rows[0].0.score > rows[1].0.score);
4961        assert_eq!(rows[0].0.symbol.name, "http_client");
4962    }
4963
4964    #[test]
4965    fn explain_reports_the_sum_of_the_name_strengths() {
4966        let rows = ranked(vec![function("action_csrf_token")], "csr token");
4967
4968        assert_eq!(rows[0].1.name_strength, 4);
4969    }
4970
4971    #[test]
4972    fn snippets_follow_the_admitting_branch() {
4973        let mut word_row = function("parse_sidecar_file");
4974        word_row.word_match = true;
4975        word_row.result.snippet = Some("parse the [sha256] sidecar file".into());
4976        let mut name_row = function("parseSha256Sidecar");
4977        name_row.name_match = true;
4978        name_row.name_terms = vec!["sha".into(), "sha256".into(), "256".into()];
4979        let mut exact_row = function("sha256");
4980        exact_row.exact_name = true;
4981        let rows = ranked(vec![word_row, name_row, exact_row], "sha256");
4982        let snippets: Vec<(&str, &str)> = rows
4983            .iter()
4984            .map(|(r, _)| (r.symbol.name.as_str(), r.snippet.as_deref().unwrap()))
4985            .collect();
4986
4987        assert_eq!(
4988            snippets,
4989            vec![
4990                ("sha256", "sha256"),
4991                ("parseSha256Sidecar", "parse[Sha256]Sidecar"),
4992                ("parse_sidecar_file", "parse the [sha256] sidecar file"),
4993            ]
4994        );
4995        assert_eq!(rows[1].1.branches, vec!["name"]);
4996        assert_eq!(rows[0].1.branches, vec!["exact"]);
4997    }
4998
4999    #[test]
5000    fn explain_is_attached_only_when_requested() {
5001        let conn = sidecar_fixture();
5002        let query = "sha256";
5003
5004        let silent = fts_search_symbols_scoped(&conn, query, None, None, false, 10).unwrap();
5005        assert!(silent.iter().all(|r| r.explain.is_none()));
5006        assert!(silent[0].score > 0.0);
5007        assert_eq!(
5008            serde_json::to_value(&silent[0]).unwrap().get("explain"),
5009            None
5010        );
5011
5012        let explained =
5013            fts_search_symbols_explained(&conn, query, None, None, false, 10, true).unwrap();
5014        let by_name = |name: &str| {
5015            explained
5016                .iter()
5017                .find(|r| r.symbol.name == name)
5018                .and_then(|r| r.explain.as_ref())
5019                .unwrap()
5020        };
5021        let name_only = by_name("parseSha256Sidecar");
5022        assert_eq!(name_only.candidates, 2);
5023        assert_eq!(name_only.branches, vec!["name"]);
5024        assert_eq!(name_only.bm25, None);
5025        let word_row = by_name("parse_sidecar_file");
5026        assert!(word_row.bm25.unwrap() < 0.0);
5027        assert_eq!(word_row.candidates, 2);
5028        assert!(
5029            serde_json::to_value(&explained[0])
5030                .unwrap()
5031                .get("explain")
5032                .is_some()
5033        );
5034    }
5035
5036    #[test]
5037    fn search_symbols_treats_like_wildcards_as_literals() {
5038        let dir = crate::safe_tempdir();
5039        let db_path = dir.path().join("search_symbols_treats_like_wildcards.db");
5040        let conn = open_read_write(&db_path).unwrap();
5041        conn.execute_batch(
5042            "CREATE TABLE symbols (
5043                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5044                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5045                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5046                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5047                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5048                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5049                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5050            );
5051            INSERT INTO symbols VALUES (
5052                's', 'f', 'src/lib.rs', 'rust', 'ordinary', 'function', NULL, NULL, NULL, NULL,
5053                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5054            );
5055            INSERT INTO symbols VALUES (
5056                'p', 'f', 'src/lib.rs', 'rust', 'literal%name', 'function', NULL, NULL, NULL, NULL,
5057                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5058            );
5059            INSERT INTO symbols VALUES (
5060                'u', 'f', 'src/lib.rs', 'rust', 'literal_name', 'function', NULL, NULL, NULL, NULL,
5061                1, 0, 1, 0, 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 0
5062            );
5063            CREATE TABLE files (
5064                file_id TEXT, path TEXT, language TEXT, content_hash TEXT,
5065                content_bytes INTEGER, line_count INTEGER, indexed_at TEXT
5066            );
5067            INSERT INTO files VALUES ('f1', 'src/literal_path/lib.rs', 'rust', 'hash', 0, 0, 'now');
5068            INSERT INTO files VALUES ('f2', 'src/literalXpath/lib.rs', 'rust', 'hash', 0, 0, 'now'
5069            );",
5070        )
5071        .unwrap();
5072
5073        assert_eq!(
5074            search_symbols(&conn, "%", None, false, 10).unwrap()[0].name,
5075            "literal%name"
5076        );
5077        assert_eq!(
5078            search_symbols(&conn, "_", None, false, 10).unwrap()[0].name,
5079            "literal_name"
5080        );
5081        assert_eq!(
5082            load_scoped_files(&conn, Some("src/literal_path"))
5083                .unwrap()
5084                .len(),
5085            1
5086        );
5087    }
5088
5089    #[test]
5090    fn find_references_for_symbol_limits_callees_by_symbol_id() {
5091        let dir = crate::safe_tempdir();
5092        let db_path = dir.path().join("find_references_for_symbol.db");
5093        let conn = open_read_write(&db_path).unwrap();
5094        conn.execute_batch(
5095            "CREATE TABLE symbols (
5096                symbol_id TEXT, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5097                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5098                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5099                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5100                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5101                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5102                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5103            );
5104            CREATE TABLE relationships (
5105                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5106                start_line INTEGER, start_column INTEGER
5107            );
5108            CREATE TABLE pending_relationships (
5109                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5110                start_line INTEGER, start_column INTEGER
5111            );
5112            INSERT INTO symbols VALUES
5113                ('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),
5114                ('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),
5115                ('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),
5116                ('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);
5117            INSERT INTO relationships VALUES
5118                ('other', 'other-callee', 'calls', 'b.rs', 1, 0),
5119                ('wanted', 'wanted-callee', 'calls', 'a.rs', 1, 0);",
5120        )
5121        .unwrap();
5122
5123        let references = find_references_for_symbol(&conn, "new", "callees", 1, "wanted").unwrap();
5124        assert_eq!(references.len(), 1);
5125        assert_eq!(references[0].to_symbol_name, "wanted_dep");
5126    }
5127
5128    #[test]
5129    fn test_fts_search_symbols_and_porter_stemming() {
5130        let dir = crate::safe_tempdir();
5131        let db_path = dir.path().join("fts_search_symbols.db");
5132        let conn = open_read_write(&db_path).unwrap();
5133
5134        conn.execute_batch(
5135            "CREATE TABLE symbols (
5136                symbol_id TEXT PRIMARY KEY,
5137                file_id TEXT,
5138                path TEXT,
5139                language TEXT,
5140                name TEXT,
5141                kind TEXT,
5142                signature TEXT,
5143                doc_comment TEXT,
5144                visibility TEXT,
5145                parent_symbol_id TEXT,
5146                start_line INTEGER,
5147                start_column INTEGER,
5148                end_line INTEGER,
5149                end_column INTEGER,
5150                start_byte INTEGER,
5151                end_byte INTEGER,
5152                body_start_line INTEGER,
5153                body_start_column INTEGER,
5154                body_end_line INTEGER,
5155                body_end_column INTEGER,
5156                body_start_byte INTEGER,
5157                body_end_byte INTEGER,
5158                body_hash TEXT,
5159                semantic_group TEXT,
5160                is_test INTEGER,
5161                test_container INTEGER
5162            );
5163            INSERT INTO symbols VALUES (
5164                's1', 'f1', 'src/payment.rs', 'rust', 'PaymentGateway', 'trait',
5165                'pub trait PaymentGateway', 'Core payment provider interface for transactions',
5166                'pub', NULL, 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash1', 'type', 0, 0
5167            );
5168            INSERT INTO symbols VALUES (
5169                's2', 'f1', 'src/payment.rs', 'rust', 'StripeClient', 'struct',
5170                'pub struct StripeClient', 'Handles HTTP requests to stripe payment API',
5171                'pub', NULL, 25, 0, 35, 1, 300, 450, 27, 4, 34, 1, 320, 440, 'hash2', 'type', 0, 0
5172            );
5173            INSERT INTO symbols VALUES (
5174                's3', 'f2', 'src/parser.rs', 'rust', 'parse_tokens', 'function',
5175                'pub fn parse_tokens(stream: &TokenStream) -> Result<Vec<Token>>', 'Parses syntax tokens from stream',
5176                'pub', NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash3', 'function', 0, 0
5177            );
5178            INSERT INTO symbols VALUES (
5179                's4', 'f3', 'tests/payment_test.rs', 'rust', 'test_payment_flow', 'function',
5180                'fn test_payment_flow()', 'Tests payment charge workflow',
5181                NULL, NULL, 5, 0, 15, 1, 50, 200, 7, 4, 14, 1, 70, 190, 'hash4', 'function', 1, 0
5182            );",
5183        )
5184        .unwrap();
5185
5186        ensure_fts_index(&conn).unwrap();
5187
5188        // 1. Porter stemming match: 'parsing' matches 'parse_tokens' and 'Parses' docstring
5189        let results =
5190            fts_search_symbols_scoped(&conn, "parsing tokens", None, None, false, 10).unwrap();
5191        assert_eq!(results.len(), 1);
5192        assert_eq!(results[0].symbol.name, "parse_tokens");
5193        assert!(results[0].snippet.is_some());
5194
5195        // 2. Docstring conceptual search: 'transactions' matches 'PaymentGateway'
5196        let results =
5197            fts_search_symbols_scoped(&conn, "transactions", None, None, false, 10).unwrap();
5198        assert_eq!(results.len(), 1);
5199        assert_eq!(results[0].symbol.name, "PaymentGateway");
5200
5201        // 3. Test filter: searching 'payment' with include_tests=false ignores 'test_payment_flow'
5202        let results = fts_search_symbols_scoped(&conn, "payment", None, None, false, 10).unwrap();
5203        assert_eq!(results.len(), 2);
5204        assert!(results.iter().all(|r| !r.symbol.is_test));
5205
5206        // 4. Test filter: searching 'payment' with include_tests=true includes 'test_payment_flow'
5207        let results = fts_search_symbols_scoped(&conn, "payment", None, None, true, 10).unwrap();
5208        assert_eq!(results.len(), 3);
5209
5210        // 5. Fallback OR matching: multi-term where only some match
5211        let results =
5212            fts_search_symbols_scoped(&conn, "stripe kafka redis", None, None, false, 10).unwrap();
5213        assert_eq!(results.len(), 1);
5214        assert_eq!(results[0].symbol.name, "StripeClient");
5215    }
5216
5217    #[test]
5218    fn find_related_tests_returns_each_test_once_under_the_limit() {
5219        let dir = crate::safe_tempdir();
5220        let conn = open_read_write(&dir.path().join("related_tests_limit.db")).unwrap();
5221        conn.execute_batch(
5222            "CREATE TABLE symbols (
5223                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5224                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5225                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5226                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5227                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5228                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5229                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5230                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5231            );
5232            CREATE TABLE relationships (
5233                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5234                start_line INTEGER, start_column INTEGER
5235            );
5236            CREATE TABLE pending_relationships (
5237                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5238                start_line INTEGER, start_column INTEGER,
5239                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5240            );
5241            CREATE TABLE type_facts (
5242                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5243            );
5244            INSERT INTO symbols VALUES
5245                ('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),
5246                ('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),
5247                ('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);
5248            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
5249                ('t_a', 'compute', 'calls', 'tests/a.rs', 3, 4, NULL, NULL, 'compute'),
5250                ('t_a', 'compute', 'calls', 'tests/a.rs', 5, 4, NULL, NULL, 'compute'),
5251                ('t_a', 'compute', 'calls', 'tests/a.rs', 7, 4, NULL, NULL, 'compute'),
5252                ('t_a', 'compute', 'calls', 'tests/a.rs', 9, 4, NULL, NULL, 'compute'),
5253                ('t_a', 'compute', 'calls', 'tests/a.rs', 11, 4, NULL, NULL, 'compute'),
5254                ('t_b', 'compute', 'calls', 'tests/b.rs', 3, 4, NULL, NULL, 'compute');",
5255        )
5256        .unwrap();
5257        let target = get_symbol_by_name(&conn, "compute", None).unwrap().unwrap();
5258
5259        let tests = find_related_tests(&conn, &target, 5).unwrap();
5260
5261        let mut names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
5262        names.sort();
5263        assert_eq!(names, vec!["first_case", "second_case"]);
5264    }
5265
5266    #[test]
5267    fn documentation_rows_rank_after_code_in_search() {
5268        let dir = crate::safe_tempdir();
5269        let conn = open_read_write(&dir.path().join("doc_rank.db")).unwrap();
5270        conn.execute_batch(
5271            "CREATE TABLE symbols (
5272                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5273                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5274                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5275                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5276                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5277                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5278                semantic_group TEXT, is_test INTEGER, test_container INTEGER, content_type TEXT
5279            );
5280            INSERT INTO symbols VALUES
5281                ('s_doc', 'f1', 'docs/plans/018.adoc', 'asciidoc', 'Reconcile offline edits',
5282                 'heading', 'Reconcile offline edits', NULL, NULL, NULL,
5283                 3, 0, 3, 1, 10, 40, 3, 0, 3, 1, 10, 40, 'hash_doc', NULL, 0, 0, 'documentation'),
5284                ('s_code', 'f2', 'src/sync.rs', 'rust', 'reconcile_offline_edits', 'function',
5285                 'fn reconcile_offline_edits()', 'Reconcile offline edits at startup', 'pub', NULL,
5286                 10, 0, 20, 1, 100, 250, 12, 4, 19, 1, 120, 240, 'hash_code', NULL, 0, 0, 'code');",
5287        )
5288        .unwrap();
5289        ensure_fts_index(&conn).unwrap();
5290
5291        let results =
5292            fts_search_symbols_scoped(&conn, "reconcile offline edits", None, None, false, 10)
5293                .unwrap();
5294
5295        assert_eq!(results.len(), 2);
5296        assert_eq!(results[0].symbol.name, "reconcile_offline_edits");
5297        assert_eq!(results[1].symbol.name, "Reconcile offline edits");
5298    }
5299
5300    #[test]
5301    fn test_queries_nocase_and_path_normalization() {
5302        let conn = Connection::open_in_memory().unwrap();
5303        conn.execute_batch(
5304            "CREATE TABLE files (
5305                file_id TEXT PRIMARY KEY,
5306                path TEXT NOT NULL,
5307                language TEXT,
5308                content_hash TEXT,
5309                content_bytes INTEGER,
5310                line_count INTEGER,
5311                indexed_at INTEGER
5312            );
5313            CREATE TABLE symbols (
5314                symbol_id TEXT PRIMARY KEY,
5315                file_id TEXT,
5316                path TEXT NOT NULL,
5317                language TEXT,
5318                name TEXT,
5319                kind TEXT,
5320                signature TEXT,
5321                doc_comment TEXT,
5322                visibility TEXT,
5323                parent_symbol_id TEXT,
5324                start_line INTEGER,
5325                start_column INTEGER,
5326                end_line INTEGER,
5327                end_column INTEGER,
5328                start_byte INTEGER,
5329                end_byte INTEGER,
5330                body_start_line INTEGER,
5331                body_start_column INTEGER,
5332                body_end_line INTEGER,
5333                body_end_column INTEGER,
5334                body_start_byte INTEGER,
5335                body_end_byte INTEGER,
5336                body_hash TEXT,
5337                semantic_group TEXT,
5338                is_test INTEGER,
5339                test_container INTEGER
5340            );
5341            -- Insert with backslashes and mixed casing to verify defensive normalization and COLLATE NOCASE
5342            INSERT INTO files VALUES ('f1', 'src\\Payment.rs', 'rust', 'hash1', 100, 10, '2026-09-14T00:00:00Z');
5343            INSERT INTO symbols VALUES (
5344                's1', 'f1', 'src\\Payment.rs', 'rust', 'ProcessPayment', 'function',
5345                'pub fn ProcessPayment()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5346                2, 4, 4, 1, 10, 45, 'bhash', 'function', 0, 0
5347            );",
5348        )
5349        .unwrap();
5350
5351        // 1. get_file: query with uppercase, lowercase, and forward slashes
5352        let file = get_file(&conn, "SRC/PAYMENT.RS")
5353            .unwrap()
5354            .expect("File should be found");
5355        assert_eq!(
5356            file.path, "src/Payment.rs",
5357            "Path should be normalized to forward slashes"
5358        );
5359
5360        let file2 = get_file(&conn, "src/payment.rs")
5361            .unwrap()
5362            .expect("File should be found");
5363        assert_eq!(file2.path, "src/Payment.rs");
5364
5365        // 2. load_file_symbols: query with uppercase and forward slashes
5366        let syms = load_file_symbols(&conn, "SRC/PAYMENT.RS").unwrap();
5367        assert_eq!(syms.len(), 1);
5368        assert_eq!(
5369            syms[0].path, "src/Payment.rs",
5370            "Symbol path should be normalized to forward slashes"
5371        );
5372
5373        // 3. get_symbol_by_name with path filter
5374        let sym = get_symbol_by_name(&conn, "ProcessPayment", Some("SRC/PAYMENT.RS"))
5375            .unwrap()
5376            .expect("Symbol should be found with case-insensitive path filter");
5377        assert_eq!(sym.path, "src/Payment.rs");
5378    }
5379
5380    #[test]
5381    fn test_exact_case_prioritized_over_nocase() {
5382        let conn = Connection::open_in_memory().unwrap();
5383        conn.execute_batch(
5384            "CREATE TABLE files (
5385                file_id TEXT PRIMARY KEY,
5386                path TEXT NOT NULL,
5387                language TEXT,
5388                content_hash TEXT,
5389                content_bytes INTEGER,
5390                line_count INTEGER,
5391                indexed_at TEXT
5392            );
5393            CREATE TABLE symbols (
5394                symbol_id TEXT PRIMARY KEY,
5395                file_id TEXT,
5396                path TEXT NOT NULL,
5397                language TEXT,
5398                name TEXT NOT NULL,
5399                kind TEXT NOT NULL,
5400                signature TEXT,
5401                doc_comment TEXT,
5402                visibility TEXT,
5403                parent_symbol_id TEXT,
5404                start_line INTEGER,
5405                start_column INTEGER,
5406                end_line INTEGER,
5407                end_column INTEGER,
5408                start_byte INTEGER,
5409                end_byte INTEGER,
5410                body_start_line INTEGER,
5411                body_start_column INTEGER,
5412                body_end_line INTEGER,
5413                body_end_column INTEGER,
5414                body_start_byte INTEGER,
5415                body_end_byte INTEGER,
5416                body_hash TEXT,
5417                semantic_group TEXT,
5418                is_test INTEGER,
5419                test_container INTEGER
5420            );
5421            INSERT INTO files VALUES ('f1', 'src/Payment.rs', 'rust', 'h1', 100, 10, '2026-09-14T00:00:00Z');
5422            INSERT INTO files VALUES ('f2', 'src/payment.rs', 'rust', 'h2', 100, 10, '2026-09-14T00:00:00Z');
5423            INSERT INTO symbols VALUES (
5424                's1', 'f1', 'src/Payment.rs', 'rust', 'pay', 'function',
5425                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5426                2, 4, 4, 1, 10, 45, 'b1', 'function', 0, 0
5427            );
5428            INSERT INTO symbols VALUES (
5429                's2', 'f2', 'src/payment.rs', 'rust', 'pay', 'function',
5430                'pub fn pay()', NULL, 'pub', NULL, 1, 0, 5, 0, 0, 50,
5431                2, 4, 4, 1, 10, 45, 'b2', 'function', 0, 0
5432            );",
5433        )
5434        .unwrap();
5435
5436        // Exact match should return exact file, not conflate with sibling differing only by case
5437        let f_lower = get_file(&conn, "src/payment.rs").unwrap().unwrap();
5438        assert_eq!(f_lower.path, "src/payment.rs");
5439        assert_eq!(f_lower.file_id, "f2");
5440
5441        let f_upper = get_file(&conn, "src/Payment.rs").unwrap().unwrap();
5442        assert_eq!(f_upper.path, "src/Payment.rs");
5443        assert_eq!(f_upper.file_id, "f1");
5444
5445        let syms_lower = load_file_symbols(&conn, "src/payment.rs").unwrap();
5446        assert_eq!(syms_lower.len(), 1);
5447        assert_eq!(syms_lower[0].file_id, "f2");
5448
5449        let syms_upper = load_file_symbols(&conn, "src/Payment.rs").unwrap();
5450        assert_eq!(syms_upper.len(), 1);
5451        assert_eq!(syms_upper[0].file_id, "f1");
5452    }
5453
5454    #[test]
5455    fn test_conservative_pending_resolution_ignores_unmatched_namespace() {
5456        let dir = crate::safe_tempdir();
5457        let db_path = dir.path().join("conservative_resolution.db");
5458        let conn = open_read_write(&db_path).unwrap();
5459
5460        conn.execute_batch(
5461            "CREATE TABLE symbols (
5462                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT,
5463                name TEXT, kind TEXT, signature TEXT, doc_comment TEXT,
5464                visibility TEXT, parent_symbol_id TEXT, start_line INTEGER,
5465                start_column INTEGER, end_line INTEGER, end_column INTEGER,
5466                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5467                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5468                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5469                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5470            );
5471            CREATE TABLE relationships (
5472                from_symbol_id TEXT, to_symbol_id TEXT, kind TEXT, path TEXT,
5473                start_line INTEGER, start_column INTEGER
5474            );
5475            CREATE TABLE pending_relationships (
5476                from_symbol_id TEXT, target_terminal_name TEXT, kind TEXT, path TEXT,
5477                start_line INTEGER, start_column INTEGER,
5478                target_receiver TEXT, target_namespace_json TEXT, target_display_name TEXT
5479            );
5480            CREATE TABLE type_facts (
5481                type_fact_id TEXT, symbol_id TEXT, language TEXT, resolved_type TEXT, generic_params_json TEXT
5482            );
5483            -- Workspace struct Workspace and method Workspace::new
5484            INSERT INTO symbols VALUES
5485                ('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),
5486                ('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),
5487                ('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);
5488
5489            -- my_func calls Vec::new() (external namespace 'Vec')
5490            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
5491                ('s_caller', 'new', 'calls', 'src/caller.rs', 3, 8, NULL, '[\"Vec\"]', 'Vec::new');",
5492        )
5493        .unwrap();
5494
5495        // When include_external is false, calling Vec::new() should NOT resolve to Workspace::new()
5496        let sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5497        assert!(sigs.is_empty(), "Expected 0 signatures, got: {:?}", sigs);
5498
5499        let refs = find_references_for_symbol(&conn, "my_func", "callees", 10, "s_caller").unwrap();
5500        assert!(refs.is_empty(), "Expected 0 references, got: {:?}", refs);
5501
5502        // Caller references for Workspace::new should NOT list my_func
5503        let callers = find_references_for_symbol(&conn, "new", "callers", 10, "s_ws_new").unwrap();
5504        assert!(
5505            callers.is_empty(),
5506            "Expected 0 callers for Workspace::new, got: {:?}",
5507            callers
5508        );
5509
5510        // Blast radius for Workspace::new should NOT impact my_func (which only called Vec::new)
5511        let blast = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5512        assert!(
5513            !blast.impacted_symbols.iter().any(|s| s.name == "my_func"),
5514            "my_func should not be impacted before calling Workspace::new: {:?}",
5515            blast.impacted_symbols
5516        );
5517
5518        // Now add a call to Workspace::new()
5519        conn.execute(
5520            "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')",
5521            [],
5522        )
5523        .unwrap();
5524
5525        let sigs2 = find_callee_signatures(&conn, "my_func", "s_caller", 10, false).unwrap();
5526        assert_eq!(
5527            sigs2.len(),
5528            1,
5529            "Expected 1 signature for Workspace::new, got: {:?}",
5530            sigs2
5531        );
5532        assert!(sigs2[0].contains("pub fn new() -> Workspace"));
5533
5534        // Blast radius for Workspace::new should now include my_func
5535        let blast2 = compute_blast_radius(&conn, &["new"], &["src/workspace.rs"], 2, 20).unwrap();
5536        assert!(
5537            blast2.impacted_symbols.iter().any(|s| s.name == "my_func"),
5538            "my_func should be impacted after calling Workspace::new: {:?}",
5539            blast2.impacted_symbols
5540        );
5541
5542        // Add a bare call to new() from an unrelated caller s_other
5543        conn.execute(
5544            "INSERT INTO symbols VALUES
5545                ('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);",
5546            [],
5547        )
5548        .unwrap();
5549        conn.execute(
5550            "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')",
5551            [],
5552        )
5553        .unwrap();
5554
5555        // Bare call from unrelated function should NOT resolve to Workspace::new
5556        let sigs_other = find_callee_signatures(&conn, "other_func", "s_other", 10, false).unwrap();
5557        assert!(
5558            sigs_other.is_empty(),
5559            "Bare call to new() from outside Workspace should not resolve to Workspace::new: {:?}",
5560            sigs_other
5561        );
5562
5563        // A sibling method inside Workspace calling bare new() SHOULD resolve to Workspace::new
5564        conn.execute(
5565            "INSERT INTO symbols VALUES
5566                ('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);",
5567            [],
5568        )
5569        .unwrap();
5570        conn.execute(
5571            "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')",
5572            [],
5573        )
5574        .unwrap();
5575
5576        let sigs_sibling =
5577            find_callee_signatures(&conn, "helper", "s_ws_helper", 10, false).unwrap();
5578        assert_eq!(
5579            sigs_sibling.len(),
5580            1,
5581            "Sibling method calling bare new() should resolve to Workspace::new: {:?}",
5582            sigs_sibling
5583        );
5584
5585        // With include_external: true, external calls should be returned
5586        let ext_sigs = find_callee_signatures(&conn, "my_func", "s_caller", 10, true).unwrap();
5587        assert!(
5588            ext_sigs.iter().any(|s| s.contains("Vec")),
5589            "include_external: true should include external Vec::new: {:?}",
5590            ext_sigs
5591        );
5592    }
5593
5594    #[test]
5595    fn test_find_structural_facts_and_literals_scoped() {
5596        let dir = crate::safe_tempdir();
5597        let db_path = dir.path().join("facts_test.db");
5598        let conn = open_read_write(&db_path).unwrap();
5599        conn.execute_batch(
5600            "CREATE TABLE symbols (
5601                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT, kind TEXT,
5602                signature TEXT, doc_comment TEXT, visibility TEXT, parent_symbol_id TEXT,
5603                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5604                start_byte INTEGER, end_byte INTEGER, body_start_line INTEGER,
5605                body_start_column INTEGER, body_end_line INTEGER, body_end_column INTEGER,
5606                body_start_byte INTEGER, body_end_byte INTEGER, body_hash TEXT,
5607                semantic_group TEXT, is_test INTEGER, test_container INTEGER
5608            );
5609            CREATE TABLE structural_facts (
5610                structural_fact_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5611                pattern_id TEXT, capture_name TEXT, node_kind TEXT, containing_symbol_id TEXT,
5612                start_line INTEGER, end_line INTEGER, confidence REAL, metadata_json TEXT
5613            );
5614            CREATE TABLE literals (
5615                literal_id TEXT PRIMARY KEY, file_id TEXT, path TEXT NOT NULL, language TEXT,
5616                kind TEXT, literal_text TEXT, carrier TEXT, containing_symbol_id TEXT,
5617                start_line INTEGER, start_column INTEGER, end_line INTEGER, end_column INTEGER,
5618                start_byte INTEGER, end_byte INTEGER
5619            );
5620            INSERT INTO structural_facts VALUES
5621                ('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\"}'),
5622                ('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\"}'),
5623                ('sf_route', 'f2', 'src/routes/api.rs', 'rust', 'axum.route.v1', 'get_users', 'function', NULL, 10, 20, 1.0, '{\"verb\":\"GET\",\"normalized_route_template\":\"/api/v1/users/:id\"}'),
5624                ('sf_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql.select_query.v1', 'select_users', 'function', NULL, 30, 40, 1.0, NULL),
5625                ('sf_model', 'f4', 'src/models/user.rs', 'rust', 'sql.table_definition.v1', 'User', 'struct', NULL, 50, 60, 1.0, NULL),
5626                ('sf_css', 'f7', 'web/site.css', 'css', 'css.media_query.v1', 'media', 'media_statement', NULL, 1, 1, 1.0, NULL),
5627                ('sf_custom', 'f5', 'src/custom.rs', 'rust', 'my_custom_pattern', 'custom_name', 'item', NULL, 70, 80, 1.0, NULL);
5628            INSERT INTO literals VALUES
5629                ('lit_toml', 'f1', 'Cargo.toml', 'toml', 'toml_key', '\"version\"', 'key', NULL, 3, 0, 3, 9, 20, 29),
5630                ('lit_route', 'f2', 'src/routes/api.rs', 'rust', 'http_route', '\"/api/v1/users\"', 'string', NULL, 12, 0, 12, 15, 100, 115),
5631                ('lit_sql', 'f3', 'src/db/queries.rs', 'rust', 'sql_query', '\"SELECT * FROM users\"', 'string', NULL, 32, 0, 32, 21, 200, 221),
5632                ('lit_model', 'f4', 'src/models/user.rs', 'rust', 'model_table', '\"users_table\"', 'string', NULL, 52, 0, 52, 13, 300, 313);",
5633        )
5634        .unwrap();
5635
5636        // 1. "config" alias
5637        let facts_config = find_structural_facts_scoped(&conn, "config", None, 10).unwrap();
5638        assert_eq!(facts_config.len(), 2);
5639        assert_eq!(facts_config[0].pattern_id, "yaml.key_value.v1");
5640        assert_eq!(facts_config[0].key.as_deref(), Some("on.name"));
5641        assert_eq!(facts_config[1].pattern_id, "toml.key_value.v1");
5642        assert_eq!(
5643            facts_config[1].key.as_deref(),
5644            Some("mcp_servers.code-kb.command")
5645        );
5646        let lits_config = find_literals_scoped(&conn, "config", None, 10).unwrap();
5647        assert_eq!(lits_config.len(), 1);
5648        assert_eq!(lits_config[0].kind, "toml_key");
5649
5650        // 2. "route" and "routes" aliases
5651        let facts_route = find_structural_facts_scoped(&conn, "route", None, 10).unwrap();
5652        assert_eq!(facts_route.len(), 1);
5653        assert_eq!(facts_route[0].pattern_id, "axum.route.v1");
5654        assert_eq!(facts_route[0].key.as_deref(), Some("/api/v1/users/:id"));
5655        let facts_routes = find_structural_facts_scoped(&conn, "routes", None, 10).unwrap();
5656        assert_eq!(facts_routes.len(), 1);
5657        let lits_route = find_literals_scoped(&conn, "route", None, 10).unwrap();
5658        assert_eq!(lits_route.len(), 1);
5659        assert_eq!(lits_route[0].kind, "http_route");
5660
5661        // 3. "query", "queries", "sql" aliases
5662        for q in &["query", "queries", "sql"] {
5663            let facts = find_structural_facts_scoped(&conn, q, None, 10).unwrap();
5664            assert_eq!(facts.len(), 2, "Failed for {}", q);
5665            assert!(facts.iter().all(|f| f.pattern_id.starts_with("sql.")));
5666            let lits = find_literals_scoped(&conn, q, None, 10).unwrap();
5667            assert_eq!(lits.len(), 1, "Failed for {}", q);
5668            assert_eq!(lits[0].kind, "sql_query");
5669        }
5670
5671        // 4. "model" and "models" aliases
5672        for m in &["model", "models"] {
5673            let facts = find_structural_facts_scoped(&conn, m, None, 10).unwrap();
5674            assert_eq!(facts.len(), 1, "Failed for {}", m);
5675            assert_eq!(facts[0].pattern_id, "sql.table_definition.v1");
5676            let lits = find_literals_scoped(&conn, m, None, 10).unwrap();
5677            assert_eq!(lits.len(), 1, "Failed for {}", m);
5678            assert_eq!(lits[0].kind, "model_table");
5679        }
5680
5681        // 5. Custom / unknown category
5682        let facts_custom = find_structural_facts_scoped(&conn, "custom_pattern", None, 10).unwrap();
5683        assert_eq!(facts_custom.len(), 1);
5684        assert_eq!(facts_custom[0].pattern_id, "my_custom_pattern");
5685        assert_eq!(facts_custom[0].key, None);
5686
5687        // 6. Path filter: exact file match
5688        let facts_exact =
5689            find_structural_facts_scoped(&conn, "config", Some("Cargo.toml"), 10).unwrap();
5690        assert_eq!(facts_exact.len(), 1);
5691        let facts_miss =
5692            find_structural_facts_scoped(&conn, "config", Some("src/routes/api.rs"), 10).unwrap();
5693        assert_eq!(facts_miss.len(), 0);
5694
5695        // 7. Path filter: directory prefix
5696        let facts_dir =
5697            find_structural_facts_scoped(&conn, "route", Some("src/routes"), 10).unwrap();
5698        assert_eq!(facts_dir.len(), 1);
5699        let facts_dir_miss =
5700            find_structural_facts_scoped(&conn, "route", Some("src/db"), 10).unwrap();
5701        assert_eq!(facts_dir_miss.len(), 0);
5702
5703        // 8. Delegating find_structural_facts and find_literals
5704        let f_del = find_structural_facts(&conn, "config", 10).unwrap();
5705        assert_eq!(f_del.len(), 2);
5706        let l_del = find_literals(&conn, "config", 10).unwrap();
5707        assert_eq!(l_del.len(), 1);
5708    }
5709
5710    fn local_variable_fixture() -> Connection {
5711        let conn = Connection::open_in_memory().unwrap();
5712        conn.execute_batch(
5713            "CREATE TABLE symbols (
5714                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5715                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5716                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5717                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5718                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5719                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5720                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5721            );
5722            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5723                                 parent_symbol_id, start_line, start_column, end_line, end_column,
5724                                 start_byte, end_byte, is_test, test_container)
5725            VALUES
5726                ('func', 'f1', 'src/db.rs', 'rust', 'open_conn', 'function',
5727                 'fn open_conn() -> sqlite Connection', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5728                ('local', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5729                 'let conn: sqlite Connection', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5730                ('pool', 'f1', 'src/db.rs', 'rust', 'Pool', 'struct',
5731                 'struct Pool sqlite', NULL, 12, 0, 16, 1, 120, 200, 0, 0),
5732                ('field', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5733                 'conn: sqlite Connection', 'pool', 13, 4, 13, 28, 130, 160, 0, 0),
5734                ('global', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5735                 'static conn: sqlite Connection', NULL, 20, 0, 20, 30, 210, 240, 0, 0),
5736                ('closure', 'f1', 'src/db.rs', 'rust', 'with_conn', 'variable',
5737                 'let with_conn = |c: sqlite Connection|', 'func', 4, 4, 6, 5, 50, 90, 0, 0),
5738                ('nested', 'f1', 'src/db.rs', 'rust', 'conn', 'variable',
5739                 'let conn = c sqlite', 'closure', 5, 8, 5, 24, 60, 80, 0, 0);",
5740        )
5741        .unwrap();
5742        conn
5743    }
5744
5745    fn matched_symbol_ids(conn: &Connection, query: &str) -> Vec<String> {
5746        let mut stmt = conn
5747            .prepare(
5748                "SELECT s.symbol_id FROM symbols_fts f
5749                 JOIN symbols s ON s.rowid = f.rowid
5750                 WHERE f.symbols_fts MATCH ?1 ORDER BY s.symbol_id",
5751            )
5752            .unwrap();
5753        let mut ids = stmt
5754            .query_map(params![query], |row| row.get::<_, String>(0))
5755            .unwrap()
5756            .collect::<Result<Vec<_>, _>>()
5757            .unwrap();
5758        ids.sort();
5759        ids
5760    }
5761
5762    #[test]
5763    fn fts_index_excludes_locals_and_rebuilds_a_stale_index() {
5764        let conn = local_variable_fixture();
5765        conn.execute_batch(
5766            "CREATE VIRTUAL TABLE symbols_fts USING fts5(
5767                name, signature, doc_comment,
5768                content='symbols', content_rowid='rowid', tokenize='porter unicode61'
5769            );
5770            INSERT INTO symbols_fts(rowid, name, signature, doc_comment)
5771            SELECT rowid, name, signature, doc_comment FROM symbols;",
5772        )
5773        .unwrap();
5774
5775        ensure_fts_index(&conn).unwrap();
5776
5777        assert_eq!(
5778            matched_symbol_ids(&conn, "sqlite"),
5779            vec!["field", "func", "global", "pool"]
5780        );
5781    }
5782
5783    #[test]
5784    fn lookup_excludes_locals_and_parameters() {
5785        let conn = local_variable_fixture();
5786
5787        let ids: Vec<String> = search_symbols_scoped(&conn, "conn", None, None, false, 10)
5788            .unwrap()
5789            .into_iter()
5790            .map(|s| s.symbol_id)
5791            .collect();
5792
5793        assert!(!ids.contains(&"local".to_string()));
5794        assert!(!ids.contains(&"nested".to_string()));
5795        assert!(ids.contains(&"field".to_string()));
5796        assert!(ids.contains(&"global".to_string()));
5797    }
5798
5799    #[test]
5800    fn search_excludes_locals_and_parameters() {
5801        let conn = local_variable_fixture();
5802        ensure_fts_index(&conn).unwrap();
5803
5804        let ids: Vec<String> = fts_search_symbols_scoped(&conn, "sqlite", None, None, false, 10)
5805            .unwrap()
5806            .into_iter()
5807            .map(|r| r.symbol.symbol_id)
5808            .collect();
5809
5810        assert!(!ids.contains(&"local".to_string()));
5811        assert!(ids.contains(&"func".to_string()));
5812    }
5813
5814    #[test]
5815    fn variable_kind_search_keeps_full_text_matching() {
5816        let conn = local_variable_fixture();
5817        ensure_fts_index(&conn).unwrap();
5818
5819        let ids: Vec<String> = fts_search_symbols_scoped(
5820            &conn,
5821            "sqlite connection",
5822            Some("variable"),
5823            None,
5824            false,
5825            10,
5826        )
5827        .unwrap()
5828        .into_iter()
5829        .map(|r| r.symbol.symbol_id)
5830        .collect();
5831
5832        assert!(ids.contains(&"global".to_string()));
5833        assert!(ids.contains(&"field".to_string()));
5834    }
5835
5836    #[test]
5837    fn qualified_lookup_returns_the_named_local_variable() {
5838        let conn = local_variable_fixture();
5839
5840        let ids: Vec<String> =
5841            search_symbols_scoped(&conn, "open_conn::conn", None, None, false, 10)
5842                .unwrap()
5843                .into_iter()
5844                .map(|s| s.symbol_id)
5845                .collect();
5846
5847        assert_eq!(ids, vec!["local".to_string()]);
5848    }
5849
5850    #[test]
5851    fn exact_local_variable_outranks_a_partial_global_match_within_the_limit() {
5852        let conn = Connection::open_in_memory().unwrap();
5853        conn.execute_batch(
5854            "CREATE TABLE symbols (
5855                symbol_id TEXT PRIMARY KEY, file_id TEXT, path TEXT, language TEXT, name TEXT,
5856                kind TEXT, signature TEXT, doc_comment TEXT, visibility TEXT,
5857                parent_symbol_id TEXT, start_line INTEGER, start_column INTEGER,
5858                end_line INTEGER, end_column INTEGER, start_byte INTEGER, end_byte INTEGER,
5859                body_start_line INTEGER, body_start_column INTEGER, body_end_line INTEGER,
5860                body_end_column INTEGER, body_start_byte INTEGER, body_end_byte INTEGER,
5861                body_hash TEXT, semantic_group TEXT, is_test INTEGER, test_container INTEGER
5862            );
5863            INSERT INTO symbols (symbol_id, file_id, path, language, name, kind, signature,
5864                                 parent_symbol_id, start_line, start_column, end_line, end_column,
5865                                 start_byte, end_byte, is_test, test_container)
5866            VALUES
5867                ('func', 'f1', 'src/sum.rs', 'rust', 'digest', 'function',
5868                 'fn digest()', NULL, 1, 0, 9, 1, 0, 100, 0, 0),
5869                ('local', 'f1', 'src/sum.rs', 'rust', 'checksum', 'variable',
5870                 'let checksum', 'func', 2, 4, 2, 30, 10, 40, 0, 0),
5871                ('global', 'f1', 'src/sum.rs', 'rust', 'getChecksum', 'variable',
5872                 'const getChecksum', NULL, 20, 0, 20, 30, 210, 240, 0, 0);",
5873        )
5874        .unwrap();
5875        ensure_fts_index(&conn).unwrap();
5876
5877        let rows =
5878            fts_search_symbols_explained(&conn, "checksum", Some("variable"), None, false, 1, true)
5879                .unwrap();
5880
5881        assert_eq!(rows.len(), 1);
5882        assert_eq!(rows[0].symbol.symbol_id, "local");
5883        let explain = rows[0].explain.as_ref().unwrap();
5884        assert_eq!(explain.name_tier, "whole");
5885        assert_eq!(explain.branches, vec!["exact", "name"]);
5886        assert_eq!(explain.candidates, 2);
5887    }
5888
5889    #[test]
5890    fn variable_kind_filter_returns_locals_and_parameters() {
5891        let conn = local_variable_fixture();
5892        ensure_fts_index(&conn).unwrap();
5893
5894        let lookup_ids: Vec<String> =
5895            search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5896                .unwrap()
5897                .into_iter()
5898                .map(|s| s.symbol_id)
5899                .collect();
5900        assert!(lookup_ids.contains(&"local".to_string()));
5901        assert!(lookup_ids.contains(&"nested".to_string()));
5902
5903        let search_ids: Vec<String> =
5904            fts_search_symbols_scoped(&conn, "conn", Some("variable"), None, false, 10)
5905                .unwrap()
5906                .into_iter()
5907                .map(|r| r.symbol.symbol_id)
5908                .collect();
5909        assert!(search_ids.contains(&"local".to_string()));
5910    }
5911}