Skip to main content

code_kb_core/
formatters.rs

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