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 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
340pub fn format_references(
342 target_name: &str,
343 refs: &[ReferenceSite],
344 direction: &str,
345 limit: usize,
346) -> String {
347 let mut out = String::new();
348 let dir_label = if direction == "callers" {
349 "Callers of"
350 } else {
351 "Callees called by"
352 };
353 out.push_str(&format!(
354 "{dir_label} `{target_name}` ({} found):\n",
355 refs.len()
356 ));
357
358 if refs.is_empty() {
359 out.push_str(" (none)\n");
360 return out;
361 }
362
363 for r in refs {
364 let line_info = match r.start_line {
365 Some(l) => format!(":{l}"),
366 None => String::new(),
367 };
368 let other = if direction == "callers" {
369 &r.from_symbol_name
370 } else {
371 &r.to_symbol_name
372 };
373 out.push_str(&format!(
374 "- `{other}` [{}{line_info}] (kind: {})\n",
375 r.path, r.kind
376 ));
377 }
378
379 if refs.len() >= limit {
380 out.push_str(&format!(
381 "\n[Showing {} references (limit reached). Increase limit to see more.]\n",
382 refs.len()
383 ));
384 }
385
386 out
387}
388
389pub fn format_find_symbol_results(
391 query: &str,
392 exact_matches: &[Symbol],
393 fts_matches: &[SymbolSearchResult],
394) -> String {
395 if !exact_matches.is_empty() {
396 let mut out = format!(
397 "Found {} symbols matching \"{query}\":\n\n",
398 exact_matches.len()
399 );
400 for s in exact_matches {
401 let sig = s.signature.as_deref().unwrap_or(&s.name);
402 out.push_str(&format!(
403 "- {} `{}` [{}:{}-{}]\n",
404 s.kind, s.name, s.path, s.start_line, s.end_line
405 ));
406 out.push_str(&format!(" Signature: {sig}\n"));
407 if let Some(doc) = &s.doc_comment {
408 let first = doc.lines().next().unwrap_or("").trim();
409 if !first.is_empty() {
410 out.push_str(&format!(" Doc: {first}\n"));
411 }
412 }
413 }
414 out
415 } else if !fts_matches.is_empty() {
416 let mut out = format!(
417 "No exact name match; {} full-text matches for \"{query}\":\n\n",
418 fts_matches.len()
419 );
420 for r in fts_matches {
421 let s = &r.symbol;
422 let sig = s.signature.as_deref().unwrap_or(&s.name);
423 out.push_str(&format!(
424 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
425 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
426 ));
427 out.push_str(&format!(" Signature: {sig}\n"));
428 if let Some(snippet) = &r.snippet {
429 let clean = snippet.replace('\r', "").trim().to_string();
430 let first = clean.lines().next().unwrap_or(&clean);
431 out.push_str(&format!(" Match: {first}\n"));
432 } else if let Some(doc) = &s.doc_comment {
433 let first = doc.lines().next().unwrap_or("").trim();
434 if !first.is_empty() {
435 out.push_str(&format!(" Doc: {first}\n"));
436 }
437 }
438 }
439 out
440 } else {
441 format!("No symbols found matching \"{query}\".\n")
442 }
443}
444
445pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
447 if categories.is_empty() {
448 return "No structural facts or literals indexed in this repository.".to_string();
449 }
450 let mut out = format!(
451 "Available structural fact & literal categories ({} found):\n\n",
452 categories.len()
453 );
454 for (name, count) in categories {
455 out.push_str(&format!("- `{name}` ({count} occurrences)\n"));
456 }
457 out
458}
459
460pub fn format_structural_facts(
462 facts: &[crate::models::StructuralFact],
463 literals: &[crate::models::LiteralFact],
464 category: &str,
465) -> String {
466 let mut out = format!(
467 "Structural facts for '{category}' ({} found):\n",
468 facts.len()
469 );
470 for f in facts {
471 let label = f.key.as_deref().unwrap_or(&f.capture_name);
472 let parent = f
473 .containing_symbol_name
474 .as_deref()
475 .map(|p| format!(", in: {p}"))
476 .unwrap_or_default();
477 out.push_str(&format!(
478 "- {label} [{}:{}] (pattern: {}{parent})\n",
479 f.path, f.start_line, f.pattern_id
480 ));
481 }
482 if !literals.is_empty() {
483 out.push_str(&format!(
484 "\nMatching literals ({} found):\n",
485 literals.len()
486 ));
487 for l in literals {
488 out.push_str(&format!(
489 "- \"{}\" [{}:{}] (kind: {})\n",
490 l.literal_text, l.path, l.start_line, l.kind
491 ));
492 }
493 }
494 out
495}
496
497pub fn format_replace_symbol_result(res: &crate::edit::EditResult) -> String {
499 let syntax_line = if res.syntax_checked {
500 "Syntax: Verified"
501 } else {
502 "Syntax: Skipped (grammar not available for file extension)"
503 };
504 format!(
505 "Successfully replaced body of `{}` in `{}`.\nOld Hash: {}\nNew Hash: {}\nBytes Written: {}\n{}",
506 res.symbol_name,
507 res.file_path,
508 res.old_body_hash,
509 res.new_body_hash,
510 res.bytes_written,
511 syntax_line
512 )
513}
514
515pub fn format_search_results(query: &str, results: &[SymbolSearchResult]) -> String {
517 if results.is_empty() {
518 return format!("No symbols found matching concept \"{query}\".");
519 }
520
521 let mut out = format!(
522 "Found {} symbols matching concept \"{query}\":\n\n",
523 results.len()
524 );
525 for r in results {
526 let s = &r.symbol;
527 let sig = s.signature.as_deref().unwrap_or(&s.name);
528 out.push_str(&format!(
529 "- {} `{}` [{}:{}-{}] (score: {:.2})\n",
530 s.kind, s.name, s.path, s.start_line, s.end_line, r.score
531 ));
532 out.push_str(&format!(" Signature: {sig}\n"));
533 if let Some(snippet) = &r.snippet {
534 let clean_snip = snippet.replace('\r', "").trim().to_string();
535 let first_line = clean_snip.lines().next().unwrap_or(&clean_snip);
536 out.push_str(&format!(" Match: {first_line}\n"));
537 } else if let Some(doc) = &s.doc_comment {
538 let first_line = doc.lines().next().unwrap_or("").trim();
539 if !first_line.is_empty() {
540 out.push_str(&format!(" Doc: {first_line}\n"));
541 }
542 }
543 }
544
545 out
546}
547
548pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
550 if result.seed_type == "none"
551 || (result.seeds.is_empty()
552 && result.likely_tests.is_empty()
553 && result.impacted_symbols.is_empty())
554 {
555 return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
556 }
557
558 let mut out = String::new();
559 let seed_label = if result.seed_type == "file" {
560 format!("Files: {}", result.seeds.join(", "))
561 } else if result.seed_type == "symbol" {
562 format!("Symbol: {}", result.seeds.join(", "))
563 } else {
564 format!("Seeds: {}", result.seeds.join(", "))
565 };
566
567 out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
568
569 const MAX_COMPACT_TESTS: usize = 20;
570 const MAX_COMPACT_IMPACTED: usize = 50;
571
572 if !result.likely_tests.is_empty() {
573 let total = result.likely_tests.len();
574 if total > MAX_COMPACT_TESTS {
575 out.push_str(&format!(
576 "### Likely Tests to Run ({} found - showing top {})\n",
577 total, MAX_COMPACT_TESTS
578 ));
579 } else {
580 out.push_str(&format!("### Likely Tests to Run ({} found)\n", total));
581 }
582
583 let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
584 std::collections::BTreeMap::new();
585 let mut file_order = Vec::new();
586 for t in result.likely_tests.iter().take(MAX_COMPACT_TESTS) {
587 if !tests_by_file.contains_key(t.path.as_str()) {
588 file_order.push(t.path.as_str());
589 }
590 tests_by_file.entry(t.path.as_str()).or_default().push(t);
591 }
592
593 for path in file_order {
594 out.push_str(&format!("{path}:\n"));
595 if let Some(tests) = tests_by_file.get(path) {
596 for t in tests {
597 out.push_str(&format!(
598 " - `{}` [line {}] ({})\n",
599 t.name, t.line, t.reason
600 ));
601 }
602 }
603 }
604
605 if total > MAX_COMPACT_TESTS {
606 out.push_str(&format!(
607 "... {} more likely tests; use --json for full list.\n",
608 total - MAX_COMPACT_TESTS
609 ));
610 }
611 out.push('\n');
612 } else {
613 out.push_str("### Likely Tests to Run\nNo direct or stem-matched tests found.\n\n");
614 }
615
616 if !result.impacted_symbols.is_empty() {
617 let total = result.impacted_symbols.len();
618 let mut visible = Vec::new();
619 let mut low_signal_count = 0;
620 for s in &result.impacted_symbols {
621 if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
622 low_signal_count += 1;
623 } else {
624 visible.push(s);
625 }
626 }
627
628 if result.traversal_ceiling_reached || total >= 200 {
629 out.push_str("### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n");
630 } else {
631 out.push_str(&format!("### Downstream Impact ({} symbols)\n", total));
632 }
633
634 if visible.is_empty() && low_signal_count > 0 {
635 let row_word = if low_signal_count == 1 {
636 "row (import/module)"
637 } else {
638 "rows (imports/modules)"
639 };
640 out.push_str(&format!(
641 "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; use --json for full list.\n"
642 ));
643 } else {
644 let visible_total = visible.len();
645 let showing_count = visible_total.min(MAX_COMPACT_IMPACTED);
646
647 let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
648 std::collections::BTreeMap::new();
649 let mut file_order = Vec::new();
650 for s in visible.iter().take(showing_count) {
651 if !syms_by_file.contains_key(s.path.as_str()) {
652 file_order.push(s.path.as_str());
653 }
654 syms_by_file.entry(s.path.as_str()).or_default().push(s);
655 }
656
657 for path in file_order {
658 out.push_str(&format!("{path}:\n"));
659 if let Some(syms) = syms_by_file.get(path) {
660 for s in syms {
661 out.push_str(&format!(
662 " - [depth {}] {} `{}` [line {}]\n",
663 s.depth, s.kind, s.name, s.line
664 ));
665 }
666 }
667 }
668
669 if visible_total > MAX_COMPACT_IMPACTED {
670 out.push_str(&format!(
671 "... {} more impacted symbols; use --json for full list.\n",
672 visible_total - MAX_COMPACT_IMPACTED
673 ));
674 }
675 if low_signal_count > 0 {
676 let row_word = if low_signal_count == 1 {
677 "row (import/module)"
678 } else {
679 "rows (imports/modules)"
680 };
681 out.push_str(&format!(
682 "... {low_signal_count} low-signal {row_word} hidden; use --json for full list.\n"
683 ));
684 }
685 }
686 } else {
687 out.push_str("### Downstream Impact\nNo downstream callers found within depth.\n");
688 }
689
690 out
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::models::{ImpactedSymbol, TestTarget};
697
698 fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
699 crate::models::StructuralFact {
700 structural_fact_id: "sf1".into(),
701 path: ".codex/config.toml".into(),
702 language: "toml".into(),
703 pattern_id: "toml.key_value.v1".into(),
704 capture_name: "key_value".into(),
705 node_kind: "table".into(),
706 key: key.map(str::to_string),
707 containing_symbol_name: None,
708 start_line: 2,
709 end_line: 2,
710 confidence: 1.0,
711 }
712 }
713
714 #[test]
715 fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
716 let with_key = format_structural_facts(
717 &[structural_fact(Some("mcp_servers.code-kb.command"))],
718 &[],
719 "config",
720 );
721 assert!(with_key.contains(
722 "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
723 ));
724
725 let without_key = format_structural_facts(&[structural_fact(None)], &[], "config");
726 assert!(
727 without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
728 );
729 }
730
731 #[test]
732 fn file_skeleton_reports_parse_errors() {
733 let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
734 assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
735
736 let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
737 assert!(one.contains("// 1 parse error: symbols may be incomplete"));
738
739 let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
740 assert!(!none.contains("parse error"));
741 }
742
743 #[test]
744 fn test_format_file_skeleton() {
745 let syms = vec![Symbol {
746 symbol_id: "s1".into(),
747 file_id: "f1".into(),
748 path: "src/lib.rs".into(),
749 language: "rust".into(),
750 name: "do_work".into(),
751 kind: "function".into(),
752 signature: Some("pub fn do_work() -> Result<()>".into()),
753 doc_comment: Some("Performs core work.".into()),
754 visibility: Some("pub".into()),
755 parent_symbol_id: None,
756 start_line: 10,
757 start_column: 0,
758 end_line: 30,
759 end_column: 1,
760 start_byte: 100,
761 end_byte: 300,
762 body_start_line: Some(11),
763 body_start_column: Some(0),
764 body_end_line: Some(29),
765 body_end_column: Some(1),
766 body_start_byte: Some(130),
767 body_end_byte: Some(298),
768 body_hash: None,
769 semantic_group: None,
770 is_test: false,
771 test_container: false,
772 }];
773
774 let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
775 assert!(skeleton.contains("/// Performs core work."));
776 assert!(skeleton.contains("19 lines hidden: L11-L29"));
777 }
778
779 #[test]
780 fn test_format_search_results() {
781 let results = vec![SymbolSearchResult {
782 symbol: Symbol {
783 symbol_id: "s1".into(),
784 file_id: "f1".into(),
785 path: "src/parser.rs".into(),
786 language: "rust".into(),
787 name: "parse_tokens".into(),
788 kind: "function".into(),
789 signature: Some("pub fn parse_tokens()".into()),
790 doc_comment: Some("Parses tokens from stream.".into()),
791 visibility: Some("pub".into()),
792 parent_symbol_id: None,
793 start_line: 15,
794 start_column: 0,
795 end_line: 25,
796 end_column: 1,
797 start_byte: 100,
798 end_byte: 250,
799 body_start_line: None,
800 body_start_column: None,
801 body_end_line: None,
802 body_end_column: None,
803 body_start_byte: None,
804 body_end_byte: None,
805 body_hash: None,
806 semantic_group: None,
807 is_test: false,
808 test_container: false,
809 },
810 score: -1.85,
811 snippet: Some("Parses [tokens] from stream.".into()),
812 }];
813
814 let formatted = format_search_results("tokens", &results);
815 assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":"));
816 assert!(
817 formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
818 );
819 assert!(formatted.contains("Match: Parses [tokens] from stream."));
820 }
821
822 #[test]
823 fn test_format_blast_radius() {
824 let res = BlastRadiusResult {
825 seed_type: "symbol".into(),
826 seeds: vec!["do_work".into()],
827 likely_tests: vec![TestTarget {
828 name: "test_do_work".into(),
829 path: "tests/work_test.rs".into(),
830 line: 15,
831 reason: "transitive caller [depth 1]".into(),
832 }],
833 impacted_symbols: vec![ImpactedSymbol {
834 name: "caller_fn".into(),
835 kind: "function".into(),
836 path: "src/caller.rs".into(),
837 line: 42,
838 depth: 1,
839 }],
840 traversal_ceiling_reached: false,
841 };
842
843 let formatted = format_blast_radius(&res);
844 assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
845 assert!(formatted.contains("### Likely Tests to Run (1 found)"));
846 assert!(formatted.contains(
847 "tests/work_test.rs:\n - `test_do_work` [line 15] (transitive caller [depth 1])"
848 ));
849 assert!(formatted.contains("src/caller.rs:\n - [depth 1] function `caller_fn` [line 42]"));
850 }
851
852 #[test]
853 fn test_format_blast_radius_grouped_and_capped() {
854 let mut likely_tests = Vec::new();
855 for i in 1..=25 {
856 likely_tests.push(TestTarget {
857 name: format!("test_{i}"),
858 path: format!("tests/test_{}.rs", (i % 3) + 1),
859 line: i * 10,
860 reason: "direct caller".into(),
861 });
862 }
863
864 let impacted_symbols = vec![
865 ImpactedSymbol {
866 name: "use_foo".into(),
867 kind: "import".into(),
868 path: "src/service.rs".into(),
869 line: 1,
870 depth: 1,
871 },
872 ImpactedSymbol {
873 name: "service_fn".into(),
874 kind: "function".into(),
875 path: "src/service.rs".into(),
876 line: 20,
877 depth: 1,
878 },
879 ImpactedSymbol {
880 name: "api_handler".into(),
881 kind: "function".into(),
882 path: "src/api.rs".into(),
883 line: 45,
884 depth: 2,
885 },
886 ];
887
888 let res = BlastRadiusResult {
889 seed_type: "file".into(),
890 seeds: vec!["src/lib.rs".into()],
891 likely_tests,
892 impacted_symbols,
893 traversal_ceiling_reached: false,
894 };
895
896 let formatted = format_blast_radius(&res);
897
898 assert!(formatted.contains("### Likely Tests to Run (25 found - showing top 20)"));
900 assert!(formatted.contains("... 5 more likely tests; use --json for full list."));
901
902 assert!(formatted.contains("tests/test_1.rs:\n"));
904 assert!(formatted.contains(" - `test_"));
905
906 assert!(!formatted.contains("use_foo"));
908 assert!(
909 formatted
910 .contains("... 1 low-signal row (import/module) hidden; use --json for full list.")
911 );
912 assert!(formatted.contains("src/service.rs:\n"));
913 assert!(formatted.contains(" - [depth 1] function `service_fn` [line 20]"));
914 }
915
916 #[test]
917 fn test_format_replace_symbol_result_shows_syntax_status() {
918 let res_checked = crate::edit::EditResult {
919 symbol_name: "my_fn".into(),
920 file_path: "src/lib.rs".into(),
921 old_body_hash: "aaa".into(),
922 new_body_hash: "bbb".into(),
923 bytes_written: 120,
924 syntax_checked: true,
925 };
926 let out_checked = format_replace_symbol_result(&res_checked);
927 assert!(out_checked.contains("Syntax: Verified"));
928
929 let res_skipped = crate::edit::EditResult {
930 symbol_name: "my_fn".into(),
931 file_path: "src/script.rb".into(),
932 old_body_hash: "aaa".into(),
933 new_body_hash: "bbb".into(),
934 bytes_written: 120,
935 syntax_checked: false,
936 };
937 let out_skipped = format_replace_symbol_result(&res_skipped);
938 assert!(out_skipped.contains("Syntax: Skipped (grammar not available for file extension)"));
939 }
940
941 fn sample_symbol(name: &str) -> Symbol {
942 Symbol {
943 symbol_id: format!("id_{name}"),
944 file_id: "f1".into(),
945 path: "src/lib.rs".into(),
946 language: "rust".into(),
947 name: name.into(),
948 kind: "function".into(),
949 signature: Some(format!("pub fn {name}()")),
950 doc_comment: None,
951 visibility: Some("pub".into()),
952 parent_symbol_id: None,
953 start_line: 1,
954 start_column: 0,
955 end_line: 10,
956 end_column: 1,
957 start_byte: 0,
958 end_byte: 100,
959 body_start_line: Some(2),
960 body_start_column: Some(0),
961 body_end_line: Some(9),
962 body_end_column: Some(1),
963 body_start_byte: Some(10),
964 body_end_byte: Some(99),
965 body_hash: None,
966 semantic_group: None,
967 is_test: false,
968 test_container: false,
969 }
970 }
971
972 fn sample_context_slice() -> ContextSlice {
973 ContextSlice {
974 target_symbol: sample_symbol("target_fn"),
975 target_body: " println!(\"hello\");\n".into(),
976 callee_signatures: Vec::new(),
977 related_types: Vec::new(),
978 related_tests: Vec::new(),
979 }
980 }
981
982 #[test]
983 fn test_context_slice_shows_truncation_notice_when_caps_hit() {
984 let mut slice = sample_context_slice();
985 slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
986 let text = format_context_slice(&slice);
987 assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
988
989 let mut slice_tests = sample_context_slice();
990 slice_tests.related_tests = (1..=5)
991 .map(|i| {
992 let mut sym = sample_symbol(&format!("test_fn_{i}"));
993 sym.path = format!("tests/test_{i}.rs");
994 sym.is_test = true;
995 sym
996 })
997 .collect();
998 let text_tests = format_context_slice(&slice_tests);
999 assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
1000 }
1001
1002 #[test]
1003 fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
1004 let impacted_symbols = (1..=200)
1005 .map(|i| ImpactedSymbol {
1006 name: format!("sym_{i}"),
1007 kind: "function".into(),
1008 path: format!("src/mod_{}.rs", i % 10),
1009 line: i,
1010 depth: 1,
1011 })
1012 .collect();
1013
1014 let res = BlastRadiusResult {
1015 seed_type: "symbol".into(),
1016 seeds: vec!["root_fn".into()],
1017 likely_tests: Vec::new(),
1018 impacted_symbols,
1019 traversal_ceiling_reached: true,
1020 };
1021
1022 let formatted = format_blast_radius(&res);
1023 assert!(formatted.contains(
1024 "### Downstream Impact (200+ symbols - traversal ceiling reached; increase depth/limit or narrow target)\n"
1025 ));
1026 }
1027}