Skip to main content

rag_rat_core/query/
impact.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use rusqlite::{Connection, OptionalExtension, params};
4use serde::Serialize;
5
6use crate::query::graph::{self, GraphHop, GraphResolutionMode, GraphTraversalOptions};
7use crate::query::memory::{self, RepoMemoryEvidence};
8use crate::query::symbol::SymbolHit;
9
10#[derive(Debug, Serialize)]
11pub struct ImpactItem {
12    pub path: String,
13    pub language: String,
14    pub kind: String,
15    pub symbol: Option<String>,
16    pub category: String,
17    pub reason: String,
18    pub evidence: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ImpactSurfaceOptions {
23    pub resolution_mode: GraphResolutionMode,
24    pub include_tests: bool,
25    pub include_docs: bool,
26    pub include_git: bool,
27    pub include_papertrail: bool,
28    pub include_text_fallback: bool,
29    pub include_memories: bool,
30}
31
32#[derive(Debug, Serialize)]
33pub struct ImpactSurfaceReport {
34    pub query: ImpactSurfaceQuery,
35    pub direct_semantic_callers: Vec<GraphHop>,
36    pub direct_semantic_callees: Vec<GraphHop>,
37    pub import_export_dependents: Vec<ImpactItem>,
38    pub tests_touching_symbol_path: Vec<ImpactItem>,
39    pub docs_mentioning_symbol_path: Vec<ImpactItem>,
40    pub text_fallback_hits: Vec<ImpactItem>,
41    pub recent_commits_touching_symbol_path: Vec<ImpactItem>,
42    pub github_rationale_issues_prs: Vec<ImpactItem>,
43    pub repo_memories: RepoMemoryEvidence,
44    pub completeness_and_caveats: ImpactCompleteness,
45}
46
47#[derive(Debug, Serialize)]
48pub struct ImpactSurfaceQuery {
49    pub symbol_id: Option<i64>,
50    pub symbol_path: Option<String>,
51    pub query: Option<String>,
52    pub resolution: String,
53    pub include_tests: bool,
54    pub include_docs: bool,
55    pub include_git: bool,
56    pub include_papertrail: bool,
57    pub include_text_fallback: bool,
58    pub include_memories: bool,
59}
60
61#[derive(Debug, Default, Serialize)]
62pub struct ImpactCompleteness {
63    pub exact_graph_callers: u64,
64    pub graph_callees: u64,
65    pub text_fallback_hits: u64,
66    pub parser_failures: u64,
67    pub stale_files: u64,
68    pub memory_status: ImpactMemoryStatus,
69    pub caveats: Vec<String>,
70}
71
72#[derive(Debug, Default, Serialize)]
73pub struct ImpactMemoryStatus {
74    pub active: u64,
75    pub stale: u64,
76}
77
78impl Default for ImpactSurfaceOptions {
79    fn default() -> Self {
80        Self {
81            resolution_mode: GraphResolutionMode::Syntactic,
82            include_tests: true,
83            include_docs: true,
84            include_git: true,
85            include_papertrail: true,
86            include_text_fallback: true,
87            include_memories: true,
88        }
89    }
90}
91
92pub fn impact_surface(
93    conn: &Connection,
94    query: &str,
95    limit: u32,
96) -> anyhow::Result<Vec<ImpactItem>> {
97    impact_surface_with_options(conn, query, limit, GraphResolutionMode::Syntactic)
98}
99
100pub fn impact_surface_report_for_symbol(
101    conn: &Connection,
102    symbol: &SymbolHit,
103    limit: u32,
104    options: &ImpactSurfaceOptions,
105) -> anyhow::Result<ImpactSurfaceReport> {
106    let graph_options = GraphTraversalOptions {
107        resolution_mode: options.resolution_mode,
108        symbol_id: Some(symbol.symbol_id),
109        logical_symbol_id: symbol.logical_symbol_id,
110        ..Default::default()
111    };
112    let direct_semantic_callers =
113        graph::traverse_with_options(conn, &symbol.qualified_name, true, limit, &graph_options)?;
114    let direct_semantic_callees =
115        graph::traverse_with_options(conn, &symbol.qualified_name, false, limit, &graph_options)?;
116    let names = vec![symbol.name.clone(), symbol.qualified_name.clone()];
117    let import_export_dependents =
118        import_export_items(conn, symbol.symbol_id, &symbol.qualified_name, &names, limit)?;
119    let tests_touching_symbol_path =
120        if options.include_tests { test_items(conn, symbol, &names, limit)? } else { Vec::new() };
121    let docs_mentioning_symbol_path =
122        if options.include_docs { docs_items(conn, symbol, &names, limit)? } else { Vec::new() };
123    let text_fallback_hits = if options.include_text_fallback {
124        text_fallback_items(conn, symbol, &names, limit)?
125    } else {
126        Vec::new()
127    };
128    let recent_commits_touching_symbol_path = if options.include_git {
129        git_commit_items(conn, std::slice::from_ref(&symbol.path), limit)?
130    } else {
131        Vec::new()
132    };
133    let github_rationale_issues_prs = if options.include_papertrail {
134        let mut items = github_ref_items(conn, std::slice::from_ref(&symbol.path), limit)?;
135        items.extend(github_rationale_items(conn, &symbol.qualified_name, limit)?);
136        items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
137        items
138    } else {
139        Vec::new()
140    };
141    let repo_memories = if options.include_memories {
142        let crossed_edge_ids = direct_semantic_callers
143            .iter()
144            .chain(direct_semantic_callees.iter())
145            .map(|hop| hop.edge_id)
146            .collect::<Vec<_>>();
147        memory::memory_evidence_for_symbol_and_edges(conn, symbol, &crossed_edge_ids, limit)?
148    } else {
149        RepoMemoryEvidence { direct: Vec::new(), path_crossed: Vec::new(), stale: Vec::new() }
150    };
151    let mut caveats = vec![
152        "Graph evidence is tree-sitter/syntactic, not compiler-grade name resolution.".to_string(),
153    ];
154    if options.resolution_mode == GraphResolutionMode::Exact
155        && direct_semantic_callers.is_empty()
156        && !text_fallback_hits.is_empty()
157    {
158        caveats.push(format!(
159            "No exact graph callers found. Text search found {} symbol/path hits. This likely indicates graph extraction or resolution gaps.",
160            text_fallback_hits.len()
161        ));
162    }
163    Ok(ImpactSurfaceReport {
164        query: ImpactSurfaceQuery {
165            symbol_id: Some(symbol.symbol_id),
166            symbol_path: Some(symbol.qualified_name.clone()),
167            query: None,
168            resolution: options.resolution_mode.as_str().to_string(),
169            include_tests: options.include_tests,
170            include_docs: options.include_docs,
171            include_git: options.include_git,
172            include_papertrail: options.include_papertrail,
173            include_text_fallback: options.include_text_fallback,
174            include_memories: options.include_memories,
175        },
176        completeness_and_caveats: ImpactCompleteness {
177            exact_graph_callers: u64::try_from(direct_semantic_callers.len()).unwrap_or(u64::MAX),
178            graph_callees: u64::try_from(direct_semantic_callees.len()).unwrap_or(u64::MAX),
179            text_fallback_hits: u64::try_from(text_fallback_hits.len()).unwrap_or(u64::MAX),
180            parser_failures: parser_failure_count(conn)?,
181            stale_files: 0,
182            memory_status: ImpactMemoryStatus {
183                active: u64::try_from(
184                    repo_memories.direct.len() + repo_memories.path_crossed.len(),
185                )
186                .unwrap_or(u64::MAX),
187                stale: u64::try_from(repo_memories.stale.len()).unwrap_or(u64::MAX),
188            },
189            caveats,
190        },
191        direct_semantic_callers,
192        direct_semantic_callees,
193        import_export_dependents,
194        tests_touching_symbol_path,
195        docs_mentioning_symbol_path,
196        text_fallback_hits,
197        recent_commits_touching_symbol_path,
198        github_rationale_issues_prs,
199        repo_memories,
200    })
201}
202
203pub fn impact_surface_with_options(
204    conn: &Connection,
205    query: &str,
206    limit: u32,
207    resolution_mode: GraphResolutionMode,
208) -> anyhow::Result<Vec<ImpactItem>> {
209    impact_surface_from_targets(conn, query, None, limit, resolution_mode)
210}
211
212pub fn impact_surface_for_symbol(
213    conn: &Connection,
214    symbol: &SymbolHit,
215    limit: u32,
216    resolution_mode: GraphResolutionMode,
217) -> anyhow::Result<Vec<ImpactItem>> {
218    let target = SymbolTarget {
219        id: symbol.symbol_id,
220        file_id: symbol.file_id,
221        path: symbol.path.clone(),
222        language: symbol.language.clone(),
223        file_kind: symbol.file_kind.clone(),
224        name: symbol.name.clone(),
225        qualified_name: symbol.qualified_name.clone(),
226    };
227    impact_surface_from_targets(
228        conn,
229        &symbol.qualified_name,
230        Some(vec![target]),
231        limit,
232        resolution_mode,
233    )
234}
235
236fn impact_surface_from_targets(
237    conn: &Connection,
238    query: &str,
239    selected_targets: Option<Vec<SymbolTarget>>,
240    limit: u32,
241    resolution_mode: GraphResolutionMode,
242) -> anyhow::Result<Vec<ImpactItem>> {
243    let max_items = usize::try_from(limit).unwrap_or(usize::MAX);
244    let mut surface = ImpactSurface::default();
245    let targets = match selected_targets {
246        Some(targets) => targets,
247        None => exact_symbols(conn, query)?,
248    };
249    let target_names = target_names(query, &targets);
250
251    for symbol in &targets {
252        surface.push(
253            ImpactCategory::DirectStructural,
254            FileSymbol {
255                path: symbol.path.clone(),
256                language: symbol.language.clone(),
257                kind: symbol.file_kind.clone(),
258                symbol: Some(symbol.qualified_name.clone()),
259            },
260            "exact_symbol_definition",
261            format!("defined as {}", symbol.qualified_name),
262        );
263    }
264
265    graph_neighbors(conn, &targets, &target_names, true, resolution_mode, &mut surface)?;
266    graph_neighbors(conn, &targets, &target_names, false, resolution_mode, &mut surface)?;
267    import_export_dependents(conn, &targets, &target_names, &mut surface)?;
268    same_file_siblings(conn, &targets, &mut surface)?;
269
270    if surface.len() < max_items {
271        let remaining = max_items.saturating_sub(surface.len());
272        textual_fallback(conn, query, &mut surface, remaining)?;
273    }
274
275    let current_paths = surface.current_paths();
276    historical_evidence(conn, &current_paths, query, &mut surface, max_items)?;
277
278    Ok(surface.into_items(max_items))
279}
280
281pub fn ffi_surface(conn: &Connection, limit: u32) -> anyhow::Result<Vec<ImpactItem>> {
282    let mut stmt = conn.prepare(
283        "
284        WITH rust_exports AS (
285            SELECT DISTINCT
286                   files.path AS path,
287                   files.language AS language,
288                   files.kind AS kind,
289                   symbols.qualified_name AS symbol,
290                   CASE
291                       WHEN symbols.kind = 'impl' THEN 'rust_uniffi_exported_impl'
292                       ELSE 'rust_uniffi_export'
293                   END AS reason
294            FROM symbols
295            JOIN files ON files.id = symbols.file_id
296            JOIN symbol_facts
297              ON symbol_facts.symbol_id = symbols.id
298             AND symbol_facts.fact_kind = 'rust_attr'
299             AND symbol_facts.fact_value = 'uniffi_export'
300            WHERE files.language = 'rust'
301              AND symbols.kind IN ('function', 'method', 'impl', 'struct', 'enum', 'trait')
302        ),
303        rust_exported_impl_members AS (
304            SELECT DISTINCT
305                   files.path AS path,
306                   files.language AS language,
307                   files.kind AS kind,
308                   members.qualified_name AS symbol,
309                   'rust_uniffi_impl_member' AS reason
310            FROM symbols AS impls
311            JOIN files ON files.id = impls.file_id
312            JOIN symbol_facts
313              ON symbol_facts.symbol_id = impls.id
314             AND symbol_facts.fact_kind = 'rust_attr'
315             AND symbol_facts.fact_value = 'uniffi_export'
316            JOIN symbols AS members
317              ON members.file_id = impls.file_id
318             AND members.start_byte > impls.start_byte
319             AND members.end_byte < impls.end_byte
320             AND members.kind IN ('function', 'method')
321            WHERE files.language = 'rust'
322              AND impls.kind = 'impl'
323        ),
324        binding_refs AS (
325            -- Generated/binding artifacts detected by path. Detection is generic on purpose:
326            -- matching specific native-symbol substrings in chunk text was project-specific and
327            -- self-matched any source that merely mentions those names (e.g. this query). The
328            -- `#[uniffi::export]` symbol facts above are the principled, language-level signal.
329            SELECT DISTINCT
330                   files.path AS path,
331                   files.language AS language,
332                   files.kind AS kind,
333                   chunks.symbol_path AS symbol,
334                   'generated_binding_artifact' AS reason
335            FROM files
336            JOIN chunks ON chunks.file_id = files.id
337            WHERE files.path LIKE '%/src/generated/%'
338               OR files.path LIKE '%/generated/%'
339               OR files.path LIKE '%generated-manifest.json'
340        )
341        SELECT path, language, kind, symbol, reason FROM rust_exports
342        UNION
343        SELECT path, language, kind, symbol, reason FROM rust_exported_impl_members
344        UNION
345        SELECT path, language, kind, symbol, reason FROM binding_refs
346        ORDER BY reason, kind DESC, path
347        LIMIT ?1
348        ",
349    )?;
350    rows_to_items(stmt.query_map([limit], |row| {
351        let reason: String = row.get(4)?;
352        Ok(ImpactItem {
353            path: row.get(0)?,
354            language: row.get(1)?,
355            kind: row.get(2)?,
356            symbol: row.get(3)?,
357            category: ImpactCategory::ProbableTextual.as_str().to_string(),
358            reason: reason.clone(),
359            evidence: ffi_surface_evidence(&reason),
360        })
361    })?)
362}
363
364fn ffi_surface_evidence(reason: &str) -> Vec<String> {
365    let mut evidence = vec![format!("ffi_surface evidence class: {reason}")];
366    match reason {
367        "rust_uniffi_impl_member" => {
368            evidence.push(
369                "member symbol is inside a chunk containing an exported UniFFI impl".to_string(),
370            );
371            evidence.push(
372                "this row is not claiming the member itself has a #[uniffi::export] attribute"
373                    .to_string(),
374            );
375        },
376        "rust_uniffi_exported_impl" => {
377            evidence.push(
378                "exported impl/type surface; member rows are reported separately when symbols are available"
379                    .to_string(),
380            );
381        },
382        _ => {},
383    }
384    evidence
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
388enum ImpactCategory {
389    DirectStructural,
390    ProbableTextual,
391    HistoricalPapertrail,
392}
393
394impl ImpactCategory {
395    fn as_str(self) -> &'static str {
396        match self {
397            Self::DirectStructural => "Direct structural impact",
398            Self::ProbableTextual => "Probable textual impact",
399            Self::HistoricalPapertrail => "Historical/papertrail evidence",
400        }
401    }
402}
403
404#[derive(Debug, Clone)]
405struct FileSymbol {
406    path: String,
407    language: String,
408    kind: String,
409    symbol: Option<String>,
410}
411
412#[derive(Debug, Clone)]
413struct SymbolTarget {
414    id: i64,
415    file_id: i64,
416    path: String,
417    language: String,
418    file_kind: String,
419    name: String,
420    qualified_name: String,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
424struct ImpactKey {
425    category: &'static str,
426    path: String,
427    symbol: Option<String>,
428    reason: String,
429}
430
431#[derive(Default)]
432struct ImpactSurface {
433    items: BTreeMap<ImpactKey, ImpactItem>,
434}
435
436impl ImpactSurface {
437    fn len(&self) -> usize {
438        self.items.len()
439    }
440
441    fn push(
442        &mut self,
443        category: ImpactCategory,
444        file_symbol: FileSymbol,
445        reason: impl Into<String>,
446        evidence: impl Into<String>,
447    ) {
448        let reason = reason.into();
449        let key = ImpactKey {
450            category: category.as_str(),
451            path: file_symbol.path.clone(),
452            symbol: file_symbol.symbol.clone(),
453            reason: reason.clone(),
454        };
455        let item = self.items.entry(key).or_insert_with(|| ImpactItem {
456            path: file_symbol.path,
457            language: file_symbol.language,
458            kind: file_symbol.kind,
459            symbol: file_symbol.symbol,
460            category: category.as_str().to_string(),
461            reason,
462            evidence: Vec::new(),
463        });
464        let evidence = evidence.into();
465        if !item.evidence.iter().any(|value| value == &evidence) {
466            item.evidence.push(evidence);
467        }
468    }
469
470    fn current_paths(&self) -> Vec<String> {
471        let mut paths = BTreeSet::new();
472        for item in self.items.values() {
473            if item.category != ImpactCategory::HistoricalPapertrail.as_str() {
474                paths.insert(item.path.clone());
475            }
476        }
477        paths.into_iter().collect()
478    }
479
480    fn into_items(self, limit: usize) -> Vec<ImpactItem> {
481        let mut items = self.items.into_values().collect::<Vec<_>>();
482        items.sort_by_key(|item| {
483            (
484                category_rank(&item.category),
485                reason_rank(&item.reason),
486                item.path.clone(),
487                item.symbol.clone().unwrap_or_default(),
488            )
489        });
490        items.truncate(limit);
491        items
492    }
493}
494
495fn category_rank(category: &str) -> u8 {
496    match category {
497        "Direct structural impact" => 0,
498        "Probable textual impact" => 1,
499        "Historical/papertrail evidence" => 2,
500        _ => 3,
501    }
502}
503
504fn reason_rank(reason: &str) -> u8 {
505    match reason {
506        "exact_symbol_definition" => 0,
507        "direct_caller" => 1,
508        "direct_callee" => 2,
509        "import_export_dependent" => 3,
510        "same_file_sibling" => 4,
511        "textual_fallback" => 5,
512        "git_commit_touched_file" => 6,
513        "github_papertrail" => 7,
514        _ => 8,
515    }
516}
517
518fn exact_symbols(conn: &Connection, query: &str) -> anyhow::Result<Vec<SymbolTarget>> {
519    let candidates = symbol_query_candidates(query);
520    if candidates.is_empty() {
521        return Ok(Vec::new());
522    }
523    let mut stmt = conn.prepare(
524        "
525        SELECT symbols.id, symbols.file_id, files.path, files.language, files.kind,
526               symbols.name, symbols.qualified_name
527        FROM symbols
528        JOIN files ON files.id = symbols.file_id
529        WHERE symbols.name = ?1 OR symbols.qualified_name = ?1
530        ORDER BY files.kind, files.path, symbols.start_byte
531        ",
532    )?;
533    let mut targets = Vec::new();
534    let mut seen = BTreeSet::new();
535    let multi_candidate_query = candidates.len() > 1;
536    for candidate in candidates {
537        let qualified_candidate = is_qualified_symbol(candidate);
538        if multi_candidate_query && !qualified_candidate && !is_high_signal_query_token(candidate) {
539            continue;
540        }
541        let rows = stmt.query_map([candidate], |row| {
542            Ok(SymbolTarget {
543                id: row.get(0)?,
544                file_id: row.get(1)?,
545                path: row.get(2)?,
546                language: row.get(3)?,
547                file_kind: row.get(4)?,
548                name: row.get(5)?,
549                qualified_name: row.get(6)?,
550            })
551        })?;
552        let rows = collect_rows(rows)?;
553        if !qualified_candidate && !is_high_signal_symbol_candidate(&rows) {
554            continue;
555        }
556        for row in rows {
557            if seen.insert(row.id) {
558                targets.push(row);
559            }
560        }
561    }
562    Ok(targets)
563}
564
565fn is_high_signal_query_token(value: &str) -> bool {
566    value.contains('_') || value.chars().next().is_some_and(char::is_uppercase)
567}
568
569fn is_high_signal_symbol_candidate(rows: &[SymbolTarget]) -> bool {
570    match rows {
571        [] => false,
572        [_] => true,
573        [first, ..] if rows.len() <= 4 => {
574            rows.iter().all(|row| row.path == first.path && row.name == first.name)
575        },
576        _ => false,
577    }
578}
579
580fn target_names(query: &str, targets: &[SymbolTarget]) -> Vec<String> {
581    let mut names = BTreeSet::new();
582    for candidate in symbol_query_candidates(query) {
583        names.insert(candidate.to_string());
584        names.insert(short_symbol_name(candidate).to_string());
585    }
586    for target in targets {
587        names.insert(target.name.clone());
588        names.insert(target.qualified_name.clone());
589    }
590    names.into_iter().collect()
591}
592
593fn symbol_query_candidates(query: &str) -> Vec<&str> {
594    query
595        .split_whitespace()
596        .map(|token| {
597            token.trim_matches(|ch: char| {
598                !(ch.is_alphanumeric() || matches!(ch, '_' | ':' | '/' | '.' | '-'))
599            })
600        })
601        .filter(|token| !token.is_empty())
602        .filter(|token| token.contains("::") || is_non_stopword_identifier(token))
603        .collect()
604}
605
606fn is_non_stopword_identifier(value: &str) -> bool {
607    let mut chars = value.chars();
608    let Some(first) = chars.next() else {
609        return false;
610    };
611    let is_identifier = (first == '_' || first.is_ascii_alphabetic())
612        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric());
613    is_identifier
614        && !matches!(
615            value,
616            "of" | "in"
617                | "to"
618                | "from"
619                | "for"
620                | "and"
621                | "or"
622                | "the"
623                | "callers"
624                | "callee"
625                | "callees"
626                | "caller"
627                | "impact"
628                | "symbol"
629        )
630}
631
632fn short_symbol_name(value: &str) -> &str {
633    value.rsplit([':', '.', '#', '/']).find(|part| !part.is_empty()).unwrap_or(value)
634}
635
636fn is_qualified_symbol(value: &str) -> bool {
637    value.contains("::") || value.contains('/')
638}
639
640fn graph_neighbors(
641    conn: &Connection,
642    targets: &[SymbolTarget],
643    target_names: &[String],
644    reverse: bool,
645    resolution_mode: GraphResolutionMode,
646    surface: &mut ImpactSurface,
647) -> anyhow::Result<()> {
648    let reason = if reverse { "direct_caller" } else { "direct_callee" };
649    let source_path_col = if reverse {
650        "COALESCE(source_files.path, from_files.path)"
651    } else {
652        "COALESCE(to_files.path, source_files.path)"
653    };
654    let source_language_col = if reverse {
655        "COALESCE(source_files.language, from_files.language)"
656    } else {
657        "COALESCE(to_files.language, source_files.language)"
658    };
659    let source_kind_col = if reverse {
660        "COALESCE(source_files.kind, from_files.kind)"
661    } else {
662        "COALESCE(to_files.kind, source_files.kind)"
663    };
664    let source_symbol_col = if reverse {
665        "COALESCE(from_symbols.qualified_name, edges.from_name)"
666    } else {
667        "COALESCE(to_symbols.qualified_name, edges.to_name)"
668    };
669    let predicate = impact_graph_predicate(reverse, resolution_mode);
670    let sql = format!(
671        "
672        SELECT {source_path_col}, {source_language_col}, {source_kind_col},
673               {source_symbol_col}, edges.edge_kind, edges.confidence
674        FROM edges
675        LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
676        LEFT JOIN files from_files ON from_files.id = from_symbols.file_id
677        LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
678        LEFT JOIN files to_files ON to_files.id = to_symbols.file_id
679        LEFT JOIN files source_files ON source_files.id = edges.source_file_id
680        WHERE edges.edge_kind IN ('calls_name', 'constructs', 'implements')
681          AND ({predicate})
682          AND {source_path_col} IS NOT NULL
683        ORDER BY
684            CASE edges.confidence
685                WHEN 'Exact' THEN 0
686                WHEN 'Syntactic' THEN 1
687                WHEN 'NameOnly' THEN 2
688                ELSE 3
689            END,
690            edges.edge_kind,
691            {source_path_col},
692            {source_symbol_col}
693        ",
694    );
695    let mut stmt = conn.prepare(&sql)?;
696    for target in targets {
697        let rows = stmt.query_map(params![target.id, target.qualified_name], |row| {
698            Ok((
699                row.get::<_, String>(0)?,
700                row.get::<_, String>(1)?,
701                row.get::<_, String>(2)?,
702                row.get::<_, Option<String>>(3)?,
703                row.get::<_, String>(4)?,
704                row.get::<_, String>(5)?,
705            ))
706        })?;
707        for row in rows {
708            let (path, language, kind, symbol, edge_kind, confidence) = row?;
709            surface.push(
710                ImpactCategory::DirectStructural,
711                FileSymbol { path, language, kind, symbol },
712                reason,
713                format!("{edge_kind} edge to {} ({confidence})", target.qualified_name),
714            );
715        }
716    }
717    for name in target_names {
718        if resolution_mode != GraphResolutionMode::Fuzzy && !is_qualified_symbol(name) {
719            continue;
720        }
721        let rows = stmt.query_map(params![Option::<i64>::None, name], |row| {
722            Ok((
723                row.get::<_, String>(0)?,
724                row.get::<_, String>(1)?,
725                row.get::<_, String>(2)?,
726                row.get::<_, Option<String>>(3)?,
727                row.get::<_, String>(4)?,
728                row.get::<_, String>(5)?,
729            ))
730        })?;
731        for row in rows {
732            let (path, language, kind, symbol, edge_kind, confidence) = row?;
733            surface.push(
734                ImpactCategory::DirectStructural,
735                FileSymbol { path, language, kind, symbol },
736                reason,
737                format!("{edge_kind} edge matching {name} ({confidence})"),
738            );
739        }
740    }
741    Ok(())
742}
743
744fn impact_graph_predicate(reverse: bool, mode: GraphResolutionMode) -> &'static str {
745    match (reverse, mode) {
746        (true, GraphResolutionMode::Exact) => "edges.to_symbol_id = ?1",
747        (false, GraphResolutionMode::Exact) => {
748            "edges.from_symbol_id = ?1 AND edges.to_symbol_id IS NOT NULL"
749        },
750        (true, GraphResolutionMode::Syntactic) => {
751            "edges.to_symbol_id = ?1 OR edges.target_qualified_name = ?2"
752        },
753        (false, GraphResolutionMode::Syntactic) => {
754            "(edges.from_symbol_id = ?1 OR edges.from_name = ?2)
755             AND (edges.to_symbol_id IS NOT NULL OR edges.target_qualified_name IS NOT NULL)"
756        },
757        (true, GraphResolutionMode::Fuzzy) => "edges.to_symbol_id = ?1 OR edges.to_name = ?2",
758        (false, GraphResolutionMode::Fuzzy) => "edges.from_symbol_id = ?1 OR edges.from_name = ?2",
759    }
760}
761
762fn import_export_dependents(
763    conn: &Connection,
764    targets: &[SymbolTarget],
765    target_names: &[String],
766    surface: &mut ImpactSurface,
767) -> anyhow::Result<()> {
768    let mut stmt = conn.prepare(
769        "
770        SELECT files.path, files.language, files.kind, edges.from_name,
771               edges.edge_kind, edges.confidence
772        FROM edges
773        JOIN files ON files.id = edges.source_file_id
774        WHERE edges.edge_kind IN ('imports', 'exports')
775          AND (edges.to_symbol_id = ?1 OR edges.to_name = ?2)
776        ORDER BY files.kind, files.path, edges.edge_kind
777        ",
778    )?;
779    for target in targets {
780        let rows = stmt.query_map(params![target.id, target.qualified_name], import_export_row)?;
781        push_import_export_rows(rows, target.qualified_name.as_str(), surface)?;
782    }
783    for name in target_names {
784        let rows = stmt.query_map(params![Option::<i64>::None, name], import_export_row)?;
785        push_import_export_rows(rows, name, surface)?;
786    }
787    Ok(())
788}
789
790fn import_export_row(
791    row: &rusqlite::Row<'_>,
792) -> rusqlite::Result<(String, String, String, Option<String>, String, String)> {
793    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?))
794}
795
796fn push_import_export_rows(
797    rows: rusqlite::MappedRows<
798        '_,
799        impl FnMut(
800            &rusqlite::Row<'_>,
801        )
802            -> rusqlite::Result<(String, String, String, Option<String>, String, String)>,
803    >,
804    target: &str,
805    surface: &mut ImpactSurface,
806) -> anyhow::Result<()> {
807    for row in rows {
808        let (path, language, kind, symbol, edge_kind, confidence) = row?;
809        surface.push(
810            ImpactCategory::DirectStructural,
811            FileSymbol { path, language, kind, symbol },
812            "import_export_dependent",
813            format!("{edge_kind} edge matching {target} ({confidence})"),
814        );
815    }
816    Ok(())
817}
818
819fn same_file_siblings(
820    conn: &Connection,
821    targets: &[SymbolTarget],
822    surface: &mut ImpactSurface,
823) -> anyhow::Result<()> {
824    let mut stmt = conn.prepare(
825        "
826        SELECT files.path, files.language, files.kind, symbols.qualified_name
827        FROM symbols
828        JOIN files ON files.id = symbols.file_id
829        WHERE symbols.file_id = ?1 AND symbols.id != ?2
830        ORDER BY symbols.start_byte
831        LIMIT 20
832        ",
833    )?;
834    for target in targets {
835        let rows = stmt.query_map(params![target.file_id, target.id], |row| {
836            Ok(FileSymbol {
837                path: row.get(0)?,
838                language: row.get(1)?,
839                kind: row.get(2)?,
840                symbol: row.get(3)?,
841            })
842        })?;
843        for row in rows {
844            surface.push(
845                ImpactCategory::DirectStructural,
846                row?,
847                "same_file_sibling",
848                format!("shares file with {}", target.qualified_name),
849            );
850        }
851    }
852    Ok(())
853}
854
855fn textual_fallback(
856    conn: &Connection,
857    query: &str,
858    surface: &mut ImpactSurface,
859    limit: usize,
860) -> anyhow::Result<()> {
861    if limit == 0 {
862        return Ok(());
863    }
864    let like = format!("%{query}%");
865    let mut stmt = conn.prepare(
866        "
867        SELECT DISTINCT files.path, files.language, files.kind, symbols.qualified_name,
868               CASE
869                   WHEN files.path LIKE ?1 THEN 'path LIKE fallback'
870                   WHEN symbols.name LIKE ?1 OR symbols.qualified_name LIKE ?1 THEN 'symbol LIKE fallback'
871                   ELSE 'chunk text LIKE fallback'
872               END
873        FROM files
874        LEFT JOIN symbols ON symbols.file_id = files.id
875        LEFT JOIN chunks ON chunks.file_id = files.id
876        WHERE files.path LIKE ?1
877           OR symbols.name LIKE ?1
878           OR symbols.qualified_name LIKE ?1
879           OR chunks.text LIKE ?1
880        ORDER BY files.kind, files.path, symbols.qualified_name
881        LIMIT ?2
882        ",
883    )?;
884    let rows = stmt.query_map(params![like, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
885        Ok((
886            FileSymbol {
887                path: row.get(0)?,
888                language: row.get(1)?,
889                kind: row.get(2)?,
890                symbol: row.get(3)?,
891            },
892            row.get::<_, String>(4)?,
893        ))
894    })?;
895    for row in rows {
896        let (file_symbol, evidence) = row?;
897        surface.push(ImpactCategory::ProbableTextual, file_symbol, "textual_fallback", evidence);
898    }
899    Ok(())
900}
901
902fn import_export_items(
903    conn: &Connection,
904    symbol_id: i64,
905    qualified_name: &str,
906    names: &[String],
907    limit: u32,
908) -> anyhow::Result<Vec<ImpactItem>> {
909    let mut items = Vec::new();
910    let mut stmt = conn.prepare(
911        "
912        SELECT files.path, files.language, files.kind, edges.from_name,
913               edges.edge_kind, edges.confidence
914        FROM edges
915        JOIN files ON files.id = edges.source_file_id
916        WHERE edges.edge_kind IN ('imports', 'exports')
917          AND (edges.to_symbol_id = ?1 OR edges.to_name = ?2)
918        ORDER BY files.kind, files.path, edges.edge_kind
919        LIMIT ?3
920        ",
921    )?;
922    for name in std::iter::once(qualified_name).chain(names.iter().map(String::as_str)) {
923        let rows = stmt.query_map(params![symbol_id, name, i64::from(limit)], |row| {
924            impact_item_row(row, "Import/export dependents", "import_export_dependent")
925        })?;
926        items.extend(rows_to_items(rows)?);
927        if items.len() >= usize::try_from(limit).unwrap_or(usize::MAX) {
928            break;
929        }
930    }
931    dedupe_items(&mut items);
932    items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
933    Ok(items)
934}
935
936fn test_items(
937    conn: &Connection,
938    symbol: &SymbolHit,
939    names: &[String],
940    limit: u32,
941) -> anyhow::Result<Vec<ImpactItem>> {
942    let mut items = Vec::new();
943    for name in names_for_like(symbol, names) {
944        items.extend(section_like_items(
945            conn,
946            &name,
947            "Tests touching this symbol/path",
948            "test_mentions_symbol_or_path",
949            "
950            files.kind = 'source'
951            AND (
952                files.path LIKE '%test%'
953                OR files.path LIKE '%spec%'
954                OR chunks.text LIKE '%#[cfg(test)]%'
955                OR chunks.text LIKE '%describe(%'
956                OR chunks.text LIKE '%it(%'
957                OR chunks.text LIKE '%test(%'
958            )
959            ",
960            limit,
961        )?);
962    }
963    let mut items = collapse_by_path(items);
964    items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
965    Ok(items)
966}
967
968fn docs_items(
969    conn: &Connection,
970    symbol: &SymbolHit,
971    names: &[String],
972    limit: u32,
973) -> anyhow::Result<Vec<ImpactItem>> {
974    let mut items = Vec::new();
975    for name in names_for_like(symbol, names) {
976        items.extend(section_like_items(
977            conn,
978            &name,
979            "Docs mentioning symbol/path",
980            "docs_mentions_symbol_or_path",
981            "files.kind = 'docs'",
982            limit,
983        )?);
984    }
985    let mut items = collapse_by_path(items);
986    items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
987    Ok(items)
988}
989
990fn text_fallback_items(
991    conn: &Connection,
992    symbol: &SymbolHit,
993    names: &[String],
994    limit: u32,
995) -> anyhow::Result<Vec<ImpactItem>> {
996    let mut items = Vec::new();
997    for name in names_for_like(symbol, names) {
998        items.extend(section_like_items(
999            conn,
1000            &name,
1001            "Text fallback hits",
1002            "text_fallback",
1003            "1 = 1",
1004            limit,
1005        )?);
1006    }
1007    let mut items = collapse_by_path(items);
1008    items.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
1009    Ok(items)
1010}
1011
1012fn names_for_like(symbol: &SymbolHit, names: &[String]) -> Vec<String> {
1013    let mut out = BTreeSet::new();
1014    out.insert(symbol.name.clone());
1015    out.insert(symbol.qualified_name.clone());
1016    out.insert(symbol.path.clone());
1017    for name in names {
1018        out.insert(name.clone());
1019    }
1020    out.into_iter().collect()
1021}
1022
1023fn section_like_items(
1024    conn: &Connection,
1025    needle: &str,
1026    category: &str,
1027    reason: &str,
1028    filter: &str,
1029    limit: u32,
1030) -> anyhow::Result<Vec<ImpactItem>> {
1031    let like = format!("%{needle}%");
1032    // Collapse to ONE row per file. The previous `LEFT JOIN symbols` without aggregation fanned a
1033    // file out into one row per symbol whenever the match was file-level (path or chunk text),
1034    // flooding the output and letting one big file starve the `LIMIT` (see issue #48). Grouping by
1035    // file keeps the match kind (symbol > path > chunk text) and names the symbol only when it was
1036    // a genuine symbol match.
1037    let sql = format!(
1038        "
1039        SELECT files.path, files.language, files.kind,
1040               MAX(CASE WHEN symbols.name LIKE ?1 OR symbols.qualified_name LIKE ?1
1041                        THEN symbols.qualified_name END) AS matched_symbol,
1042               MAX(CASE WHEN files.path LIKE ?1 THEN 1 ELSE 0 END) AS path_match
1043        FROM files
1044        LEFT JOIN symbols ON symbols.file_id = files.id
1045        LEFT JOIN chunks ON chunks.file_id = files.id
1046        WHERE ({filter})
1047          AND (
1048              files.path LIKE ?1
1049              OR symbols.name LIKE ?1
1050              OR symbols.qualified_name LIKE ?1
1051              OR chunks.text LIKE ?1
1052          )
1053        GROUP BY files.path, files.language, files.kind
1054        ORDER BY files.kind, files.path
1055        LIMIT ?2
1056        "
1057    );
1058    let mut stmt = conn.prepare(&sql)?;
1059    let rows = stmt.query_map(params![like, i64::from(limit)], |row| {
1060        let matched_symbol: Option<String> = row.get(3)?;
1061        let path_match: i64 = row.get(4)?;
1062        // Precedence is path > symbol > chunk text. A path match is checked first because a
1063        // qualified name is `path::symbol`, so when the needle is the file's own path EVERY symbol
1064        // in it matches `qualified_name LIKE` — naming any one of them (the lexically-greatest)
1065        // would be spurious. A genuine symbol-name match (needle is the bare symbol) still names
1066        // the symbol.
1067        let (symbol, match_kind) = if path_match == 1 {
1068            (None, "path match")
1069        } else if let Some(symbol) = matched_symbol {
1070            (Some(symbol), "symbol match")
1071        } else {
1072            (None, "chunk text match")
1073        };
1074        Ok(ImpactItem {
1075            path: row.get(0)?,
1076            language: row.get(1)?,
1077            kind: row.get(2)?,
1078            symbol,
1079            category: category.to_string(),
1080            reason: reason.to_string(),
1081            evidence: vec![format!("{match_kind} for `{needle}`")],
1082        })
1083    })?;
1084    rows_to_items(rows)
1085}
1086
1087fn git_commit_items(
1088    conn: &Connection,
1089    paths: &[String],
1090    limit: u32,
1091) -> anyhow::Result<Vec<ImpactItem>> {
1092    let mut surface = ImpactSurface::default();
1093    git_commits_for_paths(conn, paths, &mut surface, usize::try_from(limit).unwrap_or(usize::MAX))?;
1094    Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1095}
1096
1097fn github_ref_items(
1098    conn: &Connection,
1099    paths: &[String],
1100    limit: u32,
1101) -> anyhow::Result<Vec<ImpactItem>> {
1102    let mut surface = ImpactSurface::default();
1103    github_refs_for_paths(conn, paths, &mut surface, usize::try_from(limit).unwrap_or(usize::MAX))?;
1104    Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1105}
1106
1107fn github_rationale_items(
1108    conn: &Connection,
1109    query: &str,
1110    limit: u32,
1111) -> anyhow::Result<Vec<ImpactItem>> {
1112    let mut surface = ImpactSurface::default();
1113    github_rationale_for_query(
1114        conn,
1115        query,
1116        &mut surface,
1117        usize::try_from(limit).unwrap_or(usize::MAX),
1118    )?;
1119    Ok(surface.into_items(usize::try_from(limit).unwrap_or(usize::MAX)))
1120}
1121
1122fn impact_item_row(
1123    row: &rusqlite::Row<'_>,
1124    category: &'static str,
1125    reason: &'static str,
1126) -> rusqlite::Result<ImpactItem> {
1127    Ok(ImpactItem {
1128        path: row.get(0)?,
1129        language: row.get(1)?,
1130        kind: row.get(2)?,
1131        symbol: row.get(3)?,
1132        category: category.to_string(),
1133        reason: reason.to_string(),
1134        evidence: vec![format!("{} edge ({})", row.get::<_, String>(4)?, row.get::<_, String>(5)?)],
1135    })
1136}
1137
1138/// Collapse a file-granularity section (tests / docs / text fallback) to one row per file. Across
1139/// the several search needles (symbol name, qualified name, path) the same file can surface more
1140/// than once — keep a single representative per path, preferring the row that named a symbol (a
1141/// symbol match) over a bare path/chunk match so the more specific evidence wins.
1142fn collapse_by_path(items: Vec<ImpactItem>) -> Vec<ImpactItem> {
1143    use std::collections::btree_map::Entry;
1144
1145    let mut by_path: BTreeMap<String, ImpactItem> = BTreeMap::new();
1146    for item in items {
1147        match by_path.entry(item.path.clone()) {
1148            Entry::Vacant(slot) => {
1149                slot.insert(item);
1150            },
1151            Entry::Occupied(mut slot) => {
1152                if slot.get().symbol.is_none() && item.symbol.is_some() {
1153                    slot.insert(item);
1154                }
1155            },
1156        }
1157    }
1158    by_path.into_values().collect()
1159}
1160
1161fn dedupe_items(items: &mut Vec<ImpactItem>) {
1162    let mut seen = BTreeSet::new();
1163    items.retain(|item| {
1164        seen.insert((
1165            item.category.clone(),
1166            item.path.clone(),
1167            item.symbol.clone(),
1168            item.reason.clone(),
1169        ))
1170    });
1171}
1172
1173fn parser_failure_count(conn: &Connection) -> anyhow::Result<u64> {
1174    let count: i64 =
1175        conn.query_row("SELECT COUNT(*) FROM parser_failures", [], |row| row.get(0))?;
1176    Ok(u64::try_from(count).unwrap_or(0))
1177}
1178
1179fn historical_evidence(
1180    conn: &Connection,
1181    paths: &[String],
1182    query: &str,
1183    surface: &mut ImpactSurface,
1184    limit: usize,
1185) -> anyhow::Result<()> {
1186    if paths.is_empty() || surface.len() >= limit {
1187        return Ok(());
1188    }
1189    git_commits_for_paths(conn, paths, surface, limit.saturating_sub(surface.len()))?;
1190    if surface.len() >= limit {
1191        return Ok(());
1192    }
1193    github_refs_for_paths(conn, paths, surface, limit.saturating_sub(surface.len()))?;
1194    if surface.len() >= limit {
1195        return Ok(());
1196    }
1197    github_rationale_for_query(conn, query, surface, limit.saturating_sub(surface.len()))?;
1198    Ok(())
1199}
1200
1201fn git_commits_for_paths(
1202    conn: &Connection,
1203    paths: &[String],
1204    surface: &mut ImpactSurface,
1205    limit: usize,
1206) -> anyhow::Result<()> {
1207    let mut remaining = limit;
1208    let mut stmt = conn.prepare(
1209        "
1210        SELECT files.path, files.language, files.kind,
1211               git_commits.hash, git_commits.subject, git_commits.authored_at_s
1212        FROM git_file_changes
1213        JOIN git_commits ON git_commits.hash = git_file_changes.commit_hash
1214        LEFT JOIN files ON files.path = git_file_changes.path
1215        WHERE git_file_changes.path = ?1
1216        ORDER BY git_commits.authored_at_s DESC, git_commits.hash
1217        LIMIT ?2
1218        ",
1219    )?;
1220    for path in paths {
1221        if remaining == 0 {
1222            break;
1223        }
1224        let file = file_for_path(conn, path)?;
1225        let rows =
1226            stmt.query_map(params![path, i64::try_from(remaining).unwrap_or(i64::MAX)], |row| {
1227                Ok((
1228                    row.get::<_, Option<String>>(0)?,
1229                    row.get::<_, Option<String>>(1)?,
1230                    row.get::<_, Option<String>>(2)?,
1231                    row.get::<_, String>(3)?,
1232                    row.get::<_, String>(4)?,
1233                    row.get::<_, i64>(5)?,
1234                ))
1235            })?;
1236        for row in rows {
1237            let (row_path, language, kind, hash, subject, authored_at_s) = row?;
1238            let file_symbol = FileSymbol {
1239                path: row_path.unwrap_or_else(|| file.path.clone()),
1240                language: language.unwrap_or_else(|| file.language.clone()),
1241                kind: kind.unwrap_or_else(|| file.kind.clone()),
1242                symbol: None,
1243            };
1244            surface.push(
1245                ImpactCategory::HistoricalPapertrail,
1246                file_symbol,
1247                "git_commit_touched_file",
1248                format!("{} touched {path} at {authored_at_s}: {subject}", short_hash(&hash)),
1249            );
1250            remaining = remaining.saturating_sub(1);
1251            if remaining == 0 {
1252                break;
1253            }
1254        }
1255    }
1256    Ok(())
1257}
1258
1259fn github_refs_for_paths(
1260    conn: &Connection,
1261    paths: &[String],
1262    surface: &mut ImpactSurface,
1263    limit: usize,
1264) -> anyhow::Result<()> {
1265    let mut remaining = limit;
1266    let mut stmt = conn.prepare(
1267        "
1268        SELECT owner, repo, number, ref_kind, source_kind, source_text
1269        FROM github_refs
1270        WHERE source_path = ?1
1271        ORDER BY id DESC
1272        LIMIT ?2
1273        ",
1274    )?;
1275    for path in paths {
1276        if remaining == 0 {
1277            break;
1278        }
1279        let file = file_for_path(conn, path)?;
1280        let rows =
1281            stmt.query_map(params![path, i64::try_from(remaining).unwrap_or(i64::MAX)], |row| {
1282                Ok((
1283                    row.get::<_, String>(0)?,
1284                    row.get::<_, String>(1)?,
1285                    row.get::<_, i64>(2)?,
1286                    row.get::<_, String>(3)?,
1287                    row.get::<_, String>(4)?,
1288                    row.get::<_, String>(5)?,
1289                ))
1290            })?;
1291        for row in rows {
1292            let (owner, repo, number, ref_kind, source_kind, source_text) = row?;
1293            surface.push(
1294                ImpactCategory::HistoricalPapertrail,
1295                file.clone(),
1296                "github_papertrail",
1297                format!("{owner}/{repo}#{number} {ref_kind}/{source_kind}: {source_text}"),
1298            );
1299            remaining = remaining.saturating_sub(1);
1300            if remaining == 0 {
1301                break;
1302            }
1303        }
1304    }
1305    Ok(())
1306}
1307
1308fn github_rationale_for_query(
1309    conn: &Connection,
1310    query: &str,
1311    surface: &mut ImpactSurface,
1312    limit: usize,
1313) -> anyhow::Result<()> {
1314    let fts_query = fts_escape(query);
1315    if fts_query.is_empty() {
1316        return Ok(());
1317    }
1318    let mut stmt = conn.prepare(
1319        "
1320        SELECT url, title, classification
1321        FROM github_fts
1322        WHERE github_fts MATCH ?1
1323        ORDER BY rank
1324        LIMIT ?2
1325        ",
1326    )?;
1327    let rows = stmt
1328        .query_map(params![fts_query, i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
1329            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
1330        })?;
1331    for row in rows {
1332        let (url, title, classification) = row?;
1333        surface.push(
1334            ImpactCategory::HistoricalPapertrail,
1335            FileSymbol {
1336                path: "(github papertrail)".to_string(),
1337                language: "github".to_string(),
1338                kind: "papertrail".to_string(),
1339                symbol: None,
1340            },
1341            "github_papertrail",
1342            format!("{classification}: {title} ({url})"),
1343        );
1344    }
1345    Ok(())
1346}
1347
1348fn file_for_path(conn: &Connection, path: &str) -> anyhow::Result<FileSymbol> {
1349    let row = conn
1350        .query_row("SELECT path, language, kind FROM files WHERE path = ?1", [path], |row| {
1351            Ok(FileSymbol {
1352                path: row.get(0)?,
1353                language: row.get(1)?,
1354                kind: row.get(2)?,
1355                symbol: None,
1356            })
1357        })
1358        .optional()?;
1359    Ok(row.unwrap_or_else(|| FileSymbol {
1360        path: path.to_string(),
1361        language: "unknown".to_string(),
1362        kind: "historical".to_string(),
1363        symbol: None,
1364    }))
1365}
1366
1367fn short_hash(hash: &str) -> &str {
1368    hash.get(..12).unwrap_or(hash)
1369}
1370
1371fn fts_escape(query: &str) -> String {
1372    query
1373        .split_whitespace()
1374        .filter(|part| !part.is_empty())
1375        .map(|part| format!("\"{}\"", part.replace('"', "\"\"")))
1376        .collect::<Vec<_>>()
1377        .join(" OR ")
1378}
1379
1380fn rows_to_items(
1381    rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<ImpactItem>>,
1382) -> anyhow::Result<Vec<ImpactItem>> {
1383    let mut items = Vec::new();
1384    for row in rows {
1385        items.push(row?);
1386    }
1387    Ok(items)
1388}
1389
1390fn collect_rows<T>(
1391    rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>>,
1392) -> anyhow::Result<Vec<T>> {
1393    let mut out = Vec::new();
1394    for row in rows {
1395        out.push(row?);
1396    }
1397    Ok(out)
1398}