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