1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use crate::models::{
5 BlastRadiusResult, ContextSlice, ImpactedSymbol, ReferenceSite, SearchExplain, Symbol,
6 SymbolSearchResult, TestTarget,
7};
8
9pub fn format_file_skeleton(
11 file_path: &str,
12 symbols: &[Symbol],
13 line_count: Option<usize>,
14 parse_errors: usize,
15) -> String {
16 let mut out = String::new();
17 let lines_str = match line_count {
18 Some(c) => format!(" (Lines 1-{c})"),
19 None => String::new(),
20 };
21 out.push_str(&format!("// File: {file_path}{lines_str}\n\n"));
22
23 if parse_errors > 0 {
24 let noun = if parse_errors == 1 { "error" } else { "errors" };
25 out.push_str(&format!(
26 "// {parse_errors} parse {noun}: symbols may be incomplete\n\n"
27 ));
28 }
29
30 if symbols.is_empty() {
31 out.push_str("// No exported symbols indexed.\n");
32 return out;
33 }
34
35 let mut children_map: HashMap<Option<String>, Vec<&Symbol>> = HashMap::new();
37 for s in symbols {
38 children_map
39 .entry(s.parent_symbol_id.clone())
40 .or_default()
41 .push(s);
42 }
43
44 if let Some(roots) = children_map.get(&None) {
46 for root in roots {
47 render_symbol_skeleton(&mut out, root, &children_map, 0);
48 }
49 } else {
50 for s in symbols {
52 render_symbol_skeleton(&mut out, s, &children_map, 0);
53 }
54 }
55
56 out
57}
58
59fn is_container_kind(kind: &str) -> bool {
60 let hides_its_body = matches!(
61 kind,
62 "function" | "method" | "constructor" | "destructor" | "operator"
63 );
64 !is_skippable_kind(kind) && !hides_its_body
65}
66
67fn is_skippable_kind(kind: &str) -> bool {
68 matches!(kind, "variable" | "parameter" | "import")
69}
70
71fn sanitize_skeleton_sig<'a>(sig: &'a str, name: &'a str) -> &'a str {
72 let clean = if let Some(idx) = sig.find('{') {
73 sig[..idx].trim_end()
74 } else {
75 sig.trim_end()
76 };
77 let trimmed = clean.trim_end_matches(';').trim_end();
78 if trimmed.is_empty() { name } else { trimmed }
79}
80
81fn render_symbol_skeleton(
82 out: &mut String,
83 sym: &Symbol,
84 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
85 indent_level: usize,
86) {
87 if is_skippable_kind(&sym.kind) {
88 return;
89 }
90
91 let indent = " ".repeat(indent_level);
92
93 if let Some(ref doc) = sym.doc_comment {
95 let lines: Vec<_> = doc.lines().collect();
96 let cap = 3;
97 for line in lines.iter().take(cap) {
98 out.push_str(&format!("{indent}/// {line}\n"));
99 }
100 if lines.len() > cap {
101 out.push_str(&format!(
102 "{indent}/// ... ({} more lines)\n",
103 lines.len() - cap
104 ));
105 }
106 }
107
108 let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
109
110 let children = children_map.get(&Some(sym.symbol_id.clone()));
111
112 let spans_multiple_lines = sym.end_line > sym.start_line;
113
114 if is_container_kind(&sym.kind) && spans_multiple_lines && children.is_some() {
115 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
116 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
117 out.push_str(&format!("{indent}{sig} {{\n"));
118 if let Some(child_list) = children {
119 for child in child_list {
120 render_symbol_skeleton(out, child, children_map, indent_level + 1);
121 }
122 }
123 out.push_str(&format!("{indent}}} // {span_str}\n\n"));
124 } else {
125 if let Some(count) = sym.hidden_body_line_count() {
127 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
128 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
129 let b_start = sym.body_start_line.unwrap_or(sym.start_line);
130 let b_end = sym.body_end_line.unwrap_or(sym.end_line);
131
132 if count > 1 {
133 out.push_str(&format!(
134 "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end} */ }}\n"
135 ));
136 } else {
137 out.push_str(&format!("{indent}{sig}; // {span_str}\n"));
138 }
139 } else if let Some(ref raw_sig) = sym.signature {
140 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
141 out.push_str(&format!("{indent}{sig}; // {span_str}\n"));
142 } else {
143 out.push_str(&format!(
144 "{indent}{} {sym_name}; // {span_str}\n",
145 sym.kind,
146 sym_name = sym.name
147 ));
148 }
149 }
150}
151
152#[derive(Default)]
154pub struct OutlineNode {
155 pub files: BTreeMap<String, Vec<String>>, pub subdirs: BTreeMap<String, OutlineNode>,
157}
158
159pub fn add_path_to_outline(
161 root_node: &mut OutlineNode,
162 file_path: &str,
163 symbols_by_file: &HashMap<String, Vec<Symbol>>,
164 max_depth: usize,
165 norm_filter: &str,
166) {
167 let normalized = file_path.replace('\\', "/");
168 let rel_path_str = if norm_filter.is_empty() {
169 normalized.as_str()
170 } else if normalized.eq_ignore_ascii_case(norm_filter) {
171 Path::new(&normalized)
172 .file_name()
173 .and_then(|n| n.to_str())
174 .unwrap_or(&normalized)
175 } else if normalized.len() > norm_filter.len()
176 && normalized.as_bytes()[norm_filter.len()] == b'/'
177 && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
178 {
179 &normalized[norm_filter.len() + 1..]
180 } else {
181 return;
182 };
183
184 let path = Path::new(rel_path_str);
185 let components: Vec<&str> = path
186 .components()
187 .map(|c| c.as_os_str().to_str().unwrap_or(""))
188 .filter(|s| !s.is_empty())
189 .collect();
190
191 if components.is_empty() {
192 return;
193 }
194
195 let mut curr = root_node;
196 let depth = components.len();
197
198 for (i, comp) in components.iter().enumerate() {
199 if i == depth - 1 {
200 if depth <= max_depth {
202 let mut sym_tags = Vec::new();
203 if let Some(syms) = symbols_by_file.get(&normalized) {
204 for s in syms.iter().take(5) {
205 sym_tags.push(format!("{} {}", s.kind, s.name));
206 }
207 if syms.len() > 5 {
208 sym_tags.push(format!("+{} more", syms.len() - 5));
209 }
210 }
211 if !sym_tags.is_empty() {
212 curr.files.insert(comp.to_string(), sym_tags);
213 }
214 }
215 } else if i < max_depth {
216 curr = curr.subdirs.entry(comp.to_string()).or_default();
217 } else {
218 break;
219 }
220 }
221}
222
223pub fn render_outline_tree(
224 out: &mut String,
225 node: &OutlineNode,
226 prefix: &str,
227 depth: usize,
228 max_depth: usize,
229) {
230 if depth >= max_depth {
231 return;
232 }
233
234 let total_items = node.subdirs.len() + node.files.len();
235 let mut index = 0;
236
237 for (name, sub) in &node.subdirs {
239 index += 1;
240 let is_last = index == total_items;
241 let branch = if is_last { "└── " } else { "├── " };
242 let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
243
244 out.push_str(&format!("{prefix}{branch}{name}/\n"));
245 render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
246 }
247
248 for (file_name, syms) in &node.files {
250 index += 1;
251 let is_last = index == total_items;
252 let branch = if is_last { "└── " } else { "├── " };
253
254 let sym_suffix = if !syms.is_empty() {
255 format!(" [{}]", syms.join(", "))
256 } else {
257 String::new()
258 };
259
260 out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
261 }
262}
263
264pub fn format_symbol_body(symbol: &Symbol, body: &str) -> String {
266 let body_hash = crate::edit::hash_content(body);
267 let mut out = format!(
268 "// {}:{}-{} ({}) body_hash={body_hash}\n",
269 symbol.path, symbol.start_line, symbol.end_line, symbol.name
270 );
271 if let Some(ref sig) = symbol.signature {
272 out.push_str(sig);
273 if !sig.ends_with('\n') {
274 out.push('\n');
275 }
276 }
277 out.push_str(body);
278 if !body.ends_with('\n') {
279 out.push('\n');
280 }
281 out
282}
283
284pub fn format_context_slice(slice: &ContextSlice) -> String {
286 let sym = &slice.target_symbol;
287 let mut out = String::new();
288 let body_hash = crate::edit::hash_content(&slice.target_body);
289
290 out.push_str(&format!(
291 "### Target: `{}` ({}:{}-{}) body_hash={body_hash}\n\n",
292 sym.name, sym.path, sym.start_line, sym.end_line
293 ));
294
295 if let Some(ref sig) = sym.signature {
296 out.push_str(&format!("Signature: `{sig}`\n\n"));
297 }
298
299 out.push_str(&format!("```{}\n", sym.language));
300 out.push_str(&slice.target_body);
301 if !slice.target_body.ends_with('\n') {
302 out.push('\n');
303 }
304 out.push_str("```\n\n");
305
306 if !slice.callee_signatures.is_empty() {
307 out.push_str("### Dependencies (Signatures):\n");
308 for callee in &slice.callee_signatures {
309 out.push_str(&format!("- {callee}\n"));
310 }
311 if slice.callee_signatures.len() >= 10 {
312 out.push_str("[Showing 10 dependencies (limit reached)]\n");
313 }
314 out.push('\n');
315 }
316
317 if !slice.related_types.is_empty() {
318 out.push_str("### Types:\n");
319 for t in &slice.related_types {
320 out.push_str(&format!("- {t}\n"));
321 }
322 out.push('\n');
323 }
324
325 if !slice.related_tests.is_empty() {
326 out.push_str("### Related Tests:\n");
327 for test in &slice.related_tests {
328 out.push_str(&format!(
329 "- `{}` ({}:{})\n",
330 test.name, test.path, test.start_line
331 ));
332 }
333 if slice.related_tests.len() >= 5 {
334 out.push_str("[Showing 5 tests (limit reached)]\n");
335 }
336 out.push('\n');
337 }
338
339 out
340}
341
342fn cap_notice(shown: usize, limit: usize) -> String {
343 let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
344 "narrow the query to see more"
345 } else {
346 "increase limit to see more"
347 };
348 format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
349}
350
351pub fn format_references(
353 target_name: &str,
354 refs: &[ReferenceSite],
355 direction: &str,
356 limit: usize,
357) -> String {
358 let mut out = String::new();
359 let dir_label = if direction == "callers" {
360 "Callers of"
361 } else {
362 "Callees called by"
363 };
364 out.push_str(&format!(
365 "{dir_label} `{target_name}` ({} found):\n",
366 refs.len()
367 ));
368
369 if refs.is_empty() {
370 out.push_str(" (none)\n");
371 return out;
372 }
373
374 for r in refs {
375 let line_info = match r.start_line {
376 Some(l) => format!(":{l}"),
377 None => String::new(),
378 };
379 let other = if direction == "callers" {
380 &r.from_symbol_name
381 } else {
382 &r.to_symbol_name
383 };
384 let in_file = match r.occurrences {
385 Some(n) => format!(", {n} in file"),
386 None => String::new(),
387 };
388 out.push_str(&format!(
389 "- `{other}` [{}{line_info}] (kind: {}{in_file})\n",
390 r.path, r.kind
391 ));
392 }
393
394 if refs.len() >= limit {
395 out.push_str(&cap_notice(refs.len(), limit));
396 }
397
398 out
399}
400
401pub fn format_find_symbol_results(
403 query: &str,
404 exact_matches: &[Symbol],
405 fts_matches: &[SymbolSearchResult],
406 limit: usize,
407) -> String {
408 if !exact_matches.is_empty() {
409 let mut out = format!(
410 "Found {} symbols matching \"{query}\":\n\n",
411 exact_matches.len()
412 );
413 for s in exact_matches {
414 let sig = s.signature.as_deref().unwrap_or(&s.name);
415 out.push_str(&format!(
416 "- {} `{}` [{}:{}-{}]\n",
417 s.kind, s.name, s.path, s.start_line, s.end_line
418 ));
419 out.push_str(&format!(" Signature: {sig}\n"));
420 if let Some(doc) = &s.doc_comment {
421 let first = doc.lines().next().unwrap_or("").trim();
422 if !first.is_empty() {
423 out.push_str(&format!(" Doc: {first}\n"));
424 }
425 }
426 }
427 if exact_matches.len() >= limit {
428 out.push_str(&cap_notice(exact_matches.len(), limit));
429 }
430 out
431 } else if !fts_matches.is_empty() {
432 let mut out = format!(
433 "No exact name match; {} full-text matches for \"{query}\":\n\n",
434 fts_matches.len()
435 );
436 for r in fts_matches {
437 let s = &r.symbol;
438 let sig = s.signature.as_deref().unwrap_or(&s.name);
439 out.push_str(&format!(
440 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
441 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
442 ));
443 out.push_str(&format!(" Signature: {sig}\n"));
444 if let Some(snippet) = &r.snippet {
445 let clean = snippet.replace('\r', "").trim().to_string();
446 let first = clean.lines().next().unwrap_or(&clean);
447 out.push_str(&format!(" Match: {first}\n"));
448 } else if let Some(doc) = &s.doc_comment {
449 let first = doc.lines().next().unwrap_or("").trim();
450 if !first.is_empty() {
451 out.push_str(&format!(" Doc: {first}\n"));
452 }
453 }
454 }
455 if fts_matches.len() >= limit {
456 out.push_str(&cap_notice(fts_matches.len(), limit));
457 }
458 out
459 } else {
460 format!("No symbols found matching \"{query}\".\n")
461 }
462}
463
464pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
466 if categories.is_empty() {
467 return "No structural facts or literals indexed in this repository.".to_string();
468 }
469 let mut out = String::new();
470 let aliases = crate::queries::alias_fact_counts(categories);
471 if !aliases.is_empty() {
472 let parts: Vec<String> = aliases
473 .iter()
474 .map(|(alias, patterns, facts)| {
475 format!(
476 "{alias} ({patterns} {}, {facts} {})",
477 plural(*patterns, "pattern"),
478 plural(*facts, "fact")
479 )
480 })
481 .collect();
482 out.push_str(&format!("Aliases: {}\n\n", parts.join(", ")));
483 }
484 out.push_str(&format!(
485 "Available structural fact & literal categories ({} found):\n\n",
486 categories.len()
487 ));
488 for (name, count) in categories {
489 out.push_str(&format!("- `{name}` ({count} occurrences)\n"));
490 }
491 out
492}
493
494fn plural(count: usize, word: &str) -> String {
495 if count == 1 {
496 word.to_string()
497 } else {
498 format!("{word}s")
499 }
500}
501
502pub fn format_structural_facts(
504 facts: &[crate::models::StructuralFact],
505 literals: &[crate::models::LiteralFact],
506 category: &str,
507 limit: usize,
508) -> String {
509 let mut out = format!(
510 "Structural facts for '{category}' ({} found):\n",
511 facts.len()
512 );
513 for f in facts {
514 let label = f.key.as_deref().unwrap_or(&f.capture_name);
515 let parent = f
516 .containing_symbol_name
517 .as_deref()
518 .map(|p| format!(", in: {p}"))
519 .unwrap_or_default();
520 out.push_str(&format!(
521 "- {label} [{}:{}] (pattern: {}{parent})\n",
522 f.path, f.start_line, f.pattern_id
523 ));
524 }
525 if limit > 0 && facts.len() >= limit {
526 out.push_str(&cap_notice(facts.len(), limit));
527 }
528 if !literals.is_empty() {
529 out.push_str(&format!(
530 "\nMatching literals ({} found):\n",
531 literals.len()
532 ));
533 for l in literals {
534 out.push_str(&format!(
535 "- \"{}\" [{}:{}] (kind: {})\n",
536 l.literal_text, l.path, l.start_line, l.kind
537 ));
538 }
539 if limit > 0 && literals.len() >= limit {
540 out.push_str(&cap_notice(literals.len(), limit));
541 }
542 }
543 out
544}
545
546pub fn format_replace_symbol_result(res: &crate::edit::EditResult) -> String {
548 let syntax_line = if res.syntax_checked {
549 "Syntax: Verified"
550 } else {
551 "Syntax: Skipped (grammar not available for file extension)"
552 };
553 format!(
554 "Successfully replaced body of `{}` in `{}`.\nOld Hash: {}\nNew Hash: {}\nBytes Written: {}\n{}",
555 res.symbol_name,
556 res.file_path,
557 res.old_body_hash,
558 res.new_body_hash,
559 res.bytes_written,
560 syntax_line
561 )
562}
563
564pub fn format_edit_file_result(res: &crate::edit::TextEditResult) -> String {
566 let tier = match res.match_tier {
567 crate::edit::MatchTier::Exact => "exact match",
568 crate::edit::MatchTier::Whitespace => "match with other indentation",
569 };
570 let syntax = if res.syntax_checked {
571 "checked"
572 } else {
573 "skipped"
574 };
575 let count = if res.replacements == 1 {
576 format!("1 replacement at line {}", res.first_line)
577 } else {
578 format!(
579 "{} replacements, first at line {}",
580 res.replacements, res.first_line
581 )
582 };
583 let mut out = format!(
584 "Edited {}: {count} ({tier}). Syntax: {syntax}.",
585 res.file_path
586 );
587 if !res.touched_symbols.is_empty() {
588 out.push_str(&format!(" Touched: {}.", res.touched_symbols.join(", ")));
589 }
590 out
591}
592
593pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
595 if results.is_empty() {
596 return format!("No symbols found matching concept \"{query}\".");
597 }
598
599 let mut out = format!(
600 "Found {} symbols matching concept \"{query}\":\n",
601 results.len()
602 );
603 if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
604 out.push_str(&format!(
605 "rerank: {} candidates in {} µs",
606 explain.candidates, explain.rerank_us
607 ));
608 if !explain.word_weights.is_empty() {
609 let words: Vec<String> = explain
610 .word_weights
611 .iter()
612 .map(|(word, weight)| format!("{word} {weight:.2}"))
613 .collect();
614 out.push_str(&format!("; words {}", words.join(", ")));
615 }
616 out.push('\n');
617 }
618 out.push('\n');
619 for r in results {
620 let s = &r.symbol;
621 let sig = s.signature.as_deref().unwrap_or(&s.name);
622 out.push_str(&format!(
623 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
624 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
625 ));
626 out.push_str(&format!(" Signature: {sig}\n"));
627 if let Some(snippet) = &r.snippet {
628 let clean_snip = snippet.replace('\r', "").trim().to_string();
629 let first_line = clean_snip.lines().next().unwrap_or(&clean_snip);
630 out.push_str(&format!(" Match: {first_line}\n"));
631 } else if let Some(doc) = &s.doc_comment {
632 let first_line = doc.lines().next().unwrap_or("").trim();
633 if !first_line.is_empty() {
634 out.push_str(&format!(" Doc: {first_line}\n"));
635 }
636 }
637 if let Some(explain) = &r.explain {
638 out.push_str(&format!(" explain: {}\n", explain_line(r.score, explain)));
639 }
640 }
641
642 if results.len() >= limit {
643 out.push_str(&cap_notice(results.len(), limit));
644 }
645
646 out
647}
648
649fn explain_line(score: f64, e: &SearchExplain) -> String {
650 let mut line = format!(
651 "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
652 e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
653 );
654 if e.documentation != 0.0 {
655 line.push_str(&format!(" + doc {:.1}", e.documentation));
656 }
657 if e.test_intent != 0.0 {
658 line.push_str(&format!(" + test {:.1}", e.test_intent));
659 }
660 line.push_str(&format!(" [{}]", e.branches.join(",")));
661 if let Some(bm25) = e.bm25 {
662 line.push_str(&format!(" bm25 {bm25:.2}"));
663 }
664 if !e.terms.is_empty() {
665 let terms: Vec<String> = e
666 .terms
667 .iter()
668 .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
669 .collect();
670 line.push_str(&format!(" terms {}", terms.join(" ")));
671 }
672 line
673}
674
675pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
677 if result.seed_type == "none"
678 || (result.seeds.is_empty()
679 && result.likely_tests.is_empty()
680 && result.impacted_symbols.is_empty())
681 {
682 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
683 }
684
685 let mut out = String::new();
686 let seed_label = if result.seed_type == "file" {
687 format!("Files: {}", result.seeds.join(", "))
688 } else if result.seed_type == "symbol" {
689 format!("Symbol: {}", result.seeds.join(", "))
690 } else {
691 format!("Seeds: {}", result.seeds.join(", "))
692 };
693
694 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
695
696 const MAX_COMPACT_TESTS: usize = 20;
697 const MAX_COMPACT_IMPACTED: usize = 50;
698
699 if !result.likely_tests.is_empty() {
700 let total = result.likely_tests.len();
701 if total > MAX_COMPACT_TESTS {
702 out.push_str(&format!(
703 "### Likely Tests to Run ({} found - showing top {})\n",
704 total, MAX_COMPACT_TESTS
705 ));
706 } else {
707 out.push_str(&format!("### Likely Tests to Run ({} found)\n", total));
708 }
709
710 let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
711 std::collections::BTreeMap::new();
712 let mut file_order = Vec::new();
713 for t in result.likely_tests.iter().take(MAX_COMPACT_TESTS) {
714 if !tests_by_file.contains_key(t.path.as_str()) {
715 file_order.push(t.path.as_str());
716 }
717 tests_by_file.entry(t.path.as_str()).or_default().push(t);
718 }
719
720 for path in file_order {
721 out.push_str(&format!("{path}:\n"));
722 if let Some(tests) = tests_by_file.get(path) {
723 for t in tests {
724 out.push_str(&format!(
725 " - `{}` [line {}] ({})\n",
726 t.name, t.line, t.reason
727 ));
728 }
729 }
730 }
731
732 if total > MAX_COMPACT_TESTS {
733 out.push_str(&format!(
734 "... {} more likely tests; narrow the target. CLI --json shows the full returned list.\n",
735 total - MAX_COMPACT_TESTS
736 ));
737 }
738 out.push('\n');
739 } else {
740 out.push_str("### Likely Tests to Run\nNo direct or stem-matched tests found.\n\n");
741 }
742
743 if !result.impacted_symbols.is_empty() {
744 let total = result.impacted_symbols.len();
745 let mut visible = Vec::new();
746 let mut low_signal_count = 0;
747 for s in &result.impacted_symbols {
748 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
749 low_signal_count += 1;
750 } else {
751 visible.push(s);
752 }
753 }
754
755 if result.traversal_ceiling_reached || total >= 200 {
756 out.push_str("### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n");
757 } else {
758 out.push_str(&format!("### Downstream Impact ({} symbols)\n", total));
759 }
760
761 if visible.is_empty() && low_signal_count > 0 {
762 let row_word = if low_signal_count == 1 {
763 "row (import/module)"
764 } else {
765 "rows (imports/modules)"
766 };
767 out.push_str(&format!(
768 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
769 ));
770 } else {
771 let visible_total = visible.len();
772 let showing_count = visible_total.min(MAX_COMPACT_IMPACTED);
773
774 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
775 std::collections::BTreeMap::new();
776 let mut file_order = Vec::new();
777 for s in visible.iter().take(showing_count) {
778 if !syms_by_file.contains_key(s.path.as_str()) {
779 file_order.push(s.path.as_str());
780 }
781 syms_by_file.entry(s.path.as_str()).or_default().push(s);
782 }
783
784 for path in file_order {
785 out.push_str(&format!("{path}:\n"));
786 if let Some(syms) = syms_by_file.get(path) {
787 for s in syms {
788 out.push_str(&format!(
789 " - [depth {}] {} `{}` [line {}]\n",
790 s.depth, s.kind, s.name, s.line
791 ));
792 }
793 }
794 }
795
796 if visible_total > MAX_COMPACT_IMPACTED {
797 out.push_str(&format!(
798 "... {} more impacted symbols; narrow the target. CLI --json shows the full returned list.\n",
799 visible_total - MAX_COMPACT_IMPACTED
800 ));
801 }
802 if low_signal_count > 0 {
803 let row_word = if low_signal_count == 1 {
804 "row (import/module)"
805 } else {
806 "rows (imports/modules)"
807 };
808 out.push_str(&format!(
809 "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
810 ));
811 }
812 }
813 } else {
814 out.push_str("### Downstream Impact\nNo downstream callers found within depth.\n");
815 }
816
817 out
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use crate::models::{ImpactedSymbol, TestTarget};
824
825 fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
826 crate::models::StructuralFact {
827 structural_fact_id: "sf1".into(),
828 path: ".codex/config.toml".into(),
829 language: "toml".into(),
830 pattern_id: "toml.key_value.v1".into(),
831 capture_name: "key_value".into(),
832 node_kind: "table".into(),
833 key: key.map(str::to_string),
834 containing_symbol_name: None,
835 start_line: 2,
836 end_line: 2,
837 confidence: 1.0,
838 }
839 }
840
841 #[test]
842 fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
843 let with_key = format_structural_facts(
844 &[structural_fact(Some("mcp_servers.code-kb.command"))],
845 &[],
846 "config",
847 30,
848 );
849 assert!(with_key.contains(
850 "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
851 ));
852
853 let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
854 assert!(
855 without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
856 );
857 }
858
859 #[test]
860 fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
861 let site = |occurrences| ReferenceSite {
862 from_symbol_name: "Button".into(),
863 from_symbol_id: "s1".into(),
864 to_symbol_name: "background".into(),
865 kind: "member_access".into(),
866 path: "Ui/Button.qml".into(),
867 start_line: Some(5),
868 start_column: Some(4),
869 occurrences,
870 };
871
872 let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
873 assert!(
874 grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
875 "{grouped}"
876 );
877
878 let single = format_references("Color", &[site(None)], "callers", 30);
879 assert!(
880 single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
881 "{single}"
882 );
883 }
884
885 #[test]
886 fn file_skeleton_reports_parse_errors() {
887 let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
888 assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
889
890 let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
891 assert!(one.contains("// 1 parse error: symbols may be incomplete"));
892
893 let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
894 assert!(!none.contains("parse error"));
895 }
896
897 #[test]
898 fn test_format_file_skeleton() {
899 let syms = vec![Symbol {
900 symbol_id: "s1".into(),
901 file_id: "f1".into(),
902 path: "src/lib.rs".into(),
903 language: "rust".into(),
904 name: "do_work".into(),
905 kind: "function".into(),
906 signature: Some("pub fn do_work() -> Result<()>".into()),
907 doc_comment: Some("Performs core work.".into()),
908 visibility: Some("pub".into()),
909 parent_symbol_id: None,
910 start_line: 10,
911 start_column: 0,
912 end_line: 30,
913 end_column: 1,
914 start_byte: 100,
915 end_byte: 300,
916 body_start_line: Some(11),
917 body_start_column: Some(0),
918 body_end_line: Some(29),
919 body_end_column: Some(1),
920 body_start_byte: Some(130),
921 body_end_byte: Some(298),
922 body_hash: None,
923 semantic_group: None,
924 is_test: false,
925 test_container: false,
926 }];
927
928 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
929 assert!(skeleton.contains("/// Performs core work."));
930 assert!(skeleton.contains("19 lines hidden: L11-L29"));
931 }
932
933 #[test]
934 fn test_format_search_results() {
935 let results = vec![SymbolSearchResult {
936 symbol: Symbol {
937 symbol_id: "s1".into(),
938 file_id: "f1".into(),
939 path: "src/parser.rs".into(),
940 language: "rust".into(),
941 name: "parse_tokens".into(),
942 kind: "function".into(),
943 signature: Some("pub fn parse_tokens()".into()),
944 doc_comment: Some("Parses tokens from stream.".into()),
945 visibility: Some("pub".into()),
946 parent_symbol_id: None,
947 start_line: 15,
948 start_column: 0,
949 end_line: 25,
950 end_column: 1,
951 start_byte: 100,
952 end_byte: 250,
953 body_start_line: None,
954 body_start_column: None,
955 body_end_line: None,
956 body_end_column: None,
957 body_start_byte: None,
958 body_end_byte: None,
959 body_hash: None,
960 semantic_group: None,
961 is_test: false,
962 test_container: false,
963 },
964 score: -1.85,
965 snippet: Some("Parses [tokens] from stream.".into()),
966 explain: None,
967 }];
968
969 let formatted = format_search_results("tokens", &results, 20);
970 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
971 assert!(
972 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
973 );
974 assert!(formatted.contains("Match: Parses [tokens] from stream."));
975 assert!(!formatted.contains("explain"));
976 assert!(!formatted.contains("rerank"));
977 }
978
979 #[test]
980 fn every_printed_part_of_the_explain_line_sums_to_the_score() {
981 let e = SearchExplain {
982 bm25: Some(-3.21),
983 branches: vec!["word".into()],
984 name_tier: "all".into(),
985 name_strength: 6,
986 term_score: 24.5,
987 name_bonus: 60.0,
988 kind_prior: 4.0,
989 path_role: -10.0,
990 documentation: -200.0,
991 test_intent: 5.0,
992 terms: vec![("sha".into(), "name".into(), 3.0)],
993 word_weights: vec![("sha".into(), 2.6)],
994 candidates: 1,
995 rerank_us: 1,
996 };
997 let score = e.term_score
998 + e.name_bonus
999 + e.kind_prior
1000 + e.path_role
1001 + e.documentation
1002 + e.test_intent;
1003
1004 let line = explain_line(score, &e);
1005 let parts: f64 = line
1006 .split(" = ")
1007 .nth(1)
1008 .unwrap()
1009 .split(" [")
1010 .next()
1011 .unwrap()
1012 .split(" + ")
1013 .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
1014 .sum();
1015
1016 assert!(line.starts_with(&format!(
1017 "score {score:.1} = terms 24.5 + name all(6) 60.0 "
1018 )));
1019 assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
1020 }
1021
1022 #[test]
1023 fn test_format_search_results_prints_the_explain_breakdown_when_present() {
1024 let mut result = SymbolSearchResult {
1025 symbol: sample_symbol("parseSha256Sidecar"),
1026 score: 71.6,
1027 snippet: Some("parse[Sha256]Sidecar".into()),
1028 explain: Some(SearchExplain {
1029 bm25: Some(-3.21),
1030 branches: vec!["word".into(), "name".into()],
1031 name_tier: "all".into(),
1032 name_strength: 6,
1033 terms: vec![
1034 ("sha".into(), "name".into(), 3.0),
1035 ("256".into(), "name".into(), 3.0),
1036 ],
1037 term_score: 12.6,
1038 name_bonus: 60.0,
1039 kind_prior: 4.0,
1040 path_role: -10.0,
1041 documentation: 0.0,
1042 test_intent: 5.0,
1043 word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
1044 candidates: 37,
1045 rerank_us: 180,
1046 }),
1047 };
1048
1049 let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
1050 assert!(formatted.contains(
1051 "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
1052 ));
1053 assert!(formatted.contains(
1054 " explain: score 71.6 = terms 12.6 + name all(6) 60.0 + kind 4.0 + path -10.0 + test 5.0 [word,name] bm25 -3.21 terms sha=name:3 256=name:3\n"
1055 ));
1056
1057 result.explain = None;
1058 let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
1059 assert!(!silent.contains("explain"));
1060 assert!(!silent.contains("rerank"));
1061 }
1062
1063 #[test]
1064 fn test_format_search_results_discloses_a_reached_limit() {
1065 let results = vec![SymbolSearchResult {
1066 symbol: sample_symbol("parse_tokens"),
1067 score: 0.0,
1068 snippet: None,
1069 explain: None,
1070 }];
1071
1072 let formatted = format_search_results("tokens", &results, 1);
1073 assert!(
1074 formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
1075 );
1076
1077 let at_ceiling =
1078 format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
1079 assert!(!at_ceiling.contains("limit reached"));
1080
1081 let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
1082 .map(|_| SymbolSearchResult {
1083 symbol: sample_symbol("parse_tokens"),
1084 score: 0.0,
1085 snippet: None,
1086 explain: None,
1087 })
1088 .collect();
1089 let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
1090 assert!(capped.contains("(limit reached); narrow the query to see more.]"));
1091 }
1092
1093 #[test]
1094 fn test_format_blast_radius() {
1095 let res = BlastRadiusResult {
1096 seed_type: "symbol".into(),
1097 seeds: vec!["do_work".into()],
1098 likely_tests: vec![TestTarget {
1099 name: "test_do_work".into(),
1100 path: "tests/work_test.rs".into(),
1101 line: 15,
1102 reason: "transitive caller [depth 1]".into(),
1103 }],
1104 impacted_symbols: vec![ImpactedSymbol {
1105 name: "caller_fn".into(),
1106 kind: "function".into(),
1107 path: "src/caller.rs".into(),
1108 line: 42,
1109 depth: 1,
1110 }],
1111 traversal_ceiling_reached: false,
1112 };
1113
1114 let formatted = format_blast_radius(&res);
1115 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
1116 assert!(formatted.contains("### Likely Tests to Run (1 found)"));
1117 assert!(formatted.contains(
1118 "tests/work_test.rs:\n - `test_do_work` [line 15] (transitive caller [depth 1])"
1119 ));
1120 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
1121 }
1122
1123 #[test]
1124 fn test_format_blast_radius_grouped_and_capped() {
1125 let mut likely_tests = Vec::new();
1126 for i in 1..=25 {
1127 likely_tests.push(TestTarget {
1128 name: format!("test_{i}"),
1129 path: format!("tests/test_{}.rs", (i % 3) + 1),
1130 line: i * 10,
1131 reason: "direct caller".into(),
1132 });
1133 }
1134
1135 let impacted_symbols = vec![
1136 ImpactedSymbol {
1137 name: "use_foo".into(),
1138 kind: "import".into(),
1139 path: "src/service.rs".into(),
1140 line: 1,
1141 depth: 1,
1142 },
1143 ImpactedSymbol {
1144 name: "service_fn".into(),
1145 kind: "function".into(),
1146 path: "src/service.rs".into(),
1147 line: 20,
1148 depth: 1,
1149 },
1150 ImpactedSymbol {
1151 name: "api_handler".into(),
1152 kind: "function".into(),
1153 path: "src/api.rs".into(),
1154 line: 45,
1155 depth: 2,
1156 },
1157 ];
1158
1159 let res = BlastRadiusResult {
1160 seed_type: "file".into(),
1161 seeds: vec!["src/lib.rs".into()],
1162 likely_tests,
1163 impacted_symbols,
1164 traversal_ceiling_reached: false,
1165 };
1166
1167 let formatted = format_blast_radius(&res);
1168
1169 assert!(formatted.contains("### Likely Tests to Run (25 found - showing top 20)"));
1170 assert!(formatted.contains(
1171 "... 5 more likely tests; narrow the target. CLI --json shows the full returned list."
1172 ));
1173
1174 assert!(formatted.contains("tests/test_1.rs:\n"));
1175 assert!(formatted.contains(" - `test_"));
1176
1177 assert!(!formatted.contains("use_foo"));
1178 assert!(
1179 formatted
1180 .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
1181 );
1182 assert!(formatted.contains("src/service.rs:\n"));
1183 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
1184 }
1185
1186 #[test]
1187 fn test_format_replace_symbol_result_shows_syntax_status() {
1188 let res_checked = crate::edit::EditResult {
1189 symbol_name: "my_fn".into(),
1190 file_path: "src/lib.rs".into(),
1191 old_body_hash: "aaa".into(),
1192 new_body_hash: "bbb".into(),
1193 bytes_written: 120,
1194 syntax_checked: true,
1195 };
1196 let out_checked = format_replace_symbol_result(&res_checked);
1197 assert!(out_checked.contains("Syntax: Verified"));
1198
1199 let res_skipped = crate::edit::EditResult {
1200 symbol_name: "my_fn".into(),
1201 file_path: "src/script.rb".into(),
1202 old_body_hash: "aaa".into(),
1203 new_body_hash: "bbb".into(),
1204 bytes_written: 120,
1205 syntax_checked: false,
1206 };
1207 let out_skipped = format_replace_symbol_result(&res_skipped);
1208 assert!(out_skipped.contains("Syntax: Skipped (grammar not available for file extension)"));
1209 }
1210
1211 fn sample_symbol(name: &str) -> Symbol {
1212 Symbol {
1213 symbol_id: format!("id_{name}"),
1214 file_id: "f1".into(),
1215 path: "src/lib.rs".into(),
1216 language: "rust".into(),
1217 name: name.into(),
1218 kind: "function".into(),
1219 signature: Some(format!("pub fn {name}()")),
1220 doc_comment: None,
1221 visibility: Some("pub".into()),
1222 parent_symbol_id: None,
1223 start_line: 1,
1224 start_column: 0,
1225 end_line: 10,
1226 end_column: 1,
1227 start_byte: 0,
1228 end_byte: 100,
1229 body_start_line: Some(2),
1230 body_start_column: Some(0),
1231 body_end_line: Some(9),
1232 body_end_column: Some(1),
1233 body_start_byte: Some(10),
1234 body_end_byte: Some(99),
1235 body_hash: None,
1236 semantic_group: None,
1237 is_test: false,
1238 test_container: false,
1239 }
1240 }
1241
1242 fn sample_context_slice() -> ContextSlice {
1243 ContextSlice {
1244 target_symbol: sample_symbol("target_fn"),
1245 target_body: " println!(\"hello\");\n".into(),
1246 callee_signatures: Vec::new(),
1247 related_types: Vec::new(),
1248 related_tests: Vec::new(),
1249 }
1250 }
1251
1252 #[test]
1253 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
1254 let mut slice = sample_context_slice();
1255 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
1256 let text = format_context_slice(&slice);
1257 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
1258
1259 let mut slice_tests = sample_context_slice();
1260 slice_tests.related_tests = (1..=5)
1261 .map(|i| {
1262 let mut sym = sample_symbol(&format!("test_fn_{i}"));
1263 sym.path = format!("tests/test_{i}.rs");
1264 sym.is_test = true;
1265 sym
1266 })
1267 .collect();
1268 let text_tests = format_context_slice(&slice_tests);
1269 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
1270 }
1271
1272 #[test]
1273 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
1274 let impacted_symbols = (1..=200)
1275 .map(|i| ImpactedSymbol {
1276 name: format!("sym_{i}"),
1277 kind: "function".into(),
1278 path: format!("src/mod_{}.rs", i % 10),
1279 line: i,
1280 depth: 1,
1281 })
1282 .collect();
1283
1284 let res = BlastRadiusResult {
1285 seed_type: "symbol".into(),
1286 seeds: vec!["root_fn".into()],
1287 likely_tests: Vec::new(),
1288 impacted_symbols,
1289 traversal_ceiling_reached: true,
1290 };
1291
1292 let formatted = format_blast_radius(&res);
1293 assert!(formatted.contains(
1294 "### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n"
1295 ));
1296 }
1297
1298 fn skeleton_row(
1299 id: &str,
1300 parent: Option<&str>,
1301 kind: &str,
1302 name: &str,
1303 signature: &str,
1304 lines: (usize, usize),
1305 body: Option<(usize, usize)>,
1306 ) -> Symbol {
1307 Symbol {
1308 symbol_id: id.into(),
1309 file_id: "f1".into(),
1310 path: "src/lib.rs".into(),
1311 language: "rust".into(),
1312 name: name.into(),
1313 kind: kind.into(),
1314 signature: Some(signature.into()),
1315 doc_comment: None,
1316 visibility: None,
1317 parent_symbol_id: parent.map(str::to_string),
1318 start_line: lines.0,
1319 start_column: 0,
1320 end_line: lines.1,
1321 end_column: 1,
1322 start_byte: 0,
1323 end_byte: 0,
1324 body_start_line: body.map(|b| b.0),
1325 body_start_column: None,
1326 body_end_line: body.map(|b| b.1),
1327 body_end_column: None,
1328 body_start_byte: None,
1329 body_end_byte: None,
1330 body_hash: None,
1331 semantic_group: None,
1332 is_test: false,
1333 test_container: false,
1334 }
1335 }
1336
1337 #[test]
1338 fn skeleton_nests_an_object_under_the_field_that_declares_it() {
1339 let syms = vec![
1340 skeleton_row(
1341 "root",
1342 None,
1343 "class",
1344 "shell",
1345 "extends ShellRoot",
1346 (1, 8),
1347 None,
1348 ),
1349 skeleton_row(
1350 "timer",
1351 Some("root"),
1352 "field",
1353 "localPluginReloadTimer",
1354 "localPluginReloadTimer: Timer",
1355 (2, 7),
1356 Some((2, 7)),
1357 ),
1358 skeleton_row(
1359 "interval",
1360 Some("timer"),
1361 "property",
1362 "interval",
1363 "interval: 150",
1364 (3, 3),
1365 None,
1366 ),
1367 skeleton_row(
1368 "fire",
1369 Some("timer"),
1370 "function",
1371 "fire",
1372 "function fire()",
1373 (5, 7),
1374 Some((6, 7)),
1375 ),
1376 ];
1377
1378 assert_eq!(
1379 format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
1380 "// File: shell/shell.qml (Lines 1-8)\n\
1381 \n\
1382 extends ShellRoot {\n\
1383 \x20 localPluginReloadTimer: Timer {\n\
1384 \x20 interval: 150; // L3-3\n\
1385 \x20 function fire() { /* 2 lines hidden: L6-L7 */ }\n\
1386 \x20 } // L2-7\n\
1387 \n\
1388 } // L1-8\n\
1389 \n"
1390 );
1391 }
1392
1393 #[test]
1394 fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
1395 let syms = vec![
1396 skeleton_row(
1397 "rusqlite",
1398 None,
1399 "field",
1400 "rusqlite",
1401 "rusqlite = { workspace = true }",
1402 (15, 15),
1403 None,
1404 ),
1405 skeleton_row(
1406 "workspace",
1407 Some("rusqlite"),
1408 "property",
1409 "workspace",
1410 "workspace = true",
1411 (15, 15),
1412 None,
1413 ),
1414 ];
1415
1416 assert_eq!(
1417 format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
1418 "// File: Cargo.toml (Lines 1-15)\n\
1419 \n\
1420 rusqlite =; // L15-15\n"
1421 );
1422 }
1423
1424 #[test]
1425 fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
1426 let syms = vec![
1427 skeleton_row(
1428 "cfg",
1429 None,
1430 "struct",
1431 "Config",
1432 "pub struct Config",
1433 (1, 3),
1434 None,
1435 ),
1436 skeleton_row(
1437 "retries",
1438 Some("cfg"),
1439 "field",
1440 "retries",
1441 "pub retries: u32",
1442 (2, 2),
1443 None,
1444 ),
1445 skeleton_row(
1446 "run",
1447 None,
1448 "function",
1449 "run",
1450 "pub fn run()",
1451 (5, 9),
1452 Some((6, 8)),
1453 ),
1454 skeleton_row(
1455 "tmp",
1456 Some("run"),
1457 "variable",
1458 "tmp",
1459 "let tmp",
1460 (7, 7),
1461 None,
1462 ),
1463 ];
1464
1465 assert_eq!(
1466 format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
1467 "// File: src/lib.rs (Lines 1-9)\n\
1468 \n\
1469 pub struct Config {\n\
1470 \x20 pub retries: u32; // L2-2\n\
1471 } // L1-3\n\
1472 \n\
1473 pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
1474 );
1475 }
1476}