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