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