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