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, None, &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, None, &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
67/// The kind word to show for a symbol. julie reuses code kinds for markup and data files: an
68/// HTML element and a SQL table are `class` rows, a Markdown heading a `module`, a Markdown
69/// link an `import`. `queries::KIND_FILTER` mirrors it in SQL.
70pub(crate) fn display_kind(sym: &Symbol) -> &str {
71    match (sym.language.as_str(), sym.kind.as_str()) {
72        ("html", "class") => "element",
73        ("sql", "class") => "table",
74        ("markdown", "module") => "section",
75        ("markdown", "import") => "link",
76        (language, "property")
77            if language != "fsharp"
78                && sym.signature.as_deref().is_some_and(|sig| {
79                    ["self.", "this.", "cls."]
80                        .iter()
81                        .any(|p| sig.starts_with(p))
82                }) =>
83        {
84            "attribute"
85        }
86        (_, kind) => kind,
87    }
88}
89
90fn is_skippable_kind(kind: &str) -> bool {
91    matches!(kind, "variable" | "parameter" | "import")
92}
93
94/// A value row on one line: the declaration before its `=` stays whole, because its type is part
95/// of the interface, and the value is cut to fit about 120 characters.
96fn value_row_signature(sig: &str) -> std::borrow::Cow<'_, str> {
97    let sig = sig.trim_end().trim_end_matches(';').trim_end();
98    if !sig.contains('\n') && sig.chars().count() <= 120 {
99        return sig.into();
100    }
101    let joined = one_line(sig);
102    let Some(equals) = value_equals(&joined) else {
103        if joined.chars().count() <= 120 {
104            return joined.into();
105        }
106        let cut: String = joined.chars().take(119).collect();
107        return format!("{}…", cut.trim_end()).into();
108    };
109    let (declaration, value) = joined.split_at(equals);
110    let budget = 120usize.saturating_sub(declaration.chars().count()).max(40);
111    if value.chars().count() <= budget {
112        return joined.into();
113    }
114    let cut: String = value.chars().take(budget - 1).collect();
115    format!("{declaration}{}…", cut.trim_end()).into()
116}
117
118/// Joins a multi-line declaration into one line, without trailing `  # ` or `  // ` comments.
119fn one_line(sig: &str) -> String {
120    let mut joined = sig
121        .lines()
122        .map(|line| {
123            let code = ["  # ", "  // "]
124                .iter()
125                .filter_map(|marker| line.find(marker))
126                .min()
127                .map_or(line, |at| &line[..at]);
128            code.trim().trim_end_matches('\\').trim_end()
129        })
130        .filter(|line| !line.is_empty())
131        .collect::<Vec<_>>()
132        .join(" ");
133    for (from, to) in [
134        ("( ", "("),
135        ("[ ", "["),
136        ("{ ", "{"),
137        (", )", ")"),
138        (", ]", "]"),
139        (", }", "}"),
140        (" )", ")"),
141        (" ]", "]"),
142        (" }", "}"),
143    ] {
144        joined = joined.replace(from, to);
145    }
146    joined
147}
148
149/// Whether `sig` assigns a value: an `=` outside brackets, before any `{`, that is not part of
150/// `==`, `=>`, `>=`, `^=`, or another operator.
151fn assigns_a_value(sig: &str) -> bool {
152    value_equals(sig).is_some()
153}
154
155/// The byte index of the `=` that assigns a value, as [`assigns_a_value`] defines it.
156fn value_equals(sig: &str) -> Option<usize> {
157    let bytes = sig.as_bytes();
158    let mut depth = 0usize;
159    for (i, &byte) in bytes.iter().enumerate() {
160        match byte {
161            b'(' | b'[' => depth += 1,
162            b')' | b']' => depth = depth.saturating_sub(1),
163            b'{' if depth == 0 => return None,
164            b'=' if depth == 0 => {
165                let before = i.checked_sub(1).map(|j| bytes[j]);
166                let after = bytes.get(i + 1).copied();
167                let operator_before = before.is_some_and(|b| b"<>!=^*~|$+-/%&:?".contains(&b));
168                let operator_after = after.is_some_and(|b| b == b'=' || b == b'>');
169                if !operator_before && !operator_after {
170                    return Some(i);
171                }
172            }
173            _ => {}
174        }
175    }
176    None
177}
178
179fn sanitize_skeleton_sig<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
180    if matches!(
181        sym.kind.as_str(),
182        "variable" | "constant" | "property" | "field" | "enum_member"
183    ) && assigns_a_value(sig)
184    {
185        return value_row_signature(sig);
186    }
187    container_signature(sig, sym)
188}
189
190/// The signature cut at its first `{`, which opens the body that the skeleton renders.
191fn container_signature<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
192    let expression_arrow = if sym.language == "csharp"
193        && matches!(
194            sym.kind.as_str(),
195            "method" | "function" | "constructor" | "operator"
196        ) {
197        sig.match_indices("=>")
198            .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
199    } else {
200        None
201    };
202    let cutoff = sig
203        .find('{')
204        .into_iter()
205        .chain(expression_arrow)
206        .min()
207        .unwrap_or(sig.len());
208    let clean = sig[..cutoff].trim_end();
209    let trimmed = clean.trim_end_matches(';').trim_end();
210    if trimmed.is_empty() {
211        sym.name.as_str().into()
212    } else {
213        trimmed.into()
214    }
215}
216
217fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
218    let body = body.trim();
219    if body.is_empty() {
220        return signature;
221    }
222
223    let trimmed_signature = signature.trim_end();
224    trimmed_signature
225        .strip_suffix(body)
226        .map(str::trim_end)
227        .unwrap_or(signature)
228}
229
230/// The kind word for a leaf row whose signature does not spell it. A C++ Qt signal is declared
231/// under a `Q_SIGNALS:` label, so its signature reads like a method; the `event` kind goes into
232/// the trailing comment instead.
233fn unspelled_kind(sym: &Symbol) -> &'static str {
234    if sym.kind != "event" {
235        return "";
236    }
237    let spelled = sym.signature.as_deref().is_some_and(|sig| {
238        sig.split_whitespace()
239            .any(|word| word == "signal" || word == "event")
240    });
241    if spelled { "" } else { "event " }
242}
243
244/// `in run(), ` for an attribute that a method assigns, so the skeleton does not present it as
245/// a declaration of the class body.
246fn assigning_method(
247    sym: &Symbol,
248    parent: Option<&Symbol>,
249    children_map: &HashMap<Option<String>, Vec<&Symbol>>,
250) -> String {
251    if !sym
252        .signature
253        .as_deref()
254        .is_some_and(|sig| sig.starts_with("self."))
255    {
256        return String::new();
257    }
258    parent
259        .and_then(|parent| children_map.get(&Some(parent.symbol_id.clone())))
260        .and_then(|siblings| {
261            siblings.iter().find(|method| {
262                matches!(method.kind.as_str(), "method" | "function" | "constructor")
263                    && method.start_line <= sym.start_line
264                    && sym.end_line <= method.end_line
265            })
266        })
267        .map_or_else(String::new, |method| format!("in {}(), ", method.name))
268}
269
270/// julie gives other languages' block locals, markup elements, and data keys a `variable` row
271/// under a parent too; only a Python module or class body declares names that way.
272fn is_shown(sym: &Symbol, parent: Option<&Symbol>) -> bool {
273    let python_declaration = sym.kind == "variable"
274        && sym.language == "python"
275        && parent.is_none_or(|parent| parent.kind == "class");
276    !is_skippable_kind(&sym.kind) || python_declaration
277}
278
279/// `; defines `a`, `b`` for the functions and classes declared inside a hidden body, so a view
280/// or wrapper defined in a factory stays visible. Lambdas are left out.
281/// C and C++ cannot define a function inside a function, so such a row comes from a macro the
282/// parser misread (`JSON_CATCH (...) { }`). Test sections such as doctest `SECTION` are real.
283fn is_misparsed_c_function(child: &Symbol) -> bool {
284    matches!(child.language.as_str(), "c" | "cpp")
285        && matches!(child.kind.as_str(), "function" | "method")
286        && !child.is_test
287        && !child.test_container
288}
289
290fn nested_definitions(children: &[&Symbol]) -> String {
291    let names: Vec<String> = children
292        .iter()
293        .filter(|child| {
294            matches!(
295                child.kind.as_str(),
296                "function" | "method" | "class" | "struct"
297            )
298        })
299        .filter(|child| {
300            !child
301                .signature
302                .as_deref()
303                .is_some_and(|sig| sig.starts_with("lambda"))
304        })
305        .filter(|child| !is_misparsed_c_function(child))
306        .map(|child| format!("`{}`", child.name))
307        .fold(Vec::new(), |mut names, name| {
308            if !names.contains(&name) {
309                names.push(name);
310            }
311            names
312        });
313    match names.len() {
314        0 => String::new(),
315        1..=5 => format!("; defines {}", names.join(", ")),
316        n => format!("; defines {}, +{} more", names[..5].join(", "), n - 5),
317    }
318}
319
320fn render_symbol_skeleton(
321    out: &mut String,
322    sym: &Symbol,
323    parent: Option<&Symbol>,
324    children_map: &HashMap<Option<String>, Vec<&Symbol>>,
325    indent_level: usize,
326) {
327    if !is_shown(sym, parent) {
328        return;
329    }
330
331    let indent = "    ".repeat(indent_level);
332
333    // Doc comment: cap at 3 lines to prevent dumping huge blocks
334    if let Some(ref doc) = sym.doc_comment {
335        let lines: Vec<_> = doc.lines().collect();
336        let cap = 3;
337        for line in lines.iter().take(cap) {
338            out.push_str(&format!("{indent}/// {line}\n"));
339        }
340        if lines.len() > cap {
341            out.push_str(&format!(
342                "{indent}/// ... ({} more lines)\n",
343                lines.len() - cap
344            ));
345        }
346    }
347
348    let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
349    let leaf_note = format!(
350        "{}{}{span_str}",
351        unspelled_kind(sym),
352        assigning_method(sym, parent, children_map)
353    );
354
355    let children = children_map.get(&Some(sym.symbol_id.clone()));
356
357    let spans_multiple_lines = sym.end_line > sym.start_line;
358
359    if is_container_kind(&sym.kind)
360        && children.is_some_and(|list| list.iter().any(|child| is_shown(child, Some(sym))))
361        && (spans_multiple_lines || sym.kind == "enum")
362    {
363        let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
364        let sig = container_signature(raw_sig, sym);
365        out.push_str(&format!("{indent}{sig} {{\n"));
366        if let Some(child_list) = children {
367            for child in child_list {
368                render_symbol_skeleton(out, child, Some(sym), children_map, indent_level + 1);
369            }
370        }
371        out.push_str(&format!("{indent}}} // {span_str}\n\n"));
372    } else {
373        if let Some(count) = sym.hidden_body_line_count() {
374            let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
375            let sig = if count > 1 {
376                container_signature(raw_sig, sym)
377            } else {
378                sanitize_skeleton_sig(raw_sig, sym)
379            };
380            let b_start = sym.body_start_line.unwrap_or(sym.start_line);
381            let b_end = sym.body_end_line.unwrap_or(sym.end_line);
382
383            if count > 1 {
384                let defines = nested_definitions(children.map(Vec::as_slice).unwrap_or_default());
385                out.push_str(&format!(
386                    "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end}{defines} */ }}\n"
387                ));
388            } else {
389                out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
390            }
391        } else if let Some(ref raw_sig) = sym.signature {
392            let sig = sanitize_skeleton_sig(raw_sig, sym);
393            out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
394        } else {
395            out.push_str(&format!(
396                "{indent}{} {sym_name}; // {span_str}\n",
397                display_kind(sym),
398                sym_name = sym.name
399            ));
400        }
401        if sym.kind == "namespace"
402            && let Some(child_list) = children
403        {
404            for child in child_list {
405                render_symbol_skeleton(out, child, Some(sym), children_map, indent_level);
406            }
407        }
408    }
409}
410
411/// Node representing directory or file in codebase outline tree.
412#[derive(Default)]
413pub struct OutlineNode {
414    pub files: BTreeMap<String, Vec<String>>, // file_name -> list of top symbol names with kinds
415    pub subdirs: BTreeMap<String, OutlineNode>,
416    /// Files under this directory that lie below the depth limit.
417    pub hidden_files: usize,
418    /// Indexed files in this directory with no function, class, or test to list.
419    pub plain_files: Vec<String>,
420    /// Files under this directory that no extractor reads.
421    pub unsupported_files: usize,
422}
423
424/// The path components of `file_path` below the outline's path filter, or `None` for a path
425/// outside it.
426fn outline_components(file_path: &str, norm_filter: &str) -> Option<Vec<String>> {
427    let normalized = file_path.replace('\\', "/");
428    let rel_path_str = if norm_filter.is_empty() {
429        normalized.as_str()
430    } else if normalized.eq_ignore_ascii_case(norm_filter) {
431        Path::new(&normalized)
432            .file_name()
433            .and_then(|n| n.to_str())
434            .unwrap_or(&normalized)
435    } else if normalized.len() > norm_filter.len()
436        && normalized.as_bytes()[norm_filter.len()] == b'/'
437        && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
438    {
439        &normalized[norm_filter.len() + 1..]
440    } else {
441        return None;
442    };
443
444    let path = Path::new(rel_path_str);
445    let components: Vec<String> = path
446        .components()
447        .map(|c| c.as_os_str().to_str().unwrap_or(""))
448        .filter(|s| !s.is_empty())
449        .map(str::to_string)
450        .collect();
451
452    if components.is_empty() {
453        return None;
454    }
455
456    Some(components)
457}
458
459/// Counts a file no extractor reads on the deepest folder the outline already shows for it, or
460/// on its top-level folder. Call it after every indexed file is added.
461pub fn add_unsupported_to_outline(
462    root_node: &mut OutlineNode,
463    file_path: &str,
464    max_depth: usize,
465    norm_filter: &str,
466) {
467    let Some(components) = outline_components(file_path, norm_filter) else {
468        return;
469    };
470    let mut curr = root_node;
471    for (i, comp) in components
472        .iter()
473        .take(components.len() - 1)
474        .take(max_depth)
475        .enumerate()
476    {
477        if i > 0 && !curr.subdirs.contains_key(comp) {
478            break;
479        }
480        curr = curr.subdirs.entry(comp.clone()).or_default();
481    }
482    curr.unsupported_files += 1;
483}
484
485/// Add a file path into the outline tree, bounded by max_depth.
486pub fn add_path_to_outline(
487    root_node: &mut OutlineNode,
488    file_path: &str,
489    symbols_by_file: &HashMap<String, Vec<Symbol>>,
490    counts: &HashMap<String, crate::queries::OutlineCounts>,
491    max_depth: usize,
492    norm_filter: &str,
493) {
494    let Some(components) = outline_components(file_path, norm_filter) else {
495        return;
496    };
497    let normalized = file_path.replace('\\', "/");
498
499    let mut curr = root_node;
500    let depth = components.len();
501
502    for (i, comp) in components.iter().enumerate() {
503        if i == depth - 1 {
504            if depth > max_depth {
505                curr.hidden_files += 1;
506            } else {
507                let mut sym_tags: Vec<String> = symbols_by_file
508                    .get(&normalized)
509                    .into_iter()
510                    .flatten()
511                    .filter(|s| !s.is_test && !s.test_container)
512                    .take(5)
513                    .map(|s| format!("{} {}", display_kind(s), s.name))
514                    .collect();
515                let count = counts.get(&normalized).copied().unwrap_or_default();
516                if count.definitions > sym_tags.len() {
517                    sym_tags.push(format!("+{} more", count.definitions - sym_tags.len()));
518                }
519                if count.tests > 0 {
520                    sym_tags.push(format!("{} {}", count.tests, plural(count.tests, "test")));
521                }
522                if count.fixtures > 0 {
523                    sym_tags.push(format!(
524                        "{} {}",
525                        count.fixtures,
526                        plural(count.fixtures, "fixture")
527                    ));
528                }
529                if sym_tags.is_empty() {
530                    curr.plain_files.push(comp.to_string());
531                } else {
532                    curr.files.insert(comp.to_string(), sym_tags);
533                }
534            }
535        } else if i < max_depth {
536            curr = curr.subdirs.entry(comp.to_string()).or_default();
537        } else {
538            curr.hidden_files += 1;
539            break;
540        }
541    }
542}
543
544/// The most files with definitions a subfolder lists; the folder the outline starts at lists all.
545const FILES_LISTED_PER_FOLDER: usize = 40;
546
547pub fn render_outline_tree(
548    out: &mut String,
549    node: &OutlineNode,
550    prefix: &str,
551    depth: usize,
552    max_depth: usize,
553) {
554    if depth >= max_depth {
555        return;
556    }
557
558    let listed = if depth == 0 {
559        node.files.len()
560    } else {
561        node.files.len().min(FILES_LISTED_PER_FOLDER)
562    };
563    let more_files = node.files.len() - listed;
564    let total_items = node.subdirs.len()
565        + listed
566        + usize::from(more_files > 0)
567        + usize::from(!node.plain_files.is_empty());
568    let mut index = 0;
569
570    // Render subdirectories
571    for (name, sub) in &node.subdirs {
572        index += 1;
573        let is_last = index == total_items;
574        let branch = if is_last { "└── " } else { "├── " };
575        let next_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
576
577        let hidden = match (sub.hidden_files, sub.unsupported_files) {
578            (0, 0) => String::new(),
579            (0, m) => format!(" ({m} unsupported {})", plural(m, "file")),
580            (n, 0) => format!(" ({n} indexed {})", plural(n, "file")),
581            (n, m) => format!(" ({n} indexed {}, {m} unsupported)", plural(n, "file")),
582        };
583        out.push_str(&format!("{prefix}{branch}{name}/{hidden}\n"));
584        render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
585    }
586
587    // Render files
588    for (file_name, syms) in node.files.iter().take(listed) {
589        index += 1;
590        let is_last = index == total_items;
591        let branch = if is_last { "└── " } else { "├── " };
592
593        let sym_suffix = if !syms.is_empty() {
594            format!(" [{}]", syms.join(", "))
595        } else {
596            String::new()
597        };
598
599        out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
600    }
601
602    if more_files > 0 {
603        index += 1;
604        let branch = if index == total_items {
605            "└── "
606        } else {
607            "├── "
608        };
609        out.push_str(&format!(
610            "{prefix}{branch}(+{more_files} more {} with functions or classes; pass this folder as the path to list them)\n",
611            plural(more_files, "file")
612        ));
613    }
614
615    if !node.plain_files.is_empty() {
616        let count = node.plain_files.len();
617        let names = if count <= 5 {
618            node.plain_files.join(", ")
619        } else {
620            format!("{}, +{} more", node.plain_files[..3].join(", "), count - 3)
621        };
622        out.push_str(&format!(
623            "{prefix}└── ({count} {} without functions or classes: {names})\n",
624            plural(count, "file")
625        ));
626    }
627}
628
629/// Format symbol body with metadata header, signature, and body content.
630/// A symbol's source as written, under a location comment.
631pub fn format_symbol_body(symbol: &Symbol, source: &str) -> String {
632    let mut out = format!(
633        "// {}:{}-{} ({})\n{source}",
634        symbol.path, symbol.start_line, symbol.end_line, symbol.name
635    );
636    if !source.ends_with('\n') {
637        out.push('\n');
638    }
639    out
640}
641
642/// Format surgical context bundle for a symbol.
643pub fn format_context_slice(slice: &ContextSlice) -> String {
644    let sym = &slice.target_symbol;
645    let mut out = String::new();
646    out.push_str(&format!(
647        "### Target: `{}` ({}:{}-{})\n\n",
648        sym.name, sym.path, sym.start_line, sym.end_line
649    ));
650
651    if let Some(ref sig) = sym.signature {
652        let sig = signature_without_duplicate_body(sig, &slice.target_body);
653        if !sig.trim().is_empty() {
654            out.push_str(&format!("Signature: `{sig}`\n\n"));
655        }
656    }
657
658    out.push_str(&format!("```{}\n", sym.language));
659    out.push_str(&slice.target_body);
660    if !slice.target_body.ends_with('\n') {
661        out.push('\n');
662    }
663    out.push_str("```\n\n");
664
665    if !slice.callee_signatures.is_empty() {
666        out.push_str("### Dependencies (Signatures):\n");
667        for callee in &slice.callee_signatures {
668            out.push_str(&format!("- {callee}\n"));
669        }
670        if slice.callee_signatures.len() >= 10 {
671            out.push_str("[Showing 10 dependencies (limit reached)]\n");
672        }
673        out.push('\n');
674    }
675
676    if !slice.related_types.is_empty() {
677        out.push_str("### Types:\n");
678        for t in &slice.related_types {
679            out.push_str(&format!("- {t}\n"));
680        }
681        out.push('\n');
682    }
683
684    if !slice.related_tests.is_empty() {
685        out.push_str("### Related Tests:\n");
686        for test in &slice.related_tests {
687            out.push_str(&format!(
688                "- `{}` ({}:{})\n",
689                test.name, test.path, test.start_line
690            ));
691        }
692        if slice.related_tests.len() >= 5 {
693            out.push_str("[Showing 5 tests (limit reached)]\n");
694        }
695        out.push_str("These tests call, use, or name this symbol; blast_radius also lists tests that can reach it through callers.\n\n");
696    } else {
697        out.push_str(
698            "### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n",
699        );
700    }
701
702    out
703}
704
705fn cap_notice(shown: usize, limit: usize) -> String {
706    let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
707        "narrow the query to see more"
708    } else {
709        "increase limit to see more"
710    };
711    format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
712}
713
714/// The FTS match snippet on one line, or `None` when it only repeats the signature. The comment
715/// markers that open each doc-comment line (`//`, `///`, `//!`, `*`) are dropped when lines join.
716fn match_line(snippet: &str, signature: &str) -> Option<String> {
717    let line = snippet
718        .lines()
719        .enumerate()
720        .map(|(i, text)| match i {
721            0 => text.trim(),
722            _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
723        })
724        .flat_map(str::split_whitespace)
725        .collect::<Vec<_>>()
726        .join(" ");
727    let plain = |text: &str| {
728        text.split_whitespace()
729            .collect::<String>()
730            .replace(['[', ']'], "")
731    };
732    let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
733    (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
734}
735
736/// A callee name on one line. An unresolved call chain longer than 60 characters that holds a
737/// call keeps only its last call, so a multi-line receiver expression never floods the list.
738fn callee_name(name: &str) -> String {
739    let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
740    if line.chars().count() <= 60 || !line.contains('(') {
741        return line;
742    }
743    match line.rsplit_once('.') {
744        Some((_, last)) if !last.is_empty() => format!("….{last}"),
745        _ => line,
746    }
747}
748
749/// Format references list for callers/callees with optional limit footer.
750/// One line for the import statements of a target: their count and the first three sites.
751pub fn format_import_summary(sites: &[(String, usize)]) -> String {
752    if sites.is_empty() {
753        return String::new();
754    }
755    let files: std::collections::BTreeSet<&str> =
756        sites.iter().map(|(path, _)| path.as_str()).collect();
757    let first: Vec<String> = sites
758        .iter()
759        .take(3)
760        .map(|(path, line)| format!("{path}:{line}"))
761        .collect();
762    let more = if sites.len() > 3 { ", …" } else { "" };
763    format!(
764        "Imported {} {} in {} {}: {}{more} (lookup_symbol with kind=\"import\" lists them)\n",
765        sites.len(),
766        plural(sites.len(), "time"),
767        files.len(),
768        plural(files.len(), "file"),
769        first.join(", ")
770    )
771}
772
773pub fn format_references(
774    target_name: &str,
775    refs: &[ReferenceSite],
776    direction: &str,
777    limit: usize,
778) -> String {
779    let mut out = String::new();
780    let dir_label = if direction == "callers" {
781        "Callers of"
782    } else {
783        "Callees called by"
784    };
785    out.push_str(&format!(
786        "{dir_label} `{target_name}` ({} found):\n",
787        refs.len()
788    ));
789
790    if refs.is_empty() {
791        out.push_str("  (none)\n");
792        return out;
793    }
794
795    for r in refs {
796        let line_info = match r.start_line {
797            Some(l) => format!(":{l}"),
798            None => String::new(),
799        };
800        let other = if direction == "callers" {
801            r.from_symbol_name.clone()
802        } else {
803            callee_name(&r.to_symbol_name)
804        };
805        let in_file = match r.occurrences {
806            Some(n) => format!(", {n} in file"),
807            None => String::new(),
808        };
809        let target = match &r.target {
810            Some(target) if direction != "callers" => format!(" → {target}"),
811            _ => String::new(),
812        };
813        out.push_str(&format!(
814            "- `{other}` [{}{line_info}] (kind: {}{in_file}){target}\n",
815            r.path, r.kind
816        ));
817    }
818
819    if refs.len() >= limit {
820        out.push_str(&cap_notice(refs.len(), limit));
821    }
822
823    out
824}
825
826/// Formats exact or FTS fallback symbol results with transparent header labeling.
827/// One line for the lookup rows whose name only starts with or contains the query, shown when
828/// some row is named exactly: each distinct name once, with its kind and count.
829fn other_names_line(query: &str, others: &[&Symbol]) -> String {
830    let mut groups: Vec<(String, &str, usize)> = Vec::new();
831    for s in others {
832        let kind = display_kind(s);
833        match groups
834            .iter_mut()
835            .find(|(name, k, _)| *name == s.name && *k == kind)
836        {
837            Some(group) => group.2 += 1,
838            None => groups.push((s.name.clone(), kind, 1)),
839        }
840    }
841    let listed: Vec<String> = groups
842        .iter()
843        .take(8)
844        .map(|(name, kind, count)| match count {
845            1 => format!("`{name}` ({kind})"),
846            n => format!("`{name}` ({kind}, {n}×)"),
847        })
848        .collect();
849    let more = match groups.len().saturating_sub(8) {
850        0 => String::new(),
851        n => format!(", +{n} more"),
852    };
853    format!(
854        "- {} other {} `{query}`, ignoring case: {}{more} (lookup_symbol with one of these names lists its rows)\n",
855        others.len(),
856        if others.len() == 1 {
857            "row starts with or contains"
858        } else {
859            "rows start with or contain"
860        },
861        listed.join(", ")
862    )
863}
864
865pub fn format_find_symbol_results(
866    query: &str,
867    exact_matches: &[Symbol],
868    fts_matches: &[SymbolSearchResult],
869    limit: usize,
870) -> String {
871    if !exact_matches.is_empty() {
872        let named_exactly = exact_matches
873            .iter()
874            .filter(|s| s.name == query || s.name.ends_with(&format!(".{query}")))
875            .count();
876        let exact_note = if named_exactly < exact_matches.len() {
877            format!(" ({named_exactly} named exactly `{query}`; the rest start with or contain it)")
878        } else {
879            String::new()
880        };
881        let owner = query
882            .strip_suffix("::")
883            .or_else(|| query.strip_suffix('.'))
884            .filter(|owner| !owner.is_empty());
885        let mut out = match owner {
886            Some(owner) => format!("Found {} members of `{owner}`:\n\n", exact_matches.len()),
887            None => format!(
888                "Found {} symbols matching \"{query}\"{exact_note}:\n\n",
889                exact_matches.len()
890            ),
891        };
892        let has_definition = exact_matches.iter().any(|s| s.kind != "import");
893        let folds = |s: &Symbol| crate::queries::folds_into_import_line(query, s, has_definition);
894        let imports: Vec<&Symbol> = exact_matches.iter().filter(|s| folds(s)).collect();
895        let is_exact = |s: &Symbol| s.name == query || s.name.ends_with(&format!(".{query}"));
896        let (shown, others): (Vec<&Symbol>, Vec<&Symbol>) = exact_matches
897            .iter()
898            .filter(|s| !folds(s))
899            .partition(|s| named_exactly == 0 || is_exact(s));
900        for s in shown {
901            let sig = s.signature.as_deref().unwrap_or(&s.name);
902            out.push_str(&format!(
903                "- {} `{}` [{}:{}-{}] id={}\n",
904                display_kind(s),
905                s.name,
906                s.path,
907                s.start_line,
908                s.end_line,
909                s.symbol_id
910            ));
911            out.push_str(&format!("  Signature: {sig}\n"));
912            if let Some(doc) = &s.doc_comment {
913                let first = doc.lines().next().unwrap_or("").trim();
914                if !first.is_empty() {
915                    out.push_str(&format!("  Doc: {first}\n"));
916                }
917            }
918        }
919        if !imports.is_empty() {
920            let shown = imports
921                .iter()
922                .take(3)
923                .map(|s| format!("{}:{}", s.path, s.start_line))
924                .collect::<Vec<_>>()
925                .join(", ");
926            let more = if imports.len() > 3 { ", …" } else { "" };
927            let cut_inside_exact_names = exact_matches
928                .last()
929                .is_some_and(|s| s.name.eq_ignore_ascii_case(query) || folds(s));
930            let capped = if exact_matches.len() - imports.len() >= limit && cut_inside_exact_names {
931                "at least "
932            } else {
933                ""
934            };
935            out.push_str(&format!(
936                "- {capped}{} {} of `{query}`: {shown}{more} (lookup_symbol with kind=\"import\" lists them)\n",
937                imports.len(),
938                plural(imports.len(), "import")
939            ));
940        }
941        if !others.is_empty() {
942            out.push_str(&other_names_line(query, &others));
943        }
944        if exact_matches.len() - imports.len() >= limit {
945            out.push_str(&cap_notice(exact_matches.len() - imports.len(), limit));
946        }
947        out
948    } else if !fts_matches.is_empty() {
949        let mut out = format!(
950            "No exact name match; {} full-text matches for \"{query}\":\n\n",
951            fts_matches.len()
952        );
953        for r in fts_matches {
954            let s = &r.symbol;
955            let sig = s.signature.as_deref().unwrap_or(&s.name);
956            out.push_str(&format!(
957                "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
958                display_kind(s),
959                s.name,
960                s.path,
961                s.start_line,
962                s.end_line,
963                r.score,
964                s.symbol_id
965            ));
966            out.push_str(&format!("  Signature: {sig}\n"));
967            if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
968                out.push_str(&format!("  Match: {line}\n"));
969            } else if let Some(doc) = &s.doc_comment {
970                let first = doc.lines().next().unwrap_or("").trim();
971                if !first.is_empty() {
972                    out.push_str(&format!("  Doc: {first}\n"));
973                }
974            }
975        }
976        if fts_matches.len() >= limit {
977            out.push_str(&cap_notice(fts_matches.len(), limit));
978        }
979        out
980    } else {
981        format!("No symbols found matching \"{query}\".\n")
982    }
983}
984
985/// Format available structural fact & literal categories.
986/// The first line of a fact query that matched nothing; it names the path the query was limited to.
987pub fn no_facts_heading(category: &str, path_filter: Option<&str>) -> String {
988    match path_filter {
989        Some(path) => format!("No facts match '{category}' under `{path}`."),
990        None => format!("No facts match '{category}' in this repository."),
991    }
992}
993
994/// The answer when a category matches no fact. A known alias gets only the alias summary, and
995/// `import` says where imports are, because most extractors record imports as symbols. Any other
996/// category lists every raw category, so the agent can pick one.
997pub fn format_no_facts(
998    category: &str,
999    path_filter: Option<&str>,
1000    categories: &[(String, usize)],
1001) -> String {
1002    let mut out = format!("{}\n\n", no_facts_heading(category, path_filter));
1003    if !crate::queries::is_category_alias(category) {
1004        out.push_str(&format_fact_categories(categories));
1005        return out;
1006    }
1007    if matches!(category.to_ascii_lowercase().as_str(), "import" | "imports") {
1008        out.push_str("Most languages record imports as symbols: lookup_symbol with kind=\"import\" lists them.\n\n");
1009    }
1010    out.push_str(&alias_summary(categories));
1011    out
1012}
1013
1014fn alias_summary(categories: &[(String, usize)]) -> String {
1015    let aliases = crate::queries::alias_fact_counts(categories);
1016    if aliases.is_empty() {
1017        return String::new();
1018    }
1019    let parts: Vec<String> = aliases
1020        .iter()
1021        .map(|(alias, patterns, facts)| {
1022            format!(
1023                "{alias} ({patterns} {}, {facts} {})",
1024                plural(*patterns, "pattern"),
1025                plural(*facts, "fact")
1026            )
1027        })
1028        .collect();
1029    format!("Aliases: {}\n\n", parts.join(", "))
1030}
1031
1032pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
1033    if categories.is_empty() {
1034        return "No structural facts or literals indexed in this repository.".to_string();
1035    }
1036    let mut out = String::new();
1037    out.push_str(&alias_summary(categories));
1038    // A decorated-definition fact only records where a decorated block starts.
1039    let (patterns, literal_kinds): (Vec<_>, Vec<_>) = categories
1040        .iter()
1041        .filter(|(name, _)| !name.ends_with(".decorated_definition.v1"))
1042        .partition(|(name, _)| name.contains('.'));
1043    out.push_str(&format!(
1044        "Available structural fact categories ({} found):\n\n",
1045        patterns.len()
1046    ));
1047    for (name, count) in &patterns {
1048        out.push_str(&format!(
1049            "- `{name}` ({count} {})\n",
1050            plural(*count, "fact")
1051        ));
1052    }
1053    if !literal_kinds.is_empty() {
1054        out.push_str("\nString literal kinds (the same command lists the literals of a kind):\n\n");
1055        for (name, count) in &literal_kinds {
1056            out.push_str(&format!(
1057                "- `{name}` ({count} {})\n",
1058                plural(*count, "literal")
1059            ));
1060        }
1061    }
1062    out
1063}
1064
1065fn plural(count: usize, word: &str) -> String {
1066    if count == 1 {
1067        word.to_string()
1068    } else {
1069        format!("{word}s")
1070    }
1071}
1072
1073/// Format structural facts and matching literals into token-dense markdown.
1074pub fn format_structural_facts(
1075    facts: &[crate::models::StructuralFact],
1076    literals: &[crate::models::LiteralFact],
1077    category: &str,
1078    limit: usize,
1079) -> String {
1080    let mut out = format!(
1081        "Structural facts for '{category}' ({} found):\n",
1082        facts.len()
1083    );
1084    for f in facts {
1085        let label = f.key.as_deref().unwrap_or(&f.capture_name);
1086        let details = qt_property_details(f);
1087        let api_style = f
1088            .metadata
1089            .as_ref()
1090            .and_then(|m| m.get("api_style"))
1091            .and_then(|v| v.as_str());
1092        let place = match api_style {
1093            Some("decorator_routing") => "handler",
1094            Some("call_routing") => "registered in",
1095            _ => "in",
1096        };
1097        let parent = f
1098            .containing_symbol_name
1099            .as_deref()
1100            .map(|p| format!(", {place}: {p}"))
1101            .unwrap_or_default();
1102        out.push_str(&format!(
1103            "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
1104            f.path, f.start_line, f.pattern_id
1105        ));
1106    }
1107    if limit > 0 && facts.len() >= limit {
1108        out.push_str(&cap_notice(facts.len(), limit));
1109    }
1110    if facts
1111        .iter()
1112        .any(|f| f.pattern_id.starts_with("flask.route"))
1113    {
1114        out.push_str("Routes as declared in the source. By default Flask also answers HEAD for GET and OPTIONS for every rule, and serves `/static/<path:filename>`; the app or a rule can turn these off.\n");
1115    }
1116    if !literals.is_empty() {
1117        out.push_str(&format!(
1118            "\nMatching literals ({} found):\n",
1119            literals.len()
1120        ));
1121        for l in literals {
1122            out.push_str(&format!(
1123                "- \"{}\" [{}:{}] (kind: {})\n",
1124                l.literal_text, l.path, l.start_line, l.kind
1125            ));
1126        }
1127        if limit > 0 && literals.len() >= limit {
1128            out.push_str(&cap_notice(literals.len(), limit));
1129        }
1130    }
1131    out
1132}
1133
1134fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
1135    if fact.pattern_id != "cpp.qt_property.v1" {
1136        return String::new();
1137    }
1138    let Some(metadata) = fact.metadata.as_ref() else {
1139        return String::new();
1140    };
1141    [
1142        "property_type",
1143        "read",
1144        "write",
1145        "notify",
1146        "designable",
1147        "scriptable",
1148        "stored",
1149        "user",
1150        "revision",
1151    ]
1152    .into_iter()
1153    .filter_map(|key| metadata.get(key).map(|value| (key, value)))
1154    .map(|(key, value)| {
1155        let value = value
1156            .as_str()
1157            .map(str::to_owned)
1158            .unwrap_or_else(|| value.to_string());
1159        format!(", {key}: {value}")
1160    })
1161    .collect()
1162}
1163
1164/// Formats FTS5 conceptual search results into token-dense markdown.
1165pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
1166    if results.is_empty() {
1167        return format!("No symbols found matching concept \"{query}\".");
1168    }
1169
1170    let mut out = format!(
1171        "Found {} symbols matching concept \"{query}\":\n",
1172        results.len()
1173    );
1174    if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
1175        out.push_str(&format!(
1176            "rerank: {} candidates in {} µs",
1177            explain.candidates, explain.rerank_us
1178        ));
1179        if !explain.word_weights.is_empty() {
1180            let words: Vec<String> = explain
1181                .word_weights
1182                .iter()
1183                .map(|(word, weight)| format!("{word} {weight:.2}"))
1184                .collect();
1185            out.push_str(&format!("; words {}", words.join(", ")));
1186        }
1187        out.push('\n');
1188    }
1189    out.push('\n');
1190    for r in results {
1191        let s = &r.symbol;
1192        let sig = s.signature.as_deref().unwrap_or(&s.name);
1193        out.push_str(&format!(
1194            "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1195            display_kind(s),
1196            s.name,
1197            s.path,
1198            s.start_line,
1199            s.end_line,
1200            r.score,
1201            s.symbol_id
1202        ));
1203        out.push_str(&format!("  Signature: {sig}\n"));
1204        if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1205            out.push_str(&format!("  Match: {line}\n"));
1206        } else if let Some(doc) = &s.doc_comment {
1207            let first_line = doc.lines().next().unwrap_or("").trim();
1208            if !first_line.is_empty() {
1209                out.push_str(&format!("  Doc: {first_line}\n"));
1210            }
1211        }
1212        if let Some(explain) = &r.explain {
1213            out.push_str(&format!("  explain: {}\n", explain_line(r.score, explain)));
1214        }
1215    }
1216
1217    if results.len() >= limit {
1218        out.push_str(&cap_notice(results.len(), limit));
1219    }
1220
1221    out
1222}
1223
1224fn explain_line(score: f64, e: &SearchExplain) -> String {
1225    let mut line = format!(
1226        "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
1227        e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
1228    );
1229    if e.documentation != 0.0 {
1230        line.push_str(&format!(" + doc {:.1}", e.documentation));
1231    }
1232    if e.test_intent != 0.0 {
1233        line.push_str(&format!(" + test {:.1}", e.test_intent));
1234    }
1235    if e.nested != 0.0 {
1236        line.push_str(&format!(" + nested {:.1}", e.nested));
1237    }
1238    line.push_str(&format!(" [{}]", e.branches.join(",")));
1239    if let Some(bm25) = e.bm25 {
1240        line.push_str(&format!(" bm25 {bm25:.2}"));
1241    }
1242    if !e.terms.is_empty() {
1243        let terms: Vec<String> = e
1244            .terms
1245            .iter()
1246            .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
1247            .collect();
1248        line.push_str(&format!(" terms {}", terms.join(" ")));
1249    }
1250    line
1251}
1252
1253/// Format blast radius and likely test targets into token-dense markdown.
1254pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
1255    if result.seed_type == "none" {
1256        return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
1257    }
1258
1259    let mut out = String::new();
1260    let seed_label = if result.seed_type == "file" {
1261        format!("Files: {}", result.seeds.join(", "))
1262    } else if result.seed_type == "symbol" {
1263        format!("Symbol: {}", result.seeds.join(", "))
1264    } else {
1265        format!("Seeds: {}", result.seeds.join(", "))
1266    };
1267
1268    out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
1269
1270    let more = if result.limit_at_maximum {
1271        "limit is at its maximum, so narrow the target to a symbol or fewer files"
1272    } else {
1273        "raise limit to see the rest"
1274    };
1275    let shown_of = |shown: usize, found: usize, noun: &str| {
1276        if found > shown {
1277            format!("Showing {shown} of {found} {noun}; {more}.\n\n")
1278        } else {
1279            format!("Requested limit hid additional {noun}; {more}.\n\n")
1280        }
1281    };
1282    if result.likely_tests_truncated {
1283        out.push_str(&shown_of(
1284            result.likely_tests.len(),
1285            result.likely_tests_found,
1286            "likely tests",
1287        ));
1288    }
1289    if result.impacted_symbols_truncated {
1290        out.push_str(&shown_of(
1291            result.impacted_symbols.len(),
1292            result.impacted_symbols_found,
1293            "impacted symbols",
1294        ));
1295    }
1296    if result.traversal_ceiling_reached {
1297        out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
1298    }
1299    if result.test_file_ceiling_reached {
1300        out.push_str("Name-matched test discovery hit its fixed ceiling; narrow the target because increasing limit cannot raise it.\n\n");
1301    }
1302
1303    if !result.likely_tests.is_empty() {
1304        let total = result.likely_tests.len();
1305        out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
1306
1307        let mut tests_by_file: std::collections::BTreeMap<&str, Vec<&TestTarget>> =
1308            std::collections::BTreeMap::new();
1309        let mut file_order = Vec::new();
1310        for t in &result.likely_tests {
1311            if !tests_by_file.contains_key(t.path.as_str()) {
1312                file_order.push(t.path.as_str());
1313            }
1314            tests_by_file.entry(t.path.as_str()).or_default().push(t);
1315        }
1316
1317        for path in file_order {
1318            out.push_str(&format!("{path}:\n"));
1319            if let Some(tests) = tests_by_file.get(path) {
1320                for t in tests {
1321                    out.push_str(&format!(
1322                        "  - `{}` [line {}] ({})\n",
1323                        t.name, t.line, t.reason
1324                    ));
1325                }
1326            }
1327        }
1328
1329        if result
1330            .likely_tests
1331            .iter()
1332            .any(|t| t.reason.starts_with("possible:"))
1333        {
1334            out.push_str("`possible` rows build the class and use a test client, so they reach the target only through a runtime call to `__call__`. The index picks them by shared name words and cannot see whether they send a call that reaches the target; run the whole suite for full coverage.\n");
1335        }
1336        out.push('\n');
1337    } else if result.likely_tests_truncated
1338        || result.traversal_ceiling_reached
1339        || result.test_file_ceiling_reached
1340    {
1341        out.push_str("### Likely Tests to Run (0 returned)\n\n");
1342    } else {
1343        out.push_str(
1344            "### Likely Tests to Run (0 returned)\nNo direct or name-matched tests found.\n\n",
1345        );
1346    }
1347
1348    if !result.impacted_symbols.is_empty() {
1349        let total = result.impacted_symbols.len();
1350        let mut visible = Vec::new();
1351        let mut low_signal_count = 0;
1352        for s in &result.impacted_symbols {
1353            if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
1354                low_signal_count += 1;
1355            } else {
1356                visible.push(s);
1357            }
1358        }
1359
1360        out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
1361
1362        if visible.is_empty() && low_signal_count > 0 {
1363            let row_word = if low_signal_count == 1 {
1364                "row (import/module)"
1365            } else {
1366                "rows (imports/modules)"
1367            };
1368            out.push_str(&format!(
1369                "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
1370            ));
1371        } else {
1372            let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
1373                std::collections::BTreeMap::new();
1374            let mut file_order = Vec::new();
1375            for s in &visible {
1376                if !syms_by_file.contains_key(s.path.as_str()) {
1377                    file_order.push(s.path.as_str());
1378                }
1379                syms_by_file.entry(s.path.as_str()).or_default().push(s);
1380            }
1381
1382            for path in file_order {
1383                out.push_str(&format!("{path}:\n"));
1384                if let Some(syms) = syms_by_file.get(path) {
1385                    for s in syms {
1386                        out.push_str(&format!(
1387                            "  - [depth {}] {} `{}` [line {}]\n",
1388                            s.depth, s.kind, s.name, s.line
1389                        ));
1390                    }
1391                }
1392            }
1393
1394            if low_signal_count > 0 {
1395                let row_word = if low_signal_count == 1 {
1396                    "row (import/module)"
1397                } else {
1398                    "rows (imports/modules)"
1399                };
1400                out.push_str(&format!(
1401                    "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
1402                ));
1403            }
1404        }
1405    } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
1406        out.push_str("### Downstream Impact (0 returned)\n");
1407    } else {
1408        out.push_str(
1409            "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
1410        );
1411    }
1412
1413    out
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use crate::models::{ImpactedSymbol, TestTarget};
1420
1421    fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
1422        crate::models::StructuralFact {
1423            structural_fact_id: "sf1".into(),
1424            path: ".codex/config.toml".into(),
1425            language: "toml".into(),
1426            pattern_id: "toml.key_value.v1".into(),
1427            capture_name: "key_value".into(),
1428            node_kind: "table".into(),
1429            key: key.map(str::to_string),
1430            metadata: None,
1431            containing_symbol_name: None,
1432            start_line: 2,
1433            end_line: 2,
1434            confidence: 1.0,
1435        }
1436    }
1437
1438    #[test]
1439    fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
1440        let with_key = format_structural_facts(
1441            &[structural_fact(Some("mcp_servers.code-kb.command"))],
1442            &[],
1443            "config",
1444            30,
1445        );
1446        assert!(with_key.contains(
1447            "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
1448        ));
1449
1450        let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
1451        assert!(
1452            without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
1453        );
1454    }
1455
1456    #[test]
1457    fn format_structural_facts_renders_selected_qt_property_metadata() {
1458        let mut fact = structural_fact(Some("index"));
1459        fact.pattern_id = "cpp.qt_property.v1".into();
1460        fact.metadata = Some(serde_json::json!({
1461            "property_type": "int",
1462            "designable": false,
1463            "scriptable": true,
1464            "stored": false,
1465            "user": true,
1466            "revision": 2,
1467        }));
1468
1469        let output = format_structural_facts(&[fact], &[], "property", 30);
1470
1471        assert!(output.contains(
1472            "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
1473        ));
1474    }
1475
1476    #[test]
1477    fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
1478        let site = |occurrences| ReferenceSite {
1479            from_symbol_name: "Button".into(),
1480            from_symbol_id: "s1".into(),
1481            to_symbol_name: "background".into(),
1482            kind: "member_access".into(),
1483            path: "Ui/Button.qml".into(),
1484            start_line: Some(5),
1485            start_column: Some(4),
1486            occurrences,
1487            target: None,
1488        };
1489
1490        let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
1491        assert!(
1492            grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
1493            "{grouped}"
1494        );
1495
1496        let single = format_references("Color", &[site(None)], "callers", 30);
1497        assert!(
1498            single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
1499            "{single}"
1500        );
1501    }
1502
1503    #[test]
1504    fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
1505        assert_eq!(
1506            match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
1507            Some("...path up to the [root] (the [root] excluded)".into())
1508        );
1509        assert_eq!(
1510            match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
1511            Some("// [HelpFunc] returns the [function] set".into())
1512        );
1513        assert_eq!(
1514            match_line(
1515                "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
1516                "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
1517            ),
1518            None
1519        );
1520        assert_eq!(
1521            match_line(
1522                "...[root]: &[PathBuf]) -> bool",
1523                "fn f(root: &[PathBuf]) -> bool"
1524            ),
1525            None
1526        );
1527    }
1528
1529    #[test]
1530    fn a_long_unresolved_call_chain_shows_only_its_last_call() {
1531        assert_eq!(
1532            callee_name(
1533                "[\"a\", \"b\"]\n    .into_iter()\n    .find_map(|key| args.get(key)).ok_or_else"
1534            ),
1535            "….ok_or_else"
1536        );
1537        assert_eq!(
1538            callee_name("workspace.resolve_path"),
1539            "workspace.resolve_path"
1540        );
1541        let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
1542        assert_eq!(callee_name(selector), selector);
1543    }
1544
1545    #[test]
1546    fn file_skeleton_reports_parse_errors() {
1547        let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
1548        assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
1549
1550        let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
1551        assert!(one.contains("// 1 parse error: symbols may be incomplete"));
1552
1553        let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
1554        assert!(!none.contains("parse error"));
1555    }
1556
1557    #[test]
1558    fn test_format_file_skeleton() {
1559        let syms = vec![Symbol {
1560            symbol_id: "s1".into(),
1561            file_id: "f1".into(),
1562            path: "src/lib.rs".into(),
1563            language: "rust".into(),
1564            name: "do_work".into(),
1565            kind: "function".into(),
1566            signature: Some("pub fn do_work() -> Result<()>".into()),
1567            doc_comment: Some("Performs core work.".into()),
1568            visibility: Some("pub".into()),
1569            parent_symbol_id: None,
1570            start_line: 10,
1571            start_column: 0,
1572            end_line: 30,
1573            end_column: 1,
1574            start_byte: 100,
1575            end_byte: 300,
1576            body_start_line: Some(11),
1577            body_start_column: Some(0),
1578            body_end_line: Some(29),
1579            body_end_column: Some(1),
1580            body_start_byte: Some(130),
1581            body_end_byte: Some(298),
1582            body_hash: None,
1583            semantic_group: None,
1584            is_test: false,
1585            test_container: false,
1586        }];
1587
1588        let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
1589        assert!(skeleton.contains("/// Performs core work."));
1590        assert!(skeleton.contains("19 lines hidden: L11-L29"));
1591    }
1592
1593    #[test]
1594    fn format_symbol_body_prints_the_source_under_its_location() {
1595        let mut symbol = sample_symbol("plain");
1596        symbol.signature = Some("pub fn plain()".into());
1597
1598        assert_eq!(
1599            format_symbol_body(&symbol, "pub fn plain() {\n    1\n}"),
1600            "// src/lib.rs:1-10 (plain)\npub fn plain() {\n    1\n}\n"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_format_search_results() {
1606        let results = vec![SymbolSearchResult {
1607            symbol: Symbol {
1608                symbol_id: "s1".into(),
1609                file_id: "f1".into(),
1610                path: "src/parser.rs".into(),
1611                language: "rust".into(),
1612                name: "parse_tokens".into(),
1613                kind: "function".into(),
1614                signature: Some("pub fn parse_tokens()".into()),
1615                doc_comment: Some("Parses tokens from stream.".into()),
1616                visibility: Some("pub".into()),
1617                parent_symbol_id: None,
1618                start_line: 15,
1619                start_column: 0,
1620                end_line: 25,
1621                end_column: 1,
1622                start_byte: 100,
1623                end_byte: 250,
1624                body_start_line: None,
1625                body_start_column: None,
1626                body_end_line: None,
1627                body_end_column: None,
1628                body_start_byte: None,
1629                body_end_byte: None,
1630                body_hash: None,
1631                semantic_group: None,
1632                is_test: false,
1633                test_container: false,
1634            },
1635            score: -1.85,
1636            snippet: Some("Parses [tokens] from stream.".into()),
1637            explain: None,
1638        }];
1639
1640        let formatted = format_search_results("tokens", &results, 20);
1641        assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
1642        assert!(
1643            formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
1644        );
1645        assert!(formatted.contains("Match: Parses [tokens] from stream."));
1646        assert!(!formatted.contains("explain"));
1647        assert!(!formatted.contains("rerank"));
1648    }
1649
1650    #[test]
1651    fn exact_lookup_folds_import_rows_into_one_line_when_a_definition_exists() {
1652        let class = Symbol {
1653            kind: "class".into(),
1654            path: "src/flask/app.py".into(),
1655            ..sample_symbol("Flask")
1656        };
1657        let import = |path: &str, line: usize| Symbol {
1658            kind: "import".into(),
1659            path: path.into(),
1660            start_line: line,
1661            symbol_id: format!("id_{path}"),
1662            ..sample_symbol("Flask")
1663        };
1664        let rows = vec![
1665            class,
1666            import("src/flask/cli.py", 34),
1667            import("src/flask/ctx.py", 21),
1668            import("tests/conftest.py", 6),
1669            import("examples/app.py", 1),
1670        ];
1671
1672        let folded = format_find_symbol_results("Flask", &rows, &[], 20);
1673
1674        assert!(
1675            folded.contains("- class `Flask` [src/flask/app.py:"),
1676            "{folded}"
1677        );
1678        assert!(
1679            folded.contains(
1680                "- 4 imports of `Flask`: src/flask/cli.py:34, src/flask/ctx.py:21, tests/conftest.py:6, … (lookup_symbol with kind=\"import\" lists them)"
1681            ),
1682            "{folded}"
1683        );
1684        assert!(!folded.contains("- import `Flask`"), "{folded}");
1685
1686        let capped = format_find_symbol_results("Flask", &rows, &[], 1);
1687        assert!(
1688            capped.contains("- at least 4 imports of `Flask`"),
1689            "{capped}"
1690        );
1691
1692        let mut with_prefix_rows = rows.clone();
1693        with_prefix_rows.push(Symbol {
1694            kind: "class".into(),
1695            ..sample_symbol("FlaskGroup")
1696        });
1697        let cut_after_exact = format_find_symbol_results("Flask", &with_prefix_rows, &[], 6);
1698        assert!(
1699            cut_after_exact.contains("- 4 imports of `Flask`"),
1700            "{cut_after_exact}"
1701        );
1702
1703        let imports_only = format_find_symbol_results("Flask", &rows[1..], &[], 20);
1704        assert_eq!(imports_only.matches("- import `Flask`").count(), 4);
1705    }
1706
1707    #[test]
1708    fn skeleton_shows_python_class_and_module_variables_with_one_line_values() {
1709        let leaf = |name: &str, kind: &str, line: usize, sig: &str| Symbol {
1710            kind: kind.into(),
1711            language: "python".into(),
1712            path: "app.py".into(),
1713            parent_symbol_id: Some("id_App".into()),
1714            start_line: line,
1715            end_line: line,
1716            body_start_line: None,
1717            body_end_line: None,
1718            signature: Some(sig.into()),
1719            ..sample_symbol(name)
1720        };
1721        let class = Symbol {
1722            kind: "class".into(),
1723            language: "python".into(),
1724            path: "app.py".into(),
1725            start_line: 1,
1726            end_line: 20,
1727            body_start_line: None,
1728            body_end_line: None,
1729            signature: Some("class App".into()),
1730            ..sample_symbol("App")
1731        };
1732        let module_variable = Symbol {
1733            kind: "variable".into(),
1734            language: "python".into(),
1735            path: "app.py".into(),
1736            start_line: 30,
1737            end_line: 30,
1738            body_start_line: None,
1739            body_end_line: None,
1740            signature: Some("app = App()".into()),
1741            ..sample_symbol("app")
1742        };
1743        let symbols = vec![
1744            class,
1745            leaf(
1746                "request_class",
1747                "variable",
1748                2,
1749                "request_class: type[Request] = Request",
1750            ),
1751            leaf(
1752                "default_config",
1753                "variable",
1754                3,
1755                "default_config = ImmutableDict(\n    {\n        \"DEBUG\": None,\n    }\n)",
1756            ),
1757            leaf(
1758                "blueprints",
1759                "property",
1760                5,
1761                "self.blueprints: dict[str, Blueprint] = {}",
1762            ),
1763            leaf(
1764                "error_handler_spec",
1765                "property",
1766                7,
1767                "self.error_handler_spec: dict[\n    ft.AppOrBlueprintKey,\n    dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]],\n] = defaultdict(lambda: defaultdict(dict), default_factory_argument_that_is_long)  # type: ignore",
1768            ),
1769            module_variable,
1770        ];
1771
1772        let out = format_file_skeleton("app.py", &symbols, None, 0);
1773
1774        assert!(
1775            out.contains("    request_class: type[Request] = Request; // L2-2"),
1776            "{out}"
1777        );
1778        assert!(
1779            out.contains("    default_config = ImmutableDict({\"DEBUG\": None}); // L3-3"),
1780            "{out}"
1781        );
1782        assert!(
1783            out.contains("    self.error_handler_spec: dict[ft.AppOrBlueprintKey, dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]]] = defaultdict(lambda: defaultdict(dict)…; // L7-7"),
1784            "{out}"
1785        );
1786        assert!(
1787            out.contains("    self.blueprints: dict[str, Blueprint] = {}; // L5-5"),
1788            "{out}"
1789        );
1790        assert!(out.contains("app = App(); // L30-30"), "{out}");
1791    }
1792
1793    #[test]
1794    fn skeleton_names_the_method_that_assigns_an_attribute() {
1795        let member = |name: &str, kind: &str, start: usize, end: usize, sig: &str| Symbol {
1796            kind: kind.into(),
1797            language: "python".into(),
1798            parent_symbol_id: Some("id_Flask".into()),
1799            start_line: start,
1800            end_line: end,
1801            body_start_line: None,
1802            body_end_line: None,
1803            signature: Some(sig.into()),
1804            ..sample_symbol(name)
1805        };
1806        let class = Symbol {
1807            kind: "class".into(),
1808            language: "python".into(),
1809            start_line: 1,
1810            end_line: 30,
1811            body_start_line: None,
1812            body_end_line: None,
1813            signature: Some("class Flask(App)".into()),
1814            ..sample_symbol("Flask")
1815        };
1816        let symbols = vec![
1817            class,
1818            member("__init__", "method", 2, 10, "def __init__(self)"),
1819            member("cli", "property", 3, 3, "self.cli = cli.AppGroup()"),
1820            member("run", "method", 12, 20, "def run(self)"),
1821            member("debug", "property", 14, 14, "self.debug = get_debug_flag()"),
1822        ];
1823
1824        let out = format_file_skeleton("app.py", &symbols, None, 0);
1825
1826        assert!(
1827            out.contains("self.cli = cli.AppGroup(); // in __init__(), L3-3"),
1828            "{out}"
1829        );
1830        assert!(
1831            out.contains("self.debug = get_debug_flag(); // in run(), L14-14"),
1832            "{out}"
1833        );
1834    }
1835
1836    #[test]
1837    fn skeleton_hides_variables_that_are_not_python_class_attributes() {
1838        let parent = |name: &str, kind: &str, language: &str| Symbol {
1839            kind: kind.into(),
1840            language: language.into(),
1841            start_line: 1,
1842            end_line: 20,
1843            body_start_line: None,
1844            body_end_line: None,
1845            signature: Some(format!("{kind} {name}")),
1846            ..sample_symbol(name)
1847        };
1848        let child = |name: &str, parent: &str, language: &str, sig: &str| Symbol {
1849            kind: "variable".into(),
1850            language: language.into(),
1851            parent_symbol_id: Some(format!("id_{parent}")),
1852            start_line: 2,
1853            end_line: 2,
1854            body_start_line: None,
1855            body_end_line: None,
1856            signature: Some(sig.into()),
1857            ..sample_symbol(name)
1858        };
1859        let symbols = vec![
1860            parent("Adapters", "class", "java"),
1861            child(
1862                "accessible",
1863                "Adapters",
1864                "java",
1865                "boolean accessible = false",
1866            ),
1867            parent("body", "class", "html"),
1868            child(
1869                "img",
1870                "body",
1871                "html",
1872                "<img class=\"carat\" src=\"carat.png\" alt>",
1873            ),
1874            parent("section", "module", "yaml"),
1875            child("title", "section", "yaml", "title: Invoking jq"),
1876        ];
1877
1878        let out = format_file_skeleton("mixed", &symbols, None, 0);
1879
1880        for hidden in ["accessible", "<img", "title:"] {
1881            assert!(!out.contains(hidden), "{out}");
1882        }
1883    }
1884
1885    #[test]
1886    fn a_multi_line_macro_row_drops_its_line_continuations_and_is_cut_to_one_line() {
1887        let short = "#define PAIR(a, b)   \\\n    a,                \\\n    b";
1888        assert_eq!(value_row_signature(short), "#define PAIR(a, b) a, b");
1889
1890        let long = format!(
1891            "#define WIDE(x) \\\n{}",
1892            "    x + x + x + x + x + x + x + x \\\n".repeat(8)
1893        );
1894        let row = value_row_signature(&long);
1895        assert!(row.starts_with("#define WIDE(x) x + x"), "{row}");
1896        assert!(row.ends_with('…'), "{row}");
1897        assert!(!row.contains('\\'), "{row}");
1898        assert!(row.chars().count() <= 120, "{row}");
1899    }
1900
1901    #[test]
1902    fn skeleton_keeps_values_only_for_real_assignments() {
1903        let row =
1904            |name: &str, kind: &str, line: usize, sig: &str, body: Option<(usize, usize)>| Symbol {
1905                kind: kind.into(),
1906                start_line: line,
1907                end_line: body.map_or(line, |(_, end)| end),
1908                body_start_line: body.map(|(start, _)| start),
1909                body_end_line: body.map(|(_, end)| end),
1910                signature: Some(sig.into()),
1911                ..sample_symbol(name)
1912            };
1913        let symbols = vec![
1914            row(
1915                "img",
1916                "property",
1917                1,
1918                "[class^=rz-] img,[class^=rz-] svg { vertical-align:middle }",
1919                None,
1920            ),
1921            row(
1922                "Coordinates",
1923                "property",
1924                2,
1925                "[JsonProperty(ItemConverterType = typeof(IntToFloatConverter))] public int[,,] Coordinates { get; set; }",
1926                None,
1927            ),
1928            row(
1929                "VERSION_CHECK",
1930                "constant",
1931                3,
1932                "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor)))\n",
1933                None,
1934            ),
1935            row(
1936                "TABLE",
1937                "constant",
1938                4,
1939                "TABLE = {\n  '\"' => '%22',\n  '\\r' => '%0D',\n}.freeze",
1940                Some((4, 8)),
1941            ),
1942            row(
1943                "LIMIT",
1944                "field",
1945                10,
1946                "private static final int LIMIT = 5;",
1947                None,
1948            ),
1949        ];
1950
1951        let out = format_file_skeleton("mixed", &symbols, None, 0);
1952
1953        assert!(
1954            out.contains("[class^=rz-] img,[class^=rz-] svg; // L1-1"),
1955            "{out}"
1956        );
1957        assert!(out.contains("public int[,,] Coordinates; // L2-2"), "{out}");
1958        assert!(
1959            out.contains(
1960                "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor))); // L3-3\n"
1961            ),
1962            "{out}"
1963        );
1964        assert!(
1965            out.contains("TABLE = { /* 5 lines hidden: L4-L8 */ }"),
1966            "{out}"
1967        );
1968        assert!(
1969            out.contains("private static final int LIMIT = 5; // L10-10"),
1970            "{out}"
1971        );
1972    }
1973
1974    #[test]
1975    fn skeleton_cuts_a_field_with_members_at_its_brace() {
1976        let field = Symbol {
1977            kind: "field".into(),
1978            language: "java".into(),
1979            start_line: 1,
1980            end_line: 9,
1981            body_start_line: None,
1982            body_end_line: None,
1983            signature: Some(
1984                "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n  @Override\n}"
1985                    .into(),
1986            ),
1987            ..sample_symbol("CLASS")
1988        };
1989        let member = Symbol {
1990            kind: "method".into(),
1991            language: "java".into(),
1992            parent_symbol_id: Some("id_CLASS".into()),
1993            start_line: 2,
1994            end_line: 4,
1995            signature: Some("public void write(JsonWriter out, Class value)".into()),
1996            ..sample_symbol("write")
1997        };
1998
1999        let out = format_file_skeleton("TypeAdapters.java", &[field, member], None, 0);
2000
2001        assert!(
2002            out.contains(
2003                "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n"
2004            ),
2005            "{out}"
2006        );
2007        assert!(!out.contains('…'), "{out}");
2008    }
2009
2010    #[test]
2011    fn import_summary_counts_sites_and_files_and_lists_three() {
2012        let sites = vec![
2013            ("src/flask/__init__.py".to_string(), 2),
2014            ("src/flask/cli.py".to_string(), 34),
2015            ("src/flask/cli.py".to_string(), 45),
2016            ("tests/conftest.py".to_string(), 6),
2017        ];
2018
2019        assert_eq!(
2020            format_import_summary(&sites),
2021            "Imported 4 times in 3 files: src/flask/__init__.py:2, src/flask/cli.py:34, src/flask/cli.py:45, … (lookup_symbol with kind=\"import\" lists them)\n"
2022        );
2023        assert_eq!(format_import_summary(&[]), "");
2024    }
2025
2026    #[test]
2027    fn no_facts_for_an_alias_points_imports_at_lookup_and_skips_the_raw_list() {
2028        let categories = vec![("flask.route.v1".to_string(), 3)];
2029
2030        let imports = format_no_facts("import", None, &categories);
2031        let unknown = format_no_facts("widgets", None, &categories);
2032
2033        assert!(
2034            imports.contains("lookup_symbol with kind=\"import\""),
2035            "{imports}"
2036        );
2037        assert!(!imports.contains("`flask.route.v1`"), "{imports}");
2038        assert!(unknown.contains("`flask.route.v1` (3 facts)"), "{unknown}");
2039    }
2040
2041    #[test]
2042    fn markup_and_data_rows_show_their_own_kind_words() {
2043        let row = |language: &str, kind: &str| Symbol {
2044            language: language.into(),
2045            kind: kind.into(),
2046            ..sample_symbol("x")
2047        };
2048        assert_eq!(display_kind(&row("html", "class")), "element");
2049        assert_eq!(display_kind(&row("sql", "class")), "table");
2050        assert_eq!(display_kind(&row("markdown", "module")), "section");
2051        assert_eq!(display_kind(&row("markdown", "import")), "link");
2052        let attribute = Symbol {
2053            kind: "property".into(),
2054            signature: Some("self.extensions = {}".into()),
2055            ..sample_symbol("extensions")
2056        };
2057        assert_eq!(display_kind(&attribute), "attribute");
2058        let fsharp_member = Symbol {
2059            language: "fsharp".into(),
2060            signature: Some("this.Total = decimal this.Qty * this.Price".into()),
2061            ..attribute
2062        };
2063        assert_eq!(display_kind(&fsharp_member), "property");
2064        assert_eq!(display_kind(&row("python", "class")), "class");
2065    }
2066
2067    #[test]
2068    fn no_facts_heading_names_the_path_filter() {
2069        assert_eq!(
2070            no_facts_heading("config", Some("src/flask")),
2071            "No facts match 'config' under `src/flask`."
2072        );
2073        assert_eq!(
2074            no_facts_heading("config", None),
2075            "No facts match 'config' in this repository."
2076        );
2077    }
2078
2079    #[test]
2080    fn outline_says_how_many_definitions_it_left_out_and_counts_tests() {
2081        let mut root = OutlineNode::default();
2082        let symbol = |name: &str, is_test: bool| Symbol {
2083            kind: "function".into(),
2084            path: "tests/test_basic.py".into(),
2085            is_test,
2086            ..sample_symbol(name)
2087        };
2088        let symbols = HashMap::from([(
2089            "tests/test_basic.py".to_string(),
2090            vec![symbol("helper", false), symbol("test_one", true)],
2091        )]);
2092        let counts = HashMap::from([(
2093            "tests/test_basic.py".to_string(),
2094            crate::queries::OutlineCounts {
2095                definitions: 3,
2096                tests: 90,
2097                fixtures: 2,
2098            },
2099        )]);
2100        add_path_to_outline(&mut root, "tests/test_basic.py", &symbols, &counts, 3, "");
2101
2102        let mut out = String::new();
2103        render_outline_tree(&mut out, &root, "", 0, 3);
2104
2105        assert!(
2106            out.contains("test_basic.py [function helper, +2 more, 90 tests, 2 fixtures]"),
2107            "{out}"
2108        );
2109    }
2110
2111    #[test]
2112    fn outline_counts_the_files_below_the_depth_limit() {
2113        let mut root = OutlineNode::default();
2114        let no_symbols = HashMap::new();
2115        for path in [
2116            "src/flask/app.py",
2117            "src/flask/json/tag.py",
2118            "src/flask/cli.py",
2119            "tests/conftest.py",
2120        ] {
2121            add_path_to_outline(&mut root, path, &no_symbols, &HashMap::new(), 2, "");
2122        }
2123
2124        let mut out = String::new();
2125        render_outline_tree(&mut out, &root, "", 0, 2);
2126
2127        assert!(out.contains("└── flask/ (3 indexed files)"), "{out}");
2128        assert!(!out.contains("tests/ ("), "{out}");
2129    }
2130
2131    #[test]
2132    fn exact_lookup_folds_only_imports_named_exactly_like_the_query() {
2133        let rows = vec![
2134            Symbol {
2135                kind: "function".into(),
2136                ..sample_symbol("read")
2137            },
2138            Symbol {
2139                kind: "import".into(),
2140                path: "src/flask/cli.py".into(),
2141                start_line: 1034,
2142                ..sample_symbol("readline")
2143            },
2144            Symbol {
2145                kind: "import".into(),
2146                path: "src/Types.kt".into(),
2147                start_line: 18,
2148                ..sample_symbol("com.example.internal.read")
2149            },
2150        ];
2151
2152        let out = format_find_symbol_results("read", &rows, &[], 20);
2153
2154        assert!(
2155            out.contains(
2156                "- 1 other row starts with or contains `read`, ignoring case: `readline` (import) (lookup_symbol"
2157            ),
2158            "{out}"
2159        );
2160        assert!(
2161            out.contains(
2162                "- 1 import of `read`: src/Types.kt:18 (lookup_symbol with kind=\"import\" lists them)"
2163            ),
2164            "{out}"
2165        );
2166    }
2167
2168    #[test]
2169    fn discovery_formatters_append_ids_to_metadata_rows() {
2170        let exact = vec![sample_symbol("exact")];
2171        let fallback = vec![SymbolSearchResult {
2172            symbol: sample_symbol("fallback"),
2173            score: 1.0,
2174            snippet: None,
2175            explain: None,
2176        }];
2177
2178        let outputs = [
2179            (
2180                format_find_symbol_results("exact", &exact, &[], 20),
2181                "id_exact",
2182            ),
2183            (
2184                format_find_symbol_results("fallback query", &[], &fallback, 20),
2185                "id_fallback",
2186            ),
2187            (
2188                format_search_results("fallback", &fallback, 20),
2189                "id_fallback",
2190            ),
2191        ];
2192
2193        for (formatted, id) in outputs {
2194            assert!(
2195                formatted
2196                    .lines()
2197                    .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
2198                "{formatted}"
2199            );
2200            assert!(!formatted.contains("\n  id="), "{formatted}");
2201        }
2202    }
2203
2204    #[test]
2205    fn every_printed_part_of_the_explain_line_sums_to_the_score() {
2206        let e = SearchExplain {
2207            bm25: Some(-3.21),
2208            branches: vec!["word".into()],
2209            name_tier: "all".into(),
2210            name_strength: 6,
2211            term_score: 24.5,
2212            name_bonus: 60.0,
2213            kind_prior: 4.0,
2214            path_role: -10.0,
2215            documentation: -200.0,
2216            test_intent: 5.0,
2217            nested: 0.0,
2218            terms: vec![("sha".into(), "name".into(), 3.0)],
2219            word_weights: vec![("sha".into(), 2.6)],
2220            candidates: 1,
2221            rerank_us: 1,
2222        };
2223        let score = e.term_score
2224            + e.name_bonus
2225            + e.kind_prior
2226            + e.path_role
2227            + e.documentation
2228            + e.test_intent;
2229
2230        let line = explain_line(score, &e);
2231        let parts: f64 = line
2232            .split(" = ")
2233            .nth(1)
2234            .unwrap()
2235            .split(" [")
2236            .next()
2237            .unwrap()
2238            .split(" + ")
2239            .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
2240            .sum();
2241
2242        assert!(line.starts_with(&format!(
2243            "score {score:.1} = terms 24.5 + name all(6) 60.0 "
2244        )));
2245        assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
2246    }
2247
2248    #[test]
2249    fn test_format_search_results_prints_the_explain_breakdown_when_present() {
2250        let mut result = SymbolSearchResult {
2251            symbol: sample_symbol("parseSha256Sidecar"),
2252            score: 71.6,
2253            snippet: Some("parse[Sha256]Sidecar".into()),
2254            explain: Some(SearchExplain {
2255                bm25: Some(-3.21),
2256                branches: vec!["word".into(), "name".into()],
2257                name_tier: "all".into(),
2258                name_strength: 6,
2259                terms: vec![
2260                    ("sha".into(), "name".into(), 3.0),
2261                    ("256".into(), "name".into(), 3.0),
2262                ],
2263                term_score: 12.6,
2264                name_bonus: 60.0,
2265                kind_prior: 4.0,
2266                path_role: -10.0,
2267                documentation: 0.0,
2268                test_intent: 5.0,
2269                nested: 0.0,
2270                word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
2271                candidates: 37,
2272                rerank_us: 180,
2273            }),
2274        };
2275
2276        let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
2277        assert!(formatted.contains(
2278            "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
2279        ));
2280        assert!(formatted.contains(
2281            "  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"
2282        ));
2283
2284        result.explain = None;
2285        let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
2286        assert!(!silent.contains("explain"));
2287        assert!(!silent.contains("rerank"));
2288    }
2289
2290    #[test]
2291    fn test_format_search_results_discloses_a_reached_limit() {
2292        let results = vec![SymbolSearchResult {
2293            symbol: sample_symbol("parse_tokens"),
2294            score: 0.0,
2295            snippet: None,
2296            explain: None,
2297        }];
2298
2299        let formatted = format_search_results("tokens", &results, 1);
2300        assert!(
2301            formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
2302        );
2303
2304        let at_ceiling =
2305            format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
2306        assert!(!at_ceiling.contains("limit reached"));
2307
2308        let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
2309            .map(|_| SymbolSearchResult {
2310                symbol: sample_symbol("parse_tokens"),
2311                score: 0.0,
2312                snippet: None,
2313                explain: None,
2314            })
2315            .collect();
2316        let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
2317        assert!(capped.contains("(limit reached); narrow the query to see more.]"));
2318    }
2319
2320    #[test]
2321    fn test_format_blast_radius() {
2322        let res = BlastRadiusResult {
2323            seed_type: "symbol".into(),
2324            seeds: vec!["do_work".into()],
2325            likely_tests: vec![TestTarget {
2326                name: "test_do_work".into(),
2327                path: "tests/work_test.rs".into(),
2328                line: 15,
2329                reason: "direct caller".into(),
2330            }],
2331            impacted_symbols: vec![ImpactedSymbol {
2332                name: "caller_fn".into(),
2333                kind: "function".into(),
2334                path: "src/caller.rs".into(),
2335                line: 42,
2336                depth: 1,
2337            }],
2338            traversal_ceiling_reached: false,
2339            likely_tests_truncated: false,
2340            impacted_symbols_truncated: false,
2341            test_file_ceiling_reached: false,
2342            limit_at_maximum: false,
2343            likely_tests_found: 0,
2344            impacted_symbols_found: 0,
2345        };
2346
2347        let formatted = format_blast_radius(&res);
2348        assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
2349        assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
2350        assert!(
2351            formatted.contains("tests/work_test.rs:\n  - `test_do_work` [line 15] (direct caller)")
2352        );
2353        assert!(formatted.contains("src/caller.rs:\n  - [depth 1] function `caller_fn` [line 42]"));
2354    }
2355
2356    #[test]
2357    fn format_blast_radius_groups_every_returned_test_by_file() {
2358        let mut likely_tests = Vec::new();
2359        for i in 1..=25 {
2360            likely_tests.push(TestTarget {
2361                name: format!("test_{i}"),
2362                path: format!("tests/test_{}.rs", (i % 3) + 1),
2363                line: i * 10,
2364                reason: "direct caller".into(),
2365            });
2366        }
2367
2368        let impacted_symbols = vec![
2369            ImpactedSymbol {
2370                name: "use_foo".into(),
2371                kind: "import".into(),
2372                path: "src/service.rs".into(),
2373                line: 1,
2374                depth: 1,
2375            },
2376            ImpactedSymbol {
2377                name: "service_fn".into(),
2378                kind: "function".into(),
2379                path: "src/service.rs".into(),
2380                line: 20,
2381                depth: 1,
2382            },
2383            ImpactedSymbol {
2384                name: "api_handler".into(),
2385                kind: "function".into(),
2386                path: "src/api.rs".into(),
2387                line: 45,
2388                depth: 2,
2389            },
2390        ];
2391
2392        let res = BlastRadiusResult {
2393            seed_type: "file".into(),
2394            seeds: vec!["src/lib.rs".into()],
2395            likely_tests,
2396            impacted_symbols,
2397            traversal_ceiling_reached: false,
2398            likely_tests_truncated: false,
2399            impacted_symbols_truncated: false,
2400            test_file_ceiling_reached: false,
2401            limit_at_maximum: false,
2402            likely_tests_found: 0,
2403            impacted_symbols_found: 0,
2404        };
2405
2406        let formatted = format_blast_radius(&res);
2407
2408        assert!(formatted.contains("### Likely Tests to Run (25 returned)"));
2409        assert!(
2410            formatted.contains("  - `test_25` [line 250]"),
2411            "{formatted}"
2412        );
2413        assert!(!formatted.contains("hidden by the compact"));
2414
2415        assert!(formatted.contains("tests/test_1.rs:\n"));
2416        assert!(formatted.contains("  - `test_"));
2417
2418        assert!(!formatted.contains("use_foo"));
2419        assert!(
2420            formatted
2421                .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
2422        );
2423        assert!(formatted.contains("src/service.rs:\n"));
2424        assert!(formatted.contains("  - [depth 1] function `service_fn` [line 20]"));
2425    }
2426
2427    fn sample_symbol(name: &str) -> Symbol {
2428        Symbol {
2429            symbol_id: format!("id_{name}"),
2430            file_id: "f1".into(),
2431            path: "src/lib.rs".into(),
2432            language: "rust".into(),
2433            name: name.into(),
2434            kind: "function".into(),
2435            signature: Some(format!("pub fn {name}()")),
2436            doc_comment: None,
2437            visibility: Some("pub".into()),
2438            parent_symbol_id: None,
2439            start_line: 1,
2440            start_column: 0,
2441            end_line: 10,
2442            end_column: 1,
2443            start_byte: 0,
2444            end_byte: 100,
2445            body_start_line: Some(2),
2446            body_start_column: Some(0),
2447            body_end_line: Some(9),
2448            body_end_column: Some(1),
2449            body_start_byte: Some(10),
2450            body_end_byte: Some(99),
2451            body_hash: None,
2452            semantic_group: None,
2453            is_test: false,
2454            test_container: false,
2455        }
2456    }
2457
2458    fn sample_context_slice() -> ContextSlice {
2459        ContextSlice {
2460            target_symbol: sample_symbol("target_fn"),
2461            target_body: "    println!(\"hello\");\n".into(),
2462            callee_signatures: Vec::new(),
2463            related_types: Vec::new(),
2464            related_tests: Vec::new(),
2465        }
2466    }
2467
2468    #[test]
2469    fn format_context_slice_does_not_repeat_expression_already_in_signature() {
2470        let body = "=>\n        transport.SendAsync<Response>(\n            HttpMethod.Get,\n            $\"items/{id}\",\n            cancellationToken: cancellationToken)";
2471        let mut slice = sample_context_slice();
2472        slice.target_symbol.language = "csharp".into();
2473        slice.target_symbol.signature = Some(format!(
2474            "public static Task<Response?> GetByIdAsync(\n        int id,\n        CancellationToken cancellationToken = default){body}"
2475        ));
2476        slice.target_body = body.into();
2477
2478        let formatted = format_context_slice(&slice);
2479
2480        assert_eq!(
2481            formatted.matches("transport.SendAsync").count(),
2482            1,
2483            "{formatted}"
2484        );
2485    }
2486
2487    #[test]
2488    fn context_slice_leaves_out_a_signature_that_the_body_already_holds() {
2489        let mut slice = sample_context_slice();
2490        slice.target_symbol.signature = Some("type Person = { Name: string; Age: int }".into());
2491        slice.target_body = "type Person = { Name: string; Age: int }\n".into();
2492
2493        let text = format_context_slice(&slice);
2494
2495        assert!(!text.contains("Signature:"), "{text}");
2496        assert_eq!(text.matches("type Person").count(), 1, "{text}");
2497    }
2498
2499    #[test]
2500    fn context_slice_says_when_no_test_was_found() {
2501        let text = format_context_slice(&sample_context_slice());
2502
2503        assert!(
2504            text.ends_with("### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n"),
2505            "{text}"
2506        );
2507    }
2508
2509    #[test]
2510    fn test_context_slice_shows_truncation_notice_when_caps_hit() {
2511        let mut slice = sample_context_slice();
2512        slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
2513        let text = format_context_slice(&slice);
2514        assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
2515
2516        let mut slice_tests = sample_context_slice();
2517        slice_tests.related_tests = (1..=5)
2518            .map(|i| {
2519                let mut sym = sample_symbol(&format!("test_fn_{i}"));
2520                sym.path = format!("tests/test_{i}.rs");
2521                sym.is_test = true;
2522                sym
2523            })
2524            .collect();
2525        let text_tests = format_context_slice(&slice_tests);
2526        assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
2527    }
2528
2529    #[test]
2530    fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
2531        let impacted_symbols = (1..=200)
2532            .map(|i| ImpactedSymbol {
2533                name: format!("sym_{i}"),
2534                kind: "function".into(),
2535                path: format!("src/mod_{}.rs", i % 10),
2536                line: i,
2537                depth: 1,
2538            })
2539            .collect();
2540
2541        let res = BlastRadiusResult {
2542            seed_type: "symbol".into(),
2543            seeds: vec!["root_fn".into()],
2544            likely_tests: Vec::new(),
2545            impacted_symbols,
2546            traversal_ceiling_reached: true,
2547            likely_tests_truncated: false,
2548            impacted_symbols_truncated: false,
2549            test_file_ceiling_reached: false,
2550            limit_at_maximum: false,
2551            likely_tests_found: 0,
2552            impacted_symbols_found: 0,
2553        };
2554
2555        let formatted = format_blast_radius(&res);
2556        assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
2557    }
2558
2559    #[test]
2560    fn blast_radius_does_not_claim_no_results_after_discovery_ceiling() {
2561        let res = BlastRadiusResult {
2562            seed_type: "file".into(),
2563            seeds: vec!["src/widget.rs".into()],
2564            likely_tests: Vec::new(),
2565            impacted_symbols: Vec::new(),
2566            likely_tests_truncated: false,
2567            impacted_symbols_truncated: false,
2568            traversal_ceiling_reached: true,
2569            test_file_ceiling_reached: true,
2570            limit_at_maximum: false,
2571            likely_tests_found: 0,
2572            impacted_symbols_found: 0,
2573        };
2574
2575        let formatted = format_blast_radius(&res);
2576        assert!(formatted.contains("Likely Tests to Run (0 returned)"));
2577        assert!(formatted.contains("Downstream Impact (0 returned)"));
2578        assert!(formatted.contains("Name-matched test discovery hit its fixed ceiling"));
2579        assert!(!formatted.contains("No direct or name-matched tests found"));
2580        assert!(!formatted.contains("No downstream callers found within depth"));
2581
2582        let no_ceiling = BlastRadiusResult {
2583            traversal_ceiling_reached: false,
2584            test_file_ceiling_reached: false,
2585            limit_at_maximum: false,
2586            ..res.clone()
2587        };
2588        assert!(format_blast_radius(&no_ceiling).contains("No direct or name-matched tests found"));
2589
2590        let traversal_only = BlastRadiusResult {
2591            traversal_ceiling_reached: true,
2592            test_file_ceiling_reached: false,
2593            limit_at_maximum: false,
2594            ..res
2595        };
2596        let traversal_only_text = format_blast_radius(&traversal_only);
2597        assert!(!traversal_only_text.contains("No direct or name-matched tests found"));
2598    }
2599
2600    fn skeleton_row(
2601        id: &str,
2602        parent: Option<&str>,
2603        kind: &str,
2604        name: &str,
2605        signature: &str,
2606        lines: (usize, usize),
2607        body: Option<(usize, usize)>,
2608    ) -> Symbol {
2609        Symbol {
2610            symbol_id: id.into(),
2611            file_id: "f1".into(),
2612            path: "src/lib.rs".into(),
2613            language: "rust".into(),
2614            name: name.into(),
2615            kind: kind.into(),
2616            signature: Some(signature.into()),
2617            doc_comment: None,
2618            visibility: None,
2619            parent_symbol_id: parent.map(str::to_string),
2620            start_line: lines.0,
2621            start_column: 0,
2622            end_line: lines.1,
2623            end_column: 1,
2624            start_byte: 0,
2625            end_byte: 0,
2626            body_start_line: body.map(|b| b.0),
2627            body_start_column: None,
2628            body_end_line: body.map(|b| b.1),
2629            body_end_column: None,
2630            body_start_byte: None,
2631            body_end_byte: None,
2632            body_hash: None,
2633            semantic_group: None,
2634            is_test: false,
2635            test_container: false,
2636        }
2637    }
2638
2639    #[test]
2640    fn skeleton_nests_an_object_under_the_field_that_declares_it() {
2641        let syms = vec![
2642            skeleton_row(
2643                "root",
2644                None,
2645                "class",
2646                "shell",
2647                "extends ShellRoot",
2648                (1, 8),
2649                None,
2650            ),
2651            skeleton_row(
2652                "timer",
2653                Some("root"),
2654                "field",
2655                "localPluginReloadTimer",
2656                "localPluginReloadTimer: Timer",
2657                (2, 7),
2658                Some((2, 7)),
2659            ),
2660            skeleton_row(
2661                "interval",
2662                Some("timer"),
2663                "property",
2664                "interval",
2665                "interval: 150",
2666                (3, 3),
2667                None,
2668            ),
2669            skeleton_row(
2670                "fire",
2671                Some("timer"),
2672                "function",
2673                "fire",
2674                "function fire()",
2675                (5, 7),
2676                Some((6, 7)),
2677            ),
2678        ];
2679
2680        assert_eq!(
2681            format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
2682            "// File: shell/shell.qml (Lines 1-8)\n\
2683             \n\
2684             extends ShellRoot {\n\
2685             \x20   localPluginReloadTimer: Timer {\n\
2686             \x20       interval: 150; // L3-3\n\
2687             \x20       function fire() { /* 2 lines hidden: L6-L7 */ }\n\
2688             \x20   } // L2-7\n\
2689             \n\
2690             } // L1-8\n\
2691             \n"
2692        );
2693    }
2694
2695    #[test]
2696    fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
2697        let syms = vec![
2698            skeleton_row(
2699                "rusqlite",
2700                None,
2701                "field",
2702                "rusqlite",
2703                "rusqlite = { workspace = true }",
2704                (15, 15),
2705                None,
2706            ),
2707            skeleton_row(
2708                "workspace",
2709                Some("rusqlite"),
2710                "property",
2711                "workspace",
2712                "workspace = true",
2713                (15, 15),
2714                None,
2715            ),
2716        ];
2717
2718        assert_eq!(
2719            format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
2720            "// File: Cargo.toml (Lines 1-15)\n\
2721             \n\
2722             rusqlite = { workspace = true }; // L15-15\n"
2723        );
2724    }
2725
2726    #[test]
2727    fn skeleton_renders_members_of_a_single_line_enum() {
2728        let mut syms = vec![
2729            skeleton_row(
2730                "mode",
2731                None,
2732                "enum",
2733                "DispatchMode",
2734                "public enum DispatchMode",
2735                (21, 21),
2736                None,
2737            ),
2738            skeleton_row(
2739                "provision",
2740                Some("mode"),
2741                "enum_member",
2742                "Provision",
2743                "Provision",
2744                (21, 21),
2745                None,
2746            ),
2747            skeleton_row(
2748                "deprovision",
2749                Some("mode"),
2750                "enum_member",
2751                "Deprovision",
2752                "Deprovision",
2753                (21, 21),
2754                None,
2755            ),
2756            skeleton_row(
2757                "extension",
2758                Some("mode"),
2759                "enum_member",
2760                "Extension",
2761                "Extension",
2762                (21, 21),
2763                None,
2764            ),
2765        ];
2766        for symbol in &mut syms {
2767            symbol.language = "csharp".into();
2768        }
2769
2770        let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
2771
2772        for member in ["Provision", "Deprovision", "Extension"] {
2773            assert!(skeleton.contains(member), "{skeleton}");
2774        }
2775    }
2776
2777    #[test]
2778    fn a_hidden_body_names_the_functions_defined_inside_it_but_not_lambdas() {
2779        let mut syms = vec![
2780            skeleton_row(
2781                "factory",
2782                None,
2783                "function",
2784                "create_app",
2785                "def create_app()",
2786                (1, 9),
2787                Some((2, 9)),
2788            ),
2789            skeleton_row(
2790                "hello",
2791                Some("factory"),
2792                "function",
2793                "hello",
2794                "def hello()",
2795                (4, 5),
2796                Some((5, 5)),
2797            ),
2798            skeleton_row(
2799                "lambda",
2800                Some("factory"),
2801                "function",
2802                "lambda_7",
2803                "lambda v: v",
2804                (7, 7),
2805                None,
2806            ),
2807        ];
2808        for symbol in &mut syms {
2809            symbol.language = "python".into();
2810        }
2811
2812        let skeleton = format_file_skeleton("app.py", &syms, Some(9), 0);
2813
2814        assert!(
2815            skeleton.contains("def create_app() { /* 8 lines hidden: L2-L9; defines `hello` */ }"),
2816            "{skeleton}"
2817        );
2818
2819        for symbol in &mut syms {
2820            symbol.language = "cpp".into();
2821        }
2822        let cpp = format_file_skeleton("app.cpp", &syms, Some(9), 0);
2823        assert!(!cpp.contains("defines"), "{cpp}");
2824        syms[1].test_container = true;
2825        let cpp_section = format_file_skeleton("app.cpp", &syms, Some(9), 0);
2826        assert!(cpp_section.contains("defines `hello`"), "{cpp_section}");
2827    }
2828
2829    #[test]
2830    fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
2831        let syms = vec![
2832            skeleton_row(
2833                "cfg",
2834                None,
2835                "struct",
2836                "Config",
2837                "pub struct Config",
2838                (1, 3),
2839                None,
2840            ),
2841            skeleton_row(
2842                "retries",
2843                Some("cfg"),
2844                "field",
2845                "retries",
2846                "pub retries: u32",
2847                (2, 2),
2848                None,
2849            ),
2850            skeleton_row(
2851                "run",
2852                None,
2853                "function",
2854                "run",
2855                "pub fn run()",
2856                (5, 9),
2857                Some((6, 8)),
2858            ),
2859            skeleton_row(
2860                "tmp",
2861                Some("run"),
2862                "variable",
2863                "tmp",
2864                "let tmp",
2865                (7, 7),
2866                None,
2867            ),
2868        ];
2869
2870        assert_eq!(
2871            format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
2872            "// File: src/lib.rs (Lines 1-9)\n\
2873             \n\
2874             pub struct Config {\n\
2875             \x20   pub retries: u32; // L2-2\n\
2876             } // L1-3\n\
2877             \n\
2878             pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
2879        );
2880    }
2881
2882    #[test]
2883    fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
2884        let syms = vec![
2885            skeleton_row(
2886                "cls",
2887                None,
2888                "class",
2889                "ColumnViewAttached",
2890                "class ColumnViewAttached : public QObject",
2891                (11, 52),
2892                None,
2893            ),
2894            skeleton_row(
2895                "sig",
2896                Some("cls"),
2897                "event",
2898                "indexChanged",
2899                "void indexChanged()",
2900                (47, 47),
2901                None,
2902            ),
2903            skeleton_row(
2904                "qml",
2905                None,
2906                "event",
2907                "clicked",
2908                "signal clicked()",
2909                (60, 60),
2910                None,
2911            ),
2912        ];
2913
2914        assert_eq!(
2915            format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
2916            "// File: src/columnview.h (Lines 1-60)\n\
2917             \n\
2918             class ColumnViewAttached : public QObject {\n\
2919             \x20   void indexChanged(); // event L47-47\n\
2920             } // L11-52\n\
2921             \n\
2922             signal clicked(); // L60-60\n"
2923        );
2924    }
2925}