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};
8use crate::queries::{W_DOC, W_SIGNATURE, name_tier_score};
9
10/// Format progressive disclosure file skeleton with implementation bodies stripped.
11pub 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    // Group symbols by parent to render hierarchically
37    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    // Render top-level symbols and their children
46    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        // If parent relationships are missing or flat, render all sorted by line
52        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    // Doc comment: cap at 3 lines to prevent dumping huge blocks
94    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    // Check if this symbol is a container (class, struct, trait, enum, etc.)
111    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        // Leaf symbol or function/method
125        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/// Node representing directory or file in codebase outline tree.
152#[derive(Default)]
153pub struct OutlineNode {
154    pub files: BTreeMap<String, Vec<String>>, // file_name -> list of top symbol names with kinds
155    pub subdirs: BTreeMap<String, OutlineNode>,
156}
157
158/// Add a file path into the outline tree, bounded by max_depth.
159pub 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            // Leaf file: only insert if it is within max_depth
200            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    // Render subdirectories
237    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    // Render files
248    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
263/// Format symbol body with metadata header, signature, and body content.
264pub 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
283/// Format surgical context bundle for a symbol.
284pub 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
350/// Format references list for callers/callees with optional limit footer.
351pub 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
396/// Formats exact or FTS fallback symbol results with transparent header labeling.
397pub 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
459/// Format available structural fact & literal categories.
460pub 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
474/// Format structural facts and matching literals into token-dense markdown.
475pub 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
511/// Format result of atomic symbol body replacement.
512pub 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
529/// Formats FTS5 conceptual search results into token-dense markdown.
530pub 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
608/// Format blast radius and likely test targets into token-dense markdown.
609pub 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}