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
70pub 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 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, ¤t, 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 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 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_adj[t_idx].push(caller_idx);
298 }
299 }
300 }
301 }
302
303 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 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 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 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 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 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 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 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 #[derive(Default)]
938 struct FileHunks {
939 path: String,
940 ranges: Vec<(usize, usize, usize, usize)>, }
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 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 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 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 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 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 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 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 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 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 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 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
1644pub 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
1801fn 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
1891fn 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
1924fn 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
1952fn 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 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('&', "&")
2172 .replace('<', "<")
2173 .replace('>', ">")
2174 .replace('"', """)
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 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 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 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}