1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use crate::models::{
5 BlastRadiusResult, ContextSlice, ImpactedSymbol, ReferenceSite, Symbol, SymbolSearchResult,
6 TestTarget,
7};
8
9pub fn format_file_skeleton(
11 file_path: &str,
12 symbols: &[Symbol],
13 line_count: Option<usize>,
14) -> String {
15 let mut out = String::new();
16 let lines_str = match line_count {
17 Some(c) => format!(" (Lines 1-{c})"),
18 None => String::new(),
19 };
20 out.push_str(&format!("// File: {file_path}{lines_str}\n\n"));
21
22 if symbols.is_empty() {
23 out.push_str("// No exported symbols indexed.\n");
24 return out;
25 }
26
27 let mut children_map: HashMap<Option<String>, Vec<&Symbol>> = HashMap::new();
29 for s in symbols {
30 children_map
31 .entry(s.parent_symbol_id.clone())
32 .or_default()
33 .push(s);
34 }
35
36 if let Some(roots) = children_map.get(&None) {
38 for root in roots {
39 render_symbol_skeleton(&mut out, root, &children_map, 0);
40 }
41 } else {
42 for s in symbols {
44 render_symbol_skeleton(&mut out, s, &children_map, 0);
45 }
46 }
47
48 out
49}
50
51fn is_container_kind(kind: &str) -> bool {
52 matches!(
53 kind,
54 "struct" | "class" | "trait" | "interface" | "enum" | "impl" | "module" | "namespace"
55 )
56}
57
58fn is_skippable_kind(kind: &str) -> bool {
59 matches!(kind, "variable" | "parameter" | "import")
60}
61
62fn sanitize_skeleton_sig<'a>(sig: &'a str, name: &'a str) -> &'a str {
63 let clean = if let Some(idx) = sig.find('{') {
64 sig[..idx].trim_end()
65 } else {
66 sig.trim_end()
67 };
68 let trimmed = clean.trim_end_matches(';').trim_end();
69 if trimmed.is_empty() { name } else { trimmed }
70}
71
72fn render_symbol_skeleton(
73 out: &mut String,
74 sym: &Symbol,
75 children_map: &HashMap<Option<String>, Vec<&Symbol>>,
76 indent_level: usize,
77) {
78 if is_skippable_kind(&sym.kind) {
79 return;
80 }
81
82 let indent = " ".repeat(indent_level);
83
84 if let Some(ref doc) = sym.doc_comment {
86 let lines: Vec<_> = doc.lines().collect();
87 let cap = 3;
88 for line in lines.iter().take(cap) {
89 out.push_str(&format!("{indent}/// {line}\n"));
90 }
91 if lines.len() > cap {
92 out.push_str(&format!(
93 "{indent}/// ... ({} more lines)\n",
94 lines.len() - cap
95 ));
96 }
97 }
98
99 let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
100
101 let children = children_map.get(&Some(sym.symbol_id.clone()));
103
104 if is_container_kind(&sym.kind) && children.is_some() {
105 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
106 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
107 out.push_str(&format!("{indent}{sig} {{\n"));
108 if let Some(child_list) = children {
109 for child in child_list {
110 render_symbol_skeleton(out, child, children_map, indent_level + 1);
111 }
112 }
113 out.push_str(&format!("{indent}}} // {span_str}\n\n"));
114 } else {
115 if let Some(count) = sym.hidden_body_line_count() {
117 let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
118 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
119 let b_start = sym.body_start_line.unwrap_or(sym.start_line);
120 let b_end = sym.body_end_line.unwrap_or(sym.end_line);
121
122 if count > 1 {
123 out.push_str(&format!(
124 "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end} */ }}\n"
125 ));
126 } else {
127 out.push_str(&format!("{indent}{sig}; // {span_str}\n"));
128 }
129 } else if let Some(ref raw_sig) = sym.signature {
130 let sig = sanitize_skeleton_sig(raw_sig, &sym.name);
131 out.push_str(&format!("{indent}{sig}; // {span_str}\n"));
132 } else {
133 out.push_str(&format!(
134 "{indent}{} {sym_name}; // {span_str}\n",
135 sym.kind,
136 sym_name = sym.name
137 ));
138 }
139 }
140}
141
142#[derive(Default)]
144pub struct OutlineNode {
145 pub files: BTreeMap<String, Vec<String>>, pub subdirs: BTreeMap<String, OutlineNode>,
147}
148
149pub fn add_path_to_outline(
151 root_node: &mut OutlineNode,
152 file_path: &str,
153 symbols_by_file: &HashMap<String, Vec<Symbol>>,
154 max_depth: usize,
155 norm_filter: &str,
156) {
157 let normalized = file_path.replace('\\', "/");
158 let rel_path_str = if norm_filter.is_empty() {
159 normalized.as_str()
160 } else if normalized.eq_ignore_ascii_case(norm_filter) {
161 Path::new(&normalized)
162 .file_name()
163 .and_then(|n| n.to_str())
164 .unwrap_or(&normalized)
165 } else if normalized.len() > norm_filter.len()
166 && normalized.as_bytes()[norm_filter.len()] == b'/'
167 && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
168 {
169 &normalized[norm_filter.len() + 1..]
170 } else {
171 return;
172 };
173
174 let path = Path::new(rel_path_str);
175 let components: Vec<&str> = path
176 .components()
177 .map(|c| c.as_os_str().to_str().unwrap_or(""))
178 .filter(|s| !s.is_empty())
179 .collect();
180
181 if components.is_empty() {
182 return;
183 }
184
185 let mut curr = root_node;
186 let depth = components.len();
187
188 for (i, comp) in components.iter().enumerate() {
189 if i == depth - 1 {
190 if depth <= max_depth {
192 let mut sym_tags = Vec::new();
193 if let Some(syms) = symbols_by_file.get(&normalized) {
194 for s in syms.iter().take(5) {
195 sym_tags.push(format!("{} {}", s.kind, s.name));
196 }
197 if syms.len() > 5 {
198 sym_tags.push(format!("+{} more", syms.len() - 5));
199 }
200 }
201 if !sym_tags.is_empty() {
202 curr.files.insert(comp.to_string(), sym_tags);
203 }
204 }
205 } else if i < max_depth {
206 curr = curr.subdirs.entry(comp.to_string()).or_default();
207 } else {
208 break;
209 }
210 }
211}
212
213pub fn render_outline_tree(
214 out: &mut String,
215 node: &OutlineNode,
216 prefix: &str,
217 depth: usize,
218 max_depth: usize,
219) {
220 if depth >= max_depth {
221 return;
222 }
223
224 let total_items = node.subdirs.len() + node.files.len();
225 let mut index = 0;
226
227 for (name, sub) in &node.subdirs {
229 index += 1;
230 let is_last = index == total_items;
231 let branch = if is_last { "└── " } else { "├── " };
232 let next_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
233
234 out.push_str(&format!("{prefix}{branch}{name}/\n"));
235 render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
236 }
237
238 for (file_name, syms) in &node.files {
240 index += 1;
241 let is_last = index == total_items;
242 let branch = if is_last { "└── " } else { "├── " };
243
244 let sym_suffix = if !syms.is_empty() {
245 format!(" [{}]", syms.join(", "))
246 } else {
247 String::new()
248 };
249
250 out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
251 }
252}
253
254pub fn format_symbol_body(symbol: &Symbol, body: &str) -> String {
256 let body_hash = crate::edit::hash_content(body);
257 let mut out = format!(
258 "// {}:{}-{} ({}) body_hash={body_hash}\n",
259 symbol.path, symbol.start_line, symbol.end_line, symbol.name
260 );
261 if let Some(ref sig) = symbol.signature {
262 out.push_str(sig);
263 if !sig.ends_with('\n') {
264 out.push('\n');
265 }
266 }
267 out.push_str(body);
268 if !body.ends_with('\n') {
269 out.push('\n');
270 }
271 out
272}
273
274pub fn format_context_slice(slice: &ContextSlice) -> String {
276 let sym = &slice.target_symbol;
277 let mut out = String::new();
278 let body_hash = crate::edit::hash_content(&slice.target_body);
279
280 out.push_str(&format!(
281 "### Target: `{}` ({}:{}-{}) body_hash={body_hash}\n\n",
282 sym.name, sym.path, sym.start_line, sym.end_line
283 ));
284
285 if let Some(ref sig) = sym.signature {
286 out.push_str(&format!("Signature: `{sig}`\n\n"));
287 }
288
289 out.push_str(&format!("```{}\n", sym.language));
290 out.push_str(&slice.target_body);
291 if !slice.target_body.ends_with('\n') {
292 out.push('\n');
293 }
294 out.push_str("```\n\n");
295
296 if !slice.callee_signatures.is_empty() {
297 out.push_str("### Dependencies (Signatures):\n");
298 for callee in &slice.callee_signatures {
299 out.push_str(&format!("- {callee}\n"));
300 }
301 if slice.callee_signatures.len() >= 10 {
302 out.push_str("[Showing 10 dependencies (limit reached)]\n");
303 }
304 out.push('\n');
305 }
306
307 if !slice.related_types.is_empty() {
308 out.push_str("### Types:\n");
309 for t in &slice.related_types {
310 out.push_str(&format!("- {t}\n"));
311 }
312 out.push('\n');
313 }
314
315 if !slice.related_tests.is_empty() {
316 out.push_str("### Related Tests:\n");
317 for test in &slice.related_tests {
318 out.push_str(&format!(
319 "- `{}` ({}:{})\n",
320 test.name, test.path, test.start_line
321 ));
322 }
323 if slice.related_tests.len() >= 5 {
324 out.push_str("[Showing 5 tests (limit reached)]\n");
325 }
326 out.push('\n');
327 }
328
329 out
330}
331
332pub fn format_references(
334 target_name: &str,
335 refs: &[ReferenceSite],
336 direction: &str,
337 limit: usize,
338) -> String {
339 let mut out = String::new();
340 let dir_label = if direction == "callers" {
341 "Callers of"
342 } else {
343 "Callees called by"
344 };
345 out.push_str(&format!(
346 "{dir_label} `{target_name}` ({} found):\n",
347 refs.len()
348 ));
349
350 if refs.is_empty() {
351 out.push_str(" (none)\n");
352 return out;
353 }
354
355 for r in refs {
356 let line_info = match r.start_line {
357 Some(l) => format!(":{l}"),
358 None => String::new(),
359 };
360 let other = if direction == "callers" {
361 &r.from_symbol_name
362 } else {
363 &r.to_symbol_name
364 };
365 out.push_str(&format!(
366 "- `{other}` [{}{line_info}] (kind: {})\n",
367 r.path, r.kind
368 ));
369 }
370
371 if refs.len() >= limit {
372 out.push_str(&format!(
373 "\n[Showing {} references (limit reached). Increase limit to see more.]\n",
374 refs.len()
375 ));
376 }
377
378 out
379}
380
381pub fn format_find_symbol_results(
383 query: &str,
384 exact_matches: &[Symbol],
385 fts_matches: &[SymbolSearchResult],
386) -> String {
387 if !exact_matches.is_empty() {
388 let mut out = format!(
389 "Found {} symbols matching \"{query}\":\n\n",
390 exact_matches.len()
391 );
392 for s in exact_matches {
393 let sig = s.signature.as_deref().unwrap_or(&s.name);
394 out.push_str(&format!(
395 "- {} `{}` [{}:{}-{}]\n",
396 s.kind, s.name, s.path, s.start_line, s.end_line
397 ));
398 out.push_str(&format!(" Signature: {sig}\n"));
399 if let Some(doc) = &s.doc_comment {
400 let first = doc.lines().next().unwrap_or("").trim();
401 if !first.is_empty() {
402 out.push_str(&format!(" Doc: {first}\n"));
403 }
404 }
405 }
406 out
407 } else if !fts_matches.is_empty() {
408 let mut out = format!(
409 "No exact name match; {} full-text matches for \"{query}\":\n\n",
410 fts_matches.len()
411 );
412 for r in fts_matches {
413 let s = &r.symbol;
414 let sig = s.signature.as_deref().unwrap_or(&s.name);
415 out.push_str(&format!(
416 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
417 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
418 ));
419 out.push_str(&format!(" Signature: {sig}\n"));
420 if let Some(snippet) = &r.snippet {
421 let clean = snippet.replace('\r', "").trim().to_string();
422 let first = clean.lines().next().unwrap_or(&clean);
423 out.push_str(&format!(" Match: {first}\n"));
424 } else if let Some(doc) = &s.doc_comment {
425 let first = doc.lines().next().unwrap_or("").trim();
426 if !first.is_empty() {
427 out.push_str(&format!(" Doc: {first}\n"));
428 }
429 }
430 }
431 out
432 } else {
433 format!("No symbols found matching \"{query}\".\n")
434 }
435}
436
437pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
439 if categories.is_empty() {
440 return "No structural facts or literals indexed in this repository.".to_string();
441 }
442 let mut out = format!(
443 "Available structural fact & literal categories ({} found):\n\n",
444 categories.len()
445 );
446 for (name, count) in categories {
447 out.push_str(&format!("- `{name}` ({count} occurrences)\n"));
448 }
449 out
450}
451
452pub fn format_structural_facts(
454 facts: &[crate::models::StructuralFact],
455 literals: &[crate::models::LiteralFact],
456 category: &str,
457) -> String {
458 let mut out = format!(
459 "Structural facts for '{category}' ({} found):\n",
460 facts.len()
461 );
462 for f in facts {
463 let parent = f.containing_symbol_name.as_deref().unwrap_or("top-level");
464 out.push_str(&format!(
465 "- {} [{}:{}] (pattern: {}, in: {})\n",
466 f.capture_name, f.path, f.start_line, f.pattern_id, parent
467 ));
468 }
469 if !literals.is_empty() {
470 out.push_str(&format!(
471 "\nMatching literals ({} found):\n",
472 literals.len()
473 ));
474 for l in literals {
475 out.push_str(&format!(
476 "- \"{}\" [{}:{}] (kind: {})\n",
477 l.literal_text, l.path, l.start_line, l.kind
478 ));
479 }
480 }
481 out
482}
483
484pub fn format_replace_symbol_result(res: &crate::edit::EditResult) -> String {
486 let syntax_line = if res.syntax_checked {
487 "Syntax: Verified"
488 } else {
489 "Syntax: Skipped (grammar not available for file extension)"
490 };
491 format!(
492 "Successfully replaced body of `{}` in `{}`.\nOld Hash: {}\nNew Hash: {}\nBytes Written: {}\n{}",
493 res.symbol_name,
494 res.file_path,
495 res.old_body_hash,
496 res.new_body_hash,
497 res.bytes_written,
498 syntax_line
499 )
500}
501
502pub fn format_search_results(query: &str, results: &[SymbolSearchResult]) -> String {
504 if results.is_empty() {
505 return format!("No symbols found matching concept \"{query}\".");
506 }
507
508 let mut out = format!(
509 "Found {} symbols matching concept \"{query}\":\n\n",
510 results.len()
511 );
512 for r in results {
513 let s = &r.symbol;
514 let sig = s.signature.as_deref().unwrap_or(&s.name);
515 out.push_str(&format!(
516 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
517 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
518 ));
519 out.push_str(&format!(" Signature: {sig}\n"));
520 if let Some(snippet) = &r.snippet {
521 let clean_snip = snippet.replace('\r', "").trim().to_string();
522 let first_line = clean_snip.lines().next().unwrap_or(&clean_snip);
523 out.push_str(&format!(" Match: {first_line}\n"));
524 } else if let Some(doc) = &s.doc_comment {
525 let first_line = doc.lines().next().unwrap_or("").trim();
526 if !first_line.is_empty() {
527 out.push_str(&format!(" Doc: {first_line}\n"));
528 }
529 }
530 }
531
532 out
533}
534
535pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
537 if result.seed_type == "none"
538 || (result.seeds.is_empty()
539 && result.likely_tests.is_empty()
540 && result.impacted_symbols.is_empty())
541 {
542 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
543 }
544
545 let mut out = String::new();
546 let seed_label = if result.seed_type == "file" {
547 format!("Files: {}", result.seeds.join(", "))
548 } else if result.seed_type == "symbol" {
549 format!("Symbol: {}", result.seeds.join(", "))
550 } else {
551 format!("Seeds: {}", result.seeds.join(", "))
552 };
553
554 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
555
556 const MAX_COMPACT_TESTS: usize = 20;
557 const MAX_COMPACT_IMPACTED: usize = 50;
558
559 if !result.likely_tests.is_empty() {
560 let total = result.likely_tests.len();
561 if total > MAX_COMPACT_TESTS {
562 out.push_str(&format!(
563 "### Likely Tests to Run ({} found - showing top {})\n",
564 total, MAX_COMPACT_TESTS
565 ));
566 } else {
567 out.push_str(&format!("### Likely Tests to Run ({} found)\n", total));
568 }
569
570 let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
571 std::collections::BTreeMap::new();
572 let mut file_order = Vec::new();
573 for t in result.likely_tests.iter().take(MAX_COMPACT_TESTS) {
574 if !tests_by_file.contains_key(t.path.as_str()) {
575 file_order.push(t.path.as_str());
576 }
577 tests_by_file.entry(t.path.as_str()).or_default().push(t);
578 }
579
580 for path in file_order {
581 out.push_str(&format!("{path}:\n"));
582 if let Some(tests) = tests_by_file.get(path) {
583 for t in tests {
584 out.push_str(&format!(
585 " - `{}` [line {}] ({})\n",
586 t.name, t.line, t.reason
587 ));
588 }
589 }
590 }
591
592 if total > MAX_COMPACT_TESTS {
593 out.push_str(&format!(
594 "... {} more likely tests; use --json for full list.\n",
595 total - MAX_COMPACT_TESTS
596 ));
597 }
598 out.push('\n');
599 } else {
600 out.push_str("### Likely Tests to Run\nNo direct or stem-matched tests found.\n\n");
601 }
602
603 if !result.impacted_symbols.is_empty() {
604 let total = result.impacted_symbols.len();
605 let mut visible = Vec::new();
606 let mut low_signal_count = 0;
607 for s in &result.impacted_symbols {
608 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
609 low_signal_count += 1;
610 } else {
611 visible.push(s);
612 }
613 }
614
615 if result.traversal_ceiling_reached || total >= 200 {
616 out.push_str("### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n");
617 } else {
618 out.push_str(&format!("### Downstream Impact ({} symbols)\n", total));
619 }
620
621 if visible.is_empty() && low_signal_count > 0 {
622 let row_word = if low_signal_count == 1 {
623 "row (import/module)"
624 } else {
625 "rows (imports/modules)"
626 };
627 out.push_str(&format!(
628 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; use --json for full list.\n"
629 ));
630 } else {
631 let visible_total = visible.len();
632 let showing_count = visible_total.min(MAX_COMPACT_IMPACTED);
633
634 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
635 std::collections::BTreeMap::new();
636 let mut file_order = Vec::new();
637 for s in visible.iter().take(showing_count) {
638 if !syms_by_file.contains_key(s.path.as_str()) {
639 file_order.push(s.path.as_str());
640 }
641 syms_by_file.entry(s.path.as_str()).or_default().push(s);
642 }
643
644 for path in file_order {
645 out.push_str(&format!("{path}:\n"));
646 if let Some(syms) = syms_by_file.get(path) {
647 for s in syms {
648 out.push_str(&format!(
649 " - [depth {}] {} `{}` [line {}]\n",
650 s.depth, s.kind, s.name, s.line
651 ));
652 }
653 }
654 }
655
656 if visible_total > MAX_COMPACT_IMPACTED {
657 out.push_str(&format!(
658 "... {} more impacted symbols; use --json for full list.\n",
659 visible_total - MAX_COMPACT_IMPACTED
660 ));
661 }
662 if low_signal_count > 0 {
663 let row_word = if low_signal_count == 1 {
664 "row (import/module)"
665 } else {
666 "rows (imports/modules)"
667 };
668 out.push_str(&format!(
669 "... {low_signal_count} low-signal {row_word} hidden; use --json for full list.\n"
670 ));
671 }
672 }
673 } else {
674 out.push_str("### Downstream Impact\nNo downstream callers found within depth.\n");
675 }
676
677 out
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use crate::models::{ImpactedSymbol, TestTarget};
684
685 #[test]
686 fn test_format_file_skeleton() {
687 let syms = vec![Symbol {
688 symbol_id: "s1".into(),
689 file_id: "f1".into(),
690 path: "src/lib.rs".into(),
691 language: "rust".into(),
692 name: "do_work".into(),
693 kind: "function".into(),
694 signature: Some("pub fn do_work() -> Result<()>".into()),
695 doc_comment: Some("Performs core work.".into()),
696 visibility: Some("pub".into()),
697 parent_symbol_id: None,
698 start_line: 10,
699 start_column: 0,
700 end_line: 30,
701 end_column: 1,
702 start_byte: 100,
703 end_byte: 300,
704 body_start_line: Some(11),
705 body_start_column: Some(0),
706 body_end_line: Some(29),
707 body_end_column: Some(1),
708 body_start_byte: Some(130),
709 body_end_byte: Some(298),
710 body_hash: None,
711 semantic_group: None,
712 is_test: false,
713 test_container: false,
714 }];
715
716 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35));
717 assert!(skeleton.contains("/// Performs core work."));
718 assert!(skeleton.contains("19 lines hidden: L11-L29"));
719 }
720
721 #[test]
722 fn test_format_search_results() {
723 let results = vec![SymbolSearchResult {
724 symbol: Symbol {
725 symbol_id: "s1".into(),
726 file_id: "f1".into(),
727 path: "src/parser.rs".into(),
728 language: "rust".into(),
729 name: "parse_tokens".into(),
730 kind: "function".into(),
731 signature: Some("pub fn parse_tokens()".into()),
732 doc_comment: Some("Parses tokens from stream.".into()),
733 visibility: Some("pub".into()),
734 parent_symbol_id: None,
735 start_line: 15,
736 start_column: 0,
737 end_line: 25,
738 end_column: 1,
739 start_byte: 100,
740 end_byte: 250,
741 body_start_line: None,
742 body_start_column: None,
743 body_end_line: None,
744 body_end_column: None,
745 body_start_byte: None,
746 body_end_byte: None,
747 body_hash: None,
748 semantic_group: None,
749 is_test: false,
750 test_container: false,
751 },
752 score: -1.85,
753 snippet: Some("Parses [tokens] from stream.".into()),
754 }];
755
756 let formatted = format_search_results("tokens", &results);
757 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":"));
758 assert!(
759 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
760 );
761 assert!(formatted.contains("Match: Parses [tokens] from stream."));
762 }
763
764 #[test]
765 fn test_format_blast_radius() {
766 let res = BlastRadiusResult {
767 seed_type: "symbol".into(),
768 seeds: vec!["do_work".into()],
769 likely_tests: vec![TestTarget {
770 name: "test_do_work".into(),
771 path: "tests/work_test.rs".into(),
772 line: 15,
773 reason: "transitive caller [depth 1]".into(),
774 }],
775 impacted_symbols: vec![ImpactedSymbol {
776 name: "caller_fn".into(),
777 kind: "function".into(),
778 path: "src/caller.rs".into(),
779 line: 42,
780 depth: 1,
781 }],
782 traversal_ceiling_reached: false,
783 };
784
785 let formatted = format_blast_radius(&res);
786 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
787 assert!(formatted.contains("### Likely Tests to Run (1 found)"));
788 assert!(formatted.contains(
789 "tests/work_test.rs:\n - `test_do_work` [line 15] (transitive caller [depth 1])"
790 ));
791 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
792 }
793
794 #[test]
795 fn test_format_blast_radius_grouped_and_capped() {
796 let mut likely_tests = Vec::new();
797 for i in 1..=25 {
798 likely_tests.push(TestTarget {
799 name: format!("test_{i}"),
800 path: format!("tests/test_{}.rs", (i % 3) + 1),
801 line: i * 10,
802 reason: "direct caller".into(),
803 });
804 }
805
806 let impacted_symbols = vec![
807 ImpactedSymbol {
808 name: "use_foo".into(),
809 kind: "import".into(),
810 path: "src/service.rs".into(),
811 line: 1,
812 depth: 1,
813 },
814 ImpactedSymbol {
815 name: "service_fn".into(),
816 kind: "function".into(),
817 path: "src/service.rs".into(),
818 line: 20,
819 depth: 1,
820 },
821 ImpactedSymbol {
822 name: "api_handler".into(),
823 kind: "function".into(),
824 path: "src/api.rs".into(),
825 line: 45,
826 depth: 2,
827 },
828 ];
829
830 let res = BlastRadiusResult {
831 seed_type: "file".into(),
832 seeds: vec!["src/lib.rs".into()],
833 likely_tests,
834 impacted_symbols,
835 traversal_ceiling_reached: false,
836 };
837
838 let formatted = format_blast_radius(&res);
839
840 assert!(formatted.contains("### Likely Tests to Run (25 found - showing top 20)"));
842 assert!(formatted.contains("... 5 more likely tests; use --json for full list."));
843
844 assert!(formatted.contains("tests/test_1.rs:\n"));
846 assert!(formatted.contains(" - `test_"));
847
848 assert!(!formatted.contains("use_foo"));
850 assert!(
851 formatted
852 .contains("... 1 low-signal row (import/module) hidden; use --json for full list.")
853 );
854 assert!(formatted.contains("src/service.rs:\n"));
855 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
856 }
857
858 #[test]
859 fn test_format_replace_symbol_result_shows_syntax_status() {
860 let res_checked = crate::edit::EditResult {
861 symbol_name: "my_fn".into(),
862 file_path: "src/lib.rs".into(),
863 old_body_hash: "aaa".into(),
864 new_body_hash: "bbb".into(),
865 bytes_written: 120,
866 syntax_checked: true,
867 };
868 let out_checked = format_replace_symbol_result(&res_checked);
869 assert!(out_checked.contains("Syntax: Verified"));
870
871 let res_skipped = crate::edit::EditResult {
872 symbol_name: "my_fn".into(),
873 file_path: "src/script.rb".into(),
874 old_body_hash: "aaa".into(),
875 new_body_hash: "bbb".into(),
876 bytes_written: 120,
877 syntax_checked: false,
878 };
879 let out_skipped = format_replace_symbol_result(&res_skipped);
880 assert!(out_skipped.contains("Syntax: Skipped (grammar not available for file extension)"));
881 }
882
883 fn sample_symbol(name: &str) -> Symbol {
884 Symbol {
885 symbol_id: format!("id_{name}"),
886 file_id: "f1".into(),
887 path: "src/lib.rs".into(),
888 language: "rust".into(),
889 name: name.into(),
890 kind: "function".into(),
891 signature: Some(format!("pub fn {name}()")),
892 doc_comment: None,
893 visibility: Some("pub".into()),
894 parent_symbol_id: None,
895 start_line: 1,
896 start_column: 0,
897 end_line: 10,
898 end_column: 1,
899 start_byte: 0,
900 end_byte: 100,
901 body_start_line: Some(2),
902 body_start_column: Some(0),
903 body_end_line: Some(9),
904 body_end_column: Some(1),
905 body_start_byte: Some(10),
906 body_end_byte: Some(99),
907 body_hash: None,
908 semantic_group: None,
909 is_test: false,
910 test_container: false,
911 }
912 }
913
914 fn sample_context_slice() -> ContextSlice {
915 ContextSlice {
916 target_symbol: sample_symbol("target_fn"),
917 target_body: " println!(\"hello\");\n".into(),
918 callee_signatures: Vec::new(),
919 related_types: Vec::new(),
920 related_tests: Vec::new(),
921 }
922 }
923
924 #[test]
925 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
926 let mut slice = sample_context_slice();
927 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
928 let text = format_context_slice(&slice);
929 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
930
931 let mut slice_tests = sample_context_slice();
932 slice_tests.related_tests = (1..=5)
933 .map(|i| {
934 let mut sym = sample_symbol(&format!("test_fn_{i}"));
935 sym.path = format!("tests/test_{i}.rs");
936 sym.is_test = true;
937 sym
938 })
939 .collect();
940 let text_tests = format_context_slice(&slice_tests);
941 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
942 }
943
944 #[test]
945 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
946 let impacted_symbols = (1..=200)
947 .map(|i| ImpactedSymbol {
948 name: format!("sym_{i}"),
949 kind: "function".into(),
950 path: format!("src/mod_{}.rs", i % 10),
951 line: i,
952 depth: 1,
953 })
954 .collect();
955
956 let res = BlastRadiusResult {
957 seed_type: "symbol".into(),
958 seeds: vec!["root_fn".into()],
959 likely_tests: Vec::new(),
960 impacted_symbols,
961 traversal_ceiling_reached: true,
962 };
963
964 let formatted = format_blast_radius(&res);
965 assert!(formatted.contains(
966 "### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n"
967 ));
968 }
969}