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