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    let hides_its_body = matches!(
61        kind,
62        "function" | "method" | "constructor" | "destructor" | "operator"
63    );
64    !is_skippable_kind(kind) && !hides_its_body
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, sym: &'a Symbol) -> &'a str {
72    let expression_arrow = if sym.language == "csharp"
73        && matches!(
74            sym.kind.as_str(),
75            "method" | "function" | "constructor" | "operator"
76        ) {
77        sig.match_indices("=>")
78            .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
79    } else {
80        None
81    };
82    let cutoff = sig
83        .find('{')
84        .into_iter()
85        .chain(expression_arrow)
86        .min()
87        .unwrap_or(sig.len());
88    let clean = sig[..cutoff].trim_end();
89    let trimmed = clean.trim_end_matches(';').trim_end();
90    if trimmed.is_empty() {
91        &sym.name
92    } else {
93        trimmed
94    }
95}
96
97fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
98    let body = body.trim();
99    if body.is_empty() {
100        return signature;
101    }
102
103    let trimmed_signature = signature.trim_end();
104    trimmed_signature
105        .strip_suffix(body)
106        .map(str::trim_end)
107        .unwrap_or(signature)
108}
109
110/// The kind word for a leaf row whose signature does not spell it. A C++ Qt signal is declared
111/// under a `Q_SIGNALS:` label, so its signature reads like a method; the `event` kind goes into
112/// the trailing comment instead.
113fn unspelled_kind(sym: &Symbol) -> &'static str {
114    if sym.kind != "event" {
115        return "";
116    }
117    let spelled = sym.signature.as_deref().is_some_and(|sig| {
118        sig.split_whitespace()
119            .any(|word| word == "signal" || word == "event")
120    });
121    if spelled { "" } else { "event " }
122}
123
124fn render_symbol_skeleton(
125    out: &mut String,
126    sym: &Symbol,
127    children_map: &HashMap<Option<String>, Vec<&Symbol>>,
128    indent_level: usize,
129) {
130    if is_skippable_kind(&sym.kind) {
131        return;
132    }
133
134    let indent = "    ".repeat(indent_level);
135
136    // Doc comment: cap at 3 lines to prevent dumping huge blocks
137    if let Some(ref doc) = sym.doc_comment {
138        let lines: Vec<_> = doc.lines().collect();
139        let cap = 3;
140        for line in lines.iter().take(cap) {
141            out.push_str(&format!("{indent}/// {line}\n"));
142        }
143        if lines.len() > cap {
144            out.push_str(&format!(
145                "{indent}/// ... ({} more lines)\n",
146                lines.len() - cap
147            ));
148        }
149    }
150
151    let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
152    let leaf_note = format!("{}{span_str}", unspelled_kind(sym));
153
154    let children = children_map.get(&Some(sym.symbol_id.clone()));
155
156    let spans_multiple_lines = sym.end_line > sym.start_line;
157
158    if is_container_kind(&sym.kind)
159        && children.is_some()
160        && (spans_multiple_lines || sym.kind == "enum")
161    {
162        let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
163        let sig = sanitize_skeleton_sig(raw_sig, sym);
164        out.push_str(&format!("{indent}{sig} {{\n"));
165        if let Some(child_list) = children {
166            for child in child_list {
167                render_symbol_skeleton(out, child, children_map, indent_level + 1);
168            }
169        }
170        out.push_str(&format!("{indent}}} // {span_str}\n\n"));
171    } else {
172        if let Some(count) = sym.hidden_body_line_count() {
173            let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
174            let sig = sanitize_skeleton_sig(raw_sig, sym);
175            let b_start = sym.body_start_line.unwrap_or(sym.start_line);
176            let b_end = sym.body_end_line.unwrap_or(sym.end_line);
177
178            if count > 1 {
179                out.push_str(&format!(
180                    "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end} */ }}\n"
181                ));
182            } else {
183                out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
184            }
185        } else if let Some(ref raw_sig) = sym.signature {
186            let sig = sanitize_skeleton_sig(raw_sig, sym);
187            out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
188        } else {
189            out.push_str(&format!(
190                "{indent}{} {sym_name}; // {span_str}\n",
191                sym.kind,
192                sym_name = sym.name
193            ));
194        }
195        if sym.kind == "namespace"
196            && let Some(child_list) = children
197        {
198            for child in child_list {
199                render_symbol_skeleton(out, child, children_map, indent_level);
200            }
201        }
202    }
203}
204
205/// Node representing directory or file in codebase outline tree.
206#[derive(Default)]
207pub struct OutlineNode {
208    pub files: BTreeMap<String, Vec<String>>, // file_name -> list of top symbol names with kinds
209    pub subdirs: BTreeMap<String, OutlineNode>,
210}
211
212/// Add a file path into the outline tree, bounded by max_depth.
213pub fn add_path_to_outline(
214    root_node: &mut OutlineNode,
215    file_path: &str,
216    symbols_by_file: &HashMap<String, Vec<Symbol>>,
217    max_depth: usize,
218    norm_filter: &str,
219) {
220    let normalized = file_path.replace('\\', "/");
221    let rel_path_str = if norm_filter.is_empty() {
222        normalized.as_str()
223    } else if normalized.eq_ignore_ascii_case(norm_filter) {
224        Path::new(&normalized)
225            .file_name()
226            .and_then(|n| n.to_str())
227            .unwrap_or(&normalized)
228    } else if normalized.len() > norm_filter.len()
229        && normalized.as_bytes()[norm_filter.len()] == b'/'
230        && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
231    {
232        &normalized[norm_filter.len() + 1..]
233    } else {
234        return;
235    };
236
237    let path = Path::new(rel_path_str);
238    let components: Vec<&str> = path
239        .components()
240        .map(|c| c.as_os_str().to_str().unwrap_or(""))
241        .filter(|s| !s.is_empty())
242        .collect();
243
244    if components.is_empty() {
245        return;
246    }
247
248    let mut curr = root_node;
249    let depth = components.len();
250
251    for (i, comp) in components.iter().enumerate() {
252        if i == depth - 1 {
253            // Leaf file: only insert if it is within max_depth
254            if depth <= max_depth {
255                let mut sym_tags = Vec::new();
256                if let Some(syms) = symbols_by_file.get(&normalized) {
257                    for s in syms.iter().take(5) {
258                        sym_tags.push(format!("{} {}", s.kind, s.name));
259                    }
260                    if syms.len() > 5 {
261                        sym_tags.push(format!("+{} more", syms.len() - 5));
262                    }
263                }
264                if !sym_tags.is_empty() {
265                    curr.files.insert(comp.to_string(), sym_tags);
266                }
267            }
268        } else if i < max_depth {
269            curr = curr.subdirs.entry(comp.to_string()).or_default();
270        } else {
271            break;
272        }
273    }
274}
275
276pub fn render_outline_tree(
277    out: &mut String,
278    node: &OutlineNode,
279    prefix: &str,
280    depth: usize,
281    max_depth: usize,
282) {
283    if depth >= max_depth {
284        return;
285    }
286
287    let total_items = node.subdirs.len() + node.files.len();
288    let mut index = 0;
289
290    // Render subdirectories
291    for (name, sub) in &node.subdirs {
292        index += 1;
293        let is_last = index == total_items;
294        let branch = if is_last { "└── " } else { "├── " };
295        let next_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
296
297        out.push_str(&format!("{prefix}{branch}{name}/\n"));
298        render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
299    }
300
301    // Render files
302    for (file_name, syms) in &node.files {
303        index += 1;
304        let is_last = index == total_items;
305        let branch = if is_last { "└── " } else { "├── " };
306
307        let sym_suffix = if !syms.is_empty() {
308            format!(" [{}]", syms.join(", "))
309        } else {
310            String::new()
311        };
312
313        out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
314    }
315}
316
317/// Format symbol body with metadata header, signature, and body content.
318pub fn format_symbol_body(symbol: &Symbol, body: &str) -> String {
319    let mut out = format!(
320        "// {}:{}-{} ({})\n",
321        symbol.path, symbol.start_line, symbol.end_line, symbol.name
322    );
323    if let Some(ref sig) = symbol.signature {
324        let sig = signature_without_duplicate_body(sig, body);
325        out.push_str(sig);
326        if !sig.ends_with('\n') {
327            out.push('\n');
328        }
329    }
330    out.push_str(body);
331    if !body.ends_with('\n') {
332        out.push('\n');
333    }
334    out
335}
336
337/// Format surgical context bundle for a symbol.
338pub fn format_context_slice(slice: &ContextSlice) -> String {
339    let sym = &slice.target_symbol;
340    let mut out = String::new();
341    out.push_str(&format!(
342        "### Target: `{}` ({}:{}-{})\n\n",
343        sym.name, sym.path, sym.start_line, sym.end_line
344    ));
345
346    if let Some(ref sig) = sym.signature {
347        let sig = signature_without_duplicate_body(sig, &slice.target_body);
348        out.push_str(&format!("Signature: `{sig}`\n\n"));
349    }
350
351    out.push_str(&format!("```{}\n", sym.language));
352    out.push_str(&slice.target_body);
353    if !slice.target_body.ends_with('\n') {
354        out.push('\n');
355    }
356    out.push_str("```\n\n");
357
358    if !slice.callee_signatures.is_empty() {
359        out.push_str("### Dependencies (Signatures):\n");
360        for callee in &slice.callee_signatures {
361            out.push_str(&format!("- {callee}\n"));
362        }
363        if slice.callee_signatures.len() >= 10 {
364            out.push_str("[Showing 10 dependencies (limit reached)]\n");
365        }
366        out.push('\n');
367    }
368
369    if !slice.related_types.is_empty() {
370        out.push_str("### Types:\n");
371        for t in &slice.related_types {
372            out.push_str(&format!("- {t}\n"));
373        }
374        out.push('\n');
375    }
376
377    if !slice.related_tests.is_empty() {
378        out.push_str("### Related Tests:\n");
379        for test in &slice.related_tests {
380            out.push_str(&format!(
381                "- `{}` ({}:{})\n",
382                test.name, test.path, test.start_line
383            ));
384        }
385        if slice.related_tests.len() >= 5 {
386            out.push_str("[Showing 5 tests (limit reached)]\n");
387        }
388        out.push('\n');
389    }
390
391    out
392}
393
394fn cap_notice(shown: usize, limit: usize) -> String {
395    let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
396        "narrow the query to see more"
397    } else {
398        "increase limit to see more"
399    };
400    format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
401}
402
403/// The FTS match snippet on one line, or `None` when it only repeats the signature. The comment
404/// markers that open each doc-comment line (`//`, `///`, `//!`, `*`) are dropped when lines join.
405fn match_line(snippet: &str, signature: &str) -> Option<String> {
406    let line = snippet
407        .lines()
408        .enumerate()
409        .map(|(i, text)| match i {
410            0 => text.trim(),
411            _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
412        })
413        .flat_map(str::split_whitespace)
414        .collect::<Vec<_>>()
415        .join(" ");
416    let plain = |text: &str| {
417        text.split_whitespace()
418            .collect::<String>()
419            .replace(['[', ']'], "")
420    };
421    let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
422    (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
423}
424
425/// A callee name on one line. An unresolved call chain longer than 60 characters that holds a
426/// call keeps only its last call, so a multi-line receiver expression never floods the list.
427fn callee_name(name: &str) -> String {
428    let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
429    if line.chars().count() <= 60 || !line.contains('(') {
430        return line;
431    }
432    match line.rsplit_once('.') {
433        Some((_, last)) if !last.is_empty() => format!("….{last}"),
434        _ => line,
435    }
436}
437
438/// Format references list for callers/callees with optional limit footer.
439pub fn format_references(
440    target_name: &str,
441    refs: &[ReferenceSite],
442    direction: &str,
443    limit: usize,
444) -> String {
445    let mut out = String::new();
446    let dir_label = if direction == "callers" {
447        "Callers of"
448    } else {
449        "Callees called by"
450    };
451    out.push_str(&format!(
452        "{dir_label} `{target_name}` ({} found):\n",
453        refs.len()
454    ));
455
456    if refs.is_empty() {
457        out.push_str("  (none)\n");
458        return out;
459    }
460
461    for r in refs {
462        let line_info = match r.start_line {
463            Some(l) => format!(":{l}"),
464            None => String::new(),
465        };
466        let other = if direction == "callers" {
467            r.from_symbol_name.clone()
468        } else {
469            callee_name(&r.to_symbol_name)
470        };
471        let in_file = match r.occurrences {
472            Some(n) => format!(", {n} in file"),
473            None => String::new(),
474        };
475        out.push_str(&format!(
476            "- `{other}` [{}{line_info}] (kind: {}{in_file})\n",
477            r.path, r.kind
478        ));
479    }
480
481    if refs.len() >= limit {
482        out.push_str(&cap_notice(refs.len(), limit));
483    }
484
485    out
486}
487
488/// Formats exact or FTS fallback symbol results with transparent header labeling.
489pub fn format_find_symbol_results(
490    query: &str,
491    exact_matches: &[Symbol],
492    fts_matches: &[SymbolSearchResult],
493    limit: usize,
494) -> String {
495    if !exact_matches.is_empty() {
496        let mut out = format!(
497            "Found {} symbols matching \"{query}\":\n\n",
498            exact_matches.len()
499        );
500        for s in exact_matches {
501            let sig = s.signature.as_deref().unwrap_or(&s.name);
502            out.push_str(&format!(
503                "- {} `{}` [{}:{}-{}] id={}\n",
504                s.kind, s.name, s.path, s.start_line, s.end_line, s.symbol_id
505            ));
506            out.push_str(&format!("  Signature: {sig}\n"));
507            if let Some(doc) = &s.doc_comment {
508                let first = doc.lines().next().unwrap_or("").trim();
509                if !first.is_empty() {
510                    out.push_str(&format!("  Doc: {first}\n"));
511                }
512            }
513        }
514        if exact_matches.len() >= limit {
515            out.push_str(&cap_notice(exact_matches.len(), limit));
516        }
517        out
518    } else if !fts_matches.is_empty() {
519        let mut out = format!(
520            "No exact name match; {} full-text matches for \"{query}\":\n\n",
521            fts_matches.len()
522        );
523        for r in fts_matches {
524            let s = &r.symbol;
525            let sig = s.signature.as_deref().unwrap_or(&s.name);
526            out.push_str(&format!(
527                "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
528                s.kind, s.name, s.path, s.start_line, s.end_line, r.score, s.symbol_id
529            ));
530            out.push_str(&format!("  Signature: {sig}\n"));
531            if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
532                out.push_str(&format!("  Match: {line}\n"));
533            } else if let Some(doc) = &s.doc_comment {
534                let first = doc.lines().next().unwrap_or("").trim();
535                if !first.is_empty() {
536                    out.push_str(&format!("  Doc: {first}\n"));
537                }
538            }
539        }
540        if fts_matches.len() >= limit {
541            out.push_str(&cap_notice(fts_matches.len(), limit));
542        }
543        out
544    } else {
545        format!("No symbols found matching \"{query}\".\n")
546    }
547}
548
549/// Format available structural fact & literal categories.
550pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
551    if categories.is_empty() {
552        return "No structural facts or literals indexed in this repository.".to_string();
553    }
554    let mut out = String::new();
555    let aliases = crate::queries::alias_fact_counts(categories);
556    if !aliases.is_empty() {
557        let parts: Vec<String> = aliases
558            .iter()
559            .map(|(alias, patterns, facts)| {
560                format!(
561                    "{alias} ({patterns} {}, {facts} {})",
562                    plural(*patterns, "pattern"),
563                    plural(*facts, "fact")
564                )
565            })
566            .collect();
567        out.push_str(&format!("Aliases: {}\n\n", parts.join(", ")));
568    }
569    out.push_str(&format!(
570        "Available structural fact & literal categories ({} found):\n\n",
571        categories.len()
572    ));
573    for (name, count) in categories {
574        out.push_str(&format!("- `{name}` ({count} occurrences)\n"));
575    }
576    out
577}
578
579fn plural(count: usize, word: &str) -> String {
580    if count == 1 {
581        word.to_string()
582    } else {
583        format!("{word}s")
584    }
585}
586
587/// Format structural facts and matching literals into token-dense markdown.
588pub fn format_structural_facts(
589    facts: &[crate::models::StructuralFact],
590    literals: &[crate::models::LiteralFact],
591    category: &str,
592    limit: usize,
593) -> String {
594    let mut out = format!(
595        "Structural facts for '{category}' ({} found):\n",
596        facts.len()
597    );
598    for f in facts {
599        let label = f.key.as_deref().unwrap_or(&f.capture_name);
600        let details = qt_property_details(f);
601        let parent = f
602            .containing_symbol_name
603            .as_deref()
604            .map(|p| format!(", in: {p}"))
605            .unwrap_or_default();
606        out.push_str(&format!(
607            "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
608            f.path, f.start_line, f.pattern_id
609        ));
610    }
611    if limit > 0 && facts.len() >= limit {
612        out.push_str(&cap_notice(facts.len(), limit));
613    }
614    if !literals.is_empty() {
615        out.push_str(&format!(
616            "\nMatching literals ({} found):\n",
617            literals.len()
618        ));
619        for l in literals {
620            out.push_str(&format!(
621                "- \"{}\" [{}:{}] (kind: {})\n",
622                l.literal_text, l.path, l.start_line, l.kind
623            ));
624        }
625        if limit > 0 && literals.len() >= limit {
626            out.push_str(&cap_notice(literals.len(), limit));
627        }
628    }
629    out
630}
631
632fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
633    if fact.pattern_id != "cpp.qt_property.v1" {
634        return String::new();
635    }
636    let Some(metadata) = fact.metadata.as_ref() else {
637        return String::new();
638    };
639    [
640        "property_type",
641        "read",
642        "write",
643        "notify",
644        "designable",
645        "scriptable",
646        "stored",
647        "user",
648        "revision",
649    ]
650    .into_iter()
651    .filter_map(|key| metadata.get(key).map(|value| (key, value)))
652    .map(|(key, value)| {
653        let value = value
654            .as_str()
655            .map(str::to_owned)
656            .unwrap_or_else(|| value.to_string());
657        format!(", {key}: {value}")
658    })
659    .collect()
660}
661
662/// Formats FTS5 conceptual search results into token-dense markdown.
663pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
664    if results.is_empty() {
665        return format!("No symbols found matching concept \"{query}\".");
666    }
667
668    let mut out = format!(
669        "Found {} symbols matching concept \"{query}\":\n",
670        results.len()
671    );
672    if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
673        out.push_str(&format!(
674            "rerank: {} candidates in {} µs",
675            explain.candidates, explain.rerank_us
676        ));
677        if !explain.word_weights.is_empty() {
678            let words: Vec<String> = explain
679                .word_weights
680                .iter()
681                .map(|(word, weight)| format!("{word} {weight:.2}"))
682                .collect();
683            out.push_str(&format!("; words {}", words.join(", ")));
684        }
685        out.push('\n');
686    }
687    out.push('\n');
688    for r in results {
689        let s = &r.symbol;
690        let sig = s.signature.as_deref().unwrap_or(&s.name);
691        out.push_str(&format!(
692            "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
693            s.kind, s.name, s.path, s.start_line, s.end_line, r.score, s.symbol_id
694        ));
695        out.push_str(&format!("  Signature: {sig}\n"));
696        if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
697            out.push_str(&format!("  Match: {line}\n"));
698        } else if let Some(doc) = &s.doc_comment {
699            let first_line = doc.lines().next().unwrap_or("").trim();
700            if !first_line.is_empty() {
701                out.push_str(&format!("  Doc: {first_line}\n"));
702            }
703        }
704        if let Some(explain) = &r.explain {
705            out.push_str(&format!("  explain: {}\n", explain_line(r.score, explain)));
706        }
707    }
708
709    if results.len() >= limit {
710        out.push_str(&cap_notice(results.len(), limit));
711    }
712
713    out
714}
715
716fn explain_line(score: f64, e: &SearchExplain) -> String {
717    let mut line = format!(
718        "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
719        e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
720    );
721    if e.documentation != 0.0 {
722        line.push_str(&format!(" + doc {:.1}", e.documentation));
723    }
724    if e.test_intent != 0.0 {
725        line.push_str(&format!(" + test {:.1}", e.test_intent));
726    }
727    line.push_str(&format!(" [{}]", e.branches.join(",")));
728    if let Some(bm25) = e.bm25 {
729        line.push_str(&format!(" bm25 {bm25:.2}"));
730    }
731    if !e.terms.is_empty() {
732        let terms: Vec<String> = e
733            .terms
734            .iter()
735            .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
736            .collect();
737        line.push_str(&format!(" terms {}", terms.join(" ")));
738    }
739    line
740}
741
742/// Format blast radius and likely test targets into token-dense markdown.
743pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
744    if result.seed_type == "none" {
745        return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
746    }
747
748    let mut out = String::new();
749    let seed_label = if result.seed_type == "file" {
750        format!("Files: {}", result.seeds.join(", "))
751    } else if result.seed_type == "symbol" {
752        format!("Symbol: {}", result.seeds.join(", "))
753    } else {
754        format!("Seeds: {}", result.seeds.join(", "))
755    };
756
757    out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
758
759    const MAX_COMPACT_TESTS: usize = 20;
760    const MAX_COMPACT_IMPACTED: usize = 50;
761
762    if result.likely_tests_truncated {
763        out.push_str("Requested limit hid additional likely tests; increase limit to reveal discovered rows.\n\n");
764    }
765    if result.impacted_symbols_truncated {
766        out.push_str("Requested limit hid additional impacted symbols; increase limit to reveal discovered rows.\n\n");
767    }
768    if result.traversal_ceiling_reached {
769        out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
770    }
771    if result.test_file_ceiling_reached {
772        out.push_str("Name-matched test discovery hit its fixed ceiling; narrow the target because increasing limit cannot raise it.\n\n");
773    }
774
775    if !result.likely_tests.is_empty() {
776        let total = result.likely_tests.len();
777        if total > MAX_COMPACT_TESTS {
778            out.push_str(&format!(
779                "### Likely Tests to Run ({} returned - showing top {})\n",
780                total, MAX_COMPACT_TESTS
781            ));
782        } else {
783            out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
784        }
785
786        let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
787            std::collections::BTreeMap::new();
788        let mut file_order = Vec::new();
789        for t in result.likely_tests.iter().take(MAX_COMPACT_TESTS) {
790            if !tests_by_file.contains_key(t.path.as_str()) {
791                file_order.push(t.path.as_str());
792            }
793            tests_by_file.entry(t.path.as_str()).or_default().push(t);
794        }
795
796        for path in file_order {
797            out.push_str(&format!("{path}:\n"));
798            if let Some(tests) = tests_by_file.get(path) {
799                for t in tests {
800                    out.push_str(&format!(
801                        "  - `{}` [line {}] ({})\n",
802                        t.name, t.line, t.reason
803                    ));
804                }
805            }
806        }
807
808        if total > MAX_COMPACT_TESTS {
809            out.push_str(&format!(
810                "... {} more returned likely tests are hidden by the compact 20-row display. CLI --json shows the full returned list.\n",
811                total - MAX_COMPACT_TESTS
812            ));
813        }
814        out.push('\n');
815    } else if result.likely_tests_truncated
816        || result.traversal_ceiling_reached
817        || result.test_file_ceiling_reached
818    {
819        out.push_str("### Likely Tests to Run (0 returned)\n\n");
820    } else {
821        out.push_str(
822            "### Likely Tests to Run (0 returned)\nNo direct or name-matched tests found.\n\n",
823        );
824    }
825
826    if !result.impacted_symbols.is_empty() {
827        let total = result.impacted_symbols.len();
828        let mut visible = Vec::new();
829        let mut low_signal_count = 0;
830        for s in &result.impacted_symbols {
831            if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
832                low_signal_count += 1;
833            } else {
834                visible.push(s);
835            }
836        }
837
838        out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
839
840        if visible.is_empty() && low_signal_count > 0 {
841            let row_word = if low_signal_count == 1 {
842                "row (import/module)"
843            } else {
844                "rows (imports/modules)"
845            };
846            out.push_str(&format!(
847                "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
848            ));
849        } else {
850            let visible_total = visible.len();
851            let showing_count = visible_total.min(MAX_COMPACT_IMPACTED);
852
853            let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
854                std::collections::BTreeMap::new();
855            let mut file_order = Vec::new();
856            for s in visible.iter().take(showing_count) {
857                if !syms_by_file.contains_key(s.path.as_str()) {
858                    file_order.push(s.path.as_str());
859                }
860                syms_by_file.entry(s.path.as_str()).or_default().push(s);
861            }
862
863            for path in file_order {
864                out.push_str(&format!("{path}:\n"));
865                if let Some(syms) = syms_by_file.get(path) {
866                    for s in syms {
867                        out.push_str(&format!(
868                            "  - [depth {}] {} `{}` [line {}]\n",
869                            s.depth, s.kind, s.name, s.line
870                        ));
871                    }
872                }
873            }
874
875            if visible_total > MAX_COMPACT_IMPACTED {
876                out.push_str(&format!(
877                    "... {} more returned impacted symbols are hidden by the compact 50-row display. CLI --json shows the full returned list.\n",
878                    visible_total - MAX_COMPACT_IMPACTED
879                ));
880            }
881            if low_signal_count > 0 {
882                let row_word = if low_signal_count == 1 {
883                    "row (import/module)"
884                } else {
885                    "rows (imports/modules)"
886                };
887                out.push_str(&format!(
888                    "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
889                ));
890            }
891        }
892    } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
893        out.push_str("### Downstream Impact (0 returned)\n");
894    } else {
895        out.push_str(
896            "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
897        );
898    }
899
900    out
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::models::{ImpactedSymbol, TestTarget};
907
908    fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
909        crate::models::StructuralFact {
910            structural_fact_id: "sf1".into(),
911            path: ".codex/config.toml".into(),
912            language: "toml".into(),
913            pattern_id: "toml.key_value.v1".into(),
914            capture_name: "key_value".into(),
915            node_kind: "table".into(),
916            key: key.map(str::to_string),
917            metadata: None,
918            containing_symbol_name: None,
919            start_line: 2,
920            end_line: 2,
921            confidence: 1.0,
922        }
923    }
924
925    #[test]
926    fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
927        let with_key = format_structural_facts(
928            &[structural_fact(Some("mcp_servers.code-kb.command"))],
929            &[],
930            "config",
931            30,
932        );
933        assert!(with_key.contains(
934            "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
935        ));
936
937        let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
938        assert!(
939            without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
940        );
941    }
942
943    #[test]
944    fn format_structural_facts_renders_selected_qt_property_metadata() {
945        let mut fact = structural_fact(Some("index"));
946        fact.pattern_id = "cpp.qt_property.v1".into();
947        fact.metadata = Some(serde_json::json!({
948            "property_type": "int",
949            "designable": false,
950            "scriptable": true,
951            "stored": false,
952            "user": true,
953            "revision": 2,
954        }));
955
956        let output = format_structural_facts(&[fact], &[], "property", 30);
957
958        assert!(output.contains(
959            "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
960        ));
961    }
962
963    #[test]
964    fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
965        let site = |occurrences| ReferenceSite {
966            from_symbol_name: "Button".into(),
967            from_symbol_id: "s1".into(),
968            to_symbol_name: "background".into(),
969            kind: "member_access".into(),
970            path: "Ui/Button.qml".into(),
971            start_line: Some(5),
972            start_column: Some(4),
973            occurrences,
974        };
975
976        let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
977        assert!(
978            grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
979            "{grouped}"
980        );
981
982        let single = format_references("Color", &[site(None)], "callers", 30);
983        assert!(
984            single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
985            "{single}"
986        );
987    }
988
989    #[test]
990    fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
991        assert_eq!(
992            match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
993            Some("...path up to the [root] (the [root] excluded)".into())
994        );
995        assert_eq!(
996            match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
997            Some("// [HelpFunc] returns the [function] set".into())
998        );
999        assert_eq!(
1000            match_line(
1001                "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
1002                "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
1003            ),
1004            None
1005        );
1006        assert_eq!(
1007            match_line(
1008                "...[root]: &[PathBuf]) -> bool",
1009                "fn f(root: &[PathBuf]) -> bool"
1010            ),
1011            None
1012        );
1013    }
1014
1015    #[test]
1016    fn a_long_unresolved_call_chain_shows_only_its_last_call() {
1017        assert_eq!(
1018            callee_name(
1019                "[\"a\", \"b\"]\n    .into_iter()\n    .find_map(|key| args.get(key)).ok_or_else"
1020            ),
1021            "….ok_or_else"
1022        );
1023        assert_eq!(
1024            callee_name("workspace.resolve_path"),
1025            "workspace.resolve_path"
1026        );
1027        let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
1028        assert_eq!(callee_name(selector), selector);
1029    }
1030
1031    #[test]
1032    fn file_skeleton_reports_parse_errors() {
1033        let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
1034        assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
1035
1036        let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
1037        assert!(one.contains("// 1 parse error: symbols may be incomplete"));
1038
1039        let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
1040        assert!(!none.contains("parse error"));
1041    }
1042
1043    #[test]
1044    fn test_format_file_skeleton() {
1045        let syms = vec![Symbol {
1046            symbol_id: "s1".into(),
1047            file_id: "f1".into(),
1048            path: "src/lib.rs".into(),
1049            language: "rust".into(),
1050            name: "do_work".into(),
1051            kind: "function".into(),
1052            signature: Some("pub fn do_work() -> Result<()>".into()),
1053            doc_comment: Some("Performs core work.".into()),
1054            visibility: Some("pub".into()),
1055            parent_symbol_id: None,
1056            start_line: 10,
1057            start_column: 0,
1058            end_line: 30,
1059            end_column: 1,
1060            start_byte: 100,
1061            end_byte: 300,
1062            body_start_line: Some(11),
1063            body_start_column: Some(0),
1064            body_end_line: Some(29),
1065            body_end_column: Some(1),
1066            body_start_byte: Some(130),
1067            body_end_byte: Some(298),
1068            body_hash: None,
1069            semantic_group: None,
1070            is_test: false,
1071            test_container: false,
1072        }];
1073
1074        let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
1075        assert!(skeleton.contains("/// Performs core work."));
1076        assert!(skeleton.contains("19 lines hidden: L11-L29"));
1077    }
1078
1079    #[test]
1080    fn format_symbol_body_does_not_repeat_expression_already_in_signature() {
1081        let body = "=>\n        transport.SendAsync<OffboardingFormDto>(\n            HttpMethod.Get,\n            $\"ser/{id}\",\n            cancellationToken: cancellationToken)";
1082        let mut symbol = sample_symbol("GetByIdAsync");
1083        symbol.language = "csharp".into();
1084        symbol.signature = Some(format!(
1085            "public Task<OffboardingFormDto?> GetByIdAsync(\n        int id,\n        CancellationToken cancellationToken = default){body}"
1086        ));
1087
1088        let formatted = format_symbol_body(&symbol, body);
1089
1090        assert_eq!(
1091            formatted.matches("transport.SendAsync").count(),
1092            1,
1093            "{formatted}"
1094        );
1095    }
1096
1097    #[test]
1098    fn format_symbol_body_preserves_unmatched_signature_whitespace() {
1099        let mut symbol = sample_symbol("plain");
1100        symbol.signature = Some("pub fn plain()  ".into());
1101
1102        assert_eq!(
1103            format_symbol_body(&symbol, "return 1;"),
1104            "// src/lib.rs:1-10 (plain)\npub fn plain()  \nreturn 1;\n"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_format_search_results() {
1110        let results = vec![SymbolSearchResult {
1111            symbol: Symbol {
1112                symbol_id: "s1".into(),
1113                file_id: "f1".into(),
1114                path: "src/parser.rs".into(),
1115                language: "rust".into(),
1116                name: "parse_tokens".into(),
1117                kind: "function".into(),
1118                signature: Some("pub fn parse_tokens()".into()),
1119                doc_comment: Some("Parses tokens from stream.".into()),
1120                visibility: Some("pub".into()),
1121                parent_symbol_id: None,
1122                start_line: 15,
1123                start_column: 0,
1124                end_line: 25,
1125                end_column: 1,
1126                start_byte: 100,
1127                end_byte: 250,
1128                body_start_line: None,
1129                body_start_column: None,
1130                body_end_line: None,
1131                body_end_column: None,
1132                body_start_byte: None,
1133                body_end_byte: None,
1134                body_hash: None,
1135                semantic_group: None,
1136                is_test: false,
1137                test_container: false,
1138            },
1139            score: -1.85,
1140            snippet: Some("Parses [tokens] from stream.".into()),
1141            explain: None,
1142        }];
1143
1144        let formatted = format_search_results("tokens", &results, 20);
1145        assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
1146        assert!(
1147            formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
1148        );
1149        assert!(formatted.contains("Match: Parses [tokens] from stream."));
1150        assert!(!formatted.contains("explain"));
1151        assert!(!formatted.contains("rerank"));
1152    }
1153
1154    #[test]
1155    fn discovery_formatters_append_ids_to_metadata_rows() {
1156        let exact = vec![sample_symbol("exact")];
1157        let fallback = vec![SymbolSearchResult {
1158            symbol: sample_symbol("fallback"),
1159            score: 1.0,
1160            snippet: None,
1161            explain: None,
1162        }];
1163
1164        let outputs = [
1165            (
1166                format_find_symbol_results("exact", &exact, &[], 20),
1167                "id_exact",
1168            ),
1169            (
1170                format_find_symbol_results("fallback query", &[], &fallback, 20),
1171                "id_fallback",
1172            ),
1173            (
1174                format_search_results("fallback", &fallback, 20),
1175                "id_fallback",
1176            ),
1177        ];
1178
1179        for (formatted, id) in outputs {
1180            assert!(
1181                formatted
1182                    .lines()
1183                    .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
1184                "{formatted}"
1185            );
1186            assert!(!formatted.contains("\n  id="), "{formatted}");
1187        }
1188    }
1189
1190    #[test]
1191    fn every_printed_part_of_the_explain_line_sums_to_the_score() {
1192        let e = SearchExplain {
1193            bm25: Some(-3.21),
1194            branches: vec!["word".into()],
1195            name_tier: "all".into(),
1196            name_strength: 6,
1197            term_score: 24.5,
1198            name_bonus: 60.0,
1199            kind_prior: 4.0,
1200            path_role: -10.0,
1201            documentation: -200.0,
1202            test_intent: 5.0,
1203            terms: vec![("sha".into(), "name".into(), 3.0)],
1204            word_weights: vec![("sha".into(), 2.6)],
1205            candidates: 1,
1206            rerank_us: 1,
1207        };
1208        let score = e.term_score
1209            + e.name_bonus
1210            + e.kind_prior
1211            + e.path_role
1212            + e.documentation
1213            + e.test_intent;
1214
1215        let line = explain_line(score, &e);
1216        let parts: f64 = line
1217            .split(" = ")
1218            .nth(1)
1219            .unwrap()
1220            .split(" [")
1221            .next()
1222            .unwrap()
1223            .split(" + ")
1224            .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
1225            .sum();
1226
1227        assert!(line.starts_with(&format!(
1228            "score {score:.1} = terms 24.5 + name all(6) 60.0 "
1229        )));
1230        assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
1231    }
1232
1233    #[test]
1234    fn test_format_search_results_prints_the_explain_breakdown_when_present() {
1235        let mut result = SymbolSearchResult {
1236            symbol: sample_symbol("parseSha256Sidecar"),
1237            score: 71.6,
1238            snippet: Some("parse[Sha256]Sidecar".into()),
1239            explain: Some(SearchExplain {
1240                bm25: Some(-3.21),
1241                branches: vec!["word".into(), "name".into()],
1242                name_tier: "all".into(),
1243                name_strength: 6,
1244                terms: vec![
1245                    ("sha".into(), "name".into(), 3.0),
1246                    ("256".into(), "name".into(), 3.0),
1247                ],
1248                term_score: 12.6,
1249                name_bonus: 60.0,
1250                kind_prior: 4.0,
1251                path_role: -10.0,
1252                documentation: 0.0,
1253                test_intent: 5.0,
1254                word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
1255                candidates: 37,
1256                rerank_us: 180,
1257            }),
1258        };
1259
1260        let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
1261        assert!(formatted.contains(
1262            "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
1263        ));
1264        assert!(formatted.contains(
1265            "  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"
1266        ));
1267
1268        result.explain = None;
1269        let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
1270        assert!(!silent.contains("explain"));
1271        assert!(!silent.contains("rerank"));
1272    }
1273
1274    #[test]
1275    fn test_format_search_results_discloses_a_reached_limit() {
1276        let results = vec![SymbolSearchResult {
1277            symbol: sample_symbol("parse_tokens"),
1278            score: 0.0,
1279            snippet: None,
1280            explain: None,
1281        }];
1282
1283        let formatted = format_search_results("tokens", &results, 1);
1284        assert!(
1285            formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
1286        );
1287
1288        let at_ceiling =
1289            format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
1290        assert!(!at_ceiling.contains("limit reached"));
1291
1292        let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
1293            .map(|_| SymbolSearchResult {
1294                symbol: sample_symbol("parse_tokens"),
1295                score: 0.0,
1296                snippet: None,
1297                explain: None,
1298            })
1299            .collect();
1300        let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
1301        assert!(capped.contains("(limit reached); narrow the query to see more.]"));
1302    }
1303
1304    #[test]
1305    fn test_format_blast_radius() {
1306        let res = BlastRadiusResult {
1307            seed_type: "symbol".into(),
1308            seeds: vec!["do_work".into()],
1309            likely_tests: vec![TestTarget {
1310                name: "test_do_work".into(),
1311                path: "tests/work_test.rs".into(),
1312                line: 15,
1313                reason: "transitive caller [depth 1]".into(),
1314            }],
1315            impacted_symbols: vec![ImpactedSymbol {
1316                name: "caller_fn".into(),
1317                kind: "function".into(),
1318                path: "src/caller.rs".into(),
1319                line: 42,
1320                depth: 1,
1321            }],
1322            traversal_ceiling_reached: false,
1323            likely_tests_truncated: false,
1324            impacted_symbols_truncated: false,
1325            test_file_ceiling_reached: false,
1326        };
1327
1328        let formatted = format_blast_radius(&res);
1329        assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
1330        assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
1331        assert!(formatted.contains(
1332            "tests/work_test.rs:\n  - `test_do_work` [line 15] (transitive caller [depth 1])"
1333        ));
1334        assert!(formatted.contains("src/caller.rs:\n  - [depth 1] function `caller_fn` [line 42]"));
1335    }
1336
1337    #[test]
1338    fn test_format_blast_radius_grouped_and_capped() {
1339        let mut likely_tests = Vec::new();
1340        for i in 1..=25 {
1341            likely_tests.push(TestTarget {
1342                name: format!("test_{i}"),
1343                path: format!("tests/test_{}.rs", (i % 3) + 1),
1344                line: i * 10,
1345                reason: "direct caller".into(),
1346            });
1347        }
1348
1349        let impacted_symbols = vec![
1350            ImpactedSymbol {
1351                name: "use_foo".into(),
1352                kind: "import".into(),
1353                path: "src/service.rs".into(),
1354                line: 1,
1355                depth: 1,
1356            },
1357            ImpactedSymbol {
1358                name: "service_fn".into(),
1359                kind: "function".into(),
1360                path: "src/service.rs".into(),
1361                line: 20,
1362                depth: 1,
1363            },
1364            ImpactedSymbol {
1365                name: "api_handler".into(),
1366                kind: "function".into(),
1367                path: "src/api.rs".into(),
1368                line: 45,
1369                depth: 2,
1370            },
1371        ];
1372
1373        let res = BlastRadiusResult {
1374            seed_type: "file".into(),
1375            seeds: vec!["src/lib.rs".into()],
1376            likely_tests,
1377            impacted_symbols,
1378            traversal_ceiling_reached: false,
1379            likely_tests_truncated: false,
1380            impacted_symbols_truncated: false,
1381            test_file_ceiling_reached: false,
1382        };
1383
1384        let formatted = format_blast_radius(&res);
1385
1386        assert!(formatted.contains("### Likely Tests to Run (25 returned - showing top 20)"));
1387        assert!(formatted.contains(
1388            "... 5 more returned likely tests are hidden by the compact 20-row display. CLI --json shows the full returned list."
1389        ));
1390
1391        assert!(formatted.contains("tests/test_1.rs:\n"));
1392        assert!(formatted.contains("  - `test_"));
1393
1394        assert!(!formatted.contains("use_foo"));
1395        assert!(
1396            formatted
1397                .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
1398        );
1399        assert!(formatted.contains("src/service.rs:\n"));
1400        assert!(formatted.contains("  - [depth 1] function `service_fn` [line 20]"));
1401    }
1402
1403    fn sample_symbol(name: &str) -> Symbol {
1404        Symbol {
1405            symbol_id: format!("id_{name}"),
1406            file_id: "f1".into(),
1407            path: "src/lib.rs".into(),
1408            language: "rust".into(),
1409            name: name.into(),
1410            kind: "function".into(),
1411            signature: Some(format!("pub fn {name}()")),
1412            doc_comment: None,
1413            visibility: Some("pub".into()),
1414            parent_symbol_id: None,
1415            start_line: 1,
1416            start_column: 0,
1417            end_line: 10,
1418            end_column: 1,
1419            start_byte: 0,
1420            end_byte: 100,
1421            body_start_line: Some(2),
1422            body_start_column: Some(0),
1423            body_end_line: Some(9),
1424            body_end_column: Some(1),
1425            body_start_byte: Some(10),
1426            body_end_byte: Some(99),
1427            body_hash: None,
1428            semantic_group: None,
1429            is_test: false,
1430            test_container: false,
1431        }
1432    }
1433
1434    fn sample_context_slice() -> ContextSlice {
1435        ContextSlice {
1436            target_symbol: sample_symbol("target_fn"),
1437            target_body: "    println!(\"hello\");\n".into(),
1438            callee_signatures: Vec::new(),
1439            related_types: Vec::new(),
1440            related_tests: Vec::new(),
1441        }
1442    }
1443
1444    #[test]
1445    fn format_context_slice_does_not_repeat_expression_already_in_signature() {
1446        let body = "=>\n        transport.SendAsync<Response>(\n            HttpMethod.Get,\n            $\"items/{id}\",\n            cancellationToken: cancellationToken)";
1447        let mut slice = sample_context_slice();
1448        slice.target_symbol.language = "csharp".into();
1449        slice.target_symbol.signature = Some(format!(
1450            "public static Task<Response?> GetByIdAsync(\n        int id,\n        CancellationToken cancellationToken = default){body}"
1451        ));
1452        slice.target_body = body.into();
1453
1454        let formatted = format_context_slice(&slice);
1455
1456        assert_eq!(
1457            formatted.matches("transport.SendAsync").count(),
1458            1,
1459            "{formatted}"
1460        );
1461    }
1462
1463    #[test]
1464    fn test_context_slice_shows_truncation_notice_when_caps_hit() {
1465        let mut slice = sample_context_slice();
1466        slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
1467        let text = format_context_slice(&slice);
1468        assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
1469
1470        let mut slice_tests = sample_context_slice();
1471        slice_tests.related_tests = (1..=5)
1472            .map(|i| {
1473                let mut sym = sample_symbol(&format!("test_fn_{i}"));
1474                sym.path = format!("tests/test_{i}.rs");
1475                sym.is_test = true;
1476                sym
1477            })
1478            .collect();
1479        let text_tests = format_context_slice(&slice_tests);
1480        assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
1481    }
1482
1483    #[test]
1484    fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
1485        let impacted_symbols = (1..=200)
1486            .map(|i| ImpactedSymbol {
1487                name: format!("sym_{i}"),
1488                kind: "function".into(),
1489                path: format!("src/mod_{}.rs", i % 10),
1490                line: i,
1491                depth: 1,
1492            })
1493            .collect();
1494
1495        let res = BlastRadiusResult {
1496            seed_type: "symbol".into(),
1497            seeds: vec!["root_fn".into()],
1498            likely_tests: Vec::new(),
1499            impacted_symbols,
1500            traversal_ceiling_reached: true,
1501            likely_tests_truncated: false,
1502            impacted_symbols_truncated: false,
1503            test_file_ceiling_reached: false,
1504        };
1505
1506        let formatted = format_blast_radius(&res);
1507        assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
1508    }
1509
1510    #[test]
1511    fn blast_radius_does_not_claim_no_results_after_discovery_ceiling() {
1512        let res = BlastRadiusResult {
1513            seed_type: "file".into(),
1514            seeds: vec!["src/widget.rs".into()],
1515            likely_tests: Vec::new(),
1516            impacted_symbols: Vec::new(),
1517            likely_tests_truncated: false,
1518            impacted_symbols_truncated: false,
1519            traversal_ceiling_reached: true,
1520            test_file_ceiling_reached: true,
1521        };
1522
1523        let formatted = format_blast_radius(&res);
1524        assert!(formatted.contains("Likely Tests to Run (0 returned)"));
1525        assert!(formatted.contains("Downstream Impact (0 returned)"));
1526        assert!(formatted.contains("Name-matched test discovery hit its fixed ceiling"));
1527        assert!(!formatted.contains("No direct or name-matched tests found"));
1528        assert!(!formatted.contains("No downstream callers found within depth"));
1529
1530        let no_ceiling = BlastRadiusResult {
1531            traversal_ceiling_reached: false,
1532            test_file_ceiling_reached: false,
1533            ..res.clone()
1534        };
1535        assert!(format_blast_radius(&no_ceiling).contains("No direct or name-matched tests found"));
1536
1537        let traversal_only = BlastRadiusResult {
1538            traversal_ceiling_reached: true,
1539            test_file_ceiling_reached: false,
1540            ..res
1541        };
1542        let traversal_only_text = format_blast_radius(&traversal_only);
1543        assert!(!traversal_only_text.contains("No direct or name-matched tests found"));
1544    }
1545
1546    fn skeleton_row(
1547        id: &str,
1548        parent: Option<&str>,
1549        kind: &str,
1550        name: &str,
1551        signature: &str,
1552        lines: (usize, usize),
1553        body: Option<(usize, usize)>,
1554    ) -> Symbol {
1555        Symbol {
1556            symbol_id: id.into(),
1557            file_id: "f1".into(),
1558            path: "src/lib.rs".into(),
1559            language: "rust".into(),
1560            name: name.into(),
1561            kind: kind.into(),
1562            signature: Some(signature.into()),
1563            doc_comment: None,
1564            visibility: None,
1565            parent_symbol_id: parent.map(str::to_string),
1566            start_line: lines.0,
1567            start_column: 0,
1568            end_line: lines.1,
1569            end_column: 1,
1570            start_byte: 0,
1571            end_byte: 0,
1572            body_start_line: body.map(|b| b.0),
1573            body_start_column: None,
1574            body_end_line: body.map(|b| b.1),
1575            body_end_column: None,
1576            body_start_byte: None,
1577            body_end_byte: None,
1578            body_hash: None,
1579            semantic_group: None,
1580            is_test: false,
1581            test_container: false,
1582        }
1583    }
1584
1585    #[test]
1586    fn skeleton_nests_an_object_under_the_field_that_declares_it() {
1587        let syms = vec![
1588            skeleton_row(
1589                "root",
1590                None,
1591                "class",
1592                "shell",
1593                "extends ShellRoot",
1594                (1, 8),
1595                None,
1596            ),
1597            skeleton_row(
1598                "timer",
1599                Some("root"),
1600                "field",
1601                "localPluginReloadTimer",
1602                "localPluginReloadTimer: Timer",
1603                (2, 7),
1604                Some((2, 7)),
1605            ),
1606            skeleton_row(
1607                "interval",
1608                Some("timer"),
1609                "property",
1610                "interval",
1611                "interval: 150",
1612                (3, 3),
1613                None,
1614            ),
1615            skeleton_row(
1616                "fire",
1617                Some("timer"),
1618                "function",
1619                "fire",
1620                "function fire()",
1621                (5, 7),
1622                Some((6, 7)),
1623            ),
1624        ];
1625
1626        assert_eq!(
1627            format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
1628            "// File: shell/shell.qml (Lines 1-8)\n\
1629             \n\
1630             extends ShellRoot {\n\
1631             \x20   localPluginReloadTimer: Timer {\n\
1632             \x20       interval: 150; // L3-3\n\
1633             \x20       function fire() { /* 2 lines hidden: L6-L7 */ }\n\
1634             \x20   } // L2-7\n\
1635             \n\
1636             } // L1-8\n\
1637             \n"
1638        );
1639    }
1640
1641    #[test]
1642    fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
1643        let syms = vec![
1644            skeleton_row(
1645                "rusqlite",
1646                None,
1647                "field",
1648                "rusqlite",
1649                "rusqlite = { workspace = true }",
1650                (15, 15),
1651                None,
1652            ),
1653            skeleton_row(
1654                "workspace",
1655                Some("rusqlite"),
1656                "property",
1657                "workspace",
1658                "workspace = true",
1659                (15, 15),
1660                None,
1661            ),
1662        ];
1663
1664        assert_eq!(
1665            format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
1666            "// File: Cargo.toml (Lines 1-15)\n\
1667             \n\
1668             rusqlite =; // L15-15\n"
1669        );
1670    }
1671
1672    #[test]
1673    fn skeleton_renders_members_of_a_single_line_enum() {
1674        let mut syms = vec![
1675            skeleton_row(
1676                "mode",
1677                None,
1678                "enum",
1679                "DispatchMode",
1680                "public enum DispatchMode",
1681                (21, 21),
1682                None,
1683            ),
1684            skeleton_row(
1685                "provision",
1686                Some("mode"),
1687                "enum_member",
1688                "Provision",
1689                "Provision",
1690                (21, 21),
1691                None,
1692            ),
1693            skeleton_row(
1694                "deprovision",
1695                Some("mode"),
1696                "enum_member",
1697                "Deprovision",
1698                "Deprovision",
1699                (21, 21),
1700                None,
1701            ),
1702            skeleton_row(
1703                "extension",
1704                Some("mode"),
1705                "enum_member",
1706                "Extension",
1707                "Extension",
1708                (21, 21),
1709                None,
1710            ),
1711        ];
1712        for symbol in &mut syms {
1713            symbol.language = "csharp".into();
1714        }
1715
1716        let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
1717
1718        for member in ["Provision", "Deprovision", "Extension"] {
1719            assert!(skeleton.contains(member), "{skeleton}");
1720        }
1721    }
1722
1723    #[test]
1724    fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
1725        let syms = vec![
1726            skeleton_row(
1727                "cfg",
1728                None,
1729                "struct",
1730                "Config",
1731                "pub struct Config",
1732                (1, 3),
1733                None,
1734            ),
1735            skeleton_row(
1736                "retries",
1737                Some("cfg"),
1738                "field",
1739                "retries",
1740                "pub retries: u32",
1741                (2, 2),
1742                None,
1743            ),
1744            skeleton_row(
1745                "run",
1746                None,
1747                "function",
1748                "run",
1749                "pub fn run()",
1750                (5, 9),
1751                Some((6, 8)),
1752            ),
1753            skeleton_row(
1754                "tmp",
1755                Some("run"),
1756                "variable",
1757                "tmp",
1758                "let tmp",
1759                (7, 7),
1760                None,
1761            ),
1762        ];
1763
1764        assert_eq!(
1765            format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
1766            "// File: src/lib.rs (Lines 1-9)\n\
1767             \n\
1768             pub struct Config {\n\
1769             \x20   pub retries: u32; // L2-2\n\
1770             } // L1-3\n\
1771             \n\
1772             pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
1773        );
1774    }
1775
1776    #[test]
1777    fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
1778        let syms = vec![
1779            skeleton_row(
1780                "cls",
1781                None,
1782                "class",
1783                "ColumnViewAttached",
1784                "class ColumnViewAttached : public QObject",
1785                (11, 52),
1786                None,
1787            ),
1788            skeleton_row(
1789                "sig",
1790                Some("cls"),
1791                "event",
1792                "indexChanged",
1793                "void indexChanged()",
1794                (47, 47),
1795                None,
1796            ),
1797            skeleton_row(
1798                "qml",
1799                None,
1800                "event",
1801                "clicked",
1802                "signal clicked()",
1803                (60, 60),
1804                None,
1805            ),
1806        ];
1807
1808        assert_eq!(
1809            format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
1810            "// File: src/columnview.h (Lines 1-60)\n\
1811             \n\
1812             class ColumnViewAttached : public QObject {\n\
1813             \x20   void indexChanged(); // event L47-47\n\
1814             } // L11-52\n\
1815             \n\
1816             signal clicked(); // L60-60\n"
1817        );
1818    }
1819}