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