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, sym: &'a Symbol) -> &'a str {
72 let expression_arrow = if sym.language == "csharp"
73 && matches!(
74 sym.kind.as_str(),
75 "method" | "function" | "constructor" | "operator"
76 ) {
77 sig.match_indices("=>")
78 .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
79 } else {
80 None
81 };
82 let cutoff = sig
83 .find('{')
84 .into_iter()
85 .chain(expression_arrow)
86 .min()
87 .unwrap_or(sig.len());
88 let clean = sig[..cutoff].trim_end();
89 let trimmed = clean.trim_end_matches(';').trim_end();
90 if trimmed.is_empty() {
91 &sym.name
92 } else {
93 trimmed
94 }
95}
96
97fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
98 let body = body.trim();
99 if body.is_empty() {
100 return signature;
101 }
102
103 let trimmed_signature = signature.trim_end();
104 trimmed_signature
105 .strip_suffix(body)
106 .map(str::trim_end)
107 .unwrap_or(signature)
108}
109
110fn unspelled_kind(sym: &Symbol) -> &'static str {
114 if sym.kind != "event" {
115 return "";
116 }
117 let spelled = sym.signature.as_deref().is_some_and(|sig| {
118 sig.split_whitespace()
119 .any(|word| word == "signal" || word == "event")
120 });
121 if spelled { "" } else { "event " }
122}
123
124fn render_symbol_skeleton(
125 out: &mut String,
126 sym: &Symbol,
127 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
128 indent_level: usize,
129) {
130 if is_skippable_kind(&sym.kind) {
131 return;
132 }
133
134 let indent = " ".repeat(indent_level);
135
136 if let Some(ref doc) = sym.doc_comment {
138 let lines: Vec<_> = doc.lines().collect();
139 let cap = 3;
140 for line in lines.iter().take(cap) {
141 out.push_str(&format!("{indent}/// {line}\n"));
142 }
143 if lines.len() > cap {
144 out.push_str(&format!(
145 "{indent}/// ... ({} more lines)\n",
146 lines.len() - cap
147 ));
148 }
149 }
150
151 let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
152 let leaf_note = format!("{}{span_str}", unspelled_kind(sym));
153
154 let children = children_map.get(&Some(sym.symbol_id.clone()));
155
156 let spans_multiple_lines = sym.end_line > sym.start_line;
157
158 if is_container_kind(&sym.kind)
159 && children.is_some()
160 && (spans_multiple_lines || sym.kind == "enum")
161 {
162 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
163 let sig = sanitize_skeleton_sig(raw_sig, sym);
164 out.push_str(&format!("{indent}{sig} {{\n"));
165 if let Some(child_list) = children {
166 for child in child_list {
167 render_symbol_skeleton(out, child, children_map, indent_level + 1);
168 }
169 }
170 out.push_str(&format!("{indent}}} // {span_str}\n\n"));
171 } else {
172 if let Some(count) = sym.hidden_body_line_count() {
173 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
174 let sig = sanitize_skeleton_sig(raw_sig, sym);
175 let b_start = sym.body_start_line.unwrap_or(sym.start_line);
176 let b_end = sym.body_end_line.unwrap_or(sym.end_line);
177
178 if count > 1 {
179 out.push_str(&format!(
180 "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end} */ }}\n"
181 ));
182 } else {
183 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
184 }
185 } else if let Some(ref raw_sig) = sym.signature {
186 let sig = sanitize_skeleton_sig(raw_sig, sym);
187 out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
188 } else {
189 out.push_str(&format!(
190 "{indent}{} {sym_name}; // {span_str}\n",
191 sym.kind,
192 sym_name = sym.name
193 ));
194 }
195 if sym.kind == "namespace"
196 && let Some(child_list) = children
197 {
198 for child in child_list {
199 render_symbol_skeleton(out, child, children_map, indent_level);
200 }
201 }
202 }
203}
204
205#[derive(Default)]
207pub struct OutlineNode {
208 pub files: BTreeMap<String, Vec<String>>, pub subdirs: BTreeMap<String, OutlineNode>,
210}
211
212pub fn add_path_to_outline(
214 root_node: &mut OutlineNode,
215 file_path: &str,
216 symbols_by_file: &HashMap<String, Vec<Symbol>>,
217 max_depth: usize,
218 norm_filter: &str,
219) {
220 let normalized = file_path.replace('\\', "/");
221 let rel_path_str = if norm_filter.is_empty() {
222 normalized.as_str()
223 } else if normalized.eq_ignore_ascii_case(norm_filter) {
224 Path::new(&normalized)
225 .file_name()
226 .and_then(|n| n.to_str())
227 .unwrap_or(&normalized)
228 } else if normalized.len() > norm_filter.len()
229 && normalized.as_bytes()[norm_filter.len()] == b'/'
230 && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
231 {
232 &normalized[norm_filter.len() + 1..]
233 } else {
234 return;
235 };
236
237 let path = Path::new(rel_path_str);
238 let components: Vec<&str> = path
239 .components()
240 .map(|c| c.as_os_str().to_str().unwrap_or(""))
241 .filter(|s| !s.is_empty())
242 .collect();
243
244 if components.is_empty() {
245 return;
246 }
247
248 let mut curr = root_node;
249 let depth = components.len();
250
251 for (i, comp) in components.iter().enumerate() {
252 if i == depth - 1 {
253 if depth <= max_depth {
255 let mut sym_tags = Vec::new();
256 if let Some(syms) = symbols_by_file.get(&normalized) {
257 for s in syms.iter().take(5) {
258 sym_tags.push(format!("{} {}", s.kind, s.name));
259 }
260 if syms.len() > 5 {
261 sym_tags.push(format!("+{} more", syms.len() - 5));
262 }
263 }
264 if !sym_tags.is_empty() {
265 curr.files.insert(comp.to_string(), sym_tags);
266 }
267 }
268 } else if i < max_depth {
269 curr = curr.subdirs.entry(comp.to_string()).or_default();
270 } else {
271 break;
272 }
273 }
274}
275
276pub fn render_outline_tree(
277 out: &mut String,
278 node: &OutlineNode,
279 prefix: &str,
280 depth: usize,
281 max_depth: usize,
282) {
283 if depth >= max_depth {
284 return;
285 }
286
287 let total_items = node.subdirs.len() + node.files.len();
288 let mut index = 0;
289
290 for (name, sub) in &node.subdirs {
292 index += 1;
293 let is_last = index == total_items;
294 let branch = if is_last { "└── " } else { "├── " };
295 let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
296
297 out.push_str(&format!("{prefix}{branch}{name}/\n"));
298 render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
299 }
300
301 for (file_name, syms) in &node.files {
303 index += 1;
304 let is_last = index == total_items;
305 let branch = if is_last { "└── " } else { "├── " };
306
307 let sym_suffix = if !syms.is_empty() {
308 format!(" [{}]", syms.join(", "))
309 } else {
310 String::new()
311 };
312
313 out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
314 }
315}
316
317pub fn format_symbol_body(symbol: &Symbol, body: &str) -> String {
319 let mut out = format!(
320 "// {}:{}-{} ({})\n",
321 symbol.path, symbol.start_line, symbol.end_line, symbol.name
322 );
323 if let Some(ref sig) = symbol.signature {
324 let sig = signature_without_duplicate_body(sig, body);
325 out.push_str(sig);
326 if !sig.ends_with('\n') {
327 out.push('\n');
328 }
329 }
330 out.push_str(body);
331 if !body.ends_with('\n') {
332 out.push('\n');
333 }
334 out
335}
336
337pub fn format_context_slice(slice: &ContextSlice) -> String {
339 let sym = &slice.target_symbol;
340 let mut out = String::new();
341 out.push_str(&format!(
342 "### Target: `{}` ({}:{}-{})\n\n",
343 sym.name, sym.path, sym.start_line, sym.end_line
344 ));
345
346 if let Some(ref sig) = sym.signature {
347 let sig = signature_without_duplicate_body(sig, &slice.target_body);
348 out.push_str(&format!("Signature: `{sig}`\n\n"));
349 }
350
351 out.push_str(&format!("```{}\n", sym.language));
352 out.push_str(&slice.target_body);
353 if !slice.target_body.ends_with('\n') {
354 out.push('\n');
355 }
356 out.push_str("```\n\n");
357
358 if !slice.callee_signatures.is_empty() {
359 out.push_str("### Dependencies (Signatures):\n");
360 for callee in &slice.callee_signatures {
361 out.push_str(&format!("- {callee}\n"));
362 }
363 if slice.callee_signatures.len() >= 10 {
364 out.push_str("[Showing 10 dependencies (limit reached)]\n");
365 }
366 out.push('\n');
367 }
368
369 if !slice.related_types.is_empty() {
370 out.push_str("### Types:\n");
371 for t in &slice.related_types {
372 out.push_str(&format!("- {t}\n"));
373 }
374 out.push('\n');
375 }
376
377 if !slice.related_tests.is_empty() {
378 out.push_str("### Related Tests:\n");
379 for test in &slice.related_tests {
380 out.push_str(&format!(
381 "- `{}` ({}:{})\n",
382 test.name, test.path, test.start_line
383 ));
384 }
385 if slice.related_tests.len() >= 5 {
386 out.push_str("[Showing 5 tests (limit reached)]\n");
387 }
388 out.push('\n');
389 }
390
391 out
392}
393
394fn cap_notice(shown: usize, limit: usize) -> String {
395 let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
396 "narrow the query to see more"
397 } else {
398 "increase limit to see more"
399 };
400 format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
401}
402
403fn match_line(snippet: &str, signature: &str) -> Option<String> {
406 let line = snippet
407 .lines()
408 .enumerate()
409 .map(|(i, text)| match i {
410 0 => text.trim(),
411 _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
412 })
413 .flat_map(str::split_whitespace)
414 .collect::<Vec<_>>()
415 .join(" ");
416 let plain = |text: &str| {
417 text.split_whitespace()
418 .collect::<String>()
419 .replace(['[', ']'], "")
420 };
421 let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
422 (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
423}
424
425fn callee_name(name: &str) -> String {
428 let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
429 if line.chars().count() <= 60 || !line.contains('(') {
430 return line;
431 }
432 match line.rsplit_once('.') {
433 Some((_, last)) if !last.is_empty() => format!("….{last}"),
434 _ => line,
435 }
436}
437
438pub fn format_references(
440 target_name: &str,
441 refs: &[ReferenceSite],
442 direction: &str,
443 limit: usize,
444) -> String {
445 let mut out = String::new();
446 let dir_label = if direction == "callers" {
447 "Callers of"
448 } else {
449 "Callees called by"
450 };
451 out.push_str(&format!(
452 "{dir_label} `{target_name}` ({} found):\n",
453 refs.len()
454 ));
455
456 if refs.is_empty() {
457 out.push_str(" (none)\n");
458 return out;
459 }
460
461 for r in refs {
462 let line_info = match r.start_line {
463 Some(l) => format!(":{l}"),
464 None => String::new(),
465 };
466 let other = if direction == "callers" {
467 r.from_symbol_name.clone()
468 } else {
469 callee_name(&r.to_symbol_name)
470 };
471 let in_file = match r.occurrences {
472 Some(n) => format!(", {n} in file"),
473 None => String::new(),
474 };
475 out.push_str(&format!(
476 "- `{other}` [{}{line_info}] (kind: {}{in_file})\n",
477 r.path, r.kind
478 ));
479 }
480
481 if refs.len() >= limit {
482 out.push_str(&cap_notice(refs.len(), limit));
483 }
484
485 out
486}
487
488pub fn format_find_symbol_results(
490 query: &str,
491 exact_matches: &[Symbol],
492 fts_matches: &[SymbolSearchResult],
493 limit: usize,
494) -> String {
495 if !exact_matches.is_empty() {
496 let mut out = format!(
497 "Found {} symbols matching \"{query}\":\n\n",
498 exact_matches.len()
499 );
500 for s in exact_matches {
501 let sig = s.signature.as_deref().unwrap_or(&s.name);
502 out.push_str(&format!(
503 "- {} `{}` [{}:{}-{}] id={}\n",
504 s.kind, s.name, s.path, s.start_line, s.end_line, s.symbol_id
505 ));
506 out.push_str(&format!(" Signature: {sig}\n"));
507 if let Some(doc) = &s.doc_comment {
508 let first = doc.lines().next().unwrap_or("").trim();
509 if !first.is_empty() {
510 out.push_str(&format!(" Doc: {first}\n"));
511 }
512 }
513 }
514 if exact_matches.len() >= limit {
515 out.push_str(&cap_notice(exact_matches.len(), limit));
516 }
517 out
518 } else if !fts_matches.is_empty() {
519 let mut out = format!(
520 "No exact name match; {} full-text matches for \"{query}\":\n\n",
521 fts_matches.len()
522 );
523 for r in fts_matches {
524 let s = &r.symbol;
525 let sig = s.signature.as_deref().unwrap_or(&s.name);
526 out.push_str(&format!(
527 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
528 s.kind, s.name, s.path, s.start_line, s.end_line, r.score, s.symbol_id
529 ));
530 out.push_str(&format!(" Signature: {sig}\n"));
531 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
532 out.push_str(&format!(" Match: {line}\n"));
533 } else if let Some(doc) = &s.doc_comment {
534 let first = doc.lines().next().unwrap_or("").trim();
535 if !first.is_empty() {
536 out.push_str(&format!(" Doc: {first}\n"));
537 }
538 }
539 }
540 if fts_matches.len() >= limit {
541 out.push_str(&cap_notice(fts_matches.len(), limit));
542 }
543 out
544 } else {
545 format!("No symbols found matching \"{query}\".\n")
546 }
547}
548
549pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
551 if categories.is_empty() {
552 return "No structural facts or literals indexed in this repository.".to_string();
553 }
554 let mut out = String::new();
555 let aliases = crate::queries::alias_fact_counts(categories);
556 if !aliases.is_empty() {
557 let parts: Vec<String> = aliases
558 .iter()
559 .map(|(alias, patterns, facts)| {
560 format!(
561 "{alias} ({patterns} {}, {facts} {})",
562 plural(*patterns, "pattern"),
563 plural(*facts, "fact")
564 )
565 })
566 .collect();
567 out.push_str(&format!("Aliases: {}\n\n", parts.join(", ")));
568 }
569 out.push_str(&format!(
570 "Available structural fact & literal categories ({} found):\n\n",
571 categories.len()
572 ));
573 for (name, count) in categories {
574 out.push_str(&format!("- `{name}` ({count} occurrences)\n"));
575 }
576 out
577}
578
579fn plural(count: usize, word: &str) -> String {
580 if count == 1 {
581 word.to_string()
582 } else {
583 format!("{word}s")
584 }
585}
586
587pub fn format_structural_facts(
589 facts: &[crate::models::StructuralFact],
590 literals: &[crate::models::LiteralFact],
591 category: &str,
592 limit: usize,
593) -> String {
594 let mut out = format!(
595 "Structural facts for '{category}' ({} found):\n",
596 facts.len()
597 );
598 for f in facts {
599 let label = f.key.as_deref().unwrap_or(&f.capture_name);
600 let details = qt_property_details(f);
601 let parent = f
602 .containing_symbol_name
603 .as_deref()
604 .map(|p| format!(", in: {p}"))
605 .unwrap_or_default();
606 out.push_str(&format!(
607 "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
608 f.path, f.start_line, f.pattern_id
609 ));
610 }
611 if limit > 0 && facts.len() >= limit {
612 out.push_str(&cap_notice(facts.len(), limit));
613 }
614 if !literals.is_empty() {
615 out.push_str(&format!(
616 "\nMatching literals ({} found):\n",
617 literals.len()
618 ));
619 for l in literals {
620 out.push_str(&format!(
621 "- \"{}\" [{}:{}] (kind: {})\n",
622 l.literal_text, l.path, l.start_line, l.kind
623 ));
624 }
625 if limit > 0 && literals.len() >= limit {
626 out.push_str(&cap_notice(literals.len(), limit));
627 }
628 }
629 out
630}
631
632fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
633 if fact.pattern_id != "cpp.qt_property.v1" {
634 return String::new();
635 }
636 let Some(metadata) = fact.metadata.as_ref() else {
637 return String::new();
638 };
639 [
640 "property_type",
641 "read",
642 "write",
643 "notify",
644 "designable",
645 "scriptable",
646 "stored",
647 "user",
648 "revision",
649 ]
650 .into_iter()
651 .filter_map(|key| metadata.get(key).map(|value| (key, value)))
652 .map(|(key, value)| {
653 let value = value
654 .as_str()
655 .map(str::to_owned)
656 .unwrap_or_else(|| value.to_string());
657 format!(", {key}: {value}")
658 })
659 .collect()
660}
661
662pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
664 if results.is_empty() {
665 return format!("No symbols found matching concept \"{query}\".");
666 }
667
668 let mut out = format!(
669 "Found {} symbols matching concept \"{query}\":\n",
670 results.len()
671 );
672 if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
673 out.push_str(&format!(
674 "rerank: {} candidates in {} µs",
675 explain.candidates, explain.rerank_us
676 ));
677 if !explain.word_weights.is_empty() {
678 let words: Vec<String> = explain
679 .word_weights
680 .iter()
681 .map(|(word, weight)| format!("{word} {weight:.2}"))
682 .collect();
683 out.push_str(&format!("; words {}", words.join(", ")));
684 }
685 out.push('\n');
686 }
687 out.push('\n');
688 for r in results {
689 let s = &r.symbol;
690 let sig = s.signature.as_deref().unwrap_or(&s.name);
691 out.push_str(&format!(
692 "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
693 s.kind, s.name, s.path, s.start_line, s.end_line, r.score, s.symbol_id
694 ));
695 out.push_str(&format!(" Signature: {sig}\n"));
696 if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
697 out.push_str(&format!(" Match: {line}\n"));
698 } else if let Some(doc) = &s.doc_comment {
699 let first_line = doc.lines().next().unwrap_or("").trim();
700 if !first_line.is_empty() {
701 out.push_str(&format!(" Doc: {first_line}\n"));
702 }
703 }
704 if let Some(explain) = &r.explain {
705 out.push_str(&format!(" explain: {}\n", explain_line(r.score, explain)));
706 }
707 }
708
709 if results.len() >= limit {
710 out.push_str(&cap_notice(results.len(), limit));
711 }
712
713 out
714}
715
716fn explain_line(score: f64, e: &SearchExplain) -> String {
717 let mut line = format!(
718 "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
719 e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
720 );
721 if e.documentation != 0.0 {
722 line.push_str(&format!(" + doc {:.1}", e.documentation));
723 }
724 if e.test_intent != 0.0 {
725 line.push_str(&format!(" + test {:.1}", e.test_intent));
726 }
727 line.push_str(&format!(" [{}]", e.branches.join(",")));
728 if let Some(bm25) = e.bm25 {
729 line.push_str(&format!(" bm25 {bm25:.2}"));
730 }
731 if !e.terms.is_empty() {
732 let terms: Vec<String> = e
733 .terms
734 .iter()
735 .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
736 .collect();
737 line.push_str(&format!(" terms {}", terms.join(" ")));
738 }
739 line
740}
741
742pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
744 if result.seed_type == "none" {
745 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
746 }
747
748 let mut out = String::new();
749 let seed_label = if result.seed_type == "file" {
750 format!("Files: {}", result.seeds.join(", "))
751 } else if result.seed_type == "symbol" {
752 format!("Symbol: {}", result.seeds.join(", "))
753 } else {
754 format!("Seeds: {}", result.seeds.join(", "))
755 };
756
757 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
758
759 const MAX_COMPACT_TESTS: usize = 20;
760 const MAX_COMPACT_IMPACTED: usize = 50;
761
762 if result.likely_tests_truncated {
763 out.push_str("Requested limit hid additional likely tests; increase limit to reveal discovered rows.\n\n");
764 }
765 if result.impacted_symbols_truncated {
766 out.push_str("Requested limit hid additional impacted symbols; increase limit to reveal discovered rows.\n\n");
767 }
768 if result.traversal_ceiling_reached {
769 out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
770 }
771 if result.test_file_ceiling_reached {
772 out.push_str("Name-matched test discovery hit its fixed ceiling; narrow the target because increasing limit cannot raise it.\n\n");
773 }
774
775 if !result.likely_tests.is_empty() {
776 let total = result.likely_tests.len();
777 if total > MAX_COMPACT_TESTS {
778 out.push_str(&format!(
779 "### Likely Tests to Run ({} returned - showing top {})\n",
780 total, MAX_COMPACT_TESTS
781 ));
782 } else {
783 out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
784 }
785
786 let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
787 std::collections::BTreeMap::new();
788 let mut file_order = Vec::new();
789 for t in result.likely_tests.iter().take(MAX_COMPACT_TESTS) {
790 if !tests_by_file.contains_key(t.path.as_str()) {
791 file_order.push(t.path.as_str());
792 }
793 tests_by_file.entry(t.path.as_str()).or_default().push(t);
794 }
795
796 for path in file_order {
797 out.push_str(&format!("{path}:\n"));
798 if let Some(tests) = tests_by_file.get(path) {
799 for t in tests {
800 out.push_str(&format!(
801 " - `{}` [line {}] ({})\n",
802 t.name, t.line, t.reason
803 ));
804 }
805 }
806 }
807
808 if total > MAX_COMPACT_TESTS {
809 out.push_str(&format!(
810 "... {} more returned likely tests are hidden by the compact 20-row display. CLI --json shows the full returned list.\n",
811 total - MAX_COMPACT_TESTS
812 ));
813 }
814 out.push('\n');
815 } else if result.likely_tests_truncated
816 || result.traversal_ceiling_reached
817 || result.test_file_ceiling_reached
818 {
819 out.push_str("### Likely Tests to Run (0 returned)\n\n");
820 } else {
821 out.push_str(
822 "### Likely Tests to Run (0 returned)\nNo direct or name-matched tests found.\n\n",
823 );
824 }
825
826 if !result.impacted_symbols.is_empty() {
827 let total = result.impacted_symbols.len();
828 let mut visible = Vec::new();
829 let mut low_signal_count = 0;
830 for s in &result.impacted_symbols {
831 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
832 low_signal_count += 1;
833 } else {
834 visible.push(s);
835 }
836 }
837
838 out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
839
840 if visible.is_empty() && low_signal_count > 0 {
841 let row_word = if low_signal_count == 1 {
842 "row (import/module)"
843 } else {
844 "rows (imports/modules)"
845 };
846 out.push_str(&format!(
847 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
848 ));
849 } else {
850 let visible_total = visible.len();
851 let showing_count = visible_total.min(MAX_COMPACT_IMPACTED);
852
853 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
854 std::collections::BTreeMap::new();
855 let mut file_order = Vec::new();
856 for s in visible.iter().take(showing_count) {
857 if !syms_by_file.contains_key(s.path.as_str()) {
858 file_order.push(s.path.as_str());
859 }
860 syms_by_file.entry(s.path.as_str()).or_default().push(s);
861 }
862
863 for path in file_order {
864 out.push_str(&format!("{path}:\n"));
865 if let Some(syms) = syms_by_file.get(path) {
866 for s in syms {
867 out.push_str(&format!(
868 " - [depth {}] {} `{}` [line {}]\n",
869 s.depth, s.kind, s.name, s.line
870 ));
871 }
872 }
873 }
874
875 if visible_total > MAX_COMPACT_IMPACTED {
876 out.push_str(&format!(
877 "... {} more returned impacted symbols are hidden by the compact 50-row display. CLI --json shows the full returned list.\n",
878 visible_total - MAX_COMPACT_IMPACTED
879 ));
880 }
881 if low_signal_count > 0 {
882 let row_word = if low_signal_count == 1 {
883 "row (import/module)"
884 } else {
885 "rows (imports/modules)"
886 };
887 out.push_str(&format!(
888 "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
889 ));
890 }
891 }
892 } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
893 out.push_str("### Downstream Impact (0 returned)\n");
894 } else {
895 out.push_str(
896 "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
897 );
898 }
899
900 out
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::models::{ImpactedSymbol, TestTarget};
907
908 fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
909 crate::models::StructuralFact {
910 structural_fact_id: "sf1".into(),
911 path: ".codex/config.toml".into(),
912 language: "toml".into(),
913 pattern_id: "toml.key_value.v1".into(),
914 capture_name: "key_value".into(),
915 node_kind: "table".into(),
916 key: key.map(str::to_string),
917 metadata: None,
918 containing_symbol_name: None,
919 start_line: 2,
920 end_line: 2,
921 confidence: 1.0,
922 }
923 }
924
925 #[test]
926 fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
927 let with_key = format_structural_facts(
928 &[structural_fact(Some("mcp_servers.code-kb.command"))],
929 &[],
930 "config",
931 30,
932 );
933 assert!(with_key.contains(
934 "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
935 ));
936
937 let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
938 assert!(
939 without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
940 );
941 }
942
943 #[test]
944 fn format_structural_facts_renders_selected_qt_property_metadata() {
945 let mut fact = structural_fact(Some("index"));
946 fact.pattern_id = "cpp.qt_property.v1".into();
947 fact.metadata = Some(serde_json::json!({
948 "property_type": "int",
949 "designable": false,
950 "scriptable": true,
951 "stored": false,
952 "user": true,
953 "revision": 2,
954 }));
955
956 let output = format_structural_facts(&[fact], &[], "property", 30);
957
958 assert!(output.contains(
959 "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
960 ));
961 }
962
963 #[test]
964 fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
965 let site = |occurrences| ReferenceSite {
966 from_symbol_name: "Button".into(),
967 from_symbol_id: "s1".into(),
968 to_symbol_name: "background".into(),
969 kind: "member_access".into(),
970 path: "Ui/Button.qml".into(),
971 start_line: Some(5),
972 start_column: Some(4),
973 occurrences,
974 };
975
976 let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
977 assert!(
978 grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
979 "{grouped}"
980 );
981
982 let single = format_references("Color", &[site(None)], "callers", 30);
983 assert!(
984 single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
985 "{single}"
986 );
987 }
988
989 #[test]
990 fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
991 assert_eq!(
992 match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
993 Some("...path up to the [root] (the [root] excluded)".into())
994 );
995 assert_eq!(
996 match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
997 Some("// [HelpFunc] returns the [function] set".into())
998 );
999 assert_eq!(
1000 match_line(
1001 "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
1002 "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
1003 ),
1004 None
1005 );
1006 assert_eq!(
1007 match_line(
1008 "...[root]: &[PathBuf]) -> bool",
1009 "fn f(root: &[PathBuf]) -> bool"
1010 ),
1011 None
1012 );
1013 }
1014
1015 #[test]
1016 fn a_long_unresolved_call_chain_shows_only_its_last_call() {
1017 assert_eq!(
1018 callee_name(
1019 "[\"a\", \"b\"]\n .into_iter()\n .find_map(|key| args.get(key)).ok_or_else"
1020 ),
1021 "….ok_or_else"
1022 );
1023 assert_eq!(
1024 callee_name("workspace.resolve_path"),
1025 "workspace.resolve_path"
1026 );
1027 let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
1028 assert_eq!(callee_name(selector), selector);
1029 }
1030
1031 #[test]
1032 fn file_skeleton_reports_parse_errors() {
1033 let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
1034 assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
1035
1036 let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
1037 assert!(one.contains("// 1 parse error: symbols may be incomplete"));
1038
1039 let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
1040 assert!(!none.contains("parse error"));
1041 }
1042
1043 #[test]
1044 fn test_format_file_skeleton() {
1045 let syms = vec![Symbol {
1046 symbol_id: "s1".into(),
1047 file_id: "f1".into(),
1048 path: "src/lib.rs".into(),
1049 language: "rust".into(),
1050 name: "do_work".into(),
1051 kind: "function".into(),
1052 signature: Some("pub fn do_work() -> Result<()>".into()),
1053 doc_comment: Some("Performs core work.".into()),
1054 visibility: Some("pub".into()),
1055 parent_symbol_id: None,
1056 start_line: 10,
1057 start_column: 0,
1058 end_line: 30,
1059 end_column: 1,
1060 start_byte: 100,
1061 end_byte: 300,
1062 body_start_line: Some(11),
1063 body_start_column: Some(0),
1064 body_end_line: Some(29),
1065 body_end_column: Some(1),
1066 body_start_byte: Some(130),
1067 body_end_byte: Some(298),
1068 body_hash: None,
1069 semantic_group: None,
1070 is_test: false,
1071 test_container: false,
1072 }];
1073
1074 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
1075 assert!(skeleton.contains("/// Performs core work."));
1076 assert!(skeleton.contains("19 lines hidden: L11-L29"));
1077 }
1078
1079 #[test]
1080 fn format_symbol_body_does_not_repeat_expression_already_in_signature() {
1081 let body = "=>\n transport.SendAsync<OffboardingFormDto>(\n HttpMethod.Get,\n $\"ser/{id}\",\n cancellationToken: cancellationToken)";
1082 let mut symbol = sample_symbol("GetByIdAsync");
1083 symbol.language = "csharp".into();
1084 symbol.signature = Some(format!(
1085 "public Task<OffboardingFormDto?> GetByIdAsync(\n int id,\n CancellationToken cancellationToken = default){body}"
1086 ));
1087
1088 let formatted = format_symbol_body(&symbol, body);
1089
1090 assert_eq!(
1091 formatted.matches("transport.SendAsync").count(),
1092 1,
1093 "{formatted}"
1094 );
1095 }
1096
1097 #[test]
1098 fn format_symbol_body_preserves_unmatched_signature_whitespace() {
1099 let mut symbol = sample_symbol("plain");
1100 symbol.signature = Some("pub fn plain() ".into());
1101
1102 assert_eq!(
1103 format_symbol_body(&symbol, "return 1;"),
1104 "// src/lib.rs:1-10 (plain)\npub fn plain() \nreturn 1;\n"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_format_search_results() {
1110 let results = vec![SymbolSearchResult {
1111 symbol: Symbol {
1112 symbol_id: "s1".into(),
1113 file_id: "f1".into(),
1114 path: "src/parser.rs".into(),
1115 language: "rust".into(),
1116 name: "parse_tokens".into(),
1117 kind: "function".into(),
1118 signature: Some("pub fn parse_tokens()".into()),
1119 doc_comment: Some("Parses tokens from stream.".into()),
1120 visibility: Some("pub".into()),
1121 parent_symbol_id: None,
1122 start_line: 15,
1123 start_column: 0,
1124 end_line: 25,
1125 end_column: 1,
1126 start_byte: 100,
1127 end_byte: 250,
1128 body_start_line: None,
1129 body_start_column: None,
1130 body_end_line: None,
1131 body_end_column: None,
1132 body_start_byte: None,
1133 body_end_byte: None,
1134 body_hash: None,
1135 semantic_group: None,
1136 is_test: false,
1137 test_container: false,
1138 },
1139 score: -1.85,
1140 snippet: Some("Parses [tokens] from stream.".into()),
1141 explain: None,
1142 }];
1143
1144 let formatted = format_search_results("tokens", &results, 20);
1145 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
1146 assert!(
1147 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
1148 );
1149 assert!(formatted.contains("Match: Parses [tokens] from stream."));
1150 assert!(!formatted.contains("explain"));
1151 assert!(!formatted.contains("rerank"));
1152 }
1153
1154 #[test]
1155 fn discovery_formatters_append_ids_to_metadata_rows() {
1156 let exact = vec![sample_symbol("exact")];
1157 let fallback = vec![SymbolSearchResult {
1158 symbol: sample_symbol("fallback"),
1159 score: 1.0,
1160 snippet: None,
1161 explain: None,
1162 }];
1163
1164 let outputs = [
1165 (
1166 format_find_symbol_results("exact", &exact, &[], 20),
1167 "id_exact",
1168 ),
1169 (
1170 format_find_symbol_results("fallback query", &[], &fallback, 20),
1171 "id_fallback",
1172 ),
1173 (
1174 format_search_results("fallback", &fallback, 20),
1175 "id_fallback",
1176 ),
1177 ];
1178
1179 for (formatted, id) in outputs {
1180 assert!(
1181 formatted
1182 .lines()
1183 .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
1184 "{formatted}"
1185 );
1186 assert!(!formatted.contains("\n id="), "{formatted}");
1187 }
1188 }
1189
1190 #[test]
1191 fn every_printed_part_of_the_explain_line_sums_to_the_score() {
1192 let e = SearchExplain {
1193 bm25: Some(-3.21),
1194 branches: vec!["word".into()],
1195 name_tier: "all".into(),
1196 name_strength: 6,
1197 term_score: 24.5,
1198 name_bonus: 60.0,
1199 kind_prior: 4.0,
1200 path_role: -10.0,
1201 documentation: -200.0,
1202 test_intent: 5.0,
1203 terms: vec![("sha".into(), "name".into(), 3.0)],
1204 word_weights: vec![("sha".into(), 2.6)],
1205 candidates: 1,
1206 rerank_us: 1,
1207 };
1208 let score = e.term_score
1209 + e.name_bonus
1210 + e.kind_prior
1211 + e.path_role
1212 + e.documentation
1213 + e.test_intent;
1214
1215 let line = explain_line(score, &e);
1216 let parts: f64 = line
1217 .split(" = ")
1218 .nth(1)
1219 .unwrap()
1220 .split(" [")
1221 .next()
1222 .unwrap()
1223 .split(" + ")
1224 .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
1225 .sum();
1226
1227 assert!(line.starts_with(&format!(
1228 "score {score:.1} = terms 24.5 + name all(6) 60.0 "
1229 )));
1230 assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
1231 }
1232
1233 #[test]
1234 fn test_format_search_results_prints_the_explain_breakdown_when_present() {
1235 let mut result = SymbolSearchResult {
1236 symbol: sample_symbol("parseSha256Sidecar"),
1237 score: 71.6,
1238 snippet: Some("parse[Sha256]Sidecar".into()),
1239 explain: Some(SearchExplain {
1240 bm25: Some(-3.21),
1241 branches: vec!["word".into(), "name".into()],
1242 name_tier: "all".into(),
1243 name_strength: 6,
1244 terms: vec![
1245 ("sha".into(), "name".into(), 3.0),
1246 ("256".into(), "name".into(), 3.0),
1247 ],
1248 term_score: 12.6,
1249 name_bonus: 60.0,
1250 kind_prior: 4.0,
1251 path_role: -10.0,
1252 documentation: 0.0,
1253 test_intent: 5.0,
1254 word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
1255 candidates: 37,
1256 rerank_us: 180,
1257 }),
1258 };
1259
1260 let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
1261 assert!(formatted.contains(
1262 "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
1263 ));
1264 assert!(formatted.contains(
1265 " 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"
1266 ));
1267
1268 result.explain = None;
1269 let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
1270 assert!(!silent.contains("explain"));
1271 assert!(!silent.contains("rerank"));
1272 }
1273
1274 #[test]
1275 fn test_format_search_results_discloses_a_reached_limit() {
1276 let results = vec![SymbolSearchResult {
1277 symbol: sample_symbol("parse_tokens"),
1278 score: 0.0,
1279 snippet: None,
1280 explain: None,
1281 }];
1282
1283 let formatted = format_search_results("tokens", &results, 1);
1284 assert!(
1285 formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
1286 );
1287
1288 let at_ceiling =
1289 format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
1290 assert!(!at_ceiling.contains("limit reached"));
1291
1292 let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
1293 .map(|_| SymbolSearchResult {
1294 symbol: sample_symbol("parse_tokens"),
1295 score: 0.0,
1296 snippet: None,
1297 explain: None,
1298 })
1299 .collect();
1300 let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
1301 assert!(capped.contains("(limit reached); narrow the query to see more.]"));
1302 }
1303
1304 #[test]
1305 fn test_format_blast_radius() {
1306 let res = BlastRadiusResult {
1307 seed_type: "symbol".into(),
1308 seeds: vec!["do_work".into()],
1309 likely_tests: vec![TestTarget {
1310 name: "test_do_work".into(),
1311 path: "tests/work_test.rs".into(),
1312 line: 15,
1313 reason: "transitive caller [depth 1]".into(),
1314 }],
1315 impacted_symbols: vec![ImpactedSymbol {
1316 name: "caller_fn".into(),
1317 kind: "function".into(),
1318 path: "src/caller.rs".into(),
1319 line: 42,
1320 depth: 1,
1321 }],
1322 traversal_ceiling_reached: false,
1323 likely_tests_truncated: false,
1324 impacted_symbols_truncated: false,
1325 test_file_ceiling_reached: false,
1326 };
1327
1328 let formatted = format_blast_radius(&res);
1329 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
1330 assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
1331 assert!(formatted.contains(
1332 "tests/work_test.rs:\n - `test_do_work` [line 15] (transitive caller [depth 1])"
1333 ));
1334 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
1335 }
1336
1337 #[test]
1338 fn test_format_blast_radius_grouped_and_capped() {
1339 let mut likely_tests = Vec::new();
1340 for i in 1..=25 {
1341 likely_tests.push(TestTarget {
1342 name: format!("test_{i}"),
1343 path: format!("tests/test_{}.rs", (i % 3) + 1),
1344 line: i * 10,
1345 reason: "direct caller".into(),
1346 });
1347 }
1348
1349 let impacted_symbols = vec![
1350 ImpactedSymbol {
1351 name: "use_foo".into(),
1352 kind: "import".into(),
1353 path: "src/service.rs".into(),
1354 line: 1,
1355 depth: 1,
1356 },
1357 ImpactedSymbol {
1358 name: "service_fn".into(),
1359 kind: "function".into(),
1360 path: "src/service.rs".into(),
1361 line: 20,
1362 depth: 1,
1363 },
1364 ImpactedSymbol {
1365 name: "api_handler".into(),
1366 kind: "function".into(),
1367 path: "src/api.rs".into(),
1368 line: 45,
1369 depth: 2,
1370 },
1371 ];
1372
1373 let res = BlastRadiusResult {
1374 seed_type: "file".into(),
1375 seeds: vec!["src/lib.rs".into()],
1376 likely_tests,
1377 impacted_symbols,
1378 traversal_ceiling_reached: false,
1379 likely_tests_truncated: false,
1380 impacted_symbols_truncated: false,
1381 test_file_ceiling_reached: false,
1382 };
1383
1384 let formatted = format_blast_radius(&res);
1385
1386 assert!(formatted.contains("### Likely Tests to Run (25 returned - showing top 20)"));
1387 assert!(formatted.contains(
1388 "... 5 more returned likely tests are hidden by the compact 20-row display. CLI --json shows the full returned list."
1389 ));
1390
1391 assert!(formatted.contains("tests/test_1.rs:\n"));
1392 assert!(formatted.contains(" - `test_"));
1393
1394 assert!(!formatted.contains("use_foo"));
1395 assert!(
1396 formatted
1397 .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
1398 );
1399 assert!(formatted.contains("src/service.rs:\n"));
1400 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
1401 }
1402
1403 fn sample_symbol(name: &str) -> Symbol {
1404 Symbol {
1405 symbol_id: format!("id_{name}"),
1406 file_id: "f1".into(),
1407 path: "src/lib.rs".into(),
1408 language: "rust".into(),
1409 name: name.into(),
1410 kind: "function".into(),
1411 signature: Some(format!("pub fn {name}()")),
1412 doc_comment: None,
1413 visibility: Some("pub".into()),
1414 parent_symbol_id: None,
1415 start_line: 1,
1416 start_column: 0,
1417 end_line: 10,
1418 end_column: 1,
1419 start_byte: 0,
1420 end_byte: 100,
1421 body_start_line: Some(2),
1422 body_start_column: Some(0),
1423 body_end_line: Some(9),
1424 body_end_column: Some(1),
1425 body_start_byte: Some(10),
1426 body_end_byte: Some(99),
1427 body_hash: None,
1428 semantic_group: None,
1429 is_test: false,
1430 test_container: false,
1431 }
1432 }
1433
1434 fn sample_context_slice() -> ContextSlice {
1435 ContextSlice {
1436 target_symbol: sample_symbol("target_fn"),
1437 target_body: " println!(\"hello\");\n".into(),
1438 callee_signatures: Vec::new(),
1439 related_types: Vec::new(),
1440 related_tests: Vec::new(),
1441 }
1442 }
1443
1444 #[test]
1445 fn format_context_slice_does_not_repeat_expression_already_in_signature() {
1446 let body = "=>\n transport.SendAsync<Response>(\n HttpMethod.Get,\n $\"items/{id}\",\n cancellationToken: cancellationToken)";
1447 let mut slice = sample_context_slice();
1448 slice.target_symbol.language = "csharp".into();
1449 slice.target_symbol.signature = Some(format!(
1450 "public static Task<Response?> GetByIdAsync(\n int id,\n CancellationToken cancellationToken = default){body}"
1451 ));
1452 slice.target_body = body.into();
1453
1454 let formatted = format_context_slice(&slice);
1455
1456 assert_eq!(
1457 formatted.matches("transport.SendAsync").count(),
1458 1,
1459 "{formatted}"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
1465 let mut slice = sample_context_slice();
1466 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
1467 let text = format_context_slice(&slice);
1468 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
1469
1470 let mut slice_tests = sample_context_slice();
1471 slice_tests.related_tests = (1..=5)
1472 .map(|i| {
1473 let mut sym = sample_symbol(&format!("test_fn_{i}"));
1474 sym.path = format!("tests/test_{i}.rs");
1475 sym.is_test = true;
1476 sym
1477 })
1478 .collect();
1479 let text_tests = format_context_slice(&slice_tests);
1480 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
1481 }
1482
1483 #[test]
1484 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
1485 let impacted_symbols = (1..=200)
1486 .map(|i| ImpactedSymbol {
1487 name: format!("sym_{i}"),
1488 kind: "function".into(),
1489 path: format!("src/mod_{}.rs", i % 10),
1490 line: i,
1491 depth: 1,
1492 })
1493 .collect();
1494
1495 let res = BlastRadiusResult {
1496 seed_type: "symbol".into(),
1497 seeds: vec!["root_fn".into()],
1498 likely_tests: Vec::new(),
1499 impacted_symbols,
1500 traversal_ceiling_reached: true,
1501 likely_tests_truncated: false,
1502 impacted_symbols_truncated: false,
1503 test_file_ceiling_reached: false,
1504 };
1505
1506 let formatted = format_blast_radius(&res);
1507 assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
1508 }
1509
1510 #[test]
1511 fn blast_radius_does_not_claim_no_results_after_discovery_ceiling() {
1512 let res = BlastRadiusResult {
1513 seed_type: "file".into(),
1514 seeds: vec!["src/widget.rs".into()],
1515 likely_tests: Vec::new(),
1516 impacted_symbols: Vec::new(),
1517 likely_tests_truncated: false,
1518 impacted_symbols_truncated: false,
1519 traversal_ceiling_reached: true,
1520 test_file_ceiling_reached: true,
1521 };
1522
1523 let formatted = format_blast_radius(&res);
1524 assert!(formatted.contains("Likely Tests to Run (0 returned)"));
1525 assert!(formatted.contains("Downstream Impact (0 returned)"));
1526 assert!(formatted.contains("Name-matched test discovery hit its fixed ceiling"));
1527 assert!(!formatted.contains("No direct or name-matched tests found"));
1528 assert!(!formatted.contains("No downstream callers found within depth"));
1529
1530 let no_ceiling = BlastRadiusResult {
1531 traversal_ceiling_reached: false,
1532 test_file_ceiling_reached: false,
1533 ..res.clone()
1534 };
1535 assert!(format_blast_radius(&no_ceiling).contains("No direct or name-matched tests found"));
1536
1537 let traversal_only = BlastRadiusResult {
1538 traversal_ceiling_reached: true,
1539 test_file_ceiling_reached: false,
1540 ..res
1541 };
1542 let traversal_only_text = format_blast_radius(&traversal_only);
1543 assert!(!traversal_only_text.contains("No direct or name-matched tests found"));
1544 }
1545
1546 fn skeleton_row(
1547 id: &str,
1548 parent: Option<&str>,
1549 kind: &str,
1550 name: &str,
1551 signature: &str,
1552 lines: (usize, usize),
1553 body: Option<(usize, usize)>,
1554 ) -> Symbol {
1555 Symbol {
1556 symbol_id: id.into(),
1557 file_id: "f1".into(),
1558 path: "src/lib.rs".into(),
1559 language: "rust".into(),
1560 name: name.into(),
1561 kind: kind.into(),
1562 signature: Some(signature.into()),
1563 doc_comment: None,
1564 visibility: None,
1565 parent_symbol_id: parent.map(str::to_string),
1566 start_line: lines.0,
1567 start_column: 0,
1568 end_line: lines.1,
1569 end_column: 1,
1570 start_byte: 0,
1571 end_byte: 0,
1572 body_start_line: body.map(|b| b.0),
1573 body_start_column: None,
1574 body_end_line: body.map(|b| b.1),
1575 body_end_column: None,
1576 body_start_byte: None,
1577 body_end_byte: None,
1578 body_hash: None,
1579 semantic_group: None,
1580 is_test: false,
1581 test_container: false,
1582 }
1583 }
1584
1585 #[test]
1586 fn skeleton_nests_an_object_under_the_field_that_declares_it() {
1587 let syms = vec![
1588 skeleton_row(
1589 "root",
1590 None,
1591 "class",
1592 "shell",
1593 "extends ShellRoot",
1594 (1, 8),
1595 None,
1596 ),
1597 skeleton_row(
1598 "timer",
1599 Some("root"),
1600 "field",
1601 "localPluginReloadTimer",
1602 "localPluginReloadTimer: Timer",
1603 (2, 7),
1604 Some((2, 7)),
1605 ),
1606 skeleton_row(
1607 "interval",
1608 Some("timer"),
1609 "property",
1610 "interval",
1611 "interval: 150",
1612 (3, 3),
1613 None,
1614 ),
1615 skeleton_row(
1616 "fire",
1617 Some("timer"),
1618 "function",
1619 "fire",
1620 "function fire()",
1621 (5, 7),
1622 Some((6, 7)),
1623 ),
1624 ];
1625
1626 assert_eq!(
1627 format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
1628 "// File: shell/shell.qml (Lines 1-8)\n\
1629 \n\
1630 extends ShellRoot {\n\
1631 \x20 localPluginReloadTimer: Timer {\n\
1632 \x20 interval: 150; // L3-3\n\
1633 \x20 function fire() { /* 2 lines hidden: L6-L7 */ }\n\
1634 \x20 } // L2-7\n\
1635 \n\
1636 } // L1-8\n\
1637 \n"
1638 );
1639 }
1640
1641 #[test]
1642 fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
1643 let syms = vec![
1644 skeleton_row(
1645 "rusqlite",
1646 None,
1647 "field",
1648 "rusqlite",
1649 "rusqlite = { workspace = true }",
1650 (15, 15),
1651 None,
1652 ),
1653 skeleton_row(
1654 "workspace",
1655 Some("rusqlite"),
1656 "property",
1657 "workspace",
1658 "workspace = true",
1659 (15, 15),
1660 None,
1661 ),
1662 ];
1663
1664 assert_eq!(
1665 format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
1666 "// File: Cargo.toml (Lines 1-15)\n\
1667 \n\
1668 rusqlite =; // L15-15\n"
1669 );
1670 }
1671
1672 #[test]
1673 fn skeleton_renders_members_of_a_single_line_enum() {
1674 let mut syms = vec![
1675 skeleton_row(
1676 "mode",
1677 None,
1678 "enum",
1679 "DispatchMode",
1680 "public enum DispatchMode",
1681 (21, 21),
1682 None,
1683 ),
1684 skeleton_row(
1685 "provision",
1686 Some("mode"),
1687 "enum_member",
1688 "Provision",
1689 "Provision",
1690 (21, 21),
1691 None,
1692 ),
1693 skeleton_row(
1694 "deprovision",
1695 Some("mode"),
1696 "enum_member",
1697 "Deprovision",
1698 "Deprovision",
1699 (21, 21),
1700 None,
1701 ),
1702 skeleton_row(
1703 "extension",
1704 Some("mode"),
1705 "enum_member",
1706 "Extension",
1707 "Extension",
1708 (21, 21),
1709 None,
1710 ),
1711 ];
1712 for symbol in &mut syms {
1713 symbol.language = "csharp".into();
1714 }
1715
1716 let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
1717
1718 for member in ["Provision", "Deprovision", "Extension"] {
1719 assert!(skeleton.contains(member), "{skeleton}");
1720 }
1721 }
1722
1723 #[test]
1724 fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
1725 let syms = vec![
1726 skeleton_row(
1727 "cfg",
1728 None,
1729 "struct",
1730 "Config",
1731 "pub struct Config",
1732 (1, 3),
1733 None,
1734 ),
1735 skeleton_row(
1736 "retries",
1737 Some("cfg"),
1738 "field",
1739 "retries",
1740 "pub retries: u32",
1741 (2, 2),
1742 None,
1743 ),
1744 skeleton_row(
1745 "run",
1746 None,
1747 "function",
1748 "run",
1749 "pub fn run()",
1750 (5, 9),
1751 Some((6, 8)),
1752 ),
1753 skeleton_row(
1754 "tmp",
1755 Some("run"),
1756 "variable",
1757 "tmp",
1758 "let tmp",
1759 (7, 7),
1760 None,
1761 ),
1762 ];
1763
1764 assert_eq!(
1765 format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
1766 "// File: src/lib.rs (Lines 1-9)\n\
1767 \n\
1768 pub struct Config {\n\
1769 \x20 pub retries: u32; // L2-2\n\
1770 } // L1-3\n\
1771 \n\
1772 pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
1773 );
1774 }
1775
1776 #[test]
1777 fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
1778 let syms = vec![
1779 skeleton_row(
1780 "cls",
1781 None,
1782 "class",
1783 "ColumnViewAttached",
1784 "class ColumnViewAttached : public QObject",
1785 (11, 52),
1786 None,
1787 ),
1788 skeleton_row(
1789 "sig",
1790 Some("cls"),
1791 "event",
1792 "indexChanged",
1793 "void indexChanged()",
1794 (47, 47),
1795 None,
1796 ),
1797 skeleton_row(
1798 "qml",
1799 None,
1800 "event",
1801 "clicked",
1802 "signal clicked()",
1803 (60, 60),
1804 None,
1805 ),
1806 ];
1807
1808 assert_eq!(
1809 format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
1810 "// File: src/columnview.h (Lines 1-60)\n\
1811 \n\
1812 class ColumnViewAttached : public QObject {\n\
1813 \x20 void indexChanged(); // event L47-47\n\
1814 } // L11-52\n\
1815 \n\
1816 signal clicked(); // L60-60\n"
1817 );
1818 }
1819}