Skip to main content

rag_rat_core/query/
graph.rs

1use std::collections::BTreeSet;
2
3use rusqlite::{Connection, params_from_iter};
4use serde::Serialize;
5
6const CALL_EDGE_KINDS: &[&str] = &["calls_name", "constructs"];
7const MACRO_EDGE_KINDS: &[&str] = &["uses_macro"];
8const REFERENCE_EDGE_KINDS: &[&str] =
9    &["references_type", "imports", "exports", "contains", "implements"];
10const OPTIONAL_EDGE_KINDS: &[&str] = &[
11    "calls_name",
12    "constructs",
13    "uses_macro",
14    "references_type",
15    "imports",
16    "exports",
17    "contains",
18    "implements",
19];
20
21#[derive(Debug, Clone, Default)]
22pub struct GraphTraversalOptions {
23    pub include_references: bool,
24    pub include_unresolved: bool,
25    pub include_macros: bool,
26    pub include_common_methods: bool,
27    pub edge_kinds: Option<Vec<String>>,
28    pub resolution_mode: GraphResolutionMode,
29    pub symbol_id: Option<i64>,
30    pub logical_symbol_id: Option<i64>,
31}
32
33#[derive(Debug, Serialize)]
34pub struct GraphTraversalReport {
35    pub query: GraphTraversalQuery,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub logical_symbol: Option<LogicalSymbol>,
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub variants: Vec<LogicalSymbolVariant>,
40    pub summary: GraphTraversalSummary,
41    pub coverage: GraphCoverage,
42    pub results: Vec<GraphHop>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct GraphTraversalQuery {
47    pub tool: String,
48    pub symbol_id: Option<i64>,
49    pub logical_symbol_id: Option<i64>,
50    pub symbol_path: String,
51    pub resolution: String,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct LogicalSymbol {
56    pub logical_symbol_id: i64,
57    pub qualified_name: String,
58    pub variant_count: u64,
59    pub group_reason: String,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct LogicalSymbolVariant {
64    pub symbol_id: i64,
65    pub cfg_expr: Option<String>,
66    pub signature_hash: Option<String>,
67    pub start_line: i64,
68    pub end_line: i64,
69}
70
71#[derive(Debug, Default, Serialize)]
72pub struct GraphTraversalSummary {
73    pub returned_count: u64,
74    pub total_matching_edges: u64,
75    pub truncated: bool,
76    pub exact_verified: u64,
77    pub syntactic: u64,
78    pub name_only: u64,
79    pub ambiguous: u64,
80    pub unresolved: u64,
81    pub false_positive_risk: String,
82    pub completeness_risk: String,
83}
84
85#[derive(Debug, Default, Serialize)]
86pub struct GraphCoverage {
87    pub indexed_files: u64,
88    pub parser_failures: u64,
89    pub stale_files: u64,
90    pub known_index_gaps: Vec<String>,
91    pub parser_coverage_for_paths: Vec<GraphPathCoverage>,
92}
93
94#[derive(Debug, Serialize)]
95pub struct GraphPathCoverage {
96    pub path: String,
97    pub language: String,
98    pub parser_status: String,
99    pub graph_status: String,
100    pub last_indexed_revision: Option<String>,
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
104pub enum GraphResolutionMode {
105    Exact,
106    #[default]
107    Syntactic,
108    Fuzzy,
109}
110
111impl GraphResolutionMode {
112    pub fn parse(value: Option<&str>) -> anyhow::Result<Self> {
113        match value.unwrap_or("syntactic") {
114            "exact" => Ok(Self::Exact),
115            "syntactic" => Ok(Self::Syntactic),
116            "fuzzy" => Ok(Self::Fuzzy),
117            other => anyhow::bail!(
118                "unknown graph resolution mode `{other}`; expected exact, syntactic, or fuzzy"
119            ),
120        }
121    }
122
123    pub fn as_str(self) -> &'static str {
124        match self {
125            Self::Exact => "exact",
126            Self::Syntactic => "syntactic",
127            Self::Fuzzy => "fuzzy",
128        }
129    }
130}
131
132impl GraphTraversalOptions {
133    pub fn callee_edge_kinds(&self) -> anyhow::Result<Vec<String>> {
134        if let Some(edge_kinds) = &self.edge_kinds {
135            validate_edge_kinds(edge_kinds)?;
136            return Ok(edge_kinds.clone());
137        }
138        let mut edge_kinds =
139            CALL_EDGE_KINDS.iter().map(|value| (*value).to_string()).collect::<Vec<_>>();
140        if self.include_macros {
141            edge_kinds.extend(MACRO_EDGE_KINDS.iter().map(|value| (*value).to_string()));
142        }
143        if self.include_references {
144            edge_kinds.extend(REFERENCE_EDGE_KINDS.iter().map(|value| (*value).to_string()));
145        }
146        Ok(edge_kinds)
147    }
148
149    pub fn caller_edge_kinds(&self) -> anyhow::Result<Vec<String>> {
150        self.callee_edge_kinds()
151    }
152}
153
154#[derive(Debug, Serialize)]
155pub struct CompareGraphTextReport {
156    pub query: CompareGraphTextQuery,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub logical_symbol: Option<LogicalSymbol>,
159    #[serde(skip_serializing_if = "Vec::is_empty")]
160    pub variants: Vec<LogicalSymbolVariant>,
161    pub summary: CompareGraphTextSummary,
162    pub coverage: GraphCoverage,
163    pub matched_hits: Vec<MatchedGraphTextHit>,
164    pub text_only_hits: Vec<TextOnlyHit>,
165    pub graph_only_edges: Vec<GraphOnlyEdge>,
166    pub likely_parser_gaps: Vec<TextOnlyHit>,
167    pub likely_false_positives: Vec<GraphOnlyEdge>,
168}
169
170#[derive(Debug, Serialize)]
171pub struct CompareGraphTextQuery {
172    pub symbol_id: Option<i64>,
173    pub logical_symbol_id: Option<i64>,
174    pub symbol_path: String,
175    pub pattern: String,
176    pub resolution: String,
177    pub include_tests: bool,
178}
179
180#[derive(Debug, Default, Serialize)]
181pub struct CompareGraphTextSummary {
182    pub graph_hits: u64,
183    pub graph_edges: u64,
184    pub text_hits: u64,
185    pub matched: u64,
186    pub graph_only: u64,
187    pub text_only: u64,
188    pub text_mentions: u64,
189    pub likely_parser_gaps: u64,
190    pub likely_false_positives: u64,
191    pub likely_index_gaps: u64,
192    pub complete: bool,
193    pub recommended_fallback: String,
194    pub pattern_match_mode: String,
195    pub warnings: Vec<String>,
196}
197
198#[derive(Debug, Serialize)]
199pub struct MatchedGraphTextHit {
200    pub path: String,
201    pub line: i64,
202    pub text: String,
203    pub target: Option<String>,
204    pub edge_kind: String,
205    pub confidence: String,
206    pub resolution: String,
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct TextOnlyHit {
211    pub path: String,
212    pub line: i64,
213    pub text: String,
214    pub reason: String,
215    pub likely_gap: String,
216}
217
218#[derive(Debug, Clone, Serialize)]
219pub struct GraphOnlyEdge {
220    pub path: String,
221    pub line: i64,
222    pub target: Option<String>,
223    pub edge_kind: String,
224    pub confidence: String,
225    pub resolution: String,
226    pub evidence: Option<String>,
227    pub reason: String,
228    pub likely_reason: String,
229}
230
231#[derive(Debug, Clone, Serialize)]
232pub struct GraphHop {
233    pub edge_id: i64,
234    pub from_symbol: Option<String>,
235    pub to_symbol: Option<String>,
236    pub edge_kind: String,
237    pub confidence: String,
238    pub edge_confidence: String,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub target: Option<String>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub target_qualified_name: Option<String>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub evidence: Option<String>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub receiver_hint: Option<String>,
247    pub resolution: String,
248    pub verified_target_symbol: bool,
249    pub shown_by_default: bool,
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub callsite: Option<Callsite>,
252}
253
254#[derive(Debug, Clone, Serialize)]
255pub struct Callsite {
256    pub path: String,
257    pub line: i64,
258    pub span: [i64; 2],
259}
260
261pub fn traverse(
262    conn: &Connection,
263    symbol: &str,
264    reverse: bool,
265    limit: u32,
266) -> anyhow::Result<Vec<GraphHop>> {
267    traverse_with_options(conn, symbol, reverse, limit, &GraphTraversalOptions::default())
268}
269
270pub fn traverse_with_options(
271    conn: &Connection,
272    symbol: &str,
273    reverse: bool,
274    limit: u32,
275    options: &GraphTraversalOptions,
276) -> anyhow::Result<Vec<GraphHop>> {
277    let edge_kinds =
278        if reverse { options.caller_edge_kinds()? } else { options.callee_edge_kinds()? };
279    let quoted = quoted_placeholders(edge_kinds.len());
280    let unique_short_name = unique_symbol_name(conn, short_name(symbol))?;
281    let mode = options.resolution_mode;
282    let sql = if reverse {
283        let predicate = reverse_predicate(mode, options.logical_symbol_id.is_some());
284        let tier = reverse_tier(mode);
285        format!(
286            "
287            SELECT COALESCE(from_symbols.qualified_name, edges.from_name) AS from_symbol,
288                   COALESCE(to_symbols.qualified_name, edges.to_name) AS to_symbol,
289                   edges.id AS edge_id,
290                   edges.edge_kind AS edge_kind,
291                   edges.confidence AS confidence,
292                   edges.to_name AS target,
293                   edges.target_qualified_name AS target_qualified_name,
294                   edges.evidence AS evidence,
295                   edges.receiver_hint AS receiver_hint,
296                   edges.resolution AS edge_resolution,
297                   edges.to_symbol_id IS NOT NULL AS verified_target_symbol,
298                   source_files.path AS callsite_path,
299                   COALESCE(NULLIF(edges.source_start_line, 0), 1) AS callsite_start_line,
300                   COALESCE(NULLIF(edges.source_end_line, 0), NULLIF(edges.source_start_line, 0), 1) AS callsite_end_line,
301                   {tier} AS match_tier
302            FROM edges
303            JOIN files source_files ON source_files.id = edges.source_file_id
304            LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
305            LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
306            WHERE edges.edge_kind IN ({quoted})
307              AND ({predicate})
308            ORDER BY match_tier,
309                CASE edges.confidence
310                    WHEN 'Exact' THEN 0
311                    WHEN 'Syntactic' THEN 1
312                    WHEN 'NameOnly' THEN 2
313                    ELSE 3
314                END,
315                edges.edge_kind,
316                edges.from_name
317            LIMIT ?5
318            "
319        )
320    } else {
321        let predicate = forward_source_predicate(mode, options.logical_symbol_id.is_some());
322        let target_filter = forward_target_filter(mode, options);
323        let visibility_filter = forward_visibility_filter(options);
324        format!(
325            "
326            SELECT COALESCE(from_symbols.qualified_name, edges.from_name) AS from_symbol,
327                   COALESCE(to_symbols.qualified_name, edges.to_name) AS to_symbol,
328                   edges.id AS edge_id,
329                   edges.edge_kind AS edge_kind,
330                   edges.confidence AS confidence,
331                   edges.to_name AS target,
332                   edges.target_qualified_name AS target_qualified_name,
333                   edges.evidence AS evidence,
334                   edges.receiver_hint AS receiver_hint,
335                   edges.resolution AS edge_resolution,
336                   edges.to_symbol_id IS NOT NULL AS verified_target_symbol,
337                   source_files.path AS callsite_path,
338                   COALESCE(NULLIF(edges.source_start_line, 0), 1) AS callsite_start_line,
339                   COALESCE(NULLIF(edges.source_end_line, 0), NULLIF(edges.source_start_line, 0), 1) AS callsite_end_line,
340                   0 AS match_tier
341            FROM edges
342            JOIN files source_files ON source_files.id = edges.source_file_id
343            LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
344            LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
345            WHERE edges.edge_kind IN ({quoted})
346              AND ({predicate})
347              AND ({target_filter})
348              AND ({visibility_filter})
349              AND ?4 IN ('true', 'false')
350            ORDER BY
351                CASE edges.confidence
352                    WHEN 'Exact' THEN 0
353                    WHEN 'Syntactic' THEN 1
354                    WHEN 'NameOnly' THEN 2
355                    ELSE 3
356                END,
357                edges.edge_kind,
358                edges.to_name
359            LIMIT ?5
360            "
361        )
362    };
363    let params = traversal_params(
364        symbol,
365        limit,
366        &edge_kinds,
367        options.symbol_id,
368        options.logical_symbol_id,
369        unique_short_name,
370    );
371    let mut stmt = conn.prepare(&sql)?;
372    let rows = stmt.query_map(params_from_iter(params), |row| {
373        let edge_kind: String = row.get("edge_kind")?;
374        let confidence: String = row.get("confidence")?;
375        let verified_target_symbol = row.get("verified_target_symbol")?;
376        let resolution = resolution_label(
377            mode,
378            row.get::<_, String>("edge_resolution")?,
379            row.get("match_tier")?,
380            verified_target_symbol,
381        );
382        let callsite_path: String = row.get("callsite_path")?;
383        let callsite_start = row.get("callsite_start_line")?;
384        let callsite_end = row.get("callsite_end_line")?;
385        let confidence = normalize_confidence(&confidence).to_string();
386        Ok(GraphHop {
387            edge_id: row.get("edge_id")?,
388            from_symbol: row.get("from_symbol")?,
389            to_symbol: row.get("to_symbol")?,
390            edge_kind: edge_kind.clone(),
391            confidence: confidence.clone(),
392            edge_confidence: confidence,
393            target: row.get("target")?,
394            target_qualified_name: row.get("target_qualified_name")?,
395            evidence: row.get("evidence")?,
396            receiver_hint: row.get("receiver_hint")?,
397            resolution,
398            verified_target_symbol,
399            shown_by_default: CALL_EDGE_KINDS.contains(&edge_kind.as_str()),
400            callsite: Some(Callsite {
401                path: callsite_path,
402                line: callsite_start,
403                span: [callsite_start, callsite_end],
404            }),
405        })
406    })?;
407    let mut hops = Vec::new();
408    for row in rows {
409        hops.push(row?);
410    }
411    dedupe_hops(&mut hops);
412    Ok(hops)
413}
414
415fn dedupe_hops(hops: &mut Vec<GraphHop>) {
416    let mut seen = BTreeSet::new();
417    hops.retain(|hop| {
418        let callsite = hop.callsite.as_ref();
419        seen.insert((
420            hop.from_symbol.clone(),
421            hop.to_symbol.clone(),
422            hop.edge_id,
423            hop.edge_kind.clone(),
424            hop.target.clone(),
425            hop.target_qualified_name.clone(),
426            hop.receiver_hint.clone(),
427            callsite.map(|value| value.path.clone()),
428            callsite.map(|value| value.span),
429        ))
430    });
431}
432
433pub fn traversal_summary(
434    conn: &Connection,
435    symbol: &str,
436    reverse: bool,
437    limit: u32,
438    options: &GraphTraversalOptions,
439    returned_count: usize,
440) -> anyhow::Result<GraphTraversalSummary> {
441    let edge_kinds =
442        if reverse { options.caller_edge_kinds()? } else { options.callee_edge_kinds()? };
443    let quoted = quoted_placeholders(edge_kinds.len());
444    let unique_short_name = unique_symbol_name(conn, short_name(symbol))?;
445    let mode = options.resolution_mode;
446    let sql = if reverse {
447        let predicate = reverse_predicate(mode, options.logical_symbol_id.is_some());
448        format!(
449            "
450            SELECT
451                COUNT(*),
452                SUM(CASE WHEN edges.to_symbol_id IS NOT NULL THEN 1 ELSE 0 END),
453                SUM(CASE WHEN edges.confidence = 'Syntactic' THEN 1 ELSE 0 END),
454                SUM(CASE WHEN edges.confidence = 'NameOnly' THEN 1 ELSE 0 END),
455                SUM(CASE WHEN edges.confidence = 'Ambiguous' THEN 1 ELSE 0 END),
456                SUM(CASE WHEN edges.to_symbol_id IS NULL THEN 1 ELSE 0 END)
457            FROM edges
458            LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
459            WHERE edges.edge_kind IN ({quoted})
460              AND ({predicate})
461            "
462        )
463    } else {
464        let predicate = forward_source_predicate(mode, options.logical_symbol_id.is_some());
465        let target_filter = forward_target_filter(mode, options);
466        let visibility_filter = forward_visibility_filter(options);
467        format!(
468            "
469            SELECT
470                COUNT(*),
471                SUM(CASE WHEN edges.to_symbol_id IS NOT NULL THEN 1 ELSE 0 END),
472                SUM(CASE WHEN edges.confidence = 'Syntactic' THEN 1 ELSE 0 END),
473                SUM(CASE WHEN edges.confidence = 'NameOnly' THEN 1 ELSE 0 END),
474                SUM(CASE WHEN edges.confidence = 'Ambiguous' THEN 1 ELSE 0 END),
475                SUM(CASE WHEN edges.to_symbol_id IS NULL THEN 1 ELSE 0 END)
476            FROM edges
477            LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
478            WHERE edges.edge_kind IN ({quoted})
479              AND ({predicate})
480              AND ({target_filter})
481              AND ({visibility_filter})
482              AND ?4 IN ('true', 'false')
483            "
484        )
485    };
486    let params = traversal_params(
487        symbol,
488        limit,
489        &edge_kinds,
490        options.symbol_id,
491        options.logical_symbol_id,
492        unique_short_name,
493    );
494    let mut summary = conn.query_row(&sql, params_from_iter(params), |row| {
495        Ok(GraphTraversalSummary {
496            returned_count: u64::try_from(returned_count).unwrap_or(u64::MAX),
497            total_matching_edges: count_col(row, 0)?,
498            truncated: false,
499            exact_verified: count_col(row, 1)?,
500            syntactic: count_col(row, 2)?,
501            name_only: count_col(row, 3)?,
502            ambiguous: count_col(row, 4)?,
503            unresolved: count_col(row, 5)?,
504            false_positive_risk: String::new(),
505            completeness_risk: String::new(),
506        })
507    })?;
508    let hidden_unresolved = hidden_unresolved_candidate_count(
509        conn,
510        symbol,
511        reverse,
512        &edge_kinds,
513        options,
514        unique_short_name,
515    )?;
516    summary.total_matching_edges = summary.total_matching_edges.saturating_add(hidden_unresolved);
517    summary.unresolved = summary.unresolved.saturating_add(hidden_unresolved);
518    summary.truncated = summary.total_matching_edges > u64::from(limit);
519    summary.false_positive_risk = false_positive_risk(&summary, mode).to_string();
520    summary.completeness_risk = completeness_risk(&summary).to_string();
521    Ok(summary)
522}
523
524fn count_col(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<u64> {
525    let value = row.get::<_, Option<i64>>(index)?.unwrap_or(0);
526    Ok(u64::try_from(value).unwrap_or(0))
527}
528
529/// Normalize a raw DB edge-confidence value (`Exact`/`Syntactic`/`NameOnly`/`Ambiguous`) to the
530/// snake_case form used everywhere in tool output, so graph traversal, read_chunk, and search all
531/// serialize confidence identically.
532pub(crate) fn normalize_confidence(value: &str) -> &'static str {
533    match value {
534        "Exact" => "exact",
535        "Syntactic" => "syntactic",
536        "NameOnly" => "name_only",
537        "Ambiguous" => "ambiguous",
538        _ => "name_only",
539    }
540}
541
542fn false_positive_risk(summary: &GraphTraversalSummary, mode: GraphResolutionMode) -> &'static str {
543    // Risk reflects whether the *returned* edges could be wrong, not the mode alone. Syntactic is
544    // the default mode, so charging it "medium" unconditionally mislabels results where every edge
545    // resolved to a verified target symbol (the common, trustworthy case). Only bump for syntactic
546    // mode when some matching edge was NOT verified against the target.
547    let has_unverified = summary.exact_verified < summary.total_matching_edges;
548    if summary.ambiguous > 0 || mode == GraphResolutionMode::Fuzzy {
549        "high"
550    } else if summary.name_only > 0
551        || summary.unresolved > 0
552        || (mode == GraphResolutionMode::Syntactic && has_unverified)
553    {
554        "medium"
555    } else {
556        "low"
557    }
558}
559
560fn completeness_risk(summary: &GraphTraversalSummary) -> &'static str {
561    if summary.truncated
562        || summary.unresolved > summary.exact_verified.saturating_add(summary.syntactic)
563    {
564        "high"
565    } else if summary.unresolved > 0 || summary.name_only > 0 || summary.ambiguous > 0 {
566        "medium"
567    } else {
568        "low"
569    }
570}
571
572fn hidden_unresolved_candidate_count(
573    conn: &Connection,
574    symbol: &str,
575    reverse: bool,
576    edge_kinds: &[String],
577    options: &GraphTraversalOptions,
578    unique_short_name: bool,
579) -> anyhow::Result<u64> {
580    let mode = options.resolution_mode;
581    let quoted = quoted_placeholders(edge_kinds.len());
582    let sql = if reverse {
583        let predicate = reverse_predicate(mode, options.logical_symbol_id.is_some());
584        format!(
585            "
586            SELECT COUNT(*)
587            FROM edges
588            LEFT JOIN symbols to_symbols ON to_symbols.id = edges.to_symbol_id
589            WHERE edges.edge_kind IN ({quoted})
590              AND edges.to_symbol_id IS NULL
591              AND NOT ({predicate})
592              AND (
593                edges.target_qualified_name = ?1
594                OR edges.target_qualified_name LIKE ?2
595                OR edges.to_name = ?3
596              )
597            "
598        )
599    } else {
600        let source_predicate = forward_source_predicate(mode, options.logical_symbol_id.is_some());
601        let target_filter = forward_target_filter(mode, options);
602        let visibility_filter = forward_visibility_filter(options);
603        format!(
604            "
605            SELECT COUNT(*)
606            FROM edges
607            LEFT JOIN symbols from_symbols ON from_symbols.id = edges.from_symbol_id
608            WHERE edges.edge_kind IN ({quoted})
609              AND ({source_predicate})
610              AND edges.to_symbol_id IS NULL
611              AND NOT (({target_filter}) AND ({visibility_filter}))
612              AND ?4 IN ('true', 'false')
613            "
614        )
615    };
616    let params = traversal_params(
617        symbol,
618        0,
619        edge_kinds,
620        options.symbol_id,
621        options.logical_symbol_id,
622        unique_short_name,
623    );
624    let count = conn.query_row(&sql, params_from_iter(params), |row| count_col(row, 0))?;
625    Ok(count)
626}
627
628fn validate_edge_kinds(edge_kinds: &[String]) -> anyhow::Result<()> {
629    for edge_kind in edge_kinds {
630        if !OPTIONAL_EDGE_KINDS.contains(&edge_kind.as_str()) {
631            anyhow::bail!("unknown graph edge kind `{edge_kind}`");
632        }
633    }
634    Ok(())
635}
636
637fn traversal_params(
638    symbol: &str,
639    limit: u32,
640    edge_kinds: &[String],
641    symbol_id: Option<i64>,
642    logical_symbol_id: Option<i64>,
643    unique_short_name: bool,
644) -> Vec<String> {
645    let qualified = symbol.to_string();
646    let short = short_name(symbol).to_string();
647    let fuzzy_qualified = format!("%::{qualified}");
648    let allow_name_fallback = (!is_qualified_symbol(symbol)).to_string();
649    let mut params = vec![
650        qualified,
651        fuzzy_qualified,
652        short,
653        allow_name_fallback,
654        limit.to_string(),
655        symbol_id.unwrap_or(-1).to_string(),
656        unique_short_name.to_string(),
657        logical_symbol_id.unwrap_or(-1).to_string(),
658    ];
659    params.extend(edge_kinds.iter().cloned());
660    params
661}
662
663fn quoted_placeholders(count: usize) -> String {
664    (0..count).map(|index| format!("?{}", index + 9)).collect::<Vec<_>>().join(", ")
665}
666
667fn reverse_predicate(mode: GraphResolutionMode, logical: bool) -> &'static str {
668    if logical {
669        return match mode {
670            GraphResolutionMode::Exact => {
671                "edges.to_symbol_id IS NOT NULL
672                 AND edges.to_symbol_id IN (
673                    SELECT symbol_id
674                    FROM logical_symbol_members
675                    WHERE logical_symbol_id = ?8
676                 )"
677            },
678            GraphResolutionMode::Syntactic => {
679                "(edges.to_symbol_id IN (
680                    SELECT symbol_id
681                    FROM logical_symbol_members
682                    WHERE logical_symbol_id = ?8
683                  )
684                  OR edges.target_qualified_name = ?1)"
685            },
686            GraphResolutionMode::Fuzzy => {
687                "edges.to_symbol_id IN (
688                    SELECT symbol_id
689                    FROM logical_symbol_members
690                    WHERE logical_symbol_id = ?8
691                 )
692                 OR to_symbols.name = ?3
693                 OR to_symbols.qualified_name = ?1
694                 OR to_symbols.qualified_name LIKE ?2
695                 OR edges.target_qualified_name = ?1
696                 OR edges.target_qualified_name LIKE ?2
697                 OR (?4 = 'true' AND edges.to_name = ?3)"
698            },
699        };
700    }
701    match mode {
702        GraphResolutionMode::Exact => {
703            "edges.to_symbol_id IS NOT NULL
704             AND (edges.to_symbol_id = ?6 OR to_symbols.qualified_name = ?1)"
705        },
706        GraphResolutionMode::Syntactic => {
707            "(edges.to_symbol_id = ?6
708              OR to_symbols.qualified_name = ?1
709              OR (?7 = 'true' AND to_symbols.name = ?3)
710              OR edges.target_qualified_name = ?1)"
711        },
712        GraphResolutionMode::Fuzzy => {
713            "to_symbols.name = ?3
714             OR to_symbols.qualified_name = ?1
715             OR to_symbols.qualified_name LIKE ?2
716             OR edges.target_qualified_name = ?1
717             OR edges.target_qualified_name LIKE ?2
718             OR (?4 = 'true' AND edges.to_name = ?3)"
719        },
720    }
721}
722
723fn reverse_tier(mode: GraphResolutionMode) -> &'static str {
724    match mode {
725        GraphResolutionMode::Exact => "0",
726        GraphResolutionMode::Syntactic => {
727            "CASE
728                WHEN edges.to_symbol_id IS NOT NULL THEN 0
729                WHEN edges.target_qualified_name = ?1 THEN 1
730                ELSE 4
731             END"
732        },
733        GraphResolutionMode::Fuzzy => {
734            "CASE
735                WHEN edges.to_symbol_id IS NOT NULL THEN 0
736                WHEN edges.target_qualified_name = ?1 OR edges.target_qualified_name LIKE ?2 THEN 1
737                WHEN ?4 = 'true' AND edges.to_name = ?3 THEN 2
738                ELSE 4
739             END"
740        },
741    }
742}
743
744fn forward_source_predicate(mode: GraphResolutionMode, logical: bool) -> &'static str {
745    if logical {
746        return match mode {
747            GraphResolutionMode::Exact => {
748                "from_symbols.id IS NOT NULL
749                 AND from_symbols.id IN (
750                    SELECT symbol_id
751                    FROM logical_symbol_members
752                    WHERE logical_symbol_id = ?8
753                 )"
754            },
755            GraphResolutionMode::Syntactic => {
756                "from_symbols.id IN (
757                    SELECT symbol_id
758                    FROM logical_symbol_members
759                    WHERE logical_symbol_id = ?8
760                 )
761                 OR edges.from_name = ?1"
762            },
763            GraphResolutionMode::Fuzzy => {
764                "from_symbols.id IN (
765                    SELECT symbol_id
766                    FROM logical_symbol_members
767                    WHERE logical_symbol_id = ?8
768                 )
769                 OR from_symbols.name = ?3
770                 OR from_symbols.qualified_name = ?1
771                 OR from_symbols.qualified_name LIKE ?2
772                 OR edges.from_name = ?1
773                 OR edges.from_name LIKE ?2"
774            },
775        };
776    }
777    match mode {
778        GraphResolutionMode::Exact => {
779            "from_symbols.id IS NOT NULL
780             AND (from_symbols.id = ?6 OR from_symbols.qualified_name = ?1)"
781        },
782        GraphResolutionMode::Syntactic => {
783            "from_symbols.id = ?6
784             OR from_symbols.qualified_name = ?1
785             OR (?7 = 'true' AND from_symbols.name = ?3)
786             OR edges.from_name = ?1"
787        },
788        GraphResolutionMode::Fuzzy => {
789            "from_symbols.name = ?3
790             OR from_symbols.qualified_name = ?1
791             OR from_symbols.qualified_name LIKE ?2
792             OR edges.from_name = ?1
793             OR edges.from_name LIKE ?2"
794        },
795    }
796}
797
798fn forward_target_filter(
799    mode: GraphResolutionMode,
800    options: &GraphTraversalOptions,
801) -> &'static str {
802    match mode {
803        GraphResolutionMode::Exact => "edges.to_symbol_id IS NOT NULL",
804        GraphResolutionMode::Syntactic => {
805            if options.include_unresolved {
806                "1 = 1"
807            } else if options.include_macros {
808                "
809                edges.to_symbol_id IS NOT NULL
810                OR edges.target_qualified_name IS NOT NULL
811                OR edges.edge_kind = 'uses_macro'
812                "
813            } else {
814                "edges.to_symbol_id IS NOT NULL OR edges.target_qualified_name IS NOT NULL"
815            }
816        },
817        GraphResolutionMode::Fuzzy => "1 = 1",
818    }
819}
820
821fn forward_visibility_filter(options: &GraphTraversalOptions) -> &'static str {
822    match (
823        options.include_unresolved,
824        options.include_macros,
825        options.include_common_methods,
826    ) {
827        (true, true, true) => "1 = 1",
828        (true, true, false) => {
829            "
830            (
831                edges.edge_kind != 'calls_name'
832                OR edges.to_name NOT IN (
833                    'clone', 'map', 'map_err', 'and_then', 'unwrap_or', 'unwrap_or_else',
834                    'to_string', 'to_owned', 'as_ref', 'as_mut', 'get', 'insert',
835                    'new', 'default', 'into', 'from', 'iter', 'collect', 'unwrap',
836                    'expect', 'ok', 'err'
837                )
838                OR edges.to_symbol_id IS NOT NULL
839            )
840            "
841        },
842        (true, false, true) => "edges.edge_kind != 'uses_macro'",
843        (true, false, false) => {
844            "
845            edges.edge_kind != 'uses_macro'
846            AND (
847                edges.edge_kind != 'calls_name'
848                OR edges.to_name NOT IN (
849                    'clone', 'map', 'map_err', 'and_then', 'unwrap_or', 'unwrap_or_else',
850                    'to_string', 'to_owned', 'as_ref', 'as_mut', 'get', 'insert',
851                    'new', 'default', 'into', 'from', 'iter', 'collect', 'unwrap',
852                    'expect', 'ok', 'err'
853                )
854                OR edges.to_symbol_id IS NOT NULL
855            )
856            "
857        },
858        (false, true, true) => {
859            "
860            (
861                edges.edge_kind = 'calls_name'
862                AND (
863                    edges.to_symbol_id IS NOT NULL
864                    OR (edges.confidence = 'Syntactic' AND edges.target_qualified_name IS NOT NULL)
865                )
866            )
867            OR (
868                edges.edge_kind = 'constructs'
869                AND edges.to_symbol_id IS NOT NULL
870            )
871            OR edges.edge_kind = 'uses_macro'
872            OR edges.edge_kind NOT IN ('calls_name', 'constructs')
873            "
874        },
875        (false, true, false) => {
876            "
877            (
878                edges.edge_kind = 'calls_name'
879                AND (
880                    edges.to_symbol_id IS NOT NULL
881                    OR (edges.confidence = 'Syntactic' AND edges.target_qualified_name IS NOT NULL)
882                )
883                AND (
884                    edges.to_name NOT IN (
885                        'clone', 'map', 'map_err', 'and_then', 'unwrap_or', 'unwrap_or_else',
886                        'to_string', 'to_owned', 'as_ref', 'as_mut', 'get', 'insert',
887                        'new', 'default', 'into', 'from', 'iter', 'collect', 'unwrap',
888                        'expect', 'ok', 'err'
889                    )
890                    OR edges.to_symbol_id IS NOT NULL
891                )
892            )
893            OR (
894                edges.edge_kind = 'constructs'
895                AND edges.to_symbol_id IS NOT NULL
896            )
897            OR edges.edge_kind = 'uses_macro'
898            OR edges.edge_kind NOT IN ('calls_name', 'constructs')
899            "
900        },
901        (false, false, true) => {
902            "
903            edges.edge_kind != 'uses_macro'
904            AND (
905                (
906                    edges.edge_kind = 'calls_name'
907                    AND (
908                        edges.to_symbol_id IS NOT NULL
909                        OR (edges.confidence = 'Syntactic' AND edges.target_qualified_name IS NOT NULL)
910                    )
911                )
912                OR (
913                    edges.edge_kind = 'constructs'
914                    AND edges.to_symbol_id IS NOT NULL
915                )
916                OR edges.edge_kind NOT IN ('calls_name', 'constructs')
917            )
918            "
919        },
920        (false, false, false) => {
921            "
922            edges.edge_kind != 'uses_macro'
923            AND (
924                (
925                    edges.edge_kind = 'calls_name'
926                    AND (
927                        edges.to_symbol_id IS NOT NULL
928                        OR (edges.confidence = 'Syntactic' AND edges.target_qualified_name IS NOT NULL)
929                    )
930                    AND (
931                        edges.to_name NOT IN (
932                            'clone', 'map', 'map_err', 'and_then', 'unwrap_or', 'unwrap_or_else',
933                            'to_string', 'to_owned', 'as_ref', 'as_mut', 'get', 'insert',
934                            'new', 'default', 'into', 'from', 'iter', 'collect', 'unwrap',
935                            'expect', 'ok', 'err'
936                        )
937                        OR edges.to_symbol_id IS NOT NULL
938                    )
939                )
940                OR (
941                    edges.edge_kind = 'constructs'
942                    AND edges.to_symbol_id IS NOT NULL
943                )
944                OR edges.edge_kind NOT IN ('calls_name', 'constructs')
945            )
946            "
947        },
948    }
949}
950
951fn unique_symbol_name(conn: &Connection, name: &str) -> anyhow::Result<bool> {
952    let count: i64 = conn.query_row(
953        "SELECT COUNT(*) AS symbol_count FROM symbols WHERE name = ?1",
954        [name],
955        |row| row.get("symbol_count"),
956    )?;
957    Ok(count == 1)
958}
959
960fn resolution_label(
961    mode: GraphResolutionMode,
962    stored: String,
963    tier: i64,
964    verified_target_symbol: bool,
965) -> String {
966    if mode == GraphResolutionMode::Exact && verified_target_symbol {
967        return "exact".to_string();
968    }
969    if stored != "unresolved" {
970        return stored;
971    }
972    match tier {
973        1 => "target_qualified_suffix".to_string(),
974        2 => "target_name_fallback".to_string(),
975        _ => stored,
976    }
977}
978
979fn short_name(symbol: &str) -> &str {
980    symbol.rsplit([':', '.', '#', '/']).find(|part| !part.is_empty()).unwrap_or(symbol)
981}
982
983fn is_qualified_symbol(symbol: &str) -> bool {
984    symbol.contains("::")
985        || symbol.contains(".rs:")
986        || symbol.contains(".ts:")
987        || symbol.contains(".tsx:")
988        || symbol.contains(".kt:")
989        || symbol.contains('/')
990}