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