Skip to main content

tessera_codegraph/
query.rs

1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::Result;
7use rusqlite::{params, Connection, OptionalExtension};
8use strsim::jaro_winkler;
9
10use crate::bloom::BloomFilter;
11use crate::db;
12use crate::indexer;
13use crate::types::{
14    AlternativeQuery, ConnectResult, ContextPack, CriticalityBreakdown, DefinitionResult,
15    DiffChangedSymbol, DiffImpactResult, DiffImpactedSymbol, EditPrepResult, ExpandResult,
16    ExportResult, ImpactCaller, ImpactResult, ImportRecord, ImportedByResult, ImportsResult,
17    KindCount, Language, LanguageCount, OutlineResult, PathNode, PlanQueryResult, PlanStep,
18    QueryMeta, ReferenceRecord, ReferencesResult, SearchHit, SearchOptions, SearchResult, Sibling,
19    SiblingsResult, SignatureLine, SignatureResult, SnippetReferenceCheck, StatsResult,
20    SymbolRecord, SymbolSuggestion, TestsForResult, TopFanout, UnusedOptions, UnusedResult,
21    UnusedSymbol, ValidateResult, ValidateSnippetResult,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ExportGroupBy {
26    None,
27    File,
28    Directory,
29    Language,
30}
31
32impl ExportGroupBy {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::None => "none",
36            Self::File => "file",
37            Self::Directory => "directory",
38            Self::Language => "language",
39        }
40    }
41}
42
43#[derive(Debug, Clone)]
44pub struct ExportOptions {
45    pub format: String,
46    pub from: Option<String>,
47    pub depth: usize,
48    pub limit: usize,
49    pub group_by: ExportGroupBy,
50    pub collapse_tests: bool,
51    pub exported_only: bool,
52    pub html_out: Option<PathBuf>,
53}
54
55impl ExportOptions {
56    pub fn new(format: &str, from: Option<&str>, depth: usize, limit: usize) -> Self {
57        Self {
58            format: format.to_string(),
59            from: from.map(ToOwned::to_owned),
60            depth,
61            limit,
62            group_by: ExportGroupBy::None,
63            collapse_tests: false,
64            exported_only: false,
65            html_out: None,
66        }
67    }
68}
69
70// ─── Connection-based public API ─────────────────────────────────────────────
71// Every query takes a `&Connection`. Convenience wrappers that open the DB
72// from a path live at the bottom for the CLI.
73
74pub fn find_definition_conn(conn: &Connection, symbol: &str) -> Result<DefinitionResult> {
75    let mut stmt = conn.prepare(
76        "
77        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
78               s.start_line, s.end_line, s.signature, s.exported
79        FROM symbols s
80        JOIN files f ON f.id = s.file_id
81        WHERE s.qualified_name = ?1 OR s.name = ?1 OR s.qualified_name LIKE ?2
82        ORDER BY
83            CASE
84                WHEN s.qualified_name = ?1 THEN 0
85                WHEN s.name = ?1 THEN 1
86                ELSE 2
87            END,
88            length(s.qualified_name),
89            f.path
90        LIMIT 25
91        ",
92    )?;
93    let mut matches = stmt
94        .query_map(params![symbol, format!("%.{}", symbol)], db::map_symbol)?
95        .collect::<rusqlite::Result<Vec<_>>>()?;
96    if matches.is_empty() {
97        matches = fuzzy_symbol_matches(conn, symbol, 5)?;
98    }
99    let tokens = estimate_tokens(&matches);
100    Ok(DefinitionResult {
101        matches,
102        meta: meta(tokens, "get_outline", 320, 0.72),
103    })
104}
105
106pub fn find_references_conn(conn: &Connection, symbol: &str) -> Result<ReferencesResult> {
107    let refs = references_for_symbol(conn, symbol, 250)?;
108    let tokens = estimate_tokens(&refs);
109    Ok(ReferencesResult {
110        references: refs,
111        meta: meta(tokens, "impact", 900, 0.84),
112    })
113}
114
115pub fn get_outline_conn(conn: &Connection, path: &Path) -> Result<OutlineResult> {
116    let prefix = path.to_string_lossy().replace('\\', "/");
117    let like = if prefix == "." || prefix.is_empty() {
118        "%".to_string()
119    } else {
120        format!("{prefix}%")
121    };
122    let mut stmt = conn.prepare(
123        "
124        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
125               s.start_line, s.end_line, s.signature, s.exported
126        FROM symbols s
127        JOIN files f ON f.id = s.file_id
128        WHERE f.path = ?1 OR f.path LIKE ?2
129        ORDER BY f.path, s.start_line
130        LIMIT 1000
131        ",
132    )?;
133    let symbols = stmt
134        .query_map(params![prefix, like], db::map_symbol)?
135        .collect::<rusqlite::Result<Vec<_>>>()?;
136    let tokens = estimate_tokens(&symbols);
137    Ok(OutlineResult {
138        path: prefix,
139        symbols,
140        meta: meta(tokens, "expand_symbol", 1200, 0.9),
141    })
142}
143
144pub fn expand_symbol_conn(conn: &Connection, symbol: &str) -> Result<ExpandResult> {
145    let Some(symbol_record) = db::resolve_symbol(conn, symbol)? else {
146        return Ok(ExpandResult {
147            symbol: None,
148            body: None,
149            dependencies: Vec::new(),
150            meta: meta(20, "find_definition", 120, 0.65),
151        });
152    };
153    let body = read_symbol_body(conn, &symbol_record).ok();
154    let dependencies = references_from_symbol(conn, symbol_record.id, 100)?;
155    let tokens = estimate_tokens(&body) + estimate_tokens(&dependencies);
156    Ok(ExpandResult {
157        symbol: Some(symbol_record),
158        body,
159        dependencies,
160        meta: meta(tokens, "get_outline", 320, 0.7),
161    })
162}
163
164pub fn impact_conn(conn: &Connection, symbol: &str, depth: usize) -> Result<ImpactResult> {
165    let target = db::resolve_symbol(conn, symbol)?;
166    let target_id = target.as_ref().map(|t| t.id);
167
168    // BFS collect: caller_id -> (min_depth, SymbolRecord).
169    // We dedupe `(name, min_depth)` queue entries by `name` so that high
170    // fan-in symbols (e.g. Java's pervasive `.get()` methods) don't re-query
171    // the same callers thousands of times. Also cap the total frontier size
172    // so PageRank stays well-defined and fast on hub symbols.
173    const MAX_FRONTIER: usize = 800;
174    let mut frontier: HashMap<i64, (usize, SymbolRecord)> = HashMap::new();
175    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
176    let mut queried_names: HashSet<String> = HashSet::new();
177
178    let seed_name = |name: String, queue: &mut VecDeque<(String, usize)>| {
179        if name.is_empty() {
180            return;
181        }
182        queue.push_back((name, 1));
183    };
184    seed_name(symbol.to_string(), &mut queue);
185    if let Some(t) = &target {
186        if t.name != symbol {
187            seed_name(t.name.clone(), &mut queue);
188        }
189        if t.qualified_name != symbol && t.qualified_name != t.name {
190            seed_name(t.qualified_name.clone(), &mut queue);
191        }
192    }
193
194    'outer: while let Some((current, current_depth)) = queue.pop_front() {
195        if current_depth > depth {
196            continue;
197        }
198        if !queried_names.insert(current.clone()) {
199            continue;
200        }
201        for caller in callers_for_symbol(conn, &current, 500)? {
202            let entry = frontier
203                .entry(caller.id)
204                .or_insert_with(|| (current_depth, caller.clone()));
205            if entry.0 > current_depth {
206                entry.0 = current_depth;
207            }
208            if current_depth < depth {
209                if !queried_names.contains(&caller.name) {
210                    queue.push_back((caller.name.clone(), current_depth + 1));
211                }
212                if caller.qualified_name != caller.name
213                    && !queried_names.contains(&caller.qualified_name)
214                {
215                    queue.push_back((caller.qualified_name.clone(), current_depth + 1));
216                }
217            }
218            if frontier.len() >= MAX_FRONTIER {
219                break 'outer;
220            }
221        }
222    }
223
224    if frontier.is_empty() {
225        let tokens = 40;
226        return Ok(ImpactResult {
227            symbol: symbol.to_string(),
228            callers: Vec::new(),
229            meta: meta(tokens, "find_references", 700, 0.78),
230        });
231    }
232
233    // Build node set: target (if known) + all callers in frontier.
234    // Then build the walk graph (reverse of call graph): callee -> caller.
235    let mut name_to_ids: HashMap<String, Vec<i64>> = HashMap::new();
236    if let Some(t) = &target {
237        name_to_ids.entry(t.name.clone()).or_default().push(t.id);
238        if t.qualified_name != t.name {
239            name_to_ids
240                .entry(t.qualified_name.clone())
241                .or_default()
242                .push(t.id);
243        }
244    }
245    for (id, (_, record)) in &frontier {
246        name_to_ids
247            .entry(record.name.clone())
248            .or_default()
249            .push(*id);
250        if record.qualified_name != record.name {
251            name_to_ids
252                .entry(record.qualified_name.clone())
253                .or_default()
254                .push(*id);
255        }
256    }
257
258    let mut node_ids: Vec<i64> = frontier.keys().copied().collect();
259    if let Some(tid) = target_id {
260        if !frontier.contains_key(&tid) {
261            node_ids.push(tid);
262        }
263    }
264    node_ids.sort_unstable();
265    let index_of: HashMap<i64, usize> = node_ids
266        .iter()
267        .enumerate()
268        .map(|(i, id)| (*id, i))
269        .collect();
270
271    let n = node_ids.len();
272    let mut walk_adj: Vec<Vec<usize>> = vec![Vec::new(); n];
273    {
274        // For each node in our subgraph, find its outgoing call edges, then
275        // reverse them into the walk graph.
276        let mut stmt = conn.prepare(
277            "
278            SELECT to_symbol_name FROM edges WHERE from_symbol_id = ?1
279            ",
280        )?;
281        for &caller_id in &node_ids {
282            let caller_idx = index_of[&caller_id];
283            let rows = stmt.query_map(params![caller_id], |row| row.get::<_, String>(0))?;
284            for to_name_res in rows {
285                let to_name = to_name_res?;
286                let Some(targets) = name_to_ids.get(&to_name) else {
287                    continue;
288                };
289                for &tid in targets {
290                    let Some(&t_idx) = index_of.get(&tid) else {
291                        continue;
292                    };
293                    if t_idx == caller_idx {
294                        continue;
295                    }
296                    // Walk graph edge: callee -> caller.
297                    walk_adj[t_idx].push(caller_idx);
298                }
299            }
300        }
301    }
302
303    // Personalised PageRank: teleport on target if known, else uniform over depth-1.
304    let mut teleport = vec![0.0f32; n];
305    if let Some(tid) = target_id {
306        if let Some(&t_idx) = index_of.get(&tid) {
307            teleport[t_idx] = 1.0;
308        }
309    }
310    if teleport.iter().sum::<f32>() == 0.0 {
311        // No resolved target — spread teleport over direct callers (depth 1).
312        let direct: Vec<usize> = frontier
313            .iter()
314            .filter_map(|(id, (d, _))| {
315                if *d == 1 {
316                    index_of.get(id).copied()
317                } else {
318                    None
319                }
320            })
321            .collect();
322        if !direct.is_empty() {
323            let share = 1.0 / direct.len() as f32;
324            for idx in direct {
325                teleport[idx] = share;
326            }
327        } else if let Some(first) = index_of.values().next() {
328            teleport[*first] = 1.0;
329        }
330    }
331
332    let damping = 0.85f32;
333    let iterations = 25;
334    let mut rank = teleport.clone();
335    let out_degree: Vec<usize> = walk_adj.iter().map(|adj| adj.len()).collect();
336    for _ in 0..iterations {
337        let mut next = vec![0.0f32; n];
338        for v in 0..n {
339            next[v] += (1.0 - damping) * teleport[v];
340        }
341        let mut dangling_mass = 0.0f32;
342        for u in 0..n {
343            if out_degree[u] == 0 {
344                dangling_mass += rank[u];
345                continue;
346            }
347            let share = damping * rank[u] / out_degree[u] as f32;
348            for &v in &walk_adj[u] {
349                next[v] += share;
350            }
351        }
352        if dangling_mass > 0.0 {
353            for v in 0..n {
354                next[v] += damping * dangling_mass * teleport[v];
355            }
356        }
357        let total: f32 = next.iter().sum();
358        if total > 0.0 {
359            for v in &mut next {
360                *v /= total;
361            }
362        }
363        rank = next;
364    }
365
366    // Materialise callers with normalised criticality + breakdown.
367    let max_rank = frontier
368        .keys()
369        .filter_map(|id| index_of.get(id).map(|&i| rank[i]))
370        .fold(0.0f32, f32::max);
371    let max_rank = if max_rank > 0.0 { max_rank } else { 1.0 };
372
373    let mut callers: Vec<ImpactCaller> = frontier
374        .into_iter()
375        .filter_map(|(id, (d, record))| {
376            let idx = index_of.get(&id)?;
377            let pagerank = rank[*idx];
378            let normalised = (pagerank / max_rank * 100.0).clamp(0.0, 100.0);
379
380            let fanout_out = db::symbol_fanout(conn, id).unwrap_or(0);
381            let fanout_in = db::symbol_callers_count(conn, &record.name).unwrap_or(0);
382            let exported = record.exported;
383            let test_coverage = if is_test_path(&record.path) { 1 } else { 0 };
384            let depth_decay = 1.0 / d as f32;
385
386            Some(ImpactCaller {
387                symbol: record,
388                depth: d,
389                criticality: normalised,
390                breakdown: CriticalityBreakdown {
391                    pagerank,
392                    fanout_in,
393                    fanout_out,
394                    exported,
395                    test_coverage,
396                    depth_decay,
397                },
398            })
399        })
400        .collect();
401
402    callers.sort_by(|a, b| {
403        b.criticality
404            .partial_cmp(&a.criticality)
405            .unwrap_or(std::cmp::Ordering::Equal)
406            .then_with(|| a.depth.cmp(&b.depth))
407            .then_with(|| a.symbol.path.cmp(&b.symbol.path))
408    });
409    callers.truncate(100);
410
411    let tokens = estimate_tokens(&callers);
412    Ok(ImpactResult {
413        symbol: symbol.to_string(),
414        callers,
415        meta: meta(tokens, "find_references", 700, 0.78),
416    })
417}
418
419pub fn validate_conn(conn: &Connection, symbol: &str) -> Result<ValidateResult> {
420    let bloom_bytes = db::get_meta_blob(conn, "bloom_symbols")?;
421    let bloom_hit = bloom_bytes
422        .as_deref()
423        .and_then(BloomFilter::from_bytes)
424        .map(|b| b.contains(symbol))
425        .unwrap_or(true);
426
427    let exists = if bloom_hit {
428        symbol_exists(conn, symbol)?
429    } else {
430        false
431    };
432
433    let candidates = if exists {
434        Vec::new()
435    } else {
436        fuzzy_candidates(conn, symbol, 5)?
437    };
438
439    let tokens = estimate_tokens(&candidates).max(40);
440    Ok(ValidateResult {
441        query: symbol.to_string(),
442        exists,
443        bloom_hit,
444        candidates,
445        meta: meta(tokens, "find_definition", 320, 0.8),
446    })
447}
448
449pub fn validate_snippet_conn(
450    conn: &Connection,
451    code: &str,
452    language: Language,
453) -> Result<ValidateSnippetResult> {
454    let parsed = indexer::parse_file(language, code)?;
455    let bloom_bytes = db::get_meta_blob(conn, "bloom_symbols")?;
456    let bloom = bloom_bytes.as_deref().and_then(BloomFilter::from_bytes);
457
458    let mut checks = Vec::with_capacity(parsed.references.len());
459    let mut unresolved = 0;
460    for reference in &parsed.references {
461        let bloom_hit = bloom
462            .as_ref()
463            .map(|b| b.contains(&reference.symbol_name))
464            .unwrap_or(true);
465        let exists = if bloom_hit {
466            symbol_exists(conn, &reference.symbol_name)?
467        } else {
468            false
469        };
470        let candidates = if exists {
471            Vec::new()
472        } else {
473            unresolved += 1;
474            fuzzy_candidates(conn, &reference.symbol_name, 3)?
475        };
476        checks.push(SnippetReferenceCheck {
477            symbol_name: reference.symbol_name.clone(),
478            line: reference.line,
479            column: reference.column,
480            exists,
481            candidates,
482        });
483    }
484    let tokens = estimate_tokens(&checks).max(60);
485    Ok(ValidateSnippetResult {
486        language: language.to_string(),
487        total_calls: checks.len(),
488        unresolved_calls: unresolved,
489        checks,
490        meta: meta(tokens, "validate", 200, 0.9),
491    })
492}
493
494pub fn stats_conn(conn: &Connection, db_path: &Path) -> Result<StatsResult> {
495    let files = db::count_files(conn)?;
496    let symbols = db::count_symbols(conn)?;
497    let references = db::count_refs(conn)?;
498    let edges = db::count_edges(conn)?;
499
500    let mut languages = Vec::new();
501    {
502        let mut stmt =
503            conn.prepare("SELECT language, COUNT(*) FROM files GROUP BY language ORDER BY 2 DESC")?;
504        let rows = stmt.query_map([], |row| {
505            Ok(LanguageCount {
506                language: row.get::<_, String>(0)?,
507                count: row.get::<_, i64>(1)? as usize,
508            })
509        })?;
510        for row in rows {
511            languages.push(row?);
512        }
513    }
514
515    let mut kinds = Vec::new();
516    {
517        let mut stmt =
518            conn.prepare("SELECT kind, COUNT(*) FROM symbols GROUP BY kind ORDER BY 2 DESC")?;
519        let rows = stmt.query_map([], |row| {
520            Ok(KindCount {
521                kind: row.get::<_, String>(0)?,
522                count: row.get::<_, i64>(1)? as usize,
523            })
524        })?;
525        for row in rows {
526            kinds.push(row?);
527        }
528    }
529
530    let mut top_fanout = Vec::new();
531    {
532        let mut stmt = conn.prepare(
533            "
534            SELECT s.qualified_name, COUNT(DISTINCT e.from_symbol_id) AS callers
535            FROM edges e
536            JOIN symbols s ON s.name = e.to_symbol_name OR s.qualified_name = e.to_symbol_name
537            GROUP BY s.qualified_name
538            ORDER BY callers DESC
539            LIMIT 10
540            ",
541        )?;
542        let rows = stmt.query_map([], |row| {
543            Ok(TopFanout {
544                qualified_name: row.get::<_, String>(0)?,
545                callers: row.get::<_, i64>(1)? as usize,
546            })
547        })?;
548        for row in rows {
549            top_fanout.push(row?);
550        }
551    }
552
553    let snapshot_present = db_path
554        .parent()
555        .map(|p| p.join("snapshot.bin").exists())
556        .unwrap_or(false);
557
558    let tokens = symbols / 8 + 60;
559    Ok(StatsResult {
560        files,
561        symbols,
562        references,
563        edges,
564        languages,
565        kinds,
566        top_fanout,
567        db_path: db_path.to_string_lossy().to_string(),
568        snapshot_present,
569        meta: meta(tokens, "get_outline", 320, 0.6),
570    })
571}
572
573pub fn context_pack_conn(
574    conn: &Connection,
575    symbol: &str,
576    budget_tokens: usize,
577) -> Result<ContextPack> {
578    let budget = if budget_tokens == 0 {
579        1500
580    } else {
581        budget_tokens
582    };
583    // Budget allocation: roughly 40% body, 20% deps, 25% callers, 15% tests.
584    let body_budget = budget * 4 / 10;
585    let deps_budget = budget * 2 / 10;
586    let callers_budget = budget * 25 / 100;
587    let tests_budget = budget.saturating_sub(body_budget + deps_budget + callers_budget);
588
589    let Some(target) = db::resolve_symbol(conn, symbol)? else {
590        return Ok(ContextPack {
591            symbol: None,
592            body: None,
593            dependency_signatures: Vec::new(),
594            caller_signatures: Vec::new(),
595            tests: Vec::new(),
596            budget_tokens: budget,
597            meta: meta(40, "find_definition", 120, 0.6),
598        });
599    };
600
601    // Body — clipped to body_budget tokens.
602    let raw_body = read_symbol_body(conn, &target).ok();
603    let body = raw_body.map(|b| clip_to_token_budget(&b, body_budget));
604
605    // Dependency signatures — outgoing refs from this symbol, dedup + resolve to defining symbols.
606    let mut dep_lines: Vec<SignatureLine> = Vec::new();
607    {
608        let mut stmt = conn.prepare(
609            "
610            SELECT DISTINCT r.symbol_name FROM refs r
611            WHERE r.from_symbol_id = ?1
612            LIMIT 80
613            ",
614        )?;
615        let names = stmt
616            .query_map(params![target.id], |row| row.get::<_, String>(0))?
617            .collect::<rusqlite::Result<Vec<_>>>()?;
618        for name in names {
619            if let Some(sym) = db::resolve_symbol(conn, &name)? {
620                dep_lines.push(SignatureLine {
621                    qualified_name: sym.qualified_name.clone(),
622                    kind: sym.kind.clone(),
623                    path: sym.path.clone(),
624                    line: sym.start_line,
625                    signature: sym.signature.clone(),
626                });
627            }
628        }
629    }
630    trim_signature_lines(&mut dep_lines, deps_budget);
631
632    // Caller signatures — direct callers (depth-1 of impact).
633    let mut caller_lines: Vec<SignatureLine> = Vec::new();
634    for caller in callers_for_symbol(conn, &target.qualified_name, 60)? {
635        caller_lines.push(SignatureLine {
636            qualified_name: caller.qualified_name,
637            kind: caller.kind,
638            path: caller.path,
639            line: caller.start_line,
640            signature: caller.signature,
641        });
642    }
643    if caller_lines.is_empty() && target.qualified_name != target.name {
644        for caller in callers_for_symbol(conn, &target.name, 60)? {
645            caller_lines.push(SignatureLine {
646                qualified_name: caller.qualified_name,
647                kind: caller.kind,
648                path: caller.path,
649                line: caller.start_line,
650                signature: caller.signature,
651            });
652        }
653    }
654    trim_signature_lines(&mut caller_lines, callers_budget);
655
656    // Tests that transitively touch this symbol (capped, budget-aware).
657    let mut tests = tests_for_conn(conn, &target.qualified_name)?.tests;
658    tests.truncate(8);
659    trim_records_to_budget(&mut tests, tests_budget);
660
661    let tokens = estimate_tokens(&body)
662        + estimate_tokens(&dep_lines)
663        + estimate_tokens(&caller_lines)
664        + estimate_tokens(&tests);
665
666    Ok(ContextPack {
667        symbol: Some(target),
668        body,
669        dependency_signatures: dep_lines,
670        caller_signatures: caller_lines,
671        tests,
672        budget_tokens: budget,
673        meta: meta(tokens, "expand_symbol", 800, 0.88),
674    })
675}
676
677pub fn plan_query(task: &str, symbol: Option<&str>) -> PlanQueryResult {
678    let query = task.trim();
679    let lower = query.to_lowercase();
680    let subject = symbol
681        .map(str::trim)
682        .filter(|s| !s.is_empty())
683        .unwrap_or("<symbol>");
684
685    let (intent, mut steps) =
686        if contains_any(&lower, &["edit", "change", "modify", "refactor", "fix"]) {
687            (
688                "prepare_edit",
689                vec![
690                    plan_step(
691                        1,
692                        "validate",
693                        subject,
694                        "Confirm the target exists before spending context.",
695                        80,
696                    ),
697                    plan_step(
698                        2,
699                        "edit-prep",
700                        subject,
701                        "Fetch signature, related symbols, context, and tests in one bundle.",
702                        1300,
703                    ),
704                    plan_step(
705                        3,
706                        "impact",
707                        subject,
708                        "Check downstream callers before making the change.",
709                        500,
710                    ),
711                ],
712            )
713        } else if contains_any(&lower, &["review", "pr", "diff", "changed", "regression"]) {
714            (
715                "review_changes",
716                vec![
717                    PlanStep {
718                        order: 1,
719                        tool: "diff-impact".to_string(),
720                        command: "tessera diff-impact origin/main --depth 3".to_string(),
721                        reason: "Map changed symbols to high-impact callers for PR review."
722                            .to_string(),
723                        expected_tokens: 900,
724                    },
725                    PlanStep {
726                        order: 2,
727                        tool: "tests-for".to_string(),
728                        command: format!("tessera tests-for {subject}"),
729                        reason: "Verify whether the changed surface has focused test coverage."
730                            .to_string(),
731                        expected_tokens: 250,
732                    },
733                ],
734            )
735        } else if contains_any(&lower, &["call path", "connect", "reach", "flow"]) {
736            (
737                "trace_connection",
738                vec![
739                PlanStep {
740                    order: 1,
741                    tool: "connect".to_string(),
742                    command: "tessera connect <from> <to> --depth 8".to_string(),
743                    reason: "Find the shortest deterministic call path between two symbols."
744                        .to_string(),
745                    expected_tokens: 250,
746                },
747                PlanStep {
748                    order: 2,
749                    tool: "export".to_string(),
750                    command: "tessera export --from <from> --depth 3".to_string(),
751                    reason:
752                        "Render the forward call neighbourhood if the path needs visual context."
753                            .to_string(),
754                    expected_tokens: 450,
755                },
756            ],
757            )
758        } else if contains_any(&lower, &["unused", "dead", "cleanup"]) {
759            (
760                "find_cleanup_targets",
761                vec![
762                    PlanStep {
763                        order: 1,
764                        tool: "unused".to_string(),
765                        command: "tessera unused --limit 50".to_string(),
766                        reason: "Find indexed symbols with no inbound refs or call edges."
767                            .to_string(),
768                        expected_tokens: 600,
769                    },
770                    PlanStep {
771                        order: 2,
772                        tool: "impact".to_string(),
773                        command: format!("tessera impact {subject}"),
774                        reason: "Double-check callers before removing a candidate.".to_string(),
775                        expected_tokens: 400,
776                    },
777                ],
778            )
779        } else if contains_any(&lower, &["where", "definition", "find", "locate"]) {
780            (
781                "locate_symbol",
782                vec![
783                    plan_step(
784                        1,
785                        "validate",
786                        subject,
787                        "Catch typos and near-misses before lookup.",
788                        80,
789                    ),
790                    plan_step(
791                        2,
792                        "find-definition",
793                        subject,
794                        "Jump to exact file, line, and signature.",
795                        120,
796                    ),
797                    plan_step(
798                        3,
799                        "signature",
800                        subject,
801                        "Fetch the public shape without function bodies.",
802                        180,
803                    ),
804                ],
805            )
806        } else {
807            (
808                "understand_symbol",
809                vec![
810                    plan_step(
811                        1,
812                        "validate",
813                        subject,
814                        "Confirm the indexed symbol name.",
815                        80,
816                    ),
817                    plan_step(
818                        2,
819                        "context-pack",
820                        subject,
821                        "Bundle body, dependencies, callers, and tests under one budget.",
822                        1200,
823                    ),
824                    plan_step(
825                        3,
826                        "siblings",
827                        subject,
828                        "Find adjacent symbols that share callers.",
829                        250,
830                    ),
831                ],
832            )
833        };
834
835    for (idx, step) in steps.iter_mut().enumerate() {
836        step.order = idx + 1;
837    }
838    let tokens = steps.iter().map(|step| step.expected_tokens).sum::<usize>() + 40;
839    PlanQueryResult {
840        query: query.to_string(),
841        inferred_intent: intent.to_string(),
842        steps,
843        meta: meta(tokens, "search", 500, 0.68),
844    }
845}
846
847pub fn edit_prep_conn(
848    conn: &Connection,
849    symbol: &str,
850    budget_tokens: usize,
851) -> Result<EditPrepResult> {
852    let budget = if budget_tokens == 0 {
853        1800
854    } else {
855        budget_tokens
856    };
857    let validate = validate_conn(conn, symbol)?;
858    let signature = signature_conn(conn, symbol)?;
859    let siblings = siblings_conn(conn, symbol)?;
860    let context = context_pack_conn(conn, symbol, budget)?;
861    let tests = tests_for_conn(conn, symbol)?;
862    let next_steps = vec![
863        plan_step(
864            1,
865            "impact",
866            symbol,
867            "Review downstream callers before editing shared behavior.",
868            500,
869        ),
870        plan_step(
871            2,
872            "validate-snippet",
873            symbol,
874            "After editing, validate new call sites before relying on the graph.",
875            300,
876        ),
877        plan_step(
878            3,
879            "tests-for",
880            symbol,
881            "Run or update focused tests that transitively touch the symbol.",
882            250,
883        ),
884    ];
885    let tokens = estimate_tokens(&validate)
886        + estimate_tokens(&signature)
887        + estimate_tokens(&siblings)
888        + estimate_tokens(&context)
889        + estimate_tokens(&tests)
890        + estimate_tokens(&next_steps);
891
892    Ok(EditPrepResult {
893        symbol: symbol.to_string(),
894        validate,
895        signature,
896        siblings,
897        context,
898        tests,
899        next_steps,
900        meta: meta(tokens, "context_pack", budget, 0.92),
901    })
902}
903
904pub fn diff_impact_conn(
905    conn: &Connection,
906    from_ref: &str,
907    to_ref: Option<&str>,
908    depth: usize,
909) -> Result<DiffImpactResult> {
910    use std::process::Command;
911    let root = db::get_meta(conn, "root")?
912        .map(std::path::PathBuf::from)
913        .unwrap_or_else(|| std::path::PathBuf::from("."));
914    let to = to_ref.unwrap_or("HEAD");
915    let range = format!("{from_ref}..{to}");
916
917    let out = Command::new("git")
918        .arg("-C")
919        .arg(&root)
920        .arg("diff")
921        .arg("-U0")
922        .arg(&range)
923        .output()
924        .map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?;
925    if !out.status.success() {
926        anyhow::bail!(
927            "git diff exited {}: {}",
928            out.status,
929            String::from_utf8_lossy(&out.stderr)
930        );
931    }
932    let diff = String::from_utf8_lossy(&out.stdout);
933
934    // Parse the unified diff: track current file + line ranges of additions
935    // and removals. We treat any hunk that touches a symbol's [start, end]
936    // range as "this symbol changed".
937    #[derive(Default)]
938    struct FileHunks {
939        path: String,
940        ranges: Vec<(usize, usize, usize, usize)>, // (old_start, old_count, new_start, new_count)
941    }
942    let mut current: Option<FileHunks> = None;
943    let mut hunks: Vec<FileHunks> = Vec::new();
944    for line in diff.lines() {
945        if let Some(rest) = line.strip_prefix("+++ ") {
946            if let Some(cur) = current.take() {
947                hunks.push(cur);
948            }
949            let path = rest
950                .trim_start_matches("b/")
951                .trim_start_matches("a/")
952                .trim()
953                .to_string();
954            if path != "/dev/null" {
955                current = Some(FileHunks {
956                    path,
957                    ranges: Vec::new(),
958                });
959            }
960        } else if let Some(hunk) = line.strip_prefix("@@ ") {
961            // @@ -old_start,old_count +new_start,new_count @@
962            if let Some(rest) = hunk.split(" @@").next() {
963                let parts: Vec<&str> = rest.split_whitespace().collect();
964                if parts.len() >= 2 {
965                    let old = parse_range(parts[0].trim_start_matches('-'));
966                    let new = parse_range(parts[1].trim_start_matches('+'));
967                    if let (Some(o), Some(n)) = (old, new) {
968                        if let Some(cur) = current.as_mut() {
969                            cur.ranges.push((o.0, o.1, n.0, n.1));
970                        }
971                    }
972                }
973            }
974        }
975    }
976    if let Some(cur) = current.take() {
977        hunks.push(cur);
978    }
979
980    let changed_files = hunks.len();
981    let mut changed_symbols: Vec<DiffChangedSymbol> = Vec::new();
982
983    for fh in &hunks {
984        // Resolve file_id by path (path is repo-relative).
985        let Some((file_id, _)) = db::file_sha(conn, &fh.path)? else {
986            continue;
987        };
988        for (_, _, new_start, new_count) in &fh.ranges {
989            let hunk_start = *new_start;
990            let hunk_end = new_start + new_count.saturating_sub(1);
991            let mut stmt = conn.prepare(
992                "
993                SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
994                       s.start_line, s.end_line, s.signature, s.exported
995                FROM symbols s
996                JOIN files f ON f.id = s.file_id
997                WHERE s.file_id = ?1
998                  AND s.start_line <= ?2
999                  AND s.end_line >= ?3
1000                ",
1001            )?;
1002            let rows = stmt.query_map(
1003                params![file_id, hunk_end as i64, hunk_start as i64],
1004                db::map_symbol,
1005            )?;
1006            for row in rows {
1007                let sym = row?;
1008                if let Some(existing) = changed_symbols.iter_mut().find(|c| c.symbol.id == sym.id) {
1009                    existing.added_lines += *new_count;
1010                } else {
1011                    changed_symbols.push(DiffChangedSymbol {
1012                        symbol: sym,
1013                        added_lines: *new_count,
1014                        removed_lines: 0,
1015                    });
1016                }
1017            }
1018        }
1019    }
1020
1021    // For each changed symbol, run a shallow impact and aggregate.
1022    let mut impacted: Vec<DiffImpactedSymbol> = Vec::new();
1023    for changed in &changed_symbols {
1024        let imp = impact_conn(conn, &changed.symbol.qualified_name, depth.max(1))?;
1025        for caller in imp.callers.into_iter().take(20) {
1026            if let Some(existing) = impacted
1027                .iter_mut()
1028                .find(|e| e.symbol.id == caller.symbol.id)
1029            {
1030                existing.criticality = existing.criticality.max(caller.criticality);
1031            } else {
1032                impacted.push(DiffImpactedSymbol {
1033                    symbol: caller.symbol,
1034                    via: changed.symbol.qualified_name.clone(),
1035                    criticality: caller.criticality,
1036                });
1037            }
1038        }
1039    }
1040    impacted.sort_by(|a, b| {
1041        b.criticality
1042            .partial_cmp(&a.criticality)
1043            .unwrap_or(std::cmp::Ordering::Equal)
1044    });
1045    impacted.truncate(50);
1046
1047    let tokens = estimate_tokens(&changed_symbols) + estimate_tokens(&impacted);
1048    Ok(DiffImpactResult {
1049        from_ref: from_ref.to_string(),
1050        to_ref: to.to_string(),
1051        changed_files,
1052        changed_symbols,
1053        impacted,
1054        meta: meta(tokens, "impact", 900, 0.85),
1055    })
1056}
1057
1058fn parse_range(s: &str) -> Option<(usize, usize)> {
1059    let mut parts = s.split(',');
1060    let start: usize = parts.next()?.parse().ok()?;
1061    let count: usize = parts.next().and_then(|n| n.parse().ok()).unwrap_or(1);
1062    Some((start, count))
1063}
1064
1065pub fn imports_conn(conn: &Connection, path: &str) -> Result<ImportsResult> {
1066    let mut stmt = conn.prepare(
1067        "
1068        SELECT i.source, f.path, i.line, i.kind
1069        FROM imports i
1070        JOIN files f ON f.id = i.file_id
1071        WHERE f.path = ?1 OR f.path LIKE ?2
1072        ORDER BY f.path, i.line
1073        LIMIT 500
1074        ",
1075    )?;
1076    let like = if path.ends_with('/') || path.ends_with("**") {
1077        format!("{}%", path.trim_end_matches("**"))
1078    } else {
1079        format!("{}%", path)
1080    };
1081    let imports: Vec<ImportRecord> = stmt
1082        .query_map(params![path, like], |row| {
1083            Ok(ImportRecord {
1084                source: row.get(0)?,
1085                from_path: row.get(1)?,
1086                line: row.get::<_, i64>(2)? as usize,
1087                kind: row.get(3)?,
1088            })
1089        })?
1090        .collect::<rusqlite::Result<Vec<_>>>()?;
1091    let tokens = estimate_tokens(&imports).max(40);
1092    Ok(ImportsResult {
1093        path: path.to_string(),
1094        imports,
1095        meta: meta(tokens, "get_outline", 320, 0.7),
1096    })
1097}
1098
1099pub fn imported_by_conn(conn: &Connection, source: &str) -> Result<ImportedByResult> {
1100    // Match exact + substring (`./users` matches `./users.ts`).
1101    let mut stmt = conn.prepare(
1102        "
1103        SELECT i.source, f.path, i.line, i.kind
1104        FROM imports i
1105        JOIN files f ON f.id = i.file_id
1106        WHERE i.source = ?1 OR i.source LIKE ?2
1107        ORDER BY f.path, i.line
1108        LIMIT 500
1109        ",
1110    )?;
1111    let like = format!("%{source}%");
1112    let importers: Vec<ImportRecord> = stmt
1113        .query_map(params![source, like], |row| {
1114            Ok(ImportRecord {
1115                source: row.get(0)?,
1116                from_path: row.get(1)?,
1117                line: row.get::<_, i64>(2)? as usize,
1118                kind: row.get(3)?,
1119            })
1120        })?
1121        .collect::<rusqlite::Result<Vec<_>>>()?;
1122    let tokens = estimate_tokens(&importers).max(40);
1123    Ok(ImportedByResult {
1124        source: source.to_string(),
1125        importers,
1126        meta: meta(tokens, "imports", 200, 0.7),
1127    })
1128}
1129
1130pub fn signature_conn(conn: &Connection, symbol: &str) -> Result<SignatureResult> {
1131    let Some(target) = db::resolve_symbol(conn, symbol)? else {
1132        return Ok(SignatureResult {
1133            symbol: None,
1134            members: Vec::new(),
1135            meta: meta(30, "find_definition", 120, 0.5),
1136        });
1137    };
1138    // For container kinds, list child symbols defined inside this symbol's
1139    // line range — same file, nested. Cheap; no body included.
1140    let container = matches!(
1141        target.kind.as_str(),
1142        "class" | "struct" | "interface" | "trait" | "enum" | "record" | "impl" | "module"
1143    );
1144    let mut members: Vec<SignatureLine> = Vec::new();
1145    if container {
1146        let mut stmt = conn.prepare(
1147            "
1148            SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
1149                   s.start_line, s.end_line, s.signature, s.exported
1150            FROM symbols s
1151            JOIN files f ON f.id = s.file_id
1152            WHERE s.file_id = ?1
1153              AND s.id != ?2
1154              AND s.start_line > ?3
1155              AND s.end_line <= ?4
1156            ORDER BY s.start_line
1157            LIMIT 200
1158            ",
1159        )?;
1160        let rows = stmt.query_map(
1161            params![
1162                target.file_id,
1163                target.id,
1164                target.start_line as i64,
1165                target.end_line as i64
1166            ],
1167            db::map_symbol,
1168        )?;
1169        for row in rows {
1170            let sym = row?;
1171            members.push(SignatureLine {
1172                qualified_name: sym.qualified_name,
1173                kind: sym.kind,
1174                path: sym.path,
1175                line: sym.start_line,
1176                signature: sym.signature,
1177            });
1178        }
1179    }
1180    let tokens = estimate_tokens(&members).max(50);
1181    Ok(SignatureResult {
1182        symbol: Some(target),
1183        members,
1184        meta: meta(tokens, "expand_symbol", 600, 0.8),
1185    })
1186}
1187
1188pub fn siblings_conn(conn: &Connection, symbol: &str) -> Result<SiblingsResult> {
1189    // Symbols that share callers with `symbol`. The SQL self-join on `edges`
1190    // finds, for each from_symbol that calls our target, the OTHER things it
1191    // also calls — then ranks them by how many distinct callers they share.
1192    let mut stmt = conn.prepare(
1193        "
1194        SELECT e2.to_symbol_name, COUNT(DISTINCT e1.from_symbol_id) AS shared
1195        FROM edges e1
1196        JOIN edges e2 ON e2.from_symbol_id = e1.from_symbol_id
1197        WHERE (e1.to_symbol_name = ?1 OR e1.to_symbol_name = ?2)
1198          AND e2.to_symbol_name != ?1
1199          AND e2.to_symbol_name != ?2
1200        GROUP BY e2.to_symbol_name
1201        HAVING shared > 0
1202        ORDER BY shared DESC, e2.to_symbol_name
1203        LIMIT 50
1204        ",
1205    )?;
1206    let target = db::resolve_symbol(conn, symbol)?;
1207    let qualified = target
1208        .as_ref()
1209        .map(|t| t.qualified_name.clone())
1210        .unwrap_or_default();
1211    let name = target
1212        .as_ref()
1213        .map(|t| t.name.clone())
1214        .unwrap_or_else(|| symbol.to_string());
1215
1216    let rows = stmt.query_map(params![name, qualified], |row| {
1217        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
1218    })?;
1219
1220    let mut siblings: Vec<Sibling> = Vec::new();
1221    for row in rows {
1222        let (sibling_name, shared) = row?;
1223        let resolved = db::resolve_symbol(conn, &sibling_name).ok().flatten();
1224        siblings.push(Sibling {
1225            qualified_name: resolved
1226                .as_ref()
1227                .map(|s| s.qualified_name.clone())
1228                .unwrap_or(sibling_name),
1229            shared_callers: shared,
1230            path: resolved.as_ref().map(|s| s.path.clone()),
1231            line: resolved.as_ref().map(|s| s.start_line),
1232        });
1233    }
1234
1235    let tokens = estimate_tokens(&siblings).max(60);
1236    Ok(SiblingsResult {
1237        symbol: symbol.to_string(),
1238        siblings,
1239        meta: meta(tokens, "impact", 700, 0.75),
1240    })
1241}
1242
1243pub fn search_conn(
1244    conn: &Connection,
1245    pattern: &str,
1246    options: SearchOptions,
1247) -> Result<SearchResult> {
1248    let limit = options.limit.clamp(1, 500);
1249
1250    // Two query modes:
1251    //   - if the pattern has `*`, treat it as a glob → SQL LIKE
1252    //   - otherwise fuzzy via FTS5 trigram + Jaro-Winkler
1253    let candidates: Vec<SymbolRecord> = if pattern.contains('*') {
1254        glob_symbol_matches(conn, pattern, limit.saturating_mul(4).max(50))?
1255    } else if pattern.is_empty() {
1256        list_symbols(conn, limit.saturating_mul(4).max(50))?
1257    } else {
1258        fuzzy_symbol_matches(conn, pattern, limit.saturating_mul(4).max(50))?
1259    };
1260
1261    let mut hits: Vec<SearchHit> = candidates
1262        .into_iter()
1263        .filter(|s| options.kinds.is_empty() || options.kinds.contains(&s.kind))
1264        .filter(|s| options.languages.is_empty() || options.languages.contains(&s.language))
1265        .filter(|s| options.exported.is_none_or(|e| s.exported == e))
1266        .filter(|s| {
1267            options
1268                .path_prefix
1269                .as_deref()
1270                .is_none_or(|prefix| s.path.starts_with(prefix))
1271        })
1272        .map(|s| {
1273            let score = if pattern.is_empty() {
1274                0.0
1275            } else if pattern.contains('*') {
1276                glob_score(pattern, &s.name).max(glob_score(pattern, &s.qualified_name))
1277            } else {
1278                jaro_winkler(pattern, &s.qualified_name).max(jaro_winkler(pattern, &s.name)) as f32
1279            };
1280            SearchHit { symbol: s, score }
1281        })
1282        .collect();
1283
1284    hits.sort_by(|a, b| {
1285        b.score
1286            .partial_cmp(&a.score)
1287            .unwrap_or(std::cmp::Ordering::Equal)
1288            .then_with(|| a.symbol.qualified_name.cmp(&b.symbol.qualified_name))
1289    });
1290    hits.truncate(limit);
1291
1292    let tokens = estimate_tokens(&hits).max(40);
1293    Ok(SearchResult {
1294        query: pattern.to_string(),
1295        hits,
1296        meta: meta(tokens, "find_definition", 320, 0.7),
1297    })
1298}
1299
1300pub fn unused_conn(conn: &Connection, options: UnusedOptions) -> Result<UnusedResult> {
1301    let limit = options.limit.clamp(1, 500);
1302    let candidates = list_all_symbols(conn)?;
1303    let mut unused = Vec::new();
1304
1305    for symbol in candidates {
1306        if is_test_path(&symbol.path) {
1307            continue;
1308        }
1309        if !options.kinds.is_empty() && !options.kinds.contains(&symbol.kind) {
1310            continue;
1311        }
1312        if !options.languages.is_empty() && !options.languages.contains(&symbol.language) {
1313            continue;
1314        }
1315        if options
1316            .exported
1317            .is_some_and(|exported| symbol.exported != exported)
1318        {
1319            continue;
1320        }
1321        if options
1322            .path_prefix
1323            .as_deref()
1324            .is_some_and(|prefix| !symbol.path.starts_with(prefix))
1325        {
1326            continue;
1327        }
1328
1329        let inbound_refs = inbound_ref_count(conn, &symbol)?;
1330        let inbound_edges = inbound_edge_count(conn, &symbol)?;
1331        if inbound_refs == 0 && inbound_edges == 0 {
1332            unused.push(UnusedSymbol {
1333                symbol,
1334                inbound_refs,
1335                inbound_edges,
1336            });
1337            if unused.len() >= limit {
1338                break;
1339            }
1340        }
1341    }
1342
1343    let tokens = estimate_tokens(&unused).max(40);
1344    Ok(UnusedResult {
1345        symbols: unused,
1346        meta: meta(tokens, "search", 320, 0.7),
1347    })
1348}
1349
1350pub fn tests_for_conn(conn: &Connection, symbol: &str) -> Result<TestsForResult> {
1351    // Walk callers transitively until we either find a test-path caller or exhaust
1352    // a small depth budget. We collect any caller whose file path looks like a test.
1353    let mut seen: HashSet<i64> = HashSet::new();
1354    let mut tests: Vec<SymbolRecord> = Vec::new();
1355    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
1356    queue.push_back((symbol.to_string(), 0));
1357
1358    let max_depth = 6usize;
1359    while let Some((name, depth)) = queue.pop_front() {
1360        if depth > max_depth {
1361            continue;
1362        }
1363        for caller in callers_for_symbol(conn, &name, 500)? {
1364            if !seen.insert(caller.id) {
1365                continue;
1366            }
1367            if is_test_path(&caller.path) {
1368                tests.push(caller.clone());
1369            } else if depth + 1 < max_depth {
1370                queue.push_back((caller.name.clone(), depth + 1));
1371                if caller.qualified_name != caller.name {
1372                    queue.push_back((caller.qualified_name.clone(), depth + 1));
1373                }
1374            }
1375        }
1376    }
1377
1378    tests.sort_by(|a, b| a.path.cmp(&b.path).then(a.start_line.cmp(&b.start_line)));
1379    let tokens = estimate_tokens(&tests).max(60);
1380    Ok(TestsForResult {
1381        symbol: symbol.to_string(),
1382        tests,
1383        meta: meta(tokens, "impact", 700, 0.78),
1384    })
1385}
1386
1387pub fn connect_conn(
1388    conn: &Connection,
1389    from: &str,
1390    to: &str,
1391    max_depth: usize,
1392) -> Result<ConnectResult> {
1393    let max_depth = max_depth.clamp(1, 12);
1394
1395    let to_node = |s: &SymbolRecord| PathNode {
1396        qualified_name: s.qualified_name.clone(),
1397        kind: s.kind.clone(),
1398        path: s.path.clone(),
1399        line: s.start_line,
1400    };
1401
1402    let starts = exact_symbols(conn, from, 25)?;
1403    let targets = exact_symbols(conn, to, 50)?;
1404    if starts.is_empty() || targets.is_empty() {
1405        return Ok(ConnectResult {
1406            from: from.to_string(),
1407            to: to.to_string(),
1408            found: false,
1409            path: Vec::new(),
1410            meta: meta(40, "find_definition", 120, 0.6),
1411        });
1412    }
1413    let target_ids: HashSet<i64> = targets.iter().map(|s| s.id).collect();
1414
1415    // Trivial: `from` already resolves to a target symbol.
1416    if let Some(s) = starts.iter().find(|s| target_ids.contains(&s.id)) {
1417        return Ok(ConnectResult {
1418            from: from.to_string(),
1419            to: to.to_string(),
1420            found: true,
1421            path: vec![to_node(s)],
1422            meta: meta(40, "signature", 120, 0.7),
1423        });
1424    }
1425
1426    // BFS forward over the call graph (caller -> callee), tracking a predecessor
1427    // by symbol id so the path can be reconstructed. `prev` absent ⇒ a start node.
1428    const MAX_VISITED: usize = 5000;
1429    let mut visited: HashSet<i64> = HashSet::new();
1430    let mut prev: HashMap<i64, i64> = HashMap::new();
1431    let mut record: HashMap<i64, SymbolRecord> = HashMap::new();
1432    let mut queue: VecDeque<(SymbolRecord, usize)> = VecDeque::new();
1433
1434    for s in &starts {
1435        if visited.insert(s.id) {
1436            record.insert(s.id, s.clone());
1437            queue.push_back((s.clone(), 0));
1438        }
1439    }
1440
1441    let mut hit: Option<i64> = None;
1442    'bfs: while let Some((node, depth)) = queue.pop_front() {
1443        if depth >= max_depth {
1444            continue;
1445        }
1446        for callee in callees_for_symbol(conn, &node, 200)? {
1447            if !visited.insert(callee.id) {
1448                continue;
1449            }
1450            prev.insert(callee.id, node.id);
1451            record.insert(callee.id, callee.clone());
1452            if target_ids.contains(&callee.id) {
1453                hit = Some(callee.id);
1454                break 'bfs;
1455            }
1456            queue.push_back((callee.clone(), depth + 1));
1457            if visited.len() >= MAX_VISITED {
1458                break 'bfs;
1459            }
1460        }
1461    }
1462
1463    let mut path_nodes = Vec::new();
1464    if let Some(target_id) = hit {
1465        let mut chain = vec![target_id];
1466        let mut cur = target_id;
1467        while let Some(&p) = prev.get(&cur) {
1468            chain.push(p);
1469            cur = p;
1470        }
1471        chain.reverse();
1472        for id in chain {
1473            if let Some(rec) = record.get(&id) {
1474                path_nodes.push(to_node(rec));
1475            }
1476        }
1477    }
1478
1479    let tokens = estimate_tokens(&path_nodes).max(40);
1480    Ok(ConnectResult {
1481        from: from.to_string(),
1482        to: to.to_string(),
1483        found: hit.is_some(),
1484        path: path_nodes,
1485        meta: meta(tokens, "impact", 700, 0.8),
1486    })
1487}
1488
1489pub fn export_conn(
1490    conn: &Connection,
1491    format: &str,
1492    from: Option<&str>,
1493    depth: usize,
1494    limit: usize,
1495) -> Result<ExportResult> {
1496    export_conn_with_options(conn, ExportOptions::new(format, from, depth, limit))
1497}
1498
1499pub fn export_conn_with_options(conn: &Connection, options: ExportOptions) -> Result<ExportResult> {
1500    let fmt = if options.format.eq_ignore_ascii_case("dot") {
1501        "dot"
1502    } else {
1503        "mermaid"
1504    };
1505    let limit = options.limit.clamp(1, 5000);
1506    let depth = options.depth.clamp(1, 12);
1507    let mut edges: Vec<(String, String)> = Vec::new();
1508    let mut node_meta: HashMap<String, GraphNode> = HashMap::new();
1509    let mut truncated = false;
1510    let scope;
1511
1512    if let Some(sym) = options.from.as_deref() {
1513        scope = format!("from:{sym}");
1514        let starts = exact_symbols(conn, sym, 25)?;
1515        let mut visited: HashSet<i64> = HashSet::new();
1516        let mut queue: VecDeque<(SymbolRecord, usize)> = VecDeque::new();
1517        for s in &starts {
1518            insert_graph_node(&mut node_meta, s);
1519            if visited.insert(s.id) {
1520                queue.push_back((s.clone(), 0));
1521            }
1522        }
1523        'bfs: while let Some((node, d)) = queue.pop_front() {
1524            if d >= depth {
1525                continue;
1526            }
1527            for callee in callees_for_symbol(conn, &node, 200)? {
1528                insert_graph_node(&mut node_meta, &node);
1529                insert_graph_node(&mut node_meta, &callee);
1530                if !include_graph_node(&node, &options) || !include_graph_node(&callee, &options) {
1531                    continue;
1532                }
1533                edges.push((node.qualified_name.clone(), callee.qualified_name.clone()));
1534                if edges.len() >= limit {
1535                    truncated = true;
1536                    break 'bfs;
1537                }
1538                if visited.insert(callee.id) {
1539                    queue.push_back((callee, d + 1));
1540                }
1541            }
1542        }
1543    } else {
1544        scope = "all".to_string();
1545        let mut stmt = conn.prepare(
1546            "
1547            SELECT DISTINCT
1548                s1.qualified_name, f1.path, f1.language, s1.exported,
1549                s2.qualified_name, f2.path, f2.language, s2.exported
1550            FROM edges e
1551            JOIN symbols s1 ON s1.id = e.from_symbol_id
1552            JOIN files f1 ON f1.id = s1.file_id
1553            JOIN symbols s2 ON (s2.name = e.to_symbol_name OR s2.qualified_name = e.to_symbol_name)
1554            JOIN files f2 ON f2.id = s2.file_id
1555            WHERE s1.id != s2.id
1556            ORDER BY s1.qualified_name, s2.qualified_name
1557            LIMIT ?1
1558            ",
1559        )?;
1560        let rows = stmt.query_map(params![(limit + 1) as i64], |row| {
1561            Ok((
1562                GraphNode {
1563                    name: row.get(0)?,
1564                    path: row.get(1)?,
1565                    language: row.get(2)?,
1566                    exported: row.get::<_, i64>(3)? != 0,
1567                },
1568                GraphNode {
1569                    name: row.get(4)?,
1570                    path: row.get(5)?,
1571                    language: row.get(6)?,
1572                    exported: row.get::<_, i64>(7)? != 0,
1573                },
1574            ))
1575        })?;
1576        for row in rows {
1577            let (from_node, to_node) = row?;
1578            node_meta.insert(from_node.name.clone(), from_node.clone());
1579            node_meta.insert(to_node.name.clone(), to_node.clone());
1580            if !include_graph_node_raw(&from_node, &options)
1581                || !include_graph_node_raw(&to_node, &options)
1582            {
1583                continue;
1584            }
1585            edges.push((from_node.name, to_node.name));
1586        }
1587        if edges.len() > limit {
1588            truncated = true;
1589            edges.truncate(limit);
1590        }
1591    }
1592
1593    edges.sort();
1594    edges.dedup();
1595
1596    // Node set in deterministic order.
1597    let mut nodes: Vec<String> = Vec::new();
1598    {
1599        let mut seen: HashSet<String> = HashSet::new();
1600        for (a, b) in &edges {
1601            for n in [a, b] {
1602                if seen.insert(n.clone()) {
1603                    nodes.push(n.clone());
1604                }
1605            }
1606        }
1607        nodes.sort();
1608    }
1609
1610    let diagram = if fmt == "dot" {
1611        render_dot(&edges, &node_meta, options.group_by)
1612    } else {
1613        render_mermaid(&nodes, &edges, &node_meta, options.group_by)
1614    };
1615    let html_path = if let Some(path) = options.html_out.as_ref() {
1616        let preview_diagram = if fmt == "mermaid" {
1617            diagram.clone()
1618        } else {
1619            render_mermaid(&nodes, &edges, &node_meta, options.group_by)
1620        };
1621        fs::write(
1622            path,
1623            render_html_preview(&preview_diagram, &scope, options.group_by),
1624        )?;
1625        Some(path.to_string_lossy().to_string())
1626    } else {
1627        None
1628    };
1629
1630    let tokens = estimate_tokens(&diagram).max(40);
1631    Ok(ExportResult {
1632        format: fmt.to_string(),
1633        scope,
1634        group_by: options.group_by.as_str().to_string(),
1635        nodes: nodes.len(),
1636        edges: edges.len(),
1637        truncated,
1638        diagram,
1639        html_path,
1640        meta: meta(tokens, "impact", 900, 0.7),
1641    })
1642}
1643
1644// ─── Path-based wrappers used by the CLI ─────────────────────────────────────
1645
1646pub fn find_definition(db_path: &Path, symbol: &str) -> Result<DefinitionResult> {
1647    let conn = db::open_existing(db_path)?;
1648    find_definition_conn(&conn, symbol)
1649}
1650
1651pub fn find_references(db_path: &Path, symbol: &str) -> Result<ReferencesResult> {
1652    let conn = db::open_existing(db_path)?;
1653    find_references_conn(&conn, symbol)
1654}
1655
1656pub fn get_outline(db_path: &Path, path: &Path) -> Result<OutlineResult> {
1657    let conn = db::open_existing(db_path)?;
1658    get_outline_conn(&conn, path)
1659}
1660
1661pub fn expand_symbol(db_path: &Path, symbol: &str) -> Result<ExpandResult> {
1662    let conn = db::open_existing(db_path)?;
1663    expand_symbol_conn(&conn, symbol)
1664}
1665
1666pub fn impact(db_path: &Path, symbol: &str, depth: usize) -> Result<ImpactResult> {
1667    let conn = db::open_existing(db_path)?;
1668    impact_conn(&conn, symbol, depth)
1669}
1670
1671pub fn validate(db_path: &Path, symbol: &str) -> Result<ValidateResult> {
1672    let conn = db::open_existing(db_path)?;
1673    validate_conn(&conn, symbol)
1674}
1675
1676pub fn validate_snippet(
1677    db_path: &Path,
1678    code: &str,
1679    language: Language,
1680) -> Result<ValidateSnippetResult> {
1681    let conn = db::open_existing(db_path)?;
1682    validate_snippet_conn(&conn, code, language)
1683}
1684
1685pub fn stats(db_path: &Path) -> Result<StatsResult> {
1686    let conn = db::open_existing(db_path)?;
1687    stats_conn(&conn, db_path)
1688}
1689
1690pub fn tests_for(db_path: &Path, symbol: &str) -> Result<TestsForResult> {
1691    let conn = db::open_existing(db_path)?;
1692    tests_for_conn(&conn, symbol)
1693}
1694
1695pub fn connect(db_path: &Path, from: &str, to: &str, max_depth: usize) -> Result<ConnectResult> {
1696    let conn = db::open_existing(db_path)?;
1697    connect_conn(&conn, from, to, max_depth)
1698}
1699
1700pub fn export(
1701    db_path: &Path,
1702    format: &str,
1703    from: Option<&str>,
1704    depth: usize,
1705    limit: usize,
1706) -> Result<ExportResult> {
1707    let conn = db::open_existing(db_path)?;
1708    export_conn(&conn, format, from, depth, limit)
1709}
1710
1711pub fn export_with_options(db_path: &Path, options: ExportOptions) -> Result<ExportResult> {
1712    let conn = db::open_existing(db_path)?;
1713    export_conn_with_options(&conn, options)
1714}
1715
1716pub fn search(db_path: &Path, pattern: &str, options: SearchOptions) -> Result<SearchResult> {
1717    let conn = db::open_existing(db_path)?;
1718    search_conn(&conn, pattern, options)
1719}
1720
1721pub fn unused(db_path: &Path, options: UnusedOptions) -> Result<UnusedResult> {
1722    let conn = db::open_existing(db_path)?;
1723    unused_conn(&conn, options)
1724}
1725
1726pub fn context_pack(db_path: &Path, symbol: &str, budget: usize) -> Result<ContextPack> {
1727    let conn = db::open_existing(db_path)?;
1728    context_pack_conn(&conn, symbol, budget)
1729}
1730
1731pub fn edit_prep(db_path: &Path, symbol: &str, budget: usize) -> Result<EditPrepResult> {
1732    let conn = db::open_existing(db_path)?;
1733    edit_prep_conn(&conn, symbol, budget)
1734}
1735
1736pub fn diff_impact(
1737    db_path: &Path,
1738    from_ref: &str,
1739    to_ref: Option<&str>,
1740    depth: usize,
1741) -> Result<DiffImpactResult> {
1742    let conn = db::open_existing(db_path)?;
1743    diff_impact_conn(&conn, from_ref, to_ref, depth)
1744}
1745
1746pub fn imports(db_path: &Path, path: &str) -> Result<ImportsResult> {
1747    let conn = db::open_existing(db_path)?;
1748    imports_conn(&conn, path)
1749}
1750
1751pub fn imported_by(db_path: &Path, source: &str) -> Result<ImportedByResult> {
1752    let conn = db::open_existing(db_path)?;
1753    imported_by_conn(&conn, source)
1754}
1755
1756pub fn signature(db_path: &Path, symbol: &str) -> Result<SignatureResult> {
1757    let conn = db::open_existing(db_path)?;
1758    signature_conn(&conn, symbol)
1759}
1760
1761pub fn siblings(db_path: &Path, symbol: &str) -> Result<SiblingsResult> {
1762    let conn = db::open_existing(db_path)?;
1763    siblings_conn(&conn, symbol)
1764}
1765
1766pub fn shell(db_path: &Path) -> Result<()> {
1767    println!("Tessera shell. Commands: def <symbol>, refs <symbol>, outline <path>, expand <symbol>, impact <symbol>, validate <symbol>, stats, tests <symbol>, quit");
1768    let mut input = String::new();
1769    loop {
1770        input.clear();
1771        print!("tessera> ");
1772        io::stdout().flush()?;
1773        if io::stdin().read_line(&mut input)? == 0 {
1774            break;
1775        }
1776        let command = input.trim();
1777        if command.is_empty() {
1778            continue;
1779        }
1780        if command == "quit" || command == "exit" {
1781            break;
1782        }
1783        let mut parts = command.splitn(2, char::is_whitespace);
1784        let name = parts.next().unwrap_or_default();
1785        let arg = parts.next().unwrap_or_default().trim();
1786        match name {
1787            "def" => println!("{}", find_definition(db_path, arg)?),
1788            "refs" => println!("{}", find_references(db_path, arg)?),
1789            "outline" => println!("{}", get_outline(db_path, Path::new(arg))?),
1790            "expand" => println!("{}", expand_symbol(db_path, arg)?),
1791            "impact" => println!("{}", impact(db_path, arg, 4)?),
1792            "validate" => println!("{}", validate(db_path, arg)?),
1793            "tests" => println!("{}", tests_for(db_path, arg)?),
1794            "stats" => println!("{}", stats(db_path)?),
1795            _ => println!("Unknown command: {name}"),
1796        }
1797    }
1798    Ok(())
1799}
1800
1801// ─── Internals ───────────────────────────────────────────────────────────────
1802
1803fn contains_any(haystack: &str, needles: &[&str]) -> bool {
1804    needles.iter().any(|needle| haystack.contains(needle))
1805}
1806
1807fn plan_step(
1808    order: usize,
1809    tool: &str,
1810    symbol: &str,
1811    reason: &str,
1812    expected_tokens: usize,
1813) -> PlanStep {
1814    PlanStep {
1815        order,
1816        tool: tool.to_string(),
1817        command: format!("tessera {} {}", tool.replace('_', "-"), symbol),
1818        reason: reason.to_string(),
1819        expected_tokens,
1820    }
1821}
1822
1823fn references_for_symbol(
1824    conn: &Connection,
1825    symbol: &str,
1826    limit: usize,
1827) -> Result<Vec<ReferenceRecord>> {
1828    let mut stmt = conn.prepare(
1829        "
1830        SELECT r.id, r.symbol_name, r.from_symbol_id, s.qualified_name, f.path,
1831               r.line, r.column, r.context, r.kind
1832        FROM refs r
1833        JOIN files f ON f.id = r.file_id
1834        LEFT JOIN symbols s ON s.id = r.from_symbol_id
1835        WHERE r.symbol_name = ?1 OR r.symbol_name LIKE ?2
1836        ORDER BY f.path, r.line
1837        LIMIT ?3
1838        ",
1839    )?;
1840    let rows = stmt.query_map(
1841        params![symbol, format!("%.{}", symbol), limit as i64],
1842        db::map_reference,
1843    )?;
1844    let refs = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1845    Ok(refs)
1846}
1847
1848fn references_from_symbol(
1849    conn: &Connection,
1850    symbol_id: i64,
1851    limit: usize,
1852) -> Result<Vec<ReferenceRecord>> {
1853    let mut stmt = conn.prepare(
1854        "
1855        SELECT r.id, r.symbol_name, r.from_symbol_id, s.qualified_name, f.path,
1856               r.line, r.column, r.context, r.kind
1857        FROM refs r
1858        JOIN files f ON f.id = r.file_id
1859        LEFT JOIN symbols s ON s.id = r.from_symbol_id
1860        WHERE r.from_symbol_id = ?1
1861        ORDER BY r.line
1862        LIMIT ?2
1863        ",
1864    )?;
1865    let rows = stmt.query_map(params![symbol_id, limit as i64], db::map_reference)?;
1866    let refs = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1867    Ok(refs)
1868}
1869
1870fn callers_for_symbol(conn: &Connection, symbol: &str, limit: usize) -> Result<Vec<SymbolRecord>> {
1871    let mut stmt = conn.prepare(
1872        "
1873        SELECT DISTINCT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
1874               s.start_line, s.end_line, s.signature, s.exported
1875        FROM edges e
1876        JOIN symbols s ON s.id = e.from_symbol_id
1877        JOIN files f ON f.id = s.file_id
1878        WHERE e.to_symbol_name = ?1 OR e.to_symbol_name LIKE ?2
1879        ORDER BY f.path, s.start_line
1880        LIMIT ?3
1881        ",
1882    )?;
1883    let rows = stmt.query_map(
1884        params![symbol, format!("%.{}", symbol), limit as i64],
1885        db::map_symbol,
1886    )?;
1887    let symbols = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1888    Ok(symbols)
1889}
1890
1891/// Symbols that `caller` calls — the forward direction of the call graph (the
1892/// inverse of `callers_for_symbol`). Used by `connect` / `export`.
1893///
1894/// Each distinct callee *name* is resolved to a single best symbol, preferring
1895/// a definition in the caller's own file. Without this, a call to a common name
1896/// like `loadUser` would fan out to every `loadUser` across the index and the
1897/// forward subgraph would explode across unrelated files — defeating the point
1898/// of a precise graph.
1899fn callees_for_symbol(
1900    conn: &Connection,
1901    caller: &SymbolRecord,
1902    limit: usize,
1903) -> Result<Vec<SymbolRecord>> {
1904    let mut name_stmt = conn
1905        .prepare("SELECT DISTINCT to_symbol_name FROM edges WHERE from_symbol_id = ?1 LIMIT ?2")?;
1906    let names = name_stmt
1907        .query_map(params![caller.id, limit as i64], |row| {
1908            row.get::<_, String>(0)
1909        })?
1910        .collect::<rusqlite::Result<Vec<_>>>()?;
1911
1912    let mut out = Vec::new();
1913    let mut seen: HashSet<i64> = HashSet::new();
1914    for name in names {
1915        if let Some(sym) = resolve_callee(conn, &name, caller.file_id)? {
1916            if sym.id != caller.id && seen.insert(sym.id) {
1917                out.push(sym);
1918            }
1919        }
1920    }
1921    Ok(out)
1922}
1923
1924/// Resolve a referenced name to a single best-matching symbol, preferring a
1925/// definition in `prefer_file_id`, then exact-qualified, then exact-name.
1926fn resolve_callee(
1927    conn: &Connection,
1928    name: &str,
1929    prefer_file_id: i64,
1930) -> Result<Option<SymbolRecord>> {
1931    conn.query_row(
1932        "
1933        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
1934               s.start_line, s.end_line, s.signature, s.exported
1935        FROM symbols s
1936        JOIN files f ON f.id = s.file_id
1937        WHERE s.qualified_name = ?1 OR s.name = ?1
1938        ORDER BY
1939            CASE WHEN s.file_id = ?2 THEN 0 ELSE 1 END,
1940            CASE WHEN s.qualified_name = ?1 THEN 0 ELSE 1 END,
1941            length(s.qualified_name),
1942            f.path
1943        LIMIT 1
1944        ",
1945        params![name, prefer_file_id],
1946        db::map_symbol,
1947    )
1948    .optional()
1949    .map_err(Into::into)
1950}
1951
1952/// Exact, case-sensitive symbol matches by name or qualified name. Unlike
1953/// `find_definition_conn` this never falls back to fuzzy matching, and unlike a
1954/// `LIKE` clause it is case-sensitive — `connect` and `export` need precise
1955/// endpoints. (SQLite `LIKE` is case-insensitive by default, which would cross
1956/// `FindByID` with `findById`; `=` uses the BINARY collation, which does not.)
1957fn exact_symbols(conn: &Connection, symbol: &str, limit: usize) -> Result<Vec<SymbolRecord>> {
1958    let mut stmt = conn.prepare(
1959        "
1960        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
1961               s.start_line, s.end_line, s.signature, s.exported
1962        FROM symbols s
1963        JOIN files f ON f.id = s.file_id
1964        WHERE s.qualified_name = ?1 OR s.name = ?1
1965        ORDER BY
1966            CASE WHEN s.qualified_name = ?1 THEN 0 ELSE 1 END,
1967            length(s.qualified_name),
1968            f.path
1969        LIMIT ?2
1970        ",
1971    )?;
1972    let rows = stmt.query_map(params![symbol, limit as i64], db::map_symbol)?;
1973    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1974}
1975
1976#[derive(Debug, Clone)]
1977struct GraphNode {
1978    name: String,
1979    path: String,
1980    language: String,
1981    exported: bool,
1982}
1983
1984fn insert_graph_node(nodes: &mut HashMap<String, GraphNode>, symbol: &SymbolRecord) {
1985    nodes.insert(
1986        symbol.qualified_name.clone(),
1987        GraphNode {
1988            name: symbol.qualified_name.clone(),
1989            path: symbol.path.clone(),
1990            language: symbol.language.clone(),
1991            exported: symbol.exported,
1992        },
1993    );
1994}
1995
1996fn include_graph_node(symbol: &SymbolRecord, options: &ExportOptions) -> bool {
1997    if options.exported_only && !symbol.exported {
1998        return false;
1999    }
2000    if options.collapse_tests && is_test_path(&symbol.path) {
2001        return false;
2002    }
2003    true
2004}
2005
2006fn include_graph_node_raw(node: &GraphNode, options: &ExportOptions) -> bool {
2007    if options.exported_only && !node.exported {
2008        return false;
2009    }
2010    if options.collapse_tests && is_test_path(&node.path) {
2011        return false;
2012    }
2013    true
2014}
2015
2016fn graph_group(node: Option<&GraphNode>, group_by: ExportGroupBy) -> String {
2017    let Some(node) = node else {
2018        return "unknown".to_string();
2019    };
2020    match group_by {
2021        ExportGroupBy::None => "graph".to_string(),
2022        ExportGroupBy::File => node.path.clone(),
2023        ExportGroupBy::Directory => node
2024            .path
2025            .rsplit_once('/')
2026            .map(|(dir, _)| dir.to_string())
2027            .unwrap_or_else(|| ".".to_string()),
2028        ExportGroupBy::Language => node.language.clone(),
2029    }
2030}
2031
2032fn render_dot(
2033    edges: &[(String, String)],
2034    node_meta: &HashMap<String, GraphNode>,
2035    group_by: ExportGroupBy,
2036) -> String {
2037    let mut out = String::from(
2038        "digraph tessera {\n  rankdir=LR;\n  node [shape=box, fontname=\"monospace\"];\n",
2039    );
2040    if group_by != ExportGroupBy::None {
2041        let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
2042        for name in dot_nodes(edges) {
2043            groups
2044                .entry(graph_group(node_meta.get(&name), group_by))
2045                .or_default()
2046                .push(name);
2047        }
2048        for (idx, (group, mut nodes)) in groups.into_iter().enumerate() {
2049            nodes.sort();
2050            nodes.dedup();
2051            out.push_str(&format!(
2052                "  subgraph cluster_{idx} {{\n    label={};\n",
2053                dot_quote(&group)
2054            ));
2055            for node in nodes {
2056                out.push_str(&format!("    {};\n", dot_quote(&node)));
2057            }
2058            out.push_str("  }\n");
2059        }
2060    }
2061    for (a, b) in edges {
2062        out.push_str(&format!("  {} -> {};\n", dot_quote(a), dot_quote(b)));
2063    }
2064    out.push_str("}\n");
2065    out
2066}
2067
2068fn dot_quote(s: &str) -> String {
2069    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
2070}
2071
2072fn dot_nodes(edges: &[(String, String)]) -> Vec<String> {
2073    let mut nodes = Vec::new();
2074    for (a, b) in edges {
2075        nodes.push(a.clone());
2076        nodes.push(b.clone());
2077    }
2078    nodes.sort();
2079    nodes.dedup();
2080    nodes
2081}
2082
2083fn render_mermaid(
2084    nodes: &[String],
2085    edges: &[(String, String)],
2086    node_meta: &HashMap<String, GraphNode>,
2087    group_by: ExportGroupBy,
2088) -> String {
2089    let id_of: HashMap<&String, usize> = nodes.iter().enumerate().map(|(i, n)| (n, i)).collect();
2090    let mut out = String::from("graph TD\n");
2091    if group_by == ExportGroupBy::None {
2092        for (i, n) in nodes.iter().enumerate() {
2093            out.push_str(&format!("  n{}[\"{}\"]\n", i, mermaid_label(n)));
2094        }
2095    } else {
2096        let mut groups: BTreeMap<String, Vec<&String>> = BTreeMap::new();
2097        for n in nodes {
2098            groups
2099                .entry(graph_group(node_meta.get(n), group_by))
2100                .or_default()
2101                .push(n);
2102        }
2103        for (group, members) in groups {
2104            out.push_str(&format!("  subgraph \"{}\"\n", mermaid_label(&group)));
2105            for n in members {
2106                if let Some(&i) = id_of.get(n) {
2107                    out.push_str(&format!("    n{}[\"{}\"]\n", i, mermaid_label(n)));
2108                }
2109            }
2110            out.push_str("  end\n");
2111        }
2112    }
2113    for (a, b) in edges {
2114        if let (Some(&ai), Some(&bi)) = (id_of.get(a), id_of.get(b)) {
2115            out.push_str(&format!("  n{ai} --> n{bi}\n"));
2116        }
2117    }
2118    out
2119}
2120
2121fn mermaid_label(s: &str) -> String {
2122    // Mermaid node labels are quoted; quotes and brackets break the parser.
2123    s.replace('"', "'").replace('[', "(").replace(']', ")")
2124}
2125
2126fn render_html_preview(diagram: &str, scope: &str, group_by: ExportGroupBy) -> String {
2127    format!(
2128        r#"<!doctype html>
2129<html lang="en">
2130<head>
2131  <meta charset="utf-8">
2132  <meta name="viewport" content="width=device-width, initial-scale=1">
2133  <title>Tessera Graph Preview</title>
2134  <script type="module">
2135    import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
2136    mermaid.initialize({{ startOnLoad: true, theme: "default" }});
2137  </script>
2138  <style>
2139    body {{ margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f7f8fa; }}
2140    header {{ display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: white; border-bottom: 1px solid #d9dee7; }}
2141    main {{ padding: 18px; }}
2142    button {{ border: 1px solid #9aa7b7; background: #fff; border-radius: 6px; padding: 8px 12px; cursor: pointer; }}
2143    .meta {{ color: #5e6b7a; }}
2144    .mermaid {{ background: white; border: 1px solid #d9dee7; border-radius: 8px; padding: 18px; overflow: auto; }}
2145    pre {{ position: absolute; left: -9999px; }}
2146  </style>
2147</head>
2148<body>
2149  <header>
2150    <div>
2151      <strong>Tessera Graph Preview</strong>
2152      <div class="meta">scope: {scope} · grouped by: {group_by}</div>
2153    </div>
2154    <button type="button" onclick="navigator.clipboard.writeText(document.getElementById('diagram').textContent)">Copy Mermaid</button>
2155  </header>
2156  <main>
2157    <pre id="diagram">{diagram}</pre>
2158    <div class="mermaid">{diagram}</div>
2159  </main>
2160</body>
2161</html>
2162"#,
2163        scope = html_escape(scope),
2164        group_by = group_by.as_str(),
2165        diagram = html_escape(diagram)
2166    )
2167}
2168
2169fn html_escape(value: &str) -> String {
2170    value
2171        .replace('&', "&amp;")
2172        .replace('<', "&lt;")
2173        .replace('>', "&gt;")
2174        .replace('"', "&quot;")
2175}
2176
2177fn read_symbol_body(conn: &Connection, symbol: &SymbolRecord) -> Result<String> {
2178    let path = db::get_meta(conn, "root")?
2179        .map(|root| PathBuf::from(root).join(&symbol.path))
2180        .unwrap_or_else(|| PathBuf::from(&symbol.path));
2181    let content = fs::read_to_string(path)?;
2182    let body = content
2183        .lines()
2184        .skip(symbol.start_line.saturating_sub(1))
2185        .take(symbol.end_line.saturating_sub(symbol.start_line) + 1)
2186        .collect::<Vec<_>>()
2187        .join("\n");
2188    Ok(body)
2189}
2190
2191fn symbol_exists(conn: &Connection, symbol: &str) -> Result<bool> {
2192    let count: i64 = conn.query_row(
2193        "
2194        SELECT COUNT(*) FROM symbols
2195        WHERE qualified_name = ?1 OR name = ?1 OR qualified_name LIKE ?2
2196        ",
2197        params![symbol, format!("%.{}", symbol)],
2198        |row| row.get(0),
2199    )?;
2200    Ok(count > 0)
2201}
2202
2203fn fuzzy_candidates(
2204    conn: &Connection,
2205    symbol: &str,
2206    limit: usize,
2207) -> Result<Vec<SymbolSuggestion>> {
2208    let matches = fuzzy_symbol_matches(conn, symbol, limit)?;
2209    let mut suggestions: Vec<SymbolSuggestion> = matches
2210        .into_iter()
2211        .map(|s| {
2212            let by_qualified = jaro_winkler(symbol, &s.qualified_name) as f32;
2213            let by_name = jaro_winkler(symbol, &s.name) as f32;
2214            let confidence = by_qualified.max(by_name);
2215            SymbolSuggestion {
2216                qualified_name: s.qualified_name,
2217                name: s.name,
2218                path: s.path,
2219                line: s.start_line,
2220                confidence,
2221            }
2222        })
2223        .collect();
2224    suggestions.sort_by(|a, b| {
2225        b.confidence
2226            .partial_cmp(&a.confidence)
2227            .unwrap_or(std::cmp::Ordering::Equal)
2228    });
2229    suggestions.truncate(limit);
2230    Ok(suggestions)
2231}
2232
2233fn fuzzy_symbol_matches(
2234    conn: &Connection,
2235    symbol: &str,
2236    limit: usize,
2237) -> Result<Vec<SymbolRecord>> {
2238    // First try FTS5 (cheap, indexed). If unavailable or no hits, fall back to
2239    // scanning all symbol names and ranking by Jaro-Winkler.
2240    let escaped = escape_fts_term(symbol);
2241    let fts_query = format!("{}*", escaped);
2242    let fts_attempt: rusqlite::Result<Vec<SymbolRecord>> = (|| {
2243        let mut stmt = conn.prepare(
2244            "
2245            SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
2246                   s.start_line, s.end_line, s.signature, s.exported
2247            FROM symbols_fts fts
2248            JOIN symbols s ON s.id = fts.rowid
2249            JOIN files f ON f.id = s.file_id
2250            WHERE symbols_fts MATCH ?1
2251            LIMIT ?2
2252            ",
2253        )?;
2254        let rows = stmt.query_map(params![fts_query, (limit * 4) as i64], db::map_symbol)?;
2255        rows.collect::<rusqlite::Result<Vec<_>>>()
2256    })();
2257
2258    let mut candidates = fts_attempt.unwrap_or_default();
2259
2260    if candidates.is_empty() {
2261        let mut stmt = conn.prepare(
2262            "
2263            SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
2264                   s.start_line, s.end_line, s.signature, s.exported
2265            FROM symbols s
2266            JOIN files f ON f.id = s.file_id
2267            LIMIT 5000
2268            ",
2269        )?;
2270        let rows = stmt.query_map([], db::map_symbol)?;
2271        for row in rows {
2272            candidates.push(row?);
2273        }
2274    }
2275
2276    let mut ranked: Vec<(f32, SymbolRecord)> = candidates
2277        .into_iter()
2278        .map(|s| {
2279            let score =
2280                jaro_winkler(symbol, &s.qualified_name).max(jaro_winkler(symbol, &s.name)) as f32;
2281            (score, s)
2282        })
2283        .filter(|(score, _)| *score >= 0.6)
2284        .collect();
2285    ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
2286    ranked.truncate(limit);
2287    Ok(ranked.into_iter().map(|(_, s)| s).collect())
2288}
2289
2290fn clip_to_token_budget(text: &str, budget: usize) -> String {
2291    let max_chars = budget.saturating_mul(4);
2292    if text.len() <= max_chars {
2293        return text.to_string();
2294    }
2295    let mut clipped: String = text.chars().take(max_chars).collect();
2296    clipped.push_str("\n// … truncated by context budget");
2297    clipped
2298}
2299
2300fn trim_signature_lines(lines: &mut Vec<SignatureLine>, budget: usize) {
2301    while estimate_tokens(lines) > budget && !lines.is_empty() {
2302        lines.pop();
2303    }
2304}
2305
2306fn trim_records_to_budget(records: &mut Vec<SymbolRecord>, budget: usize) {
2307    while estimate_tokens(records) > budget && !records.is_empty() {
2308        records.pop();
2309    }
2310}
2311
2312fn escape_fts_term(term: &str) -> String {
2313    term.chars()
2314        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
2315        .collect()
2316}
2317
2318fn glob_symbol_matches(
2319    conn: &Connection,
2320    pattern: &str,
2321    limit: usize,
2322) -> Result<Vec<SymbolRecord>> {
2323    // Translate `*` → SQL `%`; escape SQL wildcards in the literal part.
2324    let like = pattern
2325        .replace('\\', "\\\\")
2326        .replace('%', "\\%")
2327        .replace('_', "\\_")
2328        .replace('*', "%");
2329    let mut stmt = conn.prepare(
2330        "
2331        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
2332               s.start_line, s.end_line, s.signature, s.exported
2333        FROM symbols s
2334        JOIN files f ON f.id = s.file_id
2335        WHERE s.name LIKE ?1 ESCAPE '\\' OR s.qualified_name LIKE ?1 ESCAPE '\\'
2336        ORDER BY length(s.qualified_name), s.qualified_name
2337        LIMIT ?2
2338        ",
2339    )?;
2340    let rows = stmt.query_map(params![like, limit as i64], db::map_symbol)?;
2341    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2342}
2343
2344fn list_symbols(conn: &Connection, limit: usize) -> Result<Vec<SymbolRecord>> {
2345    let mut stmt = conn.prepare(
2346        "
2347        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
2348               s.start_line, s.end_line, s.signature, s.exported
2349        FROM symbols s
2350        JOIN files f ON f.id = s.file_id
2351        ORDER BY s.qualified_name
2352        LIMIT ?1
2353        ",
2354    )?;
2355    let rows = stmt.query_map(params![limit as i64], db::map_symbol)?;
2356    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2357}
2358
2359fn list_all_symbols(conn: &Connection) -> Result<Vec<SymbolRecord>> {
2360    let mut stmt = conn.prepare(
2361        "
2362        SELECT s.id, s.name, s.qualified_name, s.kind, s.file_id, f.path, f.language,
2363               s.start_line, s.end_line, s.signature, s.exported
2364        FROM symbols s
2365        JOIN files f ON f.id = s.file_id
2366        ORDER BY s.qualified_name
2367        ",
2368    )?;
2369    let rows = stmt.query_map([], db::map_symbol)?;
2370    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2371}
2372
2373fn inbound_ref_count(conn: &Connection, symbol: &SymbolRecord) -> Result<usize> {
2374    let mut stmt = conn.prepare(
2375        "
2376        SELECT COUNT(*)
2377        FROM refs
2378        WHERE symbol_name = ?1 OR symbol_name = ?2
2379        ",
2380    )?;
2381    let count: i64 = stmt.query_row(params![symbol.name, symbol.qualified_name], |row| {
2382        row.get(0)
2383    })?;
2384    Ok(count as usize)
2385}
2386
2387fn inbound_edge_count(conn: &Connection, symbol: &SymbolRecord) -> Result<usize> {
2388    let mut stmt = conn.prepare(
2389        "
2390        SELECT COUNT(*)
2391        FROM edges
2392        WHERE to_symbol_name = ?1 OR to_symbol_name = ?2
2393        ",
2394    )?;
2395    let count: i64 = stmt.query_row(params![symbol.name, symbol.qualified_name], |row| {
2396        row.get(0)
2397    })?;
2398    Ok(count as usize)
2399}
2400
2401fn glob_score(pattern: &str, name: &str) -> f32 {
2402    // Convert pattern to a simple SQL LIKE → boolean match; assign a heuristic
2403    // score so shorter qualified names sort first within a glob hit set.
2404    let lp = pattern.to_lowercase();
2405    let ln = name.to_lowercase();
2406    let head = lp.trim_start_matches('*');
2407    let tail = head.trim_end_matches('*');
2408    let stripped = tail.trim_matches('*');
2409    if stripped.is_empty() {
2410        return 0.5;
2411    }
2412    if ln == stripped {
2413        return 1.0;
2414    }
2415    if ln.contains(stripped) {
2416        return 0.85;
2417    }
2418    0.5
2419}
2420
2421fn is_test_path(path: &str) -> bool {
2422    let lower = path.to_lowercase();
2423    lower.contains("/test/")
2424        || lower.contains("/tests/")
2425        || lower.contains("/__tests__/")
2426        || lower.contains(".test.")
2427        || lower.contains(".spec.")
2428        || lower.ends_with("_test.go")
2429        || lower.starts_with("test_")
2430        || lower.contains("/test_")
2431}
2432
2433fn estimate_tokens<T: serde::Serialize>(value: &T) -> usize {
2434    let bytes = serde_json::to_vec(value)
2435        .map(|json| json.len())
2436        .unwrap_or(0);
2437    (bytes / 4).max(1)
2438}
2439
2440fn meta(tokens: usize, alt_tool: &str, alt_tokens: usize, fidelity: f32) -> QueryMeta {
2441    QueryMeta {
2442        tokens_returned_estimate: tokens,
2443        alternative_queries: vec![AlternativeQuery {
2444            tool: alt_tool.to_string(),
2445            tokens_estimate: alt_tokens,
2446            fidelity,
2447        }],
2448    }
2449}