Skip to main content

code_kb_core/
formatters.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::path::Path;
3
4use crate::edit_check::{BaseSource, EditCheck, EditSite, ShapeChange};
5use crate::models::{
6    BlastRadiusResult, CallingTest, ConstructionSite, ContextSlice, ImpactedSymbol, LabeledSite,
7    OverrideRow, ReferenceReport, ReferenceSite, Registration, SearchExplain, SiteLabel, Symbol,
8    SymbolSearchResult, VariantUse,
9};
10
11/// Format progressive disclosure file skeleton with implementation bodies stripped.
12pub fn format_file_skeleton(
13    file_path: &str,
14    symbols: &[Symbol],
15    line_count: Option<usize>,
16    parse_errors: usize,
17) -> String {
18    let mut out = String::new();
19    let lines_str = match line_count {
20        Some(c) => format!(" (Lines 1-{c})"),
21        None => String::new(),
22    };
23    out.push_str(&format!("// File: {file_path}{lines_str}\n\n"));
24
25    if parse_errors > 0 {
26        let noun = if parse_errors == 1 { "error" } else { "errors" };
27        out.push_str(&format!(
28            "// {parse_errors} parse {noun}: symbols may be incomplete\n\n"
29        ));
30    }
31
32    if symbols.is_empty() {
33        out.push_str("// No exported symbols indexed.\n");
34        return out;
35    }
36
37    // A Rust `impl` block in this file can belong to a type in another file; its methods are roots here.
38    let in_file: HashSet<&str> = symbols.iter().map(|s| s.symbol_id.as_str()).collect();
39    let mut children_map: HashMap<Option<String>, Vec<&Symbol>> = HashMap::new();
40    for s in symbols {
41        let parent = s
42            .parent_symbol_id
43            .clone()
44            .filter(|parent| in_file.contains(parent.as_str()));
45        children_map.entry(parent).or_default().push(s);
46    }
47
48    // Render top-level symbols and their children
49    if let Some(roots) = children_map.get(&None) {
50        for root in roots {
51            render_symbol_skeleton(&mut out, root, None, &children_map, 0);
52        }
53    } else {
54        // If parent relationships are missing or flat, render all sorted by line
55        for s in symbols {
56            render_symbol_skeleton(&mut out, s, None, &children_map, 0);
57        }
58    }
59
60    out
61}
62
63fn is_container_kind(kind: &str) -> bool {
64    let hides_its_body = matches!(
65        kind,
66        "function" | "method" | "constructor" | "destructor" | "operator"
67    );
68    !is_skippable_kind(kind) && !hides_its_body
69}
70
71/// The kind word to show for a symbol. julie reuses code kinds for markup and data files: an
72/// HTML element and a SQL table are `class` rows, a Markdown heading a `module`, a Markdown
73/// link an `import`. `queries::KIND_FILTER` mirrors it in SQL.
74pub(crate) fn display_kind(sym: &Symbol) -> &str {
75    match (sym.language.as_str(), sym.kind.as_str()) {
76        ("html", "class") => "element",
77        ("sql", "class") => "table",
78        ("markdown", "module") => "section",
79        ("markdown", "import") => "link",
80        (language, "property")
81            if language != "fsharp"
82                && sym.signature.as_deref().is_some_and(|sig| {
83                    ["self.", "this.", "cls."]
84                        .iter()
85                        .any(|p| sig.starts_with(p))
86                }) =>
87        {
88            "attribute"
89        }
90        (_, kind) => kind,
91    }
92}
93
94fn is_skippable_kind(kind: &str) -> bool {
95    matches!(kind, "variable" | "parameter" | "import")
96}
97
98/// A value row on one line: the declaration before its `=` stays whole, because its type is part
99/// of the interface, and the value is cut to fit about 120 characters.
100fn value_row_signature(sig: &str) -> std::borrow::Cow<'_, str> {
101    let sig = sig.trim_end().trim_end_matches(';').trim_end();
102    if !sig.contains('\n') && sig.chars().count() <= 120 {
103        return sig.into();
104    }
105    let joined = one_line(sig);
106    let Some(equals) = value_equals(&joined) else {
107        if joined.chars().count() <= 120 {
108            return joined.into();
109        }
110        let cut: String = joined.chars().take(119).collect();
111        return format!("{}…", cut.trim_end()).into();
112    };
113    let (declaration, value) = joined.split_at(equals);
114    let budget = 120usize.saturating_sub(declaration.chars().count()).max(40);
115    if value.chars().count() <= budget {
116        return joined.into();
117    }
118    let cut: String = value.chars().take(budget - 1).collect();
119    format!("{declaration}{}…", cut.trim_end()).into()
120}
121
122/// Joins a multi-line declaration into one line, without trailing `  # ` or `  // ` comments.
123fn one_line(sig: &str) -> String {
124    let mut joined = sig
125        .lines()
126        .map(|line| {
127            let code = ["  # ", "  // "]
128                .iter()
129                .filter_map(|marker| line.find(marker))
130                .min()
131                .map_or(line, |at| &line[..at]);
132            code.trim().trim_end_matches('\\').trim_end()
133        })
134        .filter(|line| !line.is_empty())
135        .collect::<Vec<_>>()
136        .join(" ");
137    for (from, to) in [
138        ("( ", "("),
139        ("[ ", "["),
140        ("{ ", "{"),
141        (", )", ")"),
142        (", ]", "]"),
143        (", }", "}"),
144        (" )", ")"),
145        (" ]", "]"),
146        (" }", "}"),
147    ] {
148        joined = joined.replace(from, to);
149    }
150    joined
151}
152
153/// Whether `sig` assigns a value: an `=` outside brackets, before any `{`, that is not part of
154/// `==`, `=>`, `>=`, `^=`, or another operator.
155fn assigns_a_value(sig: &str) -> bool {
156    value_equals(sig).is_some()
157}
158
159/// The byte index of the `=` that assigns a value, as [`assigns_a_value`] defines it.
160fn value_equals(sig: &str) -> Option<usize> {
161    let bytes = sig.as_bytes();
162    let mut depth = 0usize;
163    for (i, &byte) in bytes.iter().enumerate() {
164        match byte {
165            b'(' | b'[' => depth += 1,
166            b')' | b']' => depth = depth.saturating_sub(1),
167            b'{' if depth == 0 => return None,
168            b'=' if depth == 0 => {
169                let before = i.checked_sub(1).map(|j| bytes[j]);
170                let after = bytes.get(i + 1).copied();
171                let operator_before = before.is_some_and(|b| b"<>!=^*~|$+-/%&:?".contains(&b));
172                let operator_after = after.is_some_and(|b| b == b'=' || b == b'>');
173                if !operator_before && !operator_after {
174                    return Some(i);
175                }
176            }
177            _ => {}
178        }
179    }
180    None
181}
182
183fn sanitize_skeleton_sig<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
184    if matches!(
185        sym.kind.as_str(),
186        "variable" | "constant" | "property" | "field" | "enum_member"
187    ) && assigns_a_value(sig)
188    {
189        return value_row_signature(sig);
190    }
191    container_signature(sig, sym)
192}
193
194/// The signature cut at its first `{`, which opens the body that the skeleton renders.
195fn container_signature<'a>(sig: &'a str, sym: &'a Symbol) -> std::borrow::Cow<'a, str> {
196    let expression_arrow = if sym.language == "csharp"
197        && matches!(
198            sym.kind.as_str(),
199            "method" | "function" | "constructor" | "operator"
200        ) {
201        sig.match_indices("=>")
202            .find_map(|(idx, _)| sig[..idx].trim_end().ends_with(')').then_some(idx))
203    } else {
204        None
205    };
206    let cutoff = sig
207        .find('{')
208        .into_iter()
209        .chain(expression_arrow)
210        .min()
211        .unwrap_or(sig.len());
212    let clean = sig[..cutoff].trim_end();
213    let trimmed = clean.trim_end_matches(';').trim_end();
214    if trimmed.is_empty() {
215        sym.name.as_str().into()
216    } else {
217        trimmed.into()
218    }
219}
220
221fn signature_without_duplicate_body<'a>(signature: &'a str, body: &str) -> &'a str {
222    let body = body.trim();
223    if body.is_empty() {
224        return signature;
225    }
226
227    let trimmed_signature = signature.trim_end();
228    trimmed_signature
229        .strip_suffix(body)
230        .map(str::trim_end)
231        .unwrap_or(signature)
232}
233
234/// The kind word for a leaf row whose signature does not spell it. A C++ Qt signal is declared
235/// under a `Q_SIGNALS:` label, so its signature reads like a method; the `event` kind goes into
236/// the trailing comment instead.
237fn unspelled_kind(sym: &Symbol) -> &'static str {
238    if sym.kind != "event" {
239        return "";
240    }
241    let spelled = sym.signature.as_deref().is_some_and(|sig| {
242        sig.split_whitespace()
243            .any(|word| word == "signal" || word == "event")
244    });
245    if spelled { "" } else { "event " }
246}
247
248/// `in run(), ` for an attribute that a method assigns, so the skeleton does not present it as
249/// a declaration of the class body.
250fn assigning_method(
251    sym: &Symbol,
252    parent: Option<&Symbol>,
253    children_map: &HashMap<Option<String>, Vec<&Symbol>>,
254) -> String {
255    if !sym
256        .signature
257        .as_deref()
258        .is_some_and(|sig| sig.starts_with("self."))
259    {
260        return String::new();
261    }
262    parent
263        .and_then(|parent| children_map.get(&Some(parent.symbol_id.clone())))
264        .and_then(|siblings| {
265            siblings.iter().find(|method| {
266                matches!(method.kind.as_str(), "method" | "function" | "constructor")
267                    && method.start_line <= sym.start_line
268                    && sym.end_line <= method.end_line
269            })
270        })
271        .map_or_else(String::new, |method| format!("in {}(), ", method.name))
272}
273
274/// julie gives other languages' block locals, markup elements, and data keys a `variable` row
275/// under a parent too; only a Python module or class body declares names that way.
276fn is_shown(sym: &Symbol, parent: Option<&Symbol>) -> bool {
277    let python_declaration = sym.kind == "variable"
278        && sym.language == "python"
279        && parent.is_none_or(|parent| parent.kind == "class");
280    !is_skippable_kind(&sym.kind) || python_declaration
281}
282
283/// `; defines `a`, `b`` for the functions and classes declared inside a hidden body, so a view
284/// or wrapper defined in a factory stays visible. Lambdas are left out.
285/// C and C++ cannot define a function inside a function, so such a row comes from a macro the
286/// parser misread (`JSON_CATCH (...) { }`). Test sections such as doctest `SECTION` are real.
287fn is_misparsed_c_function(child: &Symbol) -> bool {
288    matches!(child.language.as_str(), "c" | "cpp")
289        && matches!(child.kind.as_str(), "function" | "method")
290        && !child.is_test
291        && !child.test_container
292}
293
294fn nested_definitions(children: &[&Symbol]) -> String {
295    let names: Vec<String> = children
296        .iter()
297        .filter(|child| {
298            matches!(
299                child.kind.as_str(),
300                "function" | "method" | "class" | "struct"
301            )
302        })
303        .filter(|child| {
304            !child
305                .signature
306                .as_deref()
307                .is_some_and(|sig| sig.starts_with("lambda"))
308        })
309        .filter(|child| !is_misparsed_c_function(child))
310        .map(|child| format!("`{}`", child.name))
311        .fold(Vec::new(), |mut names, name| {
312            if !names.contains(&name) {
313                names.push(name);
314            }
315            names
316        });
317    match names.len() {
318        0 => String::new(),
319        1..=5 => format!("; defines {}", names.join(", ")),
320        n => format!("; defines {}, +{} more", names[..5].join(", "), n - 5),
321    }
322}
323
324fn render_symbol_skeleton(
325    out: &mut String,
326    sym: &Symbol,
327    parent: Option<&Symbol>,
328    children_map: &HashMap<Option<String>, Vec<&Symbol>>,
329    indent_level: usize,
330) {
331    if !is_shown(sym, parent) {
332        return;
333    }
334
335    let indent = "    ".repeat(indent_level);
336
337    if let Some(ref doc) = sym.doc_comment {
338        let document = crate::queries::DOCUMENTATION_LANGUAGES.contains(&sym.language.as_str());
339        let lines: Vec<_> = if document {
340            doc.lines().collect()
341        } else {
342            doc.lines()
343                .map(doc_line_text)
344                .filter(|line| !line.is_empty())
345                .collect()
346        };
347        let cap = 3;
348        for line in lines.iter().take(cap) {
349            out.push_str(&format!("{indent}/// {line}\n"));
350        }
351        if lines.len() > cap {
352            out.push_str(&format!(
353                "{indent}/// ... ({} more lines)\n",
354                lines.len() - cap
355            ));
356        }
357    }
358
359    let span_str = format!("L{}-{}", sym.start_line, sym.end_line);
360    let leaf_note = format!(
361        "{}{}{span_str}",
362        unspelled_kind(sym),
363        assigning_method(sym, parent, children_map)
364    );
365
366    let children = children_map.get(&Some(sym.symbol_id.clone()));
367
368    let spans_multiple_lines = sym.end_line > sym.start_line;
369
370    if is_container_kind(&sym.kind)
371        && children.is_some_and(|list| list.iter().any(|child| is_shown(child, Some(sym))))
372        && (spans_multiple_lines || sym.kind == "enum")
373    {
374        let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
375        let sig = container_signature(raw_sig, sym);
376        out.push_str(&format!("{indent}{sig} {{\n"));
377        if let Some(child_list) = children {
378            for child in child_list {
379                render_symbol_skeleton(out, child, Some(sym), children_map, indent_level + 1);
380            }
381        }
382        out.push_str(&format!("{indent}}} // {span_str}\n\n"));
383    } else {
384        if let Some(count) = sym.hidden_body_line_count() {
385            let raw_sig = sym.signature.as_deref().unwrap_or(&sym.name);
386            let sig = if count > 1 {
387                container_signature(raw_sig, sym)
388            } else {
389                sanitize_skeleton_sig(raw_sig, sym)
390            };
391            let b_start = sym.body_start_line.unwrap_or(sym.start_line);
392            let b_end = sym.body_end_line.unwrap_or(sym.end_line);
393
394            if count > 1 {
395                let defines = nested_definitions(children.map(Vec::as_slice).unwrap_or_default());
396                out.push_str(&format!(
397                    "{indent}{sig} {{ /* {count} lines hidden: L{b_start}-L{b_end}{defines} */ }}\n"
398                ));
399            } else {
400                out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
401            }
402        } else if let Some(ref raw_sig) = sym.signature {
403            let sig = sanitize_skeleton_sig(raw_sig, sym);
404            out.push_str(&format!("{indent}{sig}; // {leaf_note}\n"));
405        } else {
406            out.push_str(&format!(
407                "{indent}{} {sym_name}; // {span_str}\n",
408                display_kind(sym),
409                sym_name = sym.name
410            ));
411        }
412        if sym.kind == "namespace"
413            && let Some(child_list) = children
414        {
415            for child in child_list {
416                render_symbol_skeleton(out, child, Some(sym), children_map, indent_level);
417            }
418        }
419    }
420}
421
422/// Node representing directory or file in codebase outline tree.
423#[derive(Default)]
424pub struct OutlineNode {
425    pub files: BTreeMap<String, Vec<String>>, // file_name -> list of top symbol names with kinds
426    pub subdirs: BTreeMap<String, OutlineNode>,
427    /// Files under this directory that lie below the depth limit.
428    pub hidden_files: usize,
429    /// Indexed files in this directory with no function, class, or test to list.
430    pub plain_files: Vec<String>,
431    /// Files under this directory that no extractor reads.
432    pub unsupported_files: usize,
433}
434
435/// The path components of `file_path` below the outline's path filter, or `None` for a path
436/// outside it.
437fn outline_components(file_path: &str, norm_filter: &str) -> Option<Vec<String>> {
438    let normalized = file_path.replace('\\', "/");
439    let rel_path_str = if norm_filter.is_empty() {
440        normalized.as_str()
441    } else if normalized.eq_ignore_ascii_case(norm_filter) {
442        Path::new(&normalized)
443            .file_name()
444            .and_then(|n| n.to_str())
445            .unwrap_or(&normalized)
446    } else if normalized.len() > norm_filter.len()
447        && normalized.as_bytes()[norm_filter.len()] == b'/'
448        && normalized[..norm_filter.len()].eq_ignore_ascii_case(norm_filter)
449    {
450        &normalized[norm_filter.len() + 1..]
451    } else {
452        return None;
453    };
454
455    let path = Path::new(rel_path_str);
456    let components: Vec<String> = path
457        .components()
458        .map(|c| c.as_os_str().to_str().unwrap_or(""))
459        .filter(|s| !s.is_empty())
460        .map(str::to_string)
461        .collect();
462
463    if components.is_empty() {
464        return None;
465    }
466
467    Some(components)
468}
469
470/// Counts a file no extractor reads on the deepest folder the outline already shows for it, or
471/// on its top-level folder. Call it after every indexed file is added.
472pub fn add_unsupported_to_outline(
473    root_node: &mut OutlineNode,
474    file_path: &str,
475    max_depth: usize,
476    norm_filter: &str,
477) {
478    let Some(components) = outline_components(file_path, norm_filter) else {
479        return;
480    };
481    let mut curr = root_node;
482    for (i, comp) in components
483        .iter()
484        .take(components.len() - 1)
485        .take(max_depth)
486        .enumerate()
487    {
488        if i > 0 && !curr.subdirs.contains_key(comp) {
489            break;
490        }
491        curr = curr.subdirs.entry(comp.clone()).or_default();
492    }
493    curr.unsupported_files += 1;
494}
495
496/// Add a file path into the outline tree, bounded by max_depth.
497pub fn add_path_to_outline(
498    root_node: &mut OutlineNode,
499    file_path: &str,
500    symbols_by_file: &HashMap<String, Vec<Symbol>>,
501    counts: &HashMap<String, crate::queries::OutlineCounts>,
502    max_depth: usize,
503    norm_filter: &str,
504) {
505    let Some(components) = outline_components(file_path, norm_filter) else {
506        return;
507    };
508    let normalized = file_path.replace('\\', "/");
509
510    let mut curr = root_node;
511    let depth = components.len();
512
513    for (i, comp) in components.iter().enumerate() {
514        if i == depth - 1 {
515            if depth > max_depth {
516                curr.hidden_files += 1;
517            } else {
518                let mut sym_tags: Vec<String> = symbols_by_file
519                    .get(&normalized)
520                    .into_iter()
521                    .flatten()
522                    .filter(|s| !s.is_test && !s.test_container)
523                    .take(5)
524                    .map(|s| format!("{} {}", display_kind(s), s.name))
525                    .collect();
526                let count = counts.get(&normalized).copied().unwrap_or_default();
527                if count.definitions > sym_tags.len() {
528                    sym_tags.push(format!("+{} more", count.definitions - sym_tags.len()));
529                }
530                if count.tests > 0 {
531                    sym_tags.push(format!("{} {}", count.tests, plural(count.tests, "test")));
532                }
533                if count.fixtures > 0 {
534                    sym_tags.push(format!(
535                        "{} {}",
536                        count.fixtures,
537                        plural(count.fixtures, "fixture")
538                    ));
539                }
540                if sym_tags.is_empty() {
541                    curr.plain_files.push(comp.to_string());
542                } else {
543                    curr.files.insert(comp.to_string(), sym_tags);
544                }
545            }
546        } else if i < max_depth {
547            curr = curr.subdirs.entry(comp.to_string()).or_default();
548        } else {
549            curr.hidden_files += 1;
550            break;
551        }
552    }
553}
554
555/// The most files with definitions a subfolder lists; the folder the outline starts at lists all.
556const FILES_LISTED_PER_FOLDER: usize = 40;
557
558pub fn render_outline_tree(
559    out: &mut String,
560    node: &OutlineNode,
561    prefix: &str,
562    depth: usize,
563    max_depth: usize,
564) {
565    if depth >= max_depth {
566        return;
567    }
568
569    let listed = if depth == 0 {
570        node.files.len()
571    } else {
572        node.files.len().min(FILES_LISTED_PER_FOLDER)
573    };
574    let more_files = node.files.len() - listed;
575    let total_items = node.subdirs.len()
576        + listed
577        + usize::from(more_files > 0)
578        + usize::from(!node.plain_files.is_empty());
579    let mut index = 0;
580
581    // Render subdirectories
582    for (name, sub) in &node.subdirs {
583        index += 1;
584        let is_last = index == total_items;
585        let branch = if is_last { "└── " } else { "├── " };
586        let next_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
587
588        let hidden = match (sub.hidden_files, sub.unsupported_files) {
589            (0, 0) => String::new(),
590            (0, m) => format!(" ({m} unsupported {})", plural(m, "file")),
591            (n, 0) => format!(" ({n} indexed {})", plural(n, "file")),
592            (n, m) => format!(" ({n} indexed {}, {m} unsupported)", plural(n, "file")),
593        };
594        out.push_str(&format!("{prefix}{branch}{name}/{hidden}\n"));
595        render_outline_tree(out, sub, &next_prefix, depth + 1, max_depth);
596    }
597
598    // Render files
599    for (file_name, syms) in node.files.iter().take(listed) {
600        index += 1;
601        let is_last = index == total_items;
602        let branch = if is_last { "└── " } else { "├── " };
603
604        let sym_suffix = if !syms.is_empty() {
605            format!(" [{}]", syms.join(", "))
606        } else {
607            String::new()
608        };
609
610        out.push_str(&format!("{prefix}{branch}{file_name}{sym_suffix}\n"));
611    }
612
613    if more_files > 0 {
614        index += 1;
615        let branch = if index == total_items {
616            "└── "
617        } else {
618            "├── "
619        };
620        out.push_str(&format!(
621            "{prefix}{branch}(+{more_files} more {} with functions or classes; pass this folder as the path to list them)\n",
622            plural(more_files, "file")
623        ));
624    }
625
626    if !node.plain_files.is_empty() {
627        let count = node.plain_files.len();
628        let names = if count <= 5 {
629            node.plain_files.join(", ")
630        } else {
631            format!("{}, +{} more", node.plain_files[..3].join(", "), count - 3)
632        };
633        out.push_str(&format!(
634            "{prefix}└── ({count} {} without functions or classes: {names})\n",
635            plural(count, "file")
636        ));
637    }
638}
639
640/// Format symbol body with metadata header, signature, and body content.
641/// A symbol's source as written, under a location comment.
642pub fn format_symbol_body(symbol: &Symbol, source: &str) -> String {
643    let mut out = format!(
644        "// {}:{}-{} ({})\n{source}",
645        symbol.path, symbol.start_line, symbol.end_line, symbol.name
646    );
647    if !source.ends_with('\n') {
648        out.push('\n');
649    }
650    out
651}
652
653/// The bodies of one symbol or of its overloads, each under its own location comment.
654pub fn format_symbol_bodies(bodies: &[(Symbol, String)]) -> String {
655    let mut out = overloads_header(bodies.iter().map(|(symbol, _)| symbol));
656    let parts: Vec<String> = bodies
657        .iter()
658        .map(|(symbol, source)| format_symbol_body(symbol, source))
659        .collect();
660    out.push_str(&parts.join("\n"));
661    out
662}
663
664/// The context of one symbol or of each of its overloads.
665pub fn format_context_slices(slices: &[ContextSlice]) -> String {
666    let mut out = overloads_header(slices.iter().map(|slice| &slice.target_symbol));
667    let parts: Vec<String> = slices.iter().map(format_context_slice).collect();
668    out.push_str(&parts.join("\n"));
669    out
670}
671
672fn overloads_header<'a>(symbols: impl ExactSizeIterator<Item = &'a Symbol>) -> String {
673    let count = symbols.len();
674    let mut symbols = symbols;
675    match symbols.next() {
676        Some(first) if count > 1 => format!(
677            "`{}` has {count} overloads in {}; each one follows.\n\n",
678            first.name, first.path
679        ),
680        _ => String::new(),
681    }
682}
683
684/// Format surgical context bundle for a symbol.
685pub fn format_context_slice(slice: &ContextSlice) -> String {
686    let sym = &slice.target_symbol;
687    let mut out = String::new();
688    out.push_str(&format!(
689        "### Target: `{}` ({}:{}-{})\n\n",
690        sym.name, sym.path, sym.start_line, sym.end_line
691    ));
692
693    if let Some(ref sig) = sym.signature {
694        let sig = signature_without_duplicate_body(sig, &slice.target_body);
695        if !sig.trim().is_empty() {
696            out.push_str(&format!("Signature: `{sig}`\n\n"));
697        }
698    }
699
700    out.push_str(&format!("```{}\n", sym.language));
701    out.push_str(&slice.target_body);
702    if !slice.target_body.ends_with('\n') {
703        out.push('\n');
704    }
705    out.push_str("```\n\n");
706
707    if !slice.callee_signatures.is_empty() {
708        out.push_str("### Dependencies (Signatures):\n");
709        for callee in &slice.callee_signatures {
710            out.push_str(&format!("- {callee}\n"));
711        }
712        if slice.callee_signatures.len() >= 10 {
713            out.push_str("[Showing 10 dependencies (limit reached)]\n");
714        }
715        out.push('\n');
716    }
717
718    if !slice.related_types.is_empty() {
719        out.push_str("### Types:\n");
720        for t in &slice.related_types {
721            out.push_str(&format!("- {t}\n"));
722        }
723        out.push('\n');
724    }
725
726    if !slice.related_tests.is_empty() {
727        out.push_str("### Related Tests:\n");
728        for test in &slice.related_tests {
729            out.push_str(&format!(
730                "- `{}` ({}:{})\n",
731                test.name, test.path, test.start_line
732            ));
733        }
734        if slice.related_tests.len() >= 5 {
735            out.push_str("[Showing 5 tests (limit reached)]\n");
736        }
737        out.push_str("These tests call, use, or name this symbol; blast_radius also lists tests that can reach it through callers.\n\n");
738    } else {
739        out.push_str(
740            "### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n",
741        );
742    }
743
744    out
745}
746
747fn cap_notice(shown: usize, limit: usize) -> String {
748    let advice = if limit >= crate::queries::MAX_RESULT_LIMIT {
749        "narrow the query to see more"
750    } else {
751        "increase limit to see more"
752    };
753    format!("\n[Showing {shown} results (limit reached); {advice}.]\n")
754}
755
756/// The FTS match snippet on one line, or `None` when it only repeats the signature. The comment
757/// markers that open each doc-comment line (`//`, `///`, `//!`, `*`) are dropped when lines join.
758fn match_line(snippet: &str, signature: &str) -> Option<String> {
759    let line = snippet
760        .lines()
761        .enumerate()
762        .map(|(i, text)| match i {
763            0 => text.trim(),
764            _ => text.trim().trim_start_matches(['/', '*', '!']).trim(),
765        })
766        .flat_map(str::split_whitespace)
767        .collect::<Vec<_>>()
768        .join(" ");
769    let plain = |text: &str| {
770        text.split_whitespace()
771            .collect::<String>()
772            .replace(['[', ']'], "")
773    };
774    let matched = plain(line.trim_start_matches("...").trim_end_matches("..."));
775    (!line.is_empty() && !plain(signature).contains(&matched)).then_some(line)
776}
777
778/// A callee name on one line. An unresolved call chain longer than 60 characters that holds a
779/// call keeps only its last call, so a multi-line receiver expression never floods the list.
780fn callee_name(name: &str) -> String {
781    let line = name.split_whitespace().collect::<Vec<_>>().join(" ");
782    if line.chars().count() <= 60 || !line.contains('(') {
783        return line;
784    }
785    match line.rsplit_once('.') {
786        Some((_, last)) if !last.is_empty() => format!("….{last}"),
787        _ => line,
788    }
789}
790
791/// Format references list for callers/callees with optional limit footer.
792/// One line for the import statements of a target: their count and the first three sites.
793pub fn format_import_summary(sites: &[(String, usize)]) -> String {
794    if sites.is_empty() {
795        return String::new();
796    }
797    let files: std::collections::BTreeSet<&str> =
798        sites.iter().map(|(path, _)| path.as_str()).collect();
799    let first: Vec<String> = sites
800        .iter()
801        .take(3)
802        .map(|(path, line)| format!("{path}:{line}"))
803        .collect();
804    let more = if sites.len() > 3 { ", …" } else { "" };
805    format!(
806        "Imported {} {} in {} {}: {}{more} (lookup_symbol with kind=\"import\" lists them)\n",
807        sites.len(),
808        plural(sites.len(), "time"),
809        files.len(),
810        plural(files.len(), "file"),
811        first.join(", ")
812    )
813}
814
815pub fn format_references(
816    target_name: &str,
817    refs: &[ReferenceSite],
818    direction: &str,
819    limit: usize,
820) -> String {
821    let mut out = String::new();
822    let dir_label = if direction == "callers" {
823        "Callers of"
824    } else {
825        "Callees called by"
826    };
827    out.push_str(&format!(
828        "{dir_label} `{target_name}` ({} found):\n",
829        refs.len()
830    ));
831
832    if refs.is_empty() {
833        out.push_str("  (none)\n");
834        return out;
835    }
836
837    for r in refs {
838        let line_info = match r.start_line {
839            Some(l) => format!(":{l}"),
840            None => String::new(),
841        };
842        let other = if direction == "callers" {
843            caller_text(r)
844        } else {
845            format!("`{}`", callee_name(&r.to_symbol_name))
846        };
847        let in_file = match r.occurrences {
848            Some(n) => format!(", {n} in file"),
849            None => String::new(),
850        };
851        let target = match &r.target {
852            Some(target) if direction != "callers" => format!(" → {target}"),
853            _ => String::new(),
854        };
855        out.push_str(&format!(
856            "- {other} [{}{line_info}] (kind: {}{in_file}){target}\n",
857            r.path, r.kind
858        ));
859    }
860
861    if refs.len() >= limit {
862        out.push_str(&cap_notice(refs.len(), limit));
863    }
864
865    out
866}
867
868fn caller_text(site: &ReferenceSite) -> String {
869    if site.from_symbol_name.is_empty() && site.enclosing_symbol_name.is_none() {
870        "(no enclosing symbol)".to_string()
871    } else {
872        format!(
873            "`{}`",
874            displayed_symbol_name(
875                &site.from_symbol_name,
876                site.enclosing_symbol_name.as_deref()
877            )
878        )
879    }
880}
881
882const FOLD_FILES_NAMED: usize = 5;
883const TEXT_ONLY_FILES_LISTED: usize = 10;
884const TEXT_ONLY_LINES_PER_FILE: usize = 12;
885
886/// The callers answer: three header lines, the sites in label order up to `limit`, the text-only
887/// lines, the blind spots of the index, and the import summary. Counts are always complete.
888pub fn format_reference_report(report: &ReferenceReport, limit: usize) -> String {
889    let target = &report.target;
890    let counts = &report.counts;
891    let mut out = format!(
892        "References to {} `{}` ({}:{})\nSites: {} · proven {} · likely {} · unresolved {} · excluded {}\n",
893        target.kind,
894        target.display_name,
895        target.path,
896        target.line,
897        counts.sites,
898        counts.proven,
899        counts.likely,
900        counts.unresolved,
901        counts.excluded,
902    );
903    out.push_str(&text_count_line(report));
904    let scan = &report.text_scan;
905    if !scan.complete && scan.skipped.is_none() {
906        out.push_str(&format!(
907            "Text scan stopped after {} s: {} of {} files read; counts cover those files.\n",
908            (scan.elapsed_ms + 500) / 1000,
909            scan.files_scanned,
910            scan.files_total
911        ));
912    }
913
914    let mut body = site_groups(&report.sites, limit);
915    body.push_str(&override_sections(report, limit));
916    body.push_str(&construction_section(&report.constructions, limit));
917    body.push_str(&variant_section(&report.variant_uses, limit));
918    body.push_str(&registration_section(&report.registrations, limit));
919    body.push_str(&test_section(&report.tests, limit));
920    body.push_str(&text_only_section(report));
921    if !report.cannot_see.is_empty() {
922        body.push_str(&format!(
923            "Not visible to this index: {}.\n",
924            report.cannot_see.join("; ")
925        ));
926    }
927    body.push_str(&format_import_summary(&report.imports));
928    if !body.is_empty() {
929        out.push('\n');
930        out.push_str(&body);
931    }
932    out
933}
934
935fn text_count_line(report: &ReferenceReport) -> String {
936    if let Some(class) = &report.text_scan.skipped {
937        return format!(
938            "Text: skipped for a constructor; its call sites spell the class name. Use find_references on `{class}`.\n"
939        );
940    }
941    let counts = &report.counts;
942    format!(
943        "Text: `{}` is on {} {}: {} {}, {} {}, {} {}, {} text only.\n",
944        report.target.name,
945        counts.text_lines,
946        plural(counts.text_lines, "line"),
947        counts.site_lines,
948        plural(counts.site_lines, "site"),
949        counts.definition_lines,
950        plural(counts.definition_lines, "definition"),
951        counts.import_lines,
952        plural(counts.import_lines, "import"),
953        counts.text_only_lines,
954    )
955}
956
957fn site_groups(sites: &[LabeledSite], limit: usize) -> String {
958    let mut out = String::new();
959    let mut listed = 0;
960    for label in SiteLabel::ALL {
961        let group: Vec<&LabeledSite> = sites.iter().filter(|site| site.label == label).collect();
962        if group.is_empty() {
963            continue;
964        }
965        let title = match label {
966            SiteLabel::Proven => "Proven",
967            SiteLabel::Likely => "Likely",
968            SiteLabel::Unresolved => "Unresolved",
969            SiteLabel::Excluded => "Excluded",
970        };
971        out.push_str(&format!("{title} ({}):\n", group.len()));
972        let shown = limit.saturating_sub(listed).min(group.len());
973        for labeled in &group[..shown] {
974            let site = &labeled.site;
975            let line = site.start_line.map(|l| format!(":{l}")).unwrap_or_default();
976            out.push_str(&format!(
977                "- {} [{}{line}] (kind: {}; {}{})\n",
978                caller_text(site),
979                site.path,
980                site.kind,
981                labeled.basis,
982                arg_count_text(labeled)
983            ));
984        }
985        listed += shown;
986        if shown < group.len() {
987            out.push_str(&fold_line(label, &group[shown..]));
988        }
989    }
990    out
991}
992
993/// `; 2 args` after the basis, left out when the basis already starts with the count.
994fn arg_count_text(labeled: &LabeledSite) -> String {
995    match labeled.arg_count {
996        Some(count) => {
997            let noun = if count == 1 { "arg" } else { "args" };
998            let text = format!("{count} {noun}");
999            if labeled.basis.starts_with(&text) {
1000                String::new()
1001            } else {
1002                format!("; {text}")
1003            }
1004        }
1005        None => String::new(),
1006    }
1007}
1008
1009fn fold_line(label: SiteLabel, hidden: &[&LabeledSite]) -> String {
1010    let (files, named) = files_by_count(hidden.iter().map(|labeled| labeled.site.path.as_str()));
1011    format!(
1012        "… {} more {} {} in {files} {}: {named}\n",
1013        hidden.len(),
1014        label.as_str(),
1015        plural(hidden.len(), "site"),
1016        plural(files, "file"),
1017    )
1018}
1019
1020/// How many files hold `paths`, and the first `FOLD_FILES_NAMED` of them as `path (count)`,
1021/// most rows first, with `, …` when more files follow.
1022fn files_by_count<'a>(paths: impl Iterator<Item = &'a str>) -> (usize, String) {
1023    let mut per_file: BTreeMap<&str, usize> = BTreeMap::new();
1024    for path in paths {
1025        *per_file.entry(path).or_default() += 1;
1026    }
1027    let mut files: Vec<(&str, usize)> = per_file.into_iter().collect();
1028    files.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
1029    let named: Vec<String> = files
1030        .iter()
1031        .take(FOLD_FILES_NAMED)
1032        .map(|(path, count)| format!("{path} ({count})"))
1033        .collect();
1034    let more = if files.len() > FOLD_FILES_NAMED {
1035        ", …"
1036    } else {
1037        ""
1038    };
1039    (files.len(), format!("{}{more}", named.join(", ")))
1040}
1041
1042fn override_sections(report: &ReferenceReport, limit: usize) -> String {
1043    let name = &report.target.display_name;
1044    let mut out = method_section(
1045        &format!(
1046            "Overrides ({}): calls through `{name}` can reach these",
1047            report.overrides.len()
1048        ),
1049        &report.overrides,
1050        limit,
1051    );
1052    out.push_str(&method_section(
1053        &format!(
1054            "Overrides of ({}): calls to these can reach `{name}`",
1055            report.overridden.len()
1056        ),
1057        &report.overridden,
1058        limit,
1059    ));
1060    out
1061}
1062
1063fn construction_section(rows: &[ConstructionSite], limit: usize) -> String {
1064    if rows.is_empty() {
1065        return String::new();
1066    }
1067    let shown = limit.min(rows.len());
1068    let mut per_file: Vec<(&str, Vec<String>)> = Vec::new();
1069    for row in &rows[..shown] {
1070        match per_file.last_mut() {
1071            Some((path, lines)) if *path == row.path => lines.push(row.line.to_string()),
1072            _ => per_file.push((&row.path, vec![row.line.to_string()])),
1073        }
1074    }
1075    let listed: Vec<String> = per_file
1076        .iter()
1077        .map(|(path, lines)| format!("{path}: {}", lines.join(", ")))
1078        .collect();
1079    let mut out = format!("Constructions ({}):", rows.len());
1080    if !listed.is_empty() {
1081        out.push_str(&format!(" {}", listed.join(" · ")));
1082    }
1083    out.push('\n');
1084    if shown < rows.len() {
1085        out.push_str(&section_fold(
1086            rows[shown..].iter().map(|row| row.path.as_str()),
1087            "construction",
1088        ));
1089    }
1090    out
1091}
1092
1093fn variant_section(rows: &[VariantUse], limit: usize) -> String {
1094    if rows.is_empty() {
1095        return String::new();
1096    }
1097    let mut out = format!(
1098        "Variant uses ({} {}): a match in them may need a new arm\n",
1099        rows.len(),
1100        plural(rows.len(), "function")
1101    );
1102    let shown = limit.min(rows.len());
1103    for row in &rows[..shown] {
1104        let caller = if row.caller.is_empty() {
1105            "(no enclosing symbol)".to_string()
1106        } else {
1107            format!("`{}`", row.caller)
1108        };
1109        out.push_str(&format!(
1110            "- {caller} [{}:{}] names {} of {} {}\n",
1111            row.path,
1112            row.line,
1113            row.variants_used,
1114            row.variants_total,
1115            plural(row.variants_total, "variant"),
1116        ));
1117    }
1118    if shown < rows.len() {
1119        out.push_str(&section_fold(
1120            rows[shown..].iter().map(|row| row.path.as_str()),
1121            "function",
1122        ));
1123    }
1124    out
1125}
1126
1127fn registration_section(rows: &[Registration], limit: usize) -> String {
1128    if rows.is_empty() {
1129        return String::new();
1130    }
1131    let mut out = format!("Registrations ({}):\n", rows.len());
1132    let shown = limit.min(rows.len());
1133    for row in &rows[..shown] {
1134        out.push_str(&format!(
1135            "- {} [{}:{}]{}\n",
1136            registration_label(row),
1137            row.path,
1138            row.line,
1139            if row.by_name { " (by name)" } else { "" }
1140        ));
1141    }
1142    if shown < rows.len() {
1143        out.push_str(&section_fold(
1144            rows[shown..].iter().map(|row| row.path.as_str()),
1145            "registration",
1146        ));
1147    }
1148    out
1149}
1150
1151fn registration_label(row: &Registration) -> String {
1152    if !row.pattern_id.contains(".route.") {
1153        return row.pattern_id.clone();
1154    }
1155    let template = row.template.as_deref().unwrap_or(&row.pattern_id);
1156    match row.verb.as_deref() {
1157        Some(verb) => format!("route {verb} {template}"),
1158        None => format!("route {template}"),
1159    }
1160}
1161
1162fn section_fold<'a>(hidden: impl ExactSizeIterator<Item = &'a str>, word: &str) -> String {
1163    let count = hidden.len();
1164    let (files, named) = files_by_count(hidden);
1165    format!(
1166        "… {count} more {} in {files} {}: {named}\n",
1167        plural(count, word),
1168        plural(files, "file"),
1169    )
1170}
1171
1172fn test_section(rows: &[CallingTest], limit: usize) -> String {
1173    if rows.is_empty() {
1174        return String::new();
1175    }
1176    let mut out = format!("Tests that call it ({}):\n", rows.len());
1177    let shown = limit.min(rows.len());
1178    for row in &rows[..shown] {
1179        let run = row
1180            .command
1181            .as_ref()
1182            .map(|command| format!(" run: {command}"))
1183            .unwrap_or_default();
1184        let name_only = if row.name_only { " (name only)" } else { "" };
1185        out.push_str(&format!(
1186            "- `{}` [{}:{}]{name_only}{run}\n",
1187            on_one_line(&row.name),
1188            row.path,
1189            row.line
1190        ));
1191    }
1192    if shown < rows.len() {
1193        out.push_str(&section_fold(
1194            rows[shown..].iter().map(|row| row.path.as_str()),
1195            "test",
1196        ));
1197    }
1198    out
1199}
1200
1201/// `text` with each control character and Unicode line separator written as an escape (`\n`,
1202/// `\t`), so a test name never breaks a line of the report.
1203fn on_one_line(text: &str) -> String {
1204    text.chars()
1205        .map(|c| {
1206            if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') {
1207                c.escape_default().to_string()
1208            } else {
1209                c.to_string()
1210            }
1211        })
1212        .collect()
1213}
1214
1215fn method_section(title: &str, rows: &[OverrideRow], limit: usize) -> String {
1216    if rows.is_empty() {
1217        return String::new();
1218    }
1219    let mut out = format!("{title}\n");
1220    let shown = limit.min(rows.len());
1221    for row in &rows[..shown] {
1222        out.push_str(&format!(
1223            "- `{}.{}` ({}:{})\n",
1224            row.owner, row.name, row.path, row.line
1225        ));
1226    }
1227    if shown < rows.len() {
1228        out.push_str(&section_fold(
1229            rows[shown..].iter().map(|row| row.path.as_str()),
1230            "method",
1231        ));
1232    }
1233    out
1234}
1235
1236fn text_only_section(report: &ReferenceReport) -> String {
1237    if report.text_only.is_empty() {
1238        return String::new();
1239    }
1240    let mut out = format!(
1241        "Text only ({}): docs, comments, strings, or code the index did not record\n",
1242        report.counts.text_only_lines
1243    );
1244    let mut hidden_lines = 0;
1245    let mut hidden_files = 0;
1246    for (index, file) in report.text_only.iter().enumerate() {
1247        let listed = if index < TEXT_ONLY_FILES_LISTED {
1248            file.lines.len().min(TEXT_ONLY_LINES_PER_FILE)
1249        } else {
1250            0
1251        };
1252        if listed > 0 {
1253            let lines: Vec<String> = file.lines[..listed].iter().map(usize::to_string).collect();
1254            out.push_str(&format!("  {}: {}\n", file.path, lines.join(", ")));
1255        }
1256        if listed < file.lines.len() {
1257            hidden_lines += file.lines.len() - listed;
1258            hidden_files += 1;
1259        }
1260    }
1261    if hidden_lines > 0 {
1262        out.push_str(&format!(
1263            "  … {hidden_lines} more {} in {hidden_files} {}\n",
1264            plural(hidden_lines, "line"),
1265            plural(hidden_files, "file")
1266        ));
1267    }
1268    out
1269}
1270
1271pub(crate) fn displayed_symbol_name(name: &str, enclosing_symbol_name: Option<&str>) -> String {
1272    if let Some(enclosing_symbol_name) = enclosing_symbol_name {
1273        format!("<lambda> in {enclosing_symbol_name}")
1274    } else {
1275        name.to_string()
1276    }
1277}
1278
1279/// Formats exact or FTS fallback symbol results with transparent header labeling.
1280/// One line for the lookup rows whose name only starts with or contains the query, shown when
1281/// some row is named exactly: each distinct name once, with its kind and count.
1282fn other_names_line(query: &str, others: &[&Symbol]) -> String {
1283    let mut groups: Vec<(String, &str, usize)> = Vec::new();
1284    for s in others {
1285        let kind = display_kind(s);
1286        match groups
1287            .iter_mut()
1288            .find(|(name, k, _)| *name == s.name && *k == kind)
1289        {
1290            Some(group) => group.2 += 1,
1291            None => groups.push((s.name.clone(), kind, 1)),
1292        }
1293    }
1294    let listed: Vec<String> = groups
1295        .iter()
1296        .take(8)
1297        .map(|(name, kind, count)| match count {
1298            1 => format!("`{name}` ({kind})"),
1299            n => format!("`{name}` ({kind}, {n}×)"),
1300        })
1301        .collect();
1302    let more = match groups.len().saturating_sub(8) {
1303        0 => String::new(),
1304        n => format!(", +{n} more"),
1305    };
1306    format!(
1307        "- {} other {} `{query}`, ignoring case: {}{more} (lookup_symbol with one of these names lists its rows)\n",
1308        others.len(),
1309        if others.len() == 1 {
1310            "row starts with or contains"
1311        } else {
1312            "rows start with or contain"
1313        },
1314        listed.join(", ")
1315    )
1316}
1317
1318/// A doc comment line without its comment marker (`///`, `/**`, ` * `, `#`, `--`) or a
1319/// `<summary>` tag, which julie keeps in the raw comment text.
1320fn doc_line_text(line: &str) -> &str {
1321    let mut text = line.trim();
1322    for marker in [
1323        "///", "//!", "//", "/**", "/*!", "/*", "##", "#'", "#", "--", "*", "'''", "'",
1324    ] {
1325        if let Some(rest) = text.strip_prefix(marker) {
1326            text = rest.trim_start();
1327            break;
1328        }
1329    }
1330    for tag in ["<summary>", "</summary>"] {
1331        text = text.strip_prefix(tag).unwrap_or(text).trim_start();
1332    }
1333    for suffix in ["*/", "</summary>"] {
1334        text = text.strip_suffix(suffix).unwrap_or(text).trim_end();
1335    }
1336    text
1337}
1338
1339/// The byte index of the period that ends the first sentence: a `. ` outside parentheses and not
1340/// before a lowercase word, so `(e.g. route)` and `e.g. route` do not end one.
1341fn sentence_end(text: &str) -> Option<usize> {
1342    let mut depth = 0usize;
1343    for (index, c) in text.char_indices() {
1344        match c {
1345            '(' => depth += 1,
1346            ')' => depth = depth.saturating_sub(1),
1347            '.' if depth == 0 => {
1348                let rest = &text[index + 1..];
1349                if rest.starts_with(' ') && !rest[1..].starts_with(|next: char| next.is_lowercase())
1350                {
1351                    return Some(index);
1352                }
1353            }
1354            _ => {}
1355        }
1356    }
1357    None
1358}
1359
1360/// The first sentence of a doc comment's first paragraph, on one line. A paragraph with no
1361/// sentence end is cut at 200 characters with `…`.
1362fn doc_summary(doc: &str) -> Option<String> {
1363    let paragraph = doc
1364        .lines()
1365        .map(doc_line_text)
1366        .skip_while(|line| line.is_empty())
1367        .take_while(|line| !line.is_empty())
1368        .collect::<Vec<_>>()
1369        .join(" ");
1370    let summary = match sentence_end(&paragraph) {
1371        Some(end) => paragraph[..=end].to_string(),
1372        None if paragraph.chars().count() > 200 => {
1373            format!("{}…", paragraph.chars().take(200).collect::<String>())
1374        }
1375        None => paragraph,
1376    };
1377    (!summary.is_empty()).then_some(summary)
1378}
1379
1380pub fn format_find_symbol_results(
1381    query: &str,
1382    name_rows: &[Symbol],
1383    ranked_rows: &[SymbolSearchResult],
1384    limit: usize,
1385) -> String {
1386    let mut out = String::new();
1387    if !name_rows.is_empty() {
1388        out.push_str(&format_name_rows(query, name_rows, limit));
1389    }
1390    if !ranked_rows.is_empty() {
1391        if !out.is_empty() {
1392            out.push('\n');
1393        }
1394        out.push_str(&format_ranked_rows(query, ranked_rows, limit));
1395    }
1396    if out.is_empty() {
1397        out.push_str(&format!("No symbols found matching \"{query}\".\n"));
1398    }
1399    out
1400}
1401
1402fn format_name_rows(query: &str, exact_matches: &[Symbol], limit: usize) -> String {
1403    let dotted = query.replace("::", ".");
1404    let is_exact = |s: &Symbol| s.name == dotted || s.name.ends_with(&format!(".{dotted}"));
1405    let named_exactly = exact_matches.iter().filter(|s| is_exact(s)).count();
1406    let exact_note = if named_exactly < exact_matches.len() {
1407        format!(" ({named_exactly} named exactly `{query}`; the rest start with or contain it)")
1408    } else {
1409        String::new()
1410    };
1411    let owner = query
1412        .strip_suffix("::")
1413        .or_else(|| query.strip_suffix('.'))
1414        .filter(|owner| !owner.is_empty());
1415    let mut out = match owner {
1416        Some(owner) => format!("Found {} members of `{owner}`:\n\n", exact_matches.len()),
1417        None => format!(
1418            "Found {} symbols matching \"{query}\"{exact_note}:\n\n",
1419            exact_matches.len()
1420        ),
1421    };
1422    let has_definition = exact_matches.iter().any(|s| s.kind != "import");
1423    let folds = |s: &Symbol| crate::queries::folds_into_import_line(query, s, has_definition);
1424    let imports: Vec<&Symbol> = exact_matches.iter().filter(|s| folds(s)).collect();
1425    let (shown, others): (Vec<&Symbol>, Vec<&Symbol>) = exact_matches
1426        .iter()
1427        .filter(|s| !folds(s))
1428        .partition(|s| named_exactly == 0 || is_exact(s));
1429    for s in shown {
1430        let sig = s.signature.as_deref().unwrap_or(&s.name);
1431        out.push_str(&format!(
1432            "- {} `{}` [{}:{}-{}] id={}\n",
1433            display_kind(s),
1434            s.name,
1435            s.path,
1436            s.start_line,
1437            s.end_line,
1438            s.symbol_id
1439        ));
1440        out.push_str(&format!("  Signature: {sig}\n"));
1441        if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1442            out.push_str(&format!("  Doc: {doc}\n"));
1443        }
1444    }
1445    if !imports.is_empty() {
1446        let shown = imports
1447            .iter()
1448            .take(3)
1449            .map(|s| format!("{}:{}", s.path, s.start_line))
1450            .collect::<Vec<_>>()
1451            .join(", ");
1452        let more = if imports.len() > 3 { ", …" } else { "" };
1453        let cut_inside_exact_names = exact_matches
1454            .last()
1455            .is_some_and(|s| s.name.eq_ignore_ascii_case(query) || folds(s));
1456        let capped = if exact_matches.len() - imports.len() >= limit && cut_inside_exact_names {
1457            "at least "
1458        } else {
1459            ""
1460        };
1461        out.push_str(&format!(
1462            "- {capped}{} {} of `{query}`: {shown}{more} (lookup_symbol with kind=\"import\" lists them)\n",
1463            imports.len(),
1464            plural(imports.len(), "import")
1465        ));
1466    }
1467    if !others.is_empty() {
1468        out.push_str(&other_names_line(query, &others));
1469    }
1470    if exact_matches.len() - imports.len() >= limit {
1471        out.push_str(&cap_notice(exact_matches.len() - imports.len(), limit));
1472    }
1473    out
1474}
1475
1476fn format_ranked_rows(query: &str, fts_matches: &[SymbolSearchResult], limit: usize) -> String {
1477    let mut out = format!(
1478        "No exact name match; {} full-text matches for \"{query}\":\n\n",
1479        fts_matches.len()
1480    );
1481    for r in fts_matches {
1482        let s = &r.symbol;
1483        let sig = s.signature.as_deref().unwrap_or(&s.name);
1484        out.push_str(&format!(
1485            "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1486            display_kind(s),
1487            s.name,
1488            s.path,
1489            s.start_line,
1490            s.end_line,
1491            r.score,
1492            s.symbol_id
1493        ));
1494        out.push_str(&format!("  Signature: {sig}\n"));
1495        if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1496            out.push_str(&format!("  Match: {line}\n"));
1497        } else if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1498            out.push_str(&format!("  Doc: {doc}\n"));
1499        }
1500    }
1501    if fts_matches.len() >= limit {
1502        out.push_str(&cap_notice(fts_matches.len(), limit));
1503    }
1504    out
1505}
1506
1507/// Format available structural fact & literal categories.
1508/// The first line of a fact query that matched nothing; it names the path the query was limited to.
1509pub fn no_facts_heading(category: &str, path_filter: Option<&str>) -> String {
1510    match path_filter {
1511        Some(path) => format!("No facts match '{category}' under `{path}`."),
1512        None => format!("No facts match '{category}' in this repository."),
1513    }
1514}
1515
1516/// The answer when a category matches no fact. A known alias gets only the alias summary, and
1517/// `import` says where imports are, because most extractors record imports as symbols. Any other
1518/// category lists every raw category, so the agent can pick one.
1519pub fn format_no_facts(
1520    category: &str,
1521    path_filter: Option<&str>,
1522    categories: &[(String, usize)],
1523) -> String {
1524    let mut out = format!("{}\n\n", no_facts_heading(category, path_filter));
1525    if !crate::queries::is_category_alias(category) {
1526        out.push_str(&format_fact_categories(categories));
1527        return out;
1528    }
1529    if matches!(category.to_ascii_lowercase().as_str(), "import" | "imports") {
1530        out.push_str("Most languages record imports as symbols: lookup_symbol with kind=\"import\" lists them.\n\n");
1531    }
1532    out.push_str(&alias_summary(categories));
1533    out
1534}
1535
1536fn alias_summary(categories: &[(String, usize)]) -> String {
1537    let aliases = crate::queries::alias_fact_counts(categories);
1538    if aliases.is_empty() {
1539        return String::new();
1540    }
1541    let parts: Vec<String> = aliases
1542        .iter()
1543        .map(|(alias, patterns, facts)| {
1544            format!(
1545                "{alias} ({patterns} {}, {facts} {})",
1546                plural(*patterns, "pattern"),
1547                plural(*facts, "fact")
1548            )
1549        })
1550        .collect();
1551    format!("Aliases: {}\n\n", parts.join(", "))
1552}
1553
1554pub fn format_fact_categories(categories: &[(String, usize)]) -> String {
1555    if categories.is_empty() {
1556        return "No structural facts or literals indexed in this repository.".to_string();
1557    }
1558    let mut out = String::new();
1559    out.push_str(&alias_summary(categories));
1560    // A decorated-definition fact only records where a decorated block starts.
1561    let (patterns, literal_kinds): (Vec<_>, Vec<_>) = categories
1562        .iter()
1563        .filter(|(name, _)| !name.ends_with(".decorated_definition.v1"))
1564        .partition(|(name, _)| name.contains('.'));
1565    out.push_str(&format!(
1566        "Available structural fact categories ({} found):\n\n",
1567        patterns.len()
1568    ));
1569    for (name, count) in &patterns {
1570        out.push_str(&format!(
1571            "- `{name}` ({count} {})\n",
1572            plural(*count, "fact")
1573        ));
1574    }
1575    if !literal_kinds.is_empty() {
1576        out.push_str("\nString literal kinds (the same command lists the literals of a kind):\n\n");
1577        for (name, count) in &literal_kinds {
1578            out.push_str(&format!(
1579                "- `{name}` ({count} {})\n",
1580                plural(*count, "literal")
1581            ));
1582        }
1583    }
1584    out
1585}
1586
1587fn plural(count: usize, word: &str) -> String {
1588    if count == 1 {
1589        word.to_string()
1590    } else {
1591        format!("{word}s")
1592    }
1593}
1594
1595/// Format structural facts and matching literals into token-dense markdown.
1596pub fn format_structural_facts(
1597    facts: &[crate::models::StructuralFact],
1598    literals: &[crate::models::LiteralFact],
1599    category: &str,
1600    limit: usize,
1601) -> String {
1602    let mut out = format!(
1603        "Structural facts for '{category}' ({} found):\n",
1604        facts.len()
1605    );
1606    for f in facts {
1607        let label = f.key.as_deref().unwrap_or(&f.capture_name);
1608        let details = format!("{}{}", qt_property_details(f), flask_route_details(f));
1609        let api_style = f
1610            .metadata
1611            .as_ref()
1612            .and_then(|m| m.get("api_style"))
1613            .and_then(|v| v.as_str());
1614        let place = match api_style {
1615            Some("decorator_routing") => "handler",
1616            Some("call_routing") => "registered in",
1617            _ => "in",
1618        };
1619        let parent = f
1620            .containing_symbol_name
1621            .as_deref()
1622            .map(|p| format!(", {place}: {p}"))
1623            .unwrap_or_default();
1624        out.push_str(&format!(
1625            "- {label} [{}:{}] (pattern: {}{details}{parent})\n",
1626            f.path, f.start_line, f.pattern_id
1627        ));
1628    }
1629    if limit > 0 && facts.len() >= limit {
1630        out.push_str(&cap_notice(facts.len(), limit));
1631    }
1632    if facts
1633        .iter()
1634        .any(|f| f.pattern_id.starts_with("flask.route"))
1635    {
1636        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");
1637    }
1638    if !literals.is_empty() {
1639        out.push_str(&format!(
1640            "\nMatching literals ({} found):\n",
1641            literals.len()
1642        ));
1643        for l in literals {
1644            out.push_str(&format!(
1645                "- \"{}\" [{}:{}] (kind: {})\n",
1646                l.literal_text, l.path, l.start_line, l.kind
1647            ));
1648        }
1649        if limit > 0 && literals.len() >= limit {
1650            out.push_str(&cap_notice(literals.len(), limit));
1651        }
1652    }
1653    out
1654}
1655
1656/// The endpoint of a Flask route and the view of an `add_url_rule` call. A rule with no view, such
1657/// as `add_url_rule("/", endpoint="index")`, only names an endpoint for `url_for`.
1658fn flask_route_details(fact: &crate::models::StructuralFact) -> String {
1659    if !fact.pattern_id.starts_with("flask.route") {
1660        return String::new();
1661    }
1662    let Some(metadata) = fact.metadata.as_ref() else {
1663        return String::new();
1664    };
1665    let text = |key: &str| metadata.get(key).and_then(|v| v.as_str());
1666    let mut out = String::new();
1667    if let Some(endpoint) = text("endpoint") {
1668        out.push_str(&format!(", endpoint: {endpoint}"));
1669    }
1670    if text("api_style") == Some("call_routing") {
1671        match text("view_target") {
1672            Some(view) => out.push_str(&format!(", view: {view}")),
1673            None => out.push_str(", no view function"),
1674        }
1675    }
1676    out
1677}
1678
1679fn qt_property_details(fact: &crate::models::StructuralFact) -> String {
1680    if fact.pattern_id != "cpp.qt_property.v1" {
1681        return String::new();
1682    }
1683    let Some(metadata) = fact.metadata.as_ref() else {
1684        return String::new();
1685    };
1686    [
1687        "property_type",
1688        "read",
1689        "write",
1690        "notify",
1691        "designable",
1692        "scriptable",
1693        "stored",
1694        "user",
1695        "revision",
1696    ]
1697    .into_iter()
1698    .filter_map(|key| metadata.get(key).map(|value| (key, value)))
1699    .map(|(key, value)| {
1700        let value = value
1701            .as_str()
1702            .map(str::to_owned)
1703            .unwrap_or_else(|| value.to_string());
1704        format!(", {key}: {value}")
1705    })
1706    .collect()
1707}
1708
1709/// Formats FTS5 conceptual search results into token-dense markdown.
1710pub fn format_search_results(query: &str, results: &[SymbolSearchResult], limit: usize) -> String {
1711    if results.is_empty() {
1712        return format!("No symbols found matching concept \"{query}\".");
1713    }
1714
1715    let mut out = format!(
1716        "Found {} symbols matching concept \"{query}\":\n",
1717        results.len()
1718    );
1719    if let Some(explain) = results.first().and_then(|r| r.explain.as_ref()) {
1720        out.push_str(&format!(
1721            "rerank: {} candidates in {} µs",
1722            explain.candidates, explain.rerank_us
1723        ));
1724        if !explain.word_weights.is_empty() {
1725            let words: Vec<String> = explain
1726                .word_weights
1727                .iter()
1728                .map(|(word, weight)| format!("{word} {weight:.2}"))
1729                .collect();
1730            out.push_str(&format!("; words {}", words.join(", ")));
1731        }
1732        out.push('\n');
1733    }
1734    out.push('\n');
1735    for r in results {
1736        let s = &r.symbol;
1737        let sig = s.signature.as_deref().unwrap_or(&s.name);
1738        out.push_str(&format!(
1739            "- {} `{}` [{}:{}-{}] (score: {:.2}) id={}\n",
1740            display_kind(s),
1741            s.name,
1742            s.path,
1743            s.start_line,
1744            s.end_line,
1745            r.score,
1746            s.symbol_id
1747        ));
1748        out.push_str(&format!("  Signature: {sig}\n"));
1749        if let Some(line) = r.snippet.as_deref().and_then(|m| match_line(m, sig)) {
1750            out.push_str(&format!("  Match: {line}\n"));
1751        } else if let Some(doc) = s.doc_comment.as_deref().and_then(doc_summary) {
1752            out.push_str(&format!("  Doc: {doc}\n"));
1753        }
1754        if let Some(explain) = &r.explain {
1755            out.push_str(&format!("  explain: {}\n", explain_line(r.score, explain)));
1756        }
1757    }
1758
1759    if results.len() >= limit {
1760        out.push_str(&cap_notice(results.len(), limit));
1761    }
1762
1763    out
1764}
1765
1766fn explain_line(score: f64, e: &SearchExplain) -> String {
1767    let mut line = format!(
1768        "score {score:.1} = terms {:.1} + name {}({}) {:.1} + kind {:.1} + path {:.1}",
1769        e.term_score, e.name_tier, e.name_strength, e.name_bonus, e.kind_prior, e.path_role,
1770    );
1771    if e.field_diversity_bonus != 0.0 {
1772        line.push_str(&format!(" + field coverage {:.1}", e.field_diversity_bonus));
1773    }
1774    if e.documentation != 0.0 {
1775        line.push_str(&format!(" + doc {:.1}", e.documentation));
1776    }
1777    if e.test_intent != 0.0 {
1778        line.push_str(&format!(" + test {:.1}", e.test_intent));
1779    }
1780    if e.nested != 0.0 {
1781        line.push_str(&format!(" + nested {:.1}", e.nested));
1782    }
1783    line.push_str(&format!(" [{}]", e.branches.join(",")));
1784    if let Some(bm25) = e.bm25 {
1785        line.push_str(&format!(" bm25 {bm25:.2}"));
1786    }
1787    if !e.terms.is_empty() {
1788        let terms: Vec<String> = e
1789            .terms
1790            .iter()
1791            .map(|(term, field, credit)| format!("{term}={field}:{credit}"))
1792            .collect();
1793        line.push_str(&format!(" terms {}", terms.join(" ")));
1794    }
1795    line
1796}
1797
1798const EDIT_SITES_LISTED: usize = 12;
1799const EDIT_TESTS_LISTED: usize = 8;
1800pub const STOP_SITES_LISTED: usize = 15;
1801
1802/// The post-edit answer: the base, each symbol whose shape changed with its sites split by
1803/// whether the diff reached them, the tests to run, and the blind spots of the index.
1804pub fn format_edit_check(check: &EditCheck) -> String {
1805    let source = match check.source {
1806        BaseSource::Given => "given",
1807        BaseSource::Session => "session base",
1808        BaseSource::Head => "HEAD",
1809    };
1810    let mut out = String::new();
1811    if check.changes.is_empty() {
1812        out.push_str(&format!(
1813            "check_edit: working tree against {} ({source}): no symbol changed shape.\n",
1814            check.rev
1815        ));
1816        push_edit_cuts(&mut out, check);
1817        return out;
1818    }
1819    out.push_str(&format!(
1820        "check_edit: working tree against {} ({source}), {} {} changed\n{} shape {}:\n",
1821        check.rev,
1822        check.files_changed,
1823        plural(check.files_changed, "file"),
1824        check.changes.len(),
1825        plural(check.changes.len(), "change"),
1826    ));
1827    for change in &check.changes {
1828        push_shape_change(&mut out, change);
1829    }
1830    push_edit_cuts(&mut out, check);
1831    if !check.tests.is_empty() {
1832        let shown = check.tests.len().min(EDIT_TESTS_LISTED);
1833        out.push_str(&format!(
1834            "Tests to run: {}",
1835            check.tests[..shown].join("; ")
1836        ));
1837        if shown < check.tests.len() {
1838            out.push_str(&format!("; … {} more", check.tests.len() - shown));
1839        }
1840        out.push('\n');
1841    }
1842    out.push_str("Not checked: calls inside macro arguments, functions passed as values.\n");
1843    out
1844}
1845
1846/// The reason a Stop hook gives for a check: how many `proven` sites outside the diff still use
1847/// the old shape of which symbols, then the first of them. `None` when no change that
1848/// [`blocks_a_stop`] has such a site.
1849pub fn format_stop_reason(check: &EditCheck) -> Option<String> {
1850    let rows = stop_rows(check);
1851    if rows.is_empty() {
1852        return None;
1853    }
1854    let mut names: Vec<String> = Vec::new();
1855    for (_, change) in &rows {
1856        let name = format!("`{}`", change.display);
1857        if !names.contains(&name) {
1858            names.push(name);
1859        }
1860    }
1861    let mut out = format!(
1862        "Before you finish: {} sites still use the old shape of {}.\n",
1863        rows.len(),
1864        names.join(", ")
1865    );
1866    for (site, change) in rows.iter().take(STOP_SITES_LISTED) {
1867        out.push_str(&format!(
1868            "{}:{}  {}: {}\n",
1869            site.path, site.line, change.display, change.change
1870        ));
1871    }
1872    out.push_str("Edit them or say why they need no change.");
1873    Some(out)
1874}
1875
1876fn stop_rows(check: &EditCheck) -> Vec<(&EditSite, &ShapeChange)> {
1877    let mut rows: Vec<(&EditSite, &ShapeChange)> = Vec::new();
1878    for change in check.changes.iter().filter(|change| blocks_a_stop(change)) {
1879        rows.extend(change.proven.iter().map(|site| (site, change)));
1880    }
1881    rows.sort_by(|a, b| {
1882        (&a.0.path, a.0.line, &a.1.display).cmp(&(&b.0.path, b.0.line, &b.1.display))
1883    });
1884    rows
1885}
1886
1887/// The `proven` sites that block a stop, in the order its reason lists them.
1888pub fn stop_sites(check: &EditCheck) -> Vec<&EditSite> {
1889    stop_rows(check).into_iter().map(|(site, _)| site).collect()
1890}
1891
1892/// A `signature changed` row is often a type change that every call still fits (a nullable
1893/// `?`, a type alias, a return type), and JavaScript ignores the argument count. On clean
1894/// commits of real projects both blocked stops that needed no edit, so neither blocks a stop.
1895fn blocks_a_stop(change: &ShapeChange) -> bool {
1896    let javascript = [".js", ".jsx", ".mjs", ".cjs"]
1897        .iter()
1898        .any(|extension| change.path.ends_with(extension));
1899    !change.change.starts_with("signature changed")
1900        && !(javascript && change.change.starts_with("parameters"))
1901}
1902
1903fn push_edit_cuts(out: &mut String, check: &EditCheck) {
1904    for cut in &check.cuts {
1905        out.push_str(&format!("Cut: {cut}\n"));
1906    }
1907}
1908
1909fn push_shape_change(out: &mut String, change: &ShapeChange) {
1910    out.push_str(&format!(
1911        "  {} `{}` ({}): {}\n",
1912        change.kind, change.display, change.path, change.change
1913    ));
1914    if !change.in_diff.is_empty() {
1915        let shown = change.in_diff.len().min(EDIT_SITES_LISTED);
1916        let places: Vec<String> = change.in_diff[..shown]
1917            .iter()
1918            .map(|site| format!("{}:{}", site.path, site.line))
1919            .collect();
1920        out.push_str(&format!(
1921            "    In your diff ({}): {}",
1922            change.in_diff.len(),
1923            places.join(", ")
1924        ));
1925        if shown < change.in_diff.len() {
1926            out.push_str(&format!(", … {} more", change.in_diff.len() - shown));
1927        }
1928        out.push('\n');
1929    }
1930    for (label, sites) in [("proven", &change.proven), ("likely", &change.likely)] {
1931        if sites.is_empty() {
1932            continue;
1933        }
1934        out.push_str(&format!(
1935            "    Not in your diff, {label} ({}):\n",
1936            sites.len()
1937        ));
1938        for site in &sites[..sites.len().min(EDIT_SITES_LISTED)] {
1939            out.push_str(&edit_site_row(site));
1940        }
1941        if sites.len() > EDIT_SITES_LISTED {
1942            out.push_str(&format!(
1943                "      … {} more {label}\n",
1944                sites.len() - EDIT_SITES_LISTED
1945            ));
1946        }
1947    }
1948    if change.unresolved > 0 {
1949        out.push_str(&format!(
1950            "    Unresolved outside the diff: {}\n",
1951            change.unresolved
1952        ));
1953    }
1954}
1955
1956fn edit_site_row(site: &EditSite) -> String {
1957    let caller = if site.caller.is_empty() {
1958        "(no enclosing symbol)".to_string()
1959    } else {
1960        format!("`{}`", site.caller)
1961    };
1962    let note = site
1963        .note
1964        .as_deref()
1965        .map(|note| format!("  {note}"))
1966        .unwrap_or_default();
1967    format!("      {}:{}  {caller}{note}\n", site.path, site.line)
1968}
1969
1970/// Format blast radius and likely test targets into token-dense markdown.
1971pub fn format_blast_radius(result: &BlastRadiusResult) -> String {
1972    if result.seed_type == "none" {
1973        return "No uncommitted changes detected in git working tree. Pass a 'symbol' or 'file' parameter to analyze blast radius.".to_string();
1974    }
1975
1976    let mut out = String::new();
1977    let seed_label = if result.seed_type == "file" {
1978        format!("Files: {}", result.seeds.join(", "))
1979    } else if result.seed_type == "symbol" {
1980        format!("Symbol: {}", result.seeds.join(", "))
1981    } else {
1982        format!("Seeds: {}", result.seeds.join(", "))
1983    };
1984
1985    out.push_str(&format!("## Blast Radius & Test Impact ({seed_label})\n\n"));
1986
1987    let more = if result.limit_at_maximum {
1988        "limit is at its maximum, so narrow the target to a symbol or fewer files"
1989    } else {
1990        "raise limit to see the rest"
1991    };
1992    let shown_of = |shown: usize, found: usize, noun: &str| {
1993        if found > shown {
1994            format!("Showing {shown} of {found} {noun}; {more}.\n\n")
1995        } else {
1996            format!("Requested limit hid additional {noun}; {more}.\n\n")
1997        }
1998    };
1999    if result.likely_tests_truncated {
2000        out.push_str(&shown_of(
2001            result.likely_tests.len(),
2002            result.likely_tests_found,
2003            "likely tests",
2004        ));
2005    }
2006    if result.impacted_symbols_truncated {
2007        out.push_str(&shown_of(
2008            result.impacted_symbols.len(),
2009            result.impacted_symbols_found,
2010            "impacted symbols",
2011        ));
2012    }
2013    if result.traversal_ceiling_reached {
2014        out.push_str("Traversal stopped at the 200-row discovery ceiling; narrow the target because increasing limit cannot raise this ceiling.\n\n");
2015    }
2016
2017    if !result.likely_tests.is_empty() {
2018        let total = result.likely_tests.len();
2019        out.push_str(&format!("### Likely Tests to Run ({} returned)\n", total));
2020
2021        // Preserve ranked row order; collecting every row by file can put lower-ranked tests first.
2022        let mut previous_path = None;
2023        for t in &result.likely_tests {
2024            if previous_path != Some(t.path.as_str()) {
2025                out.push_str(&format!("{}:\n", t.path));
2026                previous_path = Some(t.path.as_str());
2027            }
2028            out.push_str(&format!(
2029                "  - `{}` [line {}] ({})\n",
2030                t.name, t.line, t.reason
2031            ));
2032        }
2033
2034        out.push('\n');
2035    } else if result.likely_tests_truncated || result.traversal_ceiling_reached {
2036        out.push_str("### Likely Tests to Run (0 returned)\n\n");
2037    } else if let Some(depth) = result.stopped_at_depth {
2038        out.push_str(&format!(
2039            "### Likely Tests to Run (0 returned)\nNo test reaches it within depth {depth}; a larger `depth` may find some. Otherwise run the full test suite.\n\n",
2040        ));
2041    } else {
2042        out.push_str(
2043            "### Likely Tests to Run (0 returned)\nNo test reaches it through the index. Run the full test suite.\n\n",
2044        );
2045    }
2046
2047    if !result.impacted_symbols.is_empty() {
2048        let total = result.impacted_symbols.len();
2049        let mut visible = Vec::new();
2050        let mut low_signal_count = 0;
2051        for s in &result.impacted_symbols {
2052            if matches!(s.kind.as_str(), "import" | "module" | "namespace") {
2053                low_signal_count += 1;
2054            } else {
2055                visible.push(s);
2056            }
2057        }
2058
2059        out.push_str(&format!("### Downstream Impact ({} returned)\n", total));
2060
2061        if visible.is_empty() && low_signal_count > 0 {
2062            let row_word = if low_signal_count == 1 {
2063                "row (import/module)"
2064            } else {
2065                "rows (imports/modules)"
2066            };
2067            out.push_str(&format!(
2068                "All impacted symbols are imports/modules; {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
2069            ));
2070        } else {
2071            let mut syms_by_file: std::collections::BTreeMap<&str, Vec<&ImpactedSymbol>> =
2072                std::collections::BTreeMap::new();
2073            let mut file_order = Vec::new();
2074            for s in &visible {
2075                if !syms_by_file.contains_key(s.path.as_str()) {
2076                    file_order.push(s.path.as_str());
2077                }
2078                syms_by_file.entry(s.path.as_str()).or_default().push(s);
2079            }
2080
2081            for path in file_order {
2082                out.push_str(&format!("{path}:\n"));
2083                if let Some(syms) = syms_by_file.get(path) {
2084                    for s in syms {
2085                        let name =
2086                            displayed_symbol_name(&s.name, s.enclosing_symbol_name.as_deref());
2087                        out.push_str(&format!(
2088                            "  - [depth {}] {} `{}` [line {}]\n",
2089                            s.depth, s.kind, name, s.line
2090                        ));
2091                    }
2092                }
2093            }
2094
2095            if low_signal_count > 0 {
2096                let row_word = if low_signal_count == 1 {
2097                    "row (import/module)"
2098                } else {
2099                    "rows (imports/modules)"
2100                };
2101                out.push_str(&format!(
2102                    "... {low_signal_count} low-signal {row_word} hidden; available in CLI --json.\n"
2103                ));
2104            }
2105        }
2106    } else if result.impacted_symbols_truncated || result.traversal_ceiling_reached {
2107        out.push_str("### Downstream Impact (0 returned)\n");
2108    } else {
2109        out.push_str(
2110            "### Downstream Impact (0 returned)\nNo downstream callers found within depth.\n",
2111        );
2112    }
2113
2114    out
2115}
2116
2117#[cfg(test)]
2118mod tests {
2119    use super::*;
2120    use crate::models::{
2121        ImpactedSymbol, LabeledSite, ReferenceCounts, ReferenceReport, ReferenceTarget, SiteLabel,
2122        TestTarget, TextOnlyLines, TextScanSummary,
2123    };
2124
2125    fn structural_fact(key: Option<&str>) -> crate::models::StructuralFact {
2126        crate::models::StructuralFact {
2127            structural_fact_id: "sf1".into(),
2128            path: ".codex/config.toml".into(),
2129            language: "toml".into(),
2130            pattern_id: "toml.key_value.v1".into(),
2131            capture_name: "key_value".into(),
2132            node_kind: "table".into(),
2133            key: key.map(str::to_string),
2134            metadata: None,
2135            containing_symbol_name: None,
2136            start_line: 2,
2137            end_line: 2,
2138            confidence: 1.0,
2139        }
2140    }
2141
2142    #[test]
2143    fn format_structural_facts_prints_key_and_falls_back_to_capture_name() {
2144        let with_key = format_structural_facts(
2145            &[structural_fact(Some("mcp_servers.code-kb.command"))],
2146            &[],
2147            "config",
2148            30,
2149        );
2150        assert!(with_key.contains(
2151            "- mcp_servers.code-kb.command [.codex/config.toml:2] (pattern: toml.key_value.v1)"
2152        ));
2153
2154        let without_key = format_structural_facts(&[structural_fact(None)], &[], "config", 30);
2155        assert!(
2156            without_key.contains("- key_value [.codex/config.toml:2] (pattern: toml.key_value.v1)")
2157        );
2158    }
2159
2160    #[test]
2161    fn format_structural_facts_renders_selected_qt_property_metadata() {
2162        let mut fact = structural_fact(Some("index"));
2163        fact.pattern_id = "cpp.qt_property.v1".into();
2164        fact.metadata = Some(serde_json::json!({
2165            "property_type": "int",
2166            "designable": false,
2167            "scriptable": true,
2168            "stored": false,
2169            "user": true,
2170            "revision": 2,
2171        }));
2172
2173        let output = format_structural_facts(&[fact], &[], "property", 30);
2174
2175        assert!(output.contains(
2176            "property_type: int, designable: false, scriptable: true, stored: false, user: true, revision: 2"
2177        ));
2178    }
2179
2180    #[test]
2181    fn format_structural_facts_names_the_endpoint_and_view_of_a_flask_route() {
2182        let route = |metadata| crate::models::StructuralFact {
2183            pattern_id: "flask.route.v1".into(),
2184            metadata: Some(metadata),
2185            ..structural_fact(Some("GET /"))
2186        };
2187        let alias = route(serde_json::json!({"api_style": "call_routing", "endpoint": "index"}));
2188        let rule = route(serde_json::json!({"api_style": "call_routing", "view_target": "ping"}));
2189        let decorated = route(serde_json::json!({"api_style": "decorator_routing"}));
2190
2191        let output = format_structural_facts(&[alias, rule, decorated], &[], "route", 30);
2192
2193        assert!(
2194            output.contains("(pattern: flask.route.v1, endpoint: index, no view function)"),
2195            "{output}"
2196        );
2197        assert!(
2198            output.contains("(pattern: flask.route.v1, view: ping)"),
2199            "{output}"
2200        );
2201        assert!(output.contains("(pattern: flask.route.v1)"), "{output}");
2202    }
2203
2204    #[test]
2205    fn doc_summary_ends_at_the_first_sentence_of_the_first_paragraph() {
2206        assert_eq!(
2207            doc_summary(
2208                "The actual WSGI application. This is not implemented in\n:meth:`__call__`."
2209            )
2210            .as_deref(),
2211            Some("The actual WSGI application.")
2212        );
2213        assert_eq!(
2214            doc_summary("\nCreate a runner\nfor tests\n\nMore.").as_deref(),
2215            Some("Create a runner for tests")
2216        );
2217        assert_eq!(
2218            doc_summary("Find facts by category (e.g. route, query). More.").as_deref(),
2219            Some("Find facts by category (e.g. route, query).")
2220        );
2221        assert_eq!(
2222            doc_summary("Use a name, e.g. foo. Then more.").as_deref(),
2223            Some("Use a name, e.g. foo.")
2224        );
2225        assert_eq!(
2226            doc_summary("/// <summary>\n/// Goes to previous page.\n/// </summary>").as_deref(),
2227            Some("Goes to previous page.")
2228        );
2229        assert_eq!(
2230            doc_summary("/**\n * Render the view.\n *\n * @param x\n */").as_deref(),
2231            Some("Render the view.")
2232        );
2233        assert_eq!(
2234            doc_summary("# Given a +hash+ returns the settings\n# for the environment.").as_deref(),
2235            Some("Given a +hash+ returns the settings for the environment.")
2236        );
2237        assert_eq!(
2238            doc_summary("#' A person record.").as_deref(),
2239            Some("A person record.")
2240        );
2241        assert_eq!(doc_summary("  \n"), None);
2242        assert!(doc_summary(&"word ".repeat(60)).unwrap().ends_with('…'));
2243    }
2244
2245    #[test]
2246    fn format_references_reports_the_occurrence_count_of_a_grouped_row() {
2247        let site = |occurrences| ReferenceSite {
2248            from_symbol_name: "Button".into(),
2249            from_symbol_id: "s1".into(),
2250            to_symbol_name: "background".into(),
2251            kind: "member_access".into(),
2252            path: "Ui/Button.qml".into(),
2253            start_line: Some(5),
2254            start_column: Some(4),
2255            occurrences,
2256            target: None,
2257            enclosing_symbol_name: None,
2258        };
2259
2260        let grouped = format_references("Color", &[site(Some(6))], "callers", 30);
2261        assert!(
2262            grouped.contains("- `Button` [Ui/Button.qml:5] (kind: member_access, 6 in file)"),
2263            "{grouped}"
2264        );
2265
2266        let single = format_references("Color", &[site(None)], "callers", 30);
2267        assert!(
2268            single.contains("- `Button` [Ui/Button.qml:5] (kind: member_access)"),
2269            "{single}"
2270        );
2271    }
2272
2273    #[test]
2274    fn a_match_snippet_joins_its_lines_and_is_dropped_when_it_repeats_the_signature() {
2275        assert_eq!(
2276            match_line("...path up to\n the [root] (the [root] excluded)", "fn f()"),
2277            Some("...path up to the [root] (the [root] excluded)".into())
2278        );
2279        assert_eq!(
2280            match_line("// [HelpFunc] returns the\n// [function] set", "fn f()"),
2281            Some("// [HelpFunc] returns the [function] set".into())
2282        );
2283        assert_eq!(
2284            match_line(
2285                "fn [index]_facts(workspace_[root]: &Path) -> Option<[IndexFacts]>",
2286                "fn index_facts(workspace_root: &Path) -> Option<IndexFacts>"
2287            ),
2288            None
2289        );
2290        assert_eq!(
2291            match_line(
2292                "...[root]: &[PathBuf]) -> bool",
2293                "fn f(root: &[PathBuf]) -> bool"
2294            ),
2295            None
2296        );
2297    }
2298
2299    #[test]
2300    fn a_long_unresolved_call_chain_shows_only_its_last_call() {
2301        assert_eq!(
2302            callee_name(
2303                "[\"a\", \"b\"]\n    .into_iter()\n    .find_map(|key| args.get(key)).ok_or_else"
2304            ),
2305            "….ok_or_else"
2306        );
2307        assert_eq!(
2308            callee_name("workspace.resolve_path"),
2309            "workspace.resolve_path"
2310        );
2311        let selector = "html.dash .header, html.dash .breadcrumbs, html.dash .navigation";
2312        assert_eq!(callee_name(selector), selector);
2313    }
2314
2315    #[test]
2316    fn file_skeleton_reports_parse_errors() {
2317        let two = format_file_skeleton("src/lib.rs", &[], Some(35), 2);
2318        assert!(two.contains("// 2 parse errors: symbols may be incomplete"));
2319
2320        let one = format_file_skeleton("src/lib.rs", &[], Some(35), 1);
2321        assert!(one.contains("// 1 parse error: symbols may be incomplete"));
2322
2323        let none = format_file_skeleton("src/lib.rs", &[], Some(35), 0);
2324        assert!(!none.contains("parse error"));
2325    }
2326
2327    #[test]
2328    fn test_format_file_skeleton() {
2329        let syms = vec![Symbol {
2330            symbol_id: "s1".into(),
2331            file_id: "f1".into(),
2332            path: "src/lib.rs".into(),
2333            language: "rust".into(),
2334            name: "do_work".into(),
2335            kind: "function".into(),
2336            signature: Some("pub fn do_work() -> Result<()>".into()),
2337            doc_comment: Some("Performs core work.".into()),
2338            visibility: Some("pub".into()),
2339            parent_symbol_id: None,
2340            start_line: 10,
2341            start_column: 0,
2342            end_line: 30,
2343            end_column: 1,
2344            start_byte: 100,
2345            end_byte: 300,
2346            body_start_line: Some(11),
2347            body_start_column: Some(0),
2348            body_end_line: Some(29),
2349            body_end_column: Some(1),
2350            body_start_byte: Some(130),
2351            body_end_byte: Some(298),
2352            body_hash: None,
2353            semantic_group: None,
2354            is_test: false,
2355            test_container: false,
2356        }];
2357
2358        let skeleton = format_file_skeleton("src/lib.rs", &syms, Some(35), 0);
2359        assert!(skeleton.contains("/// Performs core work."));
2360
2361        let mut commented = syms.clone();
2362        commented[0].doc_comment = Some("/**\n * Performs core work.\n */".into());
2363        let skeleton = format_file_skeleton("src/lib.rs", &commented, Some(35), 0);
2364        assert!(skeleton.contains("/// Performs core work.\n"), "{skeleton}");
2365        assert!(!skeleton.contains("/// /**"), "{skeleton}");
2366        assert!(skeleton.contains("19 lines hidden: L11-L29"));
2367    }
2368
2369    #[test]
2370    fn format_symbol_body_prints_the_source_under_its_location() {
2371        let mut symbol = sample_symbol("plain");
2372        symbol.signature = Some("pub fn plain()".into());
2373
2374        assert_eq!(
2375            format_symbol_body(&symbol, "pub fn plain() {\n    1\n}"),
2376            "// src/lib.rs:1-10 (plain)\npub fn plain() {\n    1\n}\n"
2377        );
2378    }
2379
2380    #[test]
2381    fn test_format_search_results() {
2382        let results = vec![SymbolSearchResult {
2383            symbol: Symbol {
2384                symbol_id: "s1".into(),
2385                file_id: "f1".into(),
2386                path: "src/parser.rs".into(),
2387                language: "rust".into(),
2388                name: "parse_tokens".into(),
2389                kind: "function".into(),
2390                signature: Some("pub fn parse_tokens()".into()),
2391                doc_comment: Some("Parses tokens from stream.".into()),
2392                visibility: Some("pub".into()),
2393                parent_symbol_id: None,
2394                start_line: 15,
2395                start_column: 0,
2396                end_line: 25,
2397                end_column: 1,
2398                start_byte: 100,
2399                end_byte: 250,
2400                body_start_line: None,
2401                body_start_column: None,
2402                body_end_line: None,
2403                body_end_column: None,
2404                body_start_byte: None,
2405                body_end_byte: None,
2406                body_hash: None,
2407                semantic_group: None,
2408                is_test: false,
2409                test_container: false,
2410            },
2411            score: -1.85,
2412            snippet: Some("Parses [tokens] from stream.".into()),
2413            explain: None,
2414        }];
2415
2416        let formatted = format_search_results("tokens", &results, 20);
2417        assert!(formatted.contains("Found 1 symbols matching concept \"tokens\":\n\n- "));
2418        assert!(
2419            formatted.contains("- function `parse_tokens` [src/parser.rs:15-25] (score: -1.85)")
2420        );
2421        assert!(formatted.contains("Match: Parses [tokens] from stream."));
2422        assert!(!formatted.contains("explain"));
2423        assert!(!formatted.contains("rerank"));
2424    }
2425
2426    #[test]
2427    fn exact_lookup_folds_import_rows_into_one_line_when_a_definition_exists() {
2428        let class = Symbol {
2429            kind: "class".into(),
2430            path: "src/flask/app.py".into(),
2431            ..sample_symbol("Flask")
2432        };
2433        let import = |path: &str, line: usize| Symbol {
2434            kind: "import".into(),
2435            path: path.into(),
2436            start_line: line,
2437            symbol_id: format!("id_{path}"),
2438            ..sample_symbol("Flask")
2439        };
2440        let rows = vec![
2441            class,
2442            import("src/flask/cli.py", 34),
2443            import("src/flask/ctx.py", 21),
2444            import("tests/conftest.py", 6),
2445            import("examples/app.py", 1),
2446        ];
2447
2448        let folded = format_find_symbol_results("Flask", &rows, &[], 20);
2449
2450        assert!(
2451            folded.contains("- class `Flask` [src/flask/app.py:"),
2452            "{folded}"
2453        );
2454        assert!(
2455            folded.contains(
2456                "- 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)"
2457            ),
2458            "{folded}"
2459        );
2460        assert!(!folded.contains("- import `Flask`"), "{folded}");
2461
2462        let capped = format_find_symbol_results("Flask", &rows, &[], 1);
2463        assert!(
2464            capped.contains("- at least 4 imports of `Flask`"),
2465            "{capped}"
2466        );
2467
2468        let mut with_prefix_rows = rows.clone();
2469        with_prefix_rows.push(Symbol {
2470            kind: "class".into(),
2471            ..sample_symbol("FlaskGroup")
2472        });
2473        let cut_after_exact = format_find_symbol_results("Flask", &with_prefix_rows, &[], 6);
2474        assert!(
2475            cut_after_exact.contains("- 4 imports of `Flask`"),
2476            "{cut_after_exact}"
2477        );
2478
2479        let imports_only = format_find_symbol_results("Flask", &rows[1..], &[], 20);
2480        assert_eq!(imports_only.matches("- import `Flask`").count(), 4);
2481    }
2482
2483    #[test]
2484    fn skeleton_shows_python_class_and_module_variables_with_one_line_values() {
2485        let leaf = |name: &str, kind: &str, line: usize, sig: &str| Symbol {
2486            kind: kind.into(),
2487            language: "python".into(),
2488            path: "app.py".into(),
2489            parent_symbol_id: Some("id_App".into()),
2490            start_line: line,
2491            end_line: line,
2492            body_start_line: None,
2493            body_end_line: None,
2494            signature: Some(sig.into()),
2495            ..sample_symbol(name)
2496        };
2497        let class = Symbol {
2498            kind: "class".into(),
2499            language: "python".into(),
2500            path: "app.py".into(),
2501            start_line: 1,
2502            end_line: 20,
2503            body_start_line: None,
2504            body_end_line: None,
2505            signature: Some("class App".into()),
2506            ..sample_symbol("App")
2507        };
2508        let module_variable = Symbol {
2509            kind: "variable".into(),
2510            language: "python".into(),
2511            path: "app.py".into(),
2512            start_line: 30,
2513            end_line: 30,
2514            body_start_line: None,
2515            body_end_line: None,
2516            signature: Some("app = App()".into()),
2517            ..sample_symbol("app")
2518        };
2519        let symbols = vec![
2520            class,
2521            leaf(
2522                "request_class",
2523                "variable",
2524                2,
2525                "request_class: type[Request] = Request",
2526            ),
2527            leaf(
2528                "default_config",
2529                "variable",
2530                3,
2531                "default_config = ImmutableDict(\n    {\n        \"DEBUG\": None,\n    }\n)",
2532            ),
2533            leaf(
2534                "blueprints",
2535                "property",
2536                5,
2537                "self.blueprints: dict[str, Blueprint] = {}",
2538            ),
2539            leaf(
2540                "error_handler_spec",
2541                "property",
2542                7,
2543                "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",
2544            ),
2545            module_variable,
2546        ];
2547
2548        let out = format_file_skeleton("app.py", &symbols, None, 0);
2549
2550        assert!(
2551            out.contains("    request_class: type[Request] = Request; // L2-2"),
2552            "{out}"
2553        );
2554        assert!(
2555            out.contains("    default_config = ImmutableDict({\"DEBUG\": None}); // L3-3"),
2556            "{out}"
2557        );
2558        assert!(
2559            out.contains("    self.error_handler_spec: dict[ft.AppOrBlueprintKey, dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]]] = defaultdict(lambda: defaultdict(dict)…; // L7-7"),
2560            "{out}"
2561        );
2562        assert!(
2563            out.contains("    self.blueprints: dict[str, Blueprint] = {}; // L5-5"),
2564            "{out}"
2565        );
2566        assert!(out.contains("app = App(); // L30-30"), "{out}");
2567    }
2568
2569    #[test]
2570    fn skeleton_names_the_method_that_assigns_an_attribute() {
2571        let member = |name: &str, kind: &str, start: usize, end: usize, sig: &str| Symbol {
2572            kind: kind.into(),
2573            language: "python".into(),
2574            parent_symbol_id: Some("id_Flask".into()),
2575            start_line: start,
2576            end_line: end,
2577            body_start_line: None,
2578            body_end_line: None,
2579            signature: Some(sig.into()),
2580            ..sample_symbol(name)
2581        };
2582        let class = Symbol {
2583            kind: "class".into(),
2584            language: "python".into(),
2585            start_line: 1,
2586            end_line: 30,
2587            body_start_line: None,
2588            body_end_line: None,
2589            signature: Some("class Flask(App)".into()),
2590            ..sample_symbol("Flask")
2591        };
2592        let symbols = vec![
2593            class,
2594            member("__init__", "method", 2, 10, "def __init__(self)"),
2595            member("cli", "property", 3, 3, "self.cli = cli.AppGroup()"),
2596            member("run", "method", 12, 20, "def run(self)"),
2597            member("debug", "property", 14, 14, "self.debug = get_debug_flag()"),
2598        ];
2599
2600        let out = format_file_skeleton("app.py", &symbols, None, 0);
2601
2602        assert!(
2603            out.contains("self.cli = cli.AppGroup(); // in __init__(), L3-3"),
2604            "{out}"
2605        );
2606        assert!(
2607            out.contains("self.debug = get_debug_flag(); // in run(), L14-14"),
2608            "{out}"
2609        );
2610    }
2611
2612    #[test]
2613    fn skeleton_hides_variables_that_are_not_python_class_attributes() {
2614        let parent = |name: &str, kind: &str, language: &str| Symbol {
2615            kind: kind.into(),
2616            language: language.into(),
2617            start_line: 1,
2618            end_line: 20,
2619            body_start_line: None,
2620            body_end_line: None,
2621            signature: Some(format!("{kind} {name}")),
2622            ..sample_symbol(name)
2623        };
2624        let child = |name: &str, parent: &str, language: &str, sig: &str| Symbol {
2625            kind: "variable".into(),
2626            language: language.into(),
2627            parent_symbol_id: Some(format!("id_{parent}")),
2628            start_line: 2,
2629            end_line: 2,
2630            body_start_line: None,
2631            body_end_line: None,
2632            signature: Some(sig.into()),
2633            ..sample_symbol(name)
2634        };
2635        let symbols = vec![
2636            parent("Adapters", "class", "java"),
2637            child(
2638                "accessible",
2639                "Adapters",
2640                "java",
2641                "boolean accessible = false",
2642            ),
2643            parent("body", "class", "html"),
2644            child(
2645                "img",
2646                "body",
2647                "html",
2648                "<img class=\"carat\" src=\"carat.png\" alt>",
2649            ),
2650            parent("section", "module", "yaml"),
2651            child("title", "section", "yaml", "title: Invoking jq"),
2652        ];
2653
2654        let out = format_file_skeleton("mixed", &symbols, None, 0);
2655
2656        for hidden in ["accessible", "<img", "title:"] {
2657            assert!(!out.contains(hidden), "{out}");
2658        }
2659    }
2660
2661    #[test]
2662    fn a_multi_line_macro_row_drops_its_line_continuations_and_is_cut_to_one_line() {
2663        let short = "#define PAIR(a, b)   \\\n    a,                \\\n    b";
2664        assert_eq!(value_row_signature(short), "#define PAIR(a, b) a, b");
2665
2666        let long = format!(
2667            "#define WIDE(x) \\\n{}",
2668            "    x + x + x + x + x + x + x + x \\\n".repeat(8)
2669        );
2670        let row = value_row_signature(&long);
2671        assert!(row.starts_with("#define WIDE(x) x + x"), "{row}");
2672        assert!(row.ends_with('…'), "{row}");
2673        assert!(!row.contains('\\'), "{row}");
2674        assert!(row.chars().count() <= 120, "{row}");
2675    }
2676
2677    #[test]
2678    fn skeleton_keeps_values_only_for_real_assignments() {
2679        let row =
2680            |name: &str, kind: &str, line: usize, sig: &str, body: Option<(usize, usize)>| Symbol {
2681                kind: kind.into(),
2682                start_line: line,
2683                end_line: body.map_or(line, |(_, end)| end),
2684                body_start_line: body.map(|(start, _)| start),
2685                body_end_line: body.map(|(_, end)| end),
2686                signature: Some(sig.into()),
2687                ..sample_symbol(name)
2688            };
2689        let symbols = vec![
2690            row(
2691                "img",
2692                "property",
2693                1,
2694                "[class^=rz-] img,[class^=rz-] svg { vertical-align:middle }",
2695                None,
2696            ),
2697            row(
2698                "Coordinates",
2699                "property",
2700                2,
2701                "[JsonProperty(ItemConverterType = typeof(IntToFloatConverter))] public int[,,] Coordinates { get; set; }",
2702                None,
2703            ),
2704            row(
2705                "VERSION_CHECK",
2706                "constant",
2707                3,
2708                "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor)))\n",
2709                None,
2710            ),
2711            row(
2712                "TABLE",
2713                "constant",
2714                4,
2715                "TABLE = {\n  '\"' => '%22',\n  '\\r' => '%0D',\n}.freeze",
2716                Some((4, 8)),
2717            ),
2718            row(
2719                "LIMIT",
2720                "field",
2721                10,
2722                "private static final int LIMIT = 5;",
2723                None,
2724            ),
2725        ];
2726
2727        let out = format_file_skeleton("mixed", &symbols, None, 0);
2728
2729        assert!(
2730            out.contains("[class^=rz-] img,[class^=rz-] svg; // L1-1"),
2731            "{out}"
2732        );
2733        assert!(out.contains("public int[,,] Coordinates; // L2-2"), "{out}");
2734        assert!(
2735            out.contains(
2736                "#define VERSION_CHECK(major,minor,patch) (_MSC_VER >= ((major * 100) + (minor))); // L3-3\n"
2737            ),
2738            "{out}"
2739        );
2740        assert!(
2741            out.contains("TABLE = { /* 5 lines hidden: L4-L8 */ }"),
2742            "{out}"
2743        );
2744        assert!(
2745            out.contains("private static final int LIMIT = 5; // L10-10"),
2746            "{out}"
2747        );
2748    }
2749
2750    #[test]
2751    fn skeleton_cuts_a_field_with_members_at_its_brace() {
2752        let field = Symbol {
2753            kind: "field".into(),
2754            language: "java".into(),
2755            start_line: 1,
2756            end_line: 9,
2757            body_start_line: None,
2758            body_end_line: None,
2759            signature: Some(
2760                "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n  @Override\n}"
2761                    .into(),
2762            ),
2763            ..sample_symbol("CLASS")
2764        };
2765        let member = Symbol {
2766            kind: "method".into(),
2767            language: "java".into(),
2768            parent_symbol_id: Some("id_CLASS".into()),
2769            start_line: 2,
2770            end_line: 4,
2771            signature: Some("public void write(JsonWriter out, Class value)".into()),
2772            ..sample_symbol("write")
2773        };
2774
2775        let out = format_file_skeleton("TypeAdapters.java", &[field, member], None, 0);
2776
2777        assert!(
2778            out.contains(
2779                "public static final TypeAdapter<Class> CLASS = new TypeAdapter<Class>() {\n"
2780            ),
2781            "{out}"
2782        );
2783        assert!(!out.contains('…'), "{out}");
2784    }
2785
2786    #[test]
2787    fn import_summary_counts_sites_and_files_and_lists_three() {
2788        let sites = vec![
2789            ("src/flask/__init__.py".to_string(), 2),
2790            ("src/flask/cli.py".to_string(), 34),
2791            ("src/flask/cli.py".to_string(), 45),
2792            ("tests/conftest.py".to_string(), 6),
2793        ];
2794
2795        assert_eq!(
2796            format_import_summary(&sites),
2797            "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"
2798        );
2799        assert_eq!(format_import_summary(&[]), "");
2800    }
2801
2802    #[test]
2803    fn no_facts_for_an_alias_points_imports_at_lookup_and_skips_the_raw_list() {
2804        let categories = vec![("flask.route.v1".to_string(), 3)];
2805
2806        let imports = format_no_facts("import", None, &categories);
2807        let unknown = format_no_facts("widgets", None, &categories);
2808
2809        assert!(
2810            imports.contains("lookup_symbol with kind=\"import\""),
2811            "{imports}"
2812        );
2813        assert!(!imports.contains("`flask.route.v1`"), "{imports}");
2814        assert!(unknown.contains("`flask.route.v1` (3 facts)"), "{unknown}");
2815    }
2816
2817    #[test]
2818    fn markup_and_data_rows_show_their_own_kind_words() {
2819        let row = |language: &str, kind: &str| Symbol {
2820            language: language.into(),
2821            kind: kind.into(),
2822            ..sample_symbol("x")
2823        };
2824        assert_eq!(display_kind(&row("html", "class")), "element");
2825        assert_eq!(display_kind(&row("sql", "class")), "table");
2826        assert_eq!(display_kind(&row("markdown", "module")), "section");
2827        assert_eq!(display_kind(&row("markdown", "import")), "link");
2828        let attribute = Symbol {
2829            kind: "property".into(),
2830            signature: Some("self.extensions = {}".into()),
2831            ..sample_symbol("extensions")
2832        };
2833        assert_eq!(display_kind(&attribute), "attribute");
2834        let fsharp_member = Symbol {
2835            language: "fsharp".into(),
2836            signature: Some("this.Total = decimal this.Qty * this.Price".into()),
2837            ..attribute
2838        };
2839        assert_eq!(display_kind(&fsharp_member), "property");
2840        assert_eq!(display_kind(&row("python", "class")), "class");
2841    }
2842
2843    #[test]
2844    fn no_facts_heading_names_the_path_filter() {
2845        assert_eq!(
2846            no_facts_heading("config", Some("src/flask")),
2847            "No facts match 'config' under `src/flask`."
2848        );
2849        assert_eq!(
2850            no_facts_heading("config", None),
2851            "No facts match 'config' in this repository."
2852        );
2853    }
2854
2855    #[test]
2856    fn outline_says_how_many_definitions_it_left_out_and_counts_tests() {
2857        let mut root = OutlineNode::default();
2858        let symbol = |name: &str, is_test: bool| Symbol {
2859            kind: "function".into(),
2860            path: "tests/test_basic.py".into(),
2861            is_test,
2862            ..sample_symbol(name)
2863        };
2864        let symbols = HashMap::from([(
2865            "tests/test_basic.py".to_string(),
2866            vec![symbol("helper", false), symbol("test_one", true)],
2867        )]);
2868        let counts = HashMap::from([(
2869            "tests/test_basic.py".to_string(),
2870            crate::queries::OutlineCounts {
2871                definitions: 3,
2872                tests: 90,
2873                fixtures: 2,
2874            },
2875        )]);
2876        add_path_to_outline(&mut root, "tests/test_basic.py", &symbols, &counts, 3, "");
2877
2878        let mut out = String::new();
2879        render_outline_tree(&mut out, &root, "", 0, 3);
2880
2881        assert!(
2882            out.contains("test_basic.py [function helper, +2 more, 90 tests, 2 fixtures]"),
2883            "{out}"
2884        );
2885    }
2886
2887    #[test]
2888    fn outline_counts_the_files_below_the_depth_limit() {
2889        let mut root = OutlineNode::default();
2890        let no_symbols = HashMap::new();
2891        for path in [
2892            "src/flask/app.py",
2893            "src/flask/json/tag.py",
2894            "src/flask/cli.py",
2895            "tests/conftest.py",
2896        ] {
2897            add_path_to_outline(&mut root, path, &no_symbols, &HashMap::new(), 2, "");
2898        }
2899
2900        let mut out = String::new();
2901        render_outline_tree(&mut out, &root, "", 0, 2);
2902
2903        assert!(out.contains("└── flask/ (3 indexed files)"), "{out}");
2904        assert!(!out.contains("tests/ ("), "{out}");
2905    }
2906
2907    #[test]
2908    fn a_lookup_qualified_with_double_colons_names_the_member_exactly() {
2909        let rows = vec![sample_symbol("Flask.handle_user_exception")];
2910
2911        let out = format_find_symbol_results("Flask::handle_user_exception", &rows, &[], 20);
2912
2913        assert!(!out.contains("named exactly"), "{out}");
2914    }
2915
2916    #[test]
2917    fn exact_lookup_folds_only_imports_named_exactly_like_the_query() {
2918        let rows = vec![
2919            Symbol {
2920                kind: "function".into(),
2921                ..sample_symbol("read")
2922            },
2923            Symbol {
2924                kind: "import".into(),
2925                path: "src/flask/cli.py".into(),
2926                start_line: 1034,
2927                ..sample_symbol("readline")
2928            },
2929            Symbol {
2930                kind: "import".into(),
2931                path: "src/Types.kt".into(),
2932                start_line: 18,
2933                ..sample_symbol("com.example.internal.read")
2934            },
2935        ];
2936
2937        let out = format_find_symbol_results("read", &rows, &[], 20);
2938
2939        assert!(
2940            out.contains(
2941                "- 1 other row starts with or contains `read`, ignoring case: `readline` (import) (lookup_symbol"
2942            ),
2943            "{out}"
2944        );
2945        assert!(
2946            out.contains(
2947                "- 1 import of `read`: src/Types.kt:18 (lookup_symbol with kind=\"import\" lists them)"
2948            ),
2949            "{out}"
2950        );
2951    }
2952
2953    #[test]
2954    fn discovery_formatters_append_ids_to_metadata_rows() {
2955        let exact = vec![sample_symbol("exact")];
2956        let fallback = vec![SymbolSearchResult {
2957            symbol: sample_symbol("fallback"),
2958            score: 1.0,
2959            snippet: None,
2960            explain: None,
2961        }];
2962
2963        let outputs = [
2964            (
2965                format_find_symbol_results("exact", &exact, &[], 20),
2966                "id_exact",
2967            ),
2968            (
2969                format_find_symbol_results("fallback query", &[], &fallback, 20),
2970                "id_fallback",
2971            ),
2972            (
2973                format_search_results("fallback", &fallback, 20),
2974                "id_fallback",
2975            ),
2976        ];
2977
2978        for (formatted, id) in outputs {
2979            assert!(
2980                formatted
2981                    .lines()
2982                    .any(|line| line.starts_with("- ") && line.contains(&format!("id={id}"))),
2983                "{formatted}"
2984            );
2985            assert!(!formatted.contains("\n  id="), "{formatted}");
2986        }
2987    }
2988
2989    #[test]
2990    fn lookup_lists_name_rows_before_the_ranked_rows_under_their_own_header() {
2991        let names = vec![sample_symbol("retry_count")];
2992        let ranked = vec![SymbolSearchResult {
2993            symbol: sample_symbol("send_again"),
2994            score: 7.5,
2995            snippet: None,
2996            explain: None,
2997        }];
2998
2999        let out = format_find_symbol_results("retry", &names, &ranked, 20);
3000
3001        let name_header = out.find("Found 1 symbols matching \"retry\"").unwrap();
3002        let ranked_header = out
3003            .find("\nNo exact name match; 1 full-text matches for \"retry\":\n\n")
3004            .unwrap();
3005        assert!(name_header < out.find("`retry_count`").unwrap(), "{out}");
3006        assert!(out.find("`retry_count`").unwrap() < ranked_header, "{out}");
3007        assert!(ranked_header < out.find("`send_again`").unwrap(), "{out}");
3008    }
3009
3010    #[test]
3011    fn every_printed_part_of_the_explain_line_sums_to_the_score() {
3012        let e = SearchExplain {
3013            bm25: Some(-3.21),
3014            branches: vec!["word".into()],
3015            name_tier: "all".into(),
3016            name_strength: 6,
3017            term_score: 24.5,
3018            field_diversity_bonus: 6.0,
3019            name_bonus: 60.0,
3020            kind_prior: 4.0,
3021            path_role: -10.0,
3022            documentation: -200.0,
3023            test_intent: 5.0,
3024            nested: 0.0,
3025            terms: vec![("sha".into(), "name".into(), 3.0)],
3026            word_weights: vec![("sha".into(), 2.6)],
3027            candidates: 1,
3028            rerank_us: 1,
3029        };
3030        let score = e.term_score
3031            + e.field_diversity_bonus
3032            + e.name_bonus
3033            + e.kind_prior
3034            + e.path_role
3035            + e.documentation
3036            + e.test_intent;
3037
3038        let line = explain_line(score, &e);
3039        let parts: f64 = line
3040            .split(" = ")
3041            .nth(1)
3042            .unwrap()
3043            .split(" [")
3044            .next()
3045            .unwrap()
3046            .split(" + ")
3047            .map(|part| part.rsplit(' ').next().unwrap().parse::<f64>().unwrap())
3048            .sum();
3049
3050        assert!(line.starts_with(&format!(
3051            "score {score:.1} = terms 24.5 + name all(6) 60.0 "
3052        )));
3053        assert!(line.contains("+ field coverage 6.0"));
3054        assert_eq!(format!("{parts:.1}"), format!("{score:.1}"));
3055    }
3056
3057    #[test]
3058    fn test_format_search_results_prints_the_explain_breakdown_when_present() {
3059        let mut result = SymbolSearchResult {
3060            symbol: sample_symbol("parseSha256Sidecar"),
3061            score: 71.6,
3062            snippet: Some("parse[Sha256]Sidecar".into()),
3063            explain: Some(SearchExplain {
3064                bm25: Some(-3.21),
3065                branches: vec!["word".into(), "name".into()],
3066                name_tier: "all".into(),
3067                name_strength: 6,
3068                terms: vec![
3069                    ("sha".into(), "name".into(), 3.0),
3070                    ("256".into(), "name".into(), 3.0),
3071                ],
3072                term_score: 12.6,
3073                field_diversity_bonus: 0.0,
3074                name_bonus: 60.0,
3075                kind_prior: 4.0,
3076                path_role: -10.0,
3077                documentation: 0.0,
3078                test_intent: 5.0,
3079                nested: 0.0,
3080                word_weights: vec![("sha".into(), 2.6), ("256".into(), 0.97)],
3081                candidates: 37,
3082                rerank_us: 180,
3083            }),
3084        };
3085
3086        let formatted = format_search_results("sha256", std::slice::from_ref(&result), 20);
3087        assert!(formatted.contains(
3088            "Found 1 symbols matching concept \"sha256\":\nrerank: 37 candidates in 180 µs; words sha 2.60, 256 0.97\n\n- "
3089        ));
3090        assert!(formatted.contains(
3091            "  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"
3092        ));
3093
3094        result.explain = None;
3095        let silent = format_search_results("sha256", std::slice::from_ref(&result), 20);
3096        assert!(!silent.contains("explain"));
3097        assert!(!silent.contains("rerank"));
3098    }
3099
3100    #[test]
3101    fn test_format_search_results_discloses_a_reached_limit() {
3102        let results = vec![SymbolSearchResult {
3103            symbol: sample_symbol("parse_tokens"),
3104            score: 0.0,
3105            snippet: None,
3106            explain: None,
3107        }];
3108
3109        let formatted = format_search_results("tokens", &results, 1);
3110        assert!(
3111            formatted.contains("[Showing 1 results (limit reached); increase limit to see more.]")
3112        );
3113
3114        let at_ceiling =
3115            format_search_results("tokens", &results, crate::queries::MAX_RESULT_LIMIT);
3116        assert!(!at_ceiling.contains("limit reached"));
3117
3118        let full: Vec<SymbolSearchResult> = (0..crate::queries::MAX_RESULT_LIMIT)
3119            .map(|_| SymbolSearchResult {
3120                symbol: sample_symbol("parse_tokens"),
3121                score: 0.0,
3122                snippet: None,
3123                explain: None,
3124            })
3125            .collect();
3126        let capped = format_search_results("tokens", &full, crate::queries::MAX_RESULT_LIMIT);
3127        assert!(capped.contains("(limit reached); narrow the query to see more.]"));
3128    }
3129
3130    #[test]
3131    fn test_format_blast_radius() {
3132        let res = BlastRadiusResult {
3133            seed_type: "symbol".into(),
3134            seeds: vec!["do_work".into()],
3135            likely_tests: vec![TestTarget {
3136                name: "test_do_work".into(),
3137                path: "tests/work_test.rs".into(),
3138                line: 15,
3139                reason: "direct caller".into(),
3140            }],
3141            impacted_symbols: vec![ImpactedSymbol {
3142                name: "caller_fn".into(),
3143                kind: "function".into(),
3144                path: "src/caller.rs".into(),
3145                line: 42,
3146                depth: 1,
3147                enclosing_symbol_name: None,
3148            }],
3149            traversal_ceiling_reached: false,
3150            likely_tests_truncated: false,
3151            impacted_symbols_truncated: false,
3152            limit_at_maximum: false,
3153            likely_tests_found: 0,
3154            impacted_symbols_found: 0,
3155            stopped_at_depth: None,
3156        };
3157
3158        let formatted = format_blast_radius(&res);
3159        assert!(formatted.contains("## Blast Radius & Test Impact (Symbol: do_work)"));
3160        assert!(formatted.contains("### Likely Tests to Run (1 returned)"));
3161        assert!(
3162            formatted.contains("tests/work_test.rs:\n  - `test_do_work` [line 15] (direct caller)")
3163        );
3164        assert!(formatted.contains("src/caller.rs:\n  - [depth 1] function `caller_fn` [line 42]"));
3165    }
3166
3167    #[test]
3168    fn format_blast_radius_groups_every_returned_test_by_file() {
3169        let mut likely_tests = Vec::new();
3170        for i in 1..=25 {
3171            likely_tests.push(TestTarget {
3172                name: format!("test_{i}"),
3173                path: format!("tests/test_{}.rs", (i % 3) + 1),
3174                line: i * 10,
3175                reason: "direct caller".into(),
3176            });
3177        }
3178
3179        let impacted_symbols = vec![
3180            ImpactedSymbol {
3181                name: "use_foo".into(),
3182                kind: "import".into(),
3183                path: "src/service.rs".into(),
3184                line: 1,
3185                depth: 1,
3186                enclosing_symbol_name: None,
3187            },
3188            ImpactedSymbol {
3189                name: "service_fn".into(),
3190                kind: "function".into(),
3191                path: "src/service.rs".into(),
3192                line: 20,
3193                depth: 1,
3194                enclosing_symbol_name: None,
3195            },
3196            ImpactedSymbol {
3197                name: "api_handler".into(),
3198                kind: "function".into(),
3199                path: "src/api.rs".into(),
3200                line: 45,
3201                depth: 2,
3202                enclosing_symbol_name: None,
3203            },
3204        ];
3205
3206        let res = BlastRadiusResult {
3207            seed_type: "file".into(),
3208            seeds: vec!["src/lib.rs".into()],
3209            likely_tests,
3210            impacted_symbols,
3211            traversal_ceiling_reached: false,
3212            likely_tests_truncated: false,
3213            impacted_symbols_truncated: false,
3214            limit_at_maximum: false,
3215            likely_tests_found: 0,
3216            impacted_symbols_found: 0,
3217            stopped_at_depth: None,
3218        };
3219
3220        let formatted = format_blast_radius(&res);
3221
3222        assert!(formatted.contains("### Likely Tests to Run (25 returned)"));
3223        assert!(
3224            formatted.contains("  - `test_25` [line 250]"),
3225            "{formatted}"
3226        );
3227        assert!(!formatted.contains("hidden by the compact"));
3228
3229        assert!(formatted.contains("tests/test_1.rs:\n"));
3230        assert!(formatted.contains("  - `test_"));
3231
3232        assert!(!formatted.contains("use_foo"));
3233        assert!(
3234            formatted
3235                .contains("... 1 low-signal row (import/module) hidden; available in CLI --json.")
3236        );
3237        assert!(formatted.contains("src/service.rs:\n"));
3238        assert!(formatted.contains("  - [depth 1] function `service_fn` [line 20]"));
3239    }
3240
3241    fn sample_symbol(name: &str) -> Symbol {
3242        Symbol {
3243            symbol_id: format!("id_{name}"),
3244            file_id: "f1".into(),
3245            path: "src/lib.rs".into(),
3246            language: "rust".into(),
3247            name: name.into(),
3248            kind: "function".into(),
3249            signature: Some(format!("pub fn {name}()")),
3250            doc_comment: None,
3251            visibility: Some("pub".into()),
3252            parent_symbol_id: None,
3253            start_line: 1,
3254            start_column: 0,
3255            end_line: 10,
3256            end_column: 1,
3257            start_byte: 0,
3258            end_byte: 100,
3259            body_start_line: Some(2),
3260            body_start_column: Some(0),
3261            body_end_line: Some(9),
3262            body_end_column: Some(1),
3263            body_start_byte: Some(10),
3264            body_end_byte: Some(99),
3265            body_hash: None,
3266            semantic_group: None,
3267            is_test: false,
3268            test_container: false,
3269        }
3270    }
3271
3272    fn sample_context_slice() -> ContextSlice {
3273        ContextSlice {
3274            target_symbol: sample_symbol("target_fn"),
3275            target_body: "    println!(\"hello\");\n".into(),
3276            callee_signatures: Vec::new(),
3277            related_types: Vec::new(),
3278            related_tests: Vec::new(),
3279        }
3280    }
3281
3282    #[test]
3283    fn format_context_slice_does_not_repeat_expression_already_in_signature() {
3284        let body = "=>\n        transport.SendAsync<Response>(\n            HttpMethod.Get,\n            $\"items/{id}\",\n            cancellationToken: cancellationToken)";
3285        let mut slice = sample_context_slice();
3286        slice.target_symbol.language = "csharp".into();
3287        slice.target_symbol.signature = Some(format!(
3288            "public static Task<Response?> GetByIdAsync(\n        int id,\n        CancellationToken cancellationToken = default){body}"
3289        ));
3290        slice.target_body = body.into();
3291
3292        let formatted = format_context_slice(&slice);
3293
3294        assert_eq!(
3295            formatted.matches("transport.SendAsync").count(),
3296            1,
3297            "{formatted}"
3298        );
3299    }
3300
3301    #[test]
3302    fn context_slice_leaves_out_a_signature_that_the_body_already_holds() {
3303        let mut slice = sample_context_slice();
3304        slice.target_symbol.signature = Some("type Person = { Name: string; Age: int }".into());
3305        slice.target_body = "type Person = { Name: string; Age: int }\n".into();
3306
3307        let text = format_context_slice(&slice);
3308
3309        assert!(!text.contains("Signature:"), "{text}");
3310        assert_eq!(text.matches("type Person").count(), 1, "{text}");
3311    }
3312
3313    #[test]
3314    fn context_slice_says_when_no_test_was_found() {
3315        let text = format_context_slice(&sample_context_slice());
3316
3317        assert!(
3318            text.ends_with("### Related Tests:\nNo test calls, uses, or names this symbol; blast_radius lists tests that can reach it through callers.\n"),
3319            "{text}"
3320        );
3321    }
3322
3323    #[test]
3324    fn test_context_slice_shows_truncation_notice_when_caps_hit() {
3325        let mut slice = sample_context_slice();
3326        slice.callee_signatures = (1..=10).map(|i| format!("fn callee_{i}()")).collect();
3327        let text = format_context_slice(&slice);
3328        assert!(text.contains("[Showing 10 dependencies (limit reached)]"));
3329
3330        let mut slice_tests = sample_context_slice();
3331        slice_tests.related_tests = (1..=5)
3332            .map(|i| {
3333                let mut sym = sample_symbol(&format!("test_fn_{i}"));
3334                sym.path = format!("tests/test_{i}.rs");
3335                sym.is_test = true;
3336                sym
3337            })
3338            .collect();
3339        let text_tests = format_context_slice(&slice_tests);
3340        assert!(text_tests.contains("[Showing 5 tests (limit reached)]"));
3341    }
3342
3343    #[test]
3344    fn test_blast_radius_shows_traversal_ceiling_at_200_symbols() {
3345        let impacted_symbols = (1..=200)
3346            .map(|i| ImpactedSymbol {
3347                name: format!("sym_{i}"),
3348                kind: "function".into(),
3349                path: format!("src/mod_{}.rs", i % 10),
3350                line: i,
3351                depth: 1,
3352                enclosing_symbol_name: None,
3353            })
3354            .collect();
3355
3356        let res = BlastRadiusResult {
3357            seed_type: "symbol".into(),
3358            seeds: vec!["root_fn".into()],
3359            likely_tests: Vec::new(),
3360            impacted_symbols,
3361            traversal_ceiling_reached: true,
3362            likely_tests_truncated: false,
3363            impacted_symbols_truncated: false,
3364            limit_at_maximum: false,
3365            likely_tests_found: 0,
3366            impacted_symbols_found: 0,
3367            stopped_at_depth: None,
3368        };
3369
3370        let formatted = format_blast_radius(&res);
3371        assert!(formatted.contains("### Downstream Impact (200 returned)\n"));
3372    }
3373
3374    #[test]
3375    fn blast_radius_does_not_claim_no_results_after_the_traversal_ceiling() {
3376        let res = BlastRadiusResult {
3377            seed_type: "file".into(),
3378            seeds: vec!["src/widget.rs".into()],
3379            likely_tests: Vec::new(),
3380            impacted_symbols: Vec::new(),
3381            likely_tests_truncated: false,
3382            impacted_symbols_truncated: false,
3383            traversal_ceiling_reached: true,
3384            limit_at_maximum: false,
3385            likely_tests_found: 0,
3386            impacted_symbols_found: 0,
3387            stopped_at_depth: None,
3388        };
3389
3390        let formatted = format_blast_radius(&res);
3391        assert!(formatted.contains("Likely Tests to Run (0 returned)"));
3392        assert!(formatted.contains("Downstream Impact (0 returned)"));
3393        assert!(!formatted.contains("No test reaches it through the index"));
3394        assert!(!formatted.contains("No downstream callers found within depth"));
3395
3396        let no_ceiling = BlastRadiusResult {
3397            traversal_ceiling_reached: false,
3398            ..res
3399        };
3400        assert!(
3401            format_blast_radius(&no_ceiling)
3402                .contains("No test reaches it through the index. Run the full test suite.\n")
3403        );
3404
3405        let cut_by_depth = BlastRadiusResult {
3406            stopped_at_depth: Some(2),
3407            ..no_ceiling
3408        };
3409        let formatted = format_blast_radius(&cut_by_depth);
3410        assert!(formatted.contains(
3411            "No test reaches it within depth 2; a larger `depth` may find some. Otherwise run the full test suite.\n"
3412        ));
3413        assert!(!formatted.contains("through the index"));
3414    }
3415
3416    fn skeleton_row(
3417        id: &str,
3418        parent: Option<&str>,
3419        kind: &str,
3420        name: &str,
3421        signature: &str,
3422        lines: (usize, usize),
3423        body: Option<(usize, usize)>,
3424    ) -> Symbol {
3425        Symbol {
3426            symbol_id: id.into(),
3427            file_id: "f1".into(),
3428            path: "src/lib.rs".into(),
3429            language: "rust".into(),
3430            name: name.into(),
3431            kind: kind.into(),
3432            signature: Some(signature.into()),
3433            doc_comment: None,
3434            visibility: None,
3435            parent_symbol_id: parent.map(str::to_string),
3436            start_line: lines.0,
3437            start_column: 0,
3438            end_line: lines.1,
3439            end_column: 1,
3440            start_byte: 0,
3441            end_byte: 0,
3442            body_start_line: body.map(|b| b.0),
3443            body_start_column: None,
3444            body_end_line: body.map(|b| b.1),
3445            body_end_column: None,
3446            body_start_byte: None,
3447            body_end_byte: None,
3448            body_hash: None,
3449            semantic_group: None,
3450            is_test: false,
3451            test_container: false,
3452        }
3453    }
3454
3455    #[test]
3456    fn skeleton_nests_an_object_under_the_field_that_declares_it() {
3457        let syms = vec![
3458            skeleton_row(
3459                "root",
3460                None,
3461                "class",
3462                "shell",
3463                "extends ShellRoot",
3464                (1, 8),
3465                None,
3466            ),
3467            skeleton_row(
3468                "timer",
3469                Some("root"),
3470                "field",
3471                "localPluginReloadTimer",
3472                "localPluginReloadTimer: Timer",
3473                (2, 7),
3474                Some((2, 7)),
3475            ),
3476            skeleton_row(
3477                "interval",
3478                Some("timer"),
3479                "property",
3480                "interval",
3481                "interval: 150",
3482                (3, 3),
3483                None,
3484            ),
3485            skeleton_row(
3486                "fire",
3487                Some("timer"),
3488                "function",
3489                "fire",
3490                "function fire()",
3491                (5, 7),
3492                Some((6, 7)),
3493            ),
3494        ];
3495
3496        assert_eq!(
3497            format_file_skeleton("shell/shell.qml", &syms, Some(8), 0),
3498            "// File: shell/shell.qml (Lines 1-8)\n\
3499             \n\
3500             extends ShellRoot {\n\
3501             \x20   localPluginReloadTimer: Timer {\n\
3502             \x20       interval: 150; // L3-3\n\
3503             \x20       function fire() { /* 2 lines hidden: L6-L7 */ }\n\
3504             \x20   } // L2-7\n\
3505             \n\
3506             } // L1-8\n\
3507             \n"
3508        );
3509    }
3510
3511    #[test]
3512    fn skeleton_renders_a_single_line_symbol_with_children_as_a_leaf() {
3513        let syms = vec![
3514            skeleton_row(
3515                "rusqlite",
3516                None,
3517                "field",
3518                "rusqlite",
3519                "rusqlite = { workspace = true }",
3520                (15, 15),
3521                None,
3522            ),
3523            skeleton_row(
3524                "workspace",
3525                Some("rusqlite"),
3526                "property",
3527                "workspace",
3528                "workspace = true",
3529                (15, 15),
3530                None,
3531            ),
3532        ];
3533
3534        assert_eq!(
3535            format_file_skeleton("Cargo.toml", &syms, Some(15), 0),
3536            "// File: Cargo.toml (Lines 1-15)\n\
3537             \n\
3538             rusqlite = { workspace = true }; // L15-15\n"
3539        );
3540    }
3541
3542    #[test]
3543    fn skeleton_renders_members_of_a_single_line_enum() {
3544        let mut syms = vec![
3545            skeleton_row(
3546                "mode",
3547                None,
3548                "enum",
3549                "DispatchMode",
3550                "public enum DispatchMode",
3551                (21, 21),
3552                None,
3553            ),
3554            skeleton_row(
3555                "provision",
3556                Some("mode"),
3557                "enum_member",
3558                "Provision",
3559                "Provision",
3560                (21, 21),
3561                None,
3562            ),
3563            skeleton_row(
3564                "deprovision",
3565                Some("mode"),
3566                "enum_member",
3567                "Deprovision",
3568                "Deprovision",
3569                (21, 21),
3570                None,
3571            ),
3572            skeleton_row(
3573                "extension",
3574                Some("mode"),
3575                "enum_member",
3576                "Extension",
3577                "Extension",
3578                (21, 21),
3579                None,
3580            ),
3581        ];
3582        for symbol in &mut syms {
3583            symbol.language = "csharp".into();
3584        }
3585
3586        let skeleton = format_file_skeleton("DispatchContext.cs", &syms, Some(21), 0);
3587
3588        for member in ["Provision", "Deprovision", "Extension"] {
3589            assert!(skeleton.contains(member), "{skeleton}");
3590        }
3591    }
3592
3593    #[test]
3594    fn a_hidden_body_names_the_functions_defined_inside_it_but_not_lambdas() {
3595        let mut syms = vec![
3596            skeleton_row(
3597                "factory",
3598                None,
3599                "function",
3600                "create_app",
3601                "def create_app()",
3602                (1, 9),
3603                Some((2, 9)),
3604            ),
3605            skeleton_row(
3606                "hello",
3607                Some("factory"),
3608                "function",
3609                "hello",
3610                "def hello()",
3611                (4, 5),
3612                Some((5, 5)),
3613            ),
3614            skeleton_row(
3615                "lambda",
3616                Some("factory"),
3617                "function",
3618                "lambda_7",
3619                "lambda v: v",
3620                (7, 7),
3621                None,
3622            ),
3623        ];
3624        for symbol in &mut syms {
3625            symbol.language = "python".into();
3626        }
3627
3628        let skeleton = format_file_skeleton("app.py", &syms, Some(9), 0);
3629
3630        assert!(
3631            skeleton.contains("def create_app() { /* 8 lines hidden: L2-L9; defines `hello` */ }"),
3632            "{skeleton}"
3633        );
3634
3635        for symbol in &mut syms {
3636            symbol.language = "cpp".into();
3637        }
3638        let cpp = format_file_skeleton("app.cpp", &syms, Some(9), 0);
3639        assert!(!cpp.contains("defines"), "{cpp}");
3640        syms[1].test_container = true;
3641        let cpp_section = format_file_skeleton("app.cpp", &syms, Some(9), 0);
3642        assert!(cpp_section.contains("defines `hello`"), "{cpp_section}");
3643    }
3644
3645    #[test]
3646    fn skeleton_keeps_plain_fields_and_function_locals_unchanged() {
3647        let syms = vec![
3648            skeleton_row(
3649                "cfg",
3650                None,
3651                "struct",
3652                "Config",
3653                "pub struct Config",
3654                (1, 3),
3655                None,
3656            ),
3657            skeleton_row(
3658                "retries",
3659                Some("cfg"),
3660                "field",
3661                "retries",
3662                "pub retries: u32",
3663                (2, 2),
3664                None,
3665            ),
3666            skeleton_row(
3667                "run",
3668                None,
3669                "function",
3670                "run",
3671                "pub fn run()",
3672                (5, 9),
3673                Some((6, 8)),
3674            ),
3675            skeleton_row(
3676                "tmp",
3677                Some("run"),
3678                "variable",
3679                "tmp",
3680                "let tmp",
3681                (7, 7),
3682                None,
3683            ),
3684        ];
3685
3686        assert_eq!(
3687            format_file_skeleton("src/lib.rs", &syms, Some(9), 0),
3688            "// File: src/lib.rs (Lines 1-9)\n\
3689             \n\
3690             pub struct Config {\n\
3691             \x20   pub retries: u32; // L2-2\n\
3692             } // L1-3\n\
3693             \n\
3694             pub fn run() { /* 3 lines hidden: L6-L8 */ }\n"
3695        );
3696    }
3697
3698    #[test]
3699    fn skeleton_marks_an_event_row_whose_signature_does_not_spell_it() {
3700        let syms = vec![
3701            skeleton_row(
3702                "cls",
3703                None,
3704                "class",
3705                "ColumnViewAttached",
3706                "class ColumnViewAttached : public QObject",
3707                (11, 52),
3708                None,
3709            ),
3710            skeleton_row(
3711                "sig",
3712                Some("cls"),
3713                "event",
3714                "indexChanged",
3715                "void indexChanged()",
3716                (47, 47),
3717                None,
3718            ),
3719            skeleton_row(
3720                "qml",
3721                None,
3722                "event",
3723                "clicked",
3724                "signal clicked()",
3725                (60, 60),
3726                None,
3727            ),
3728        ];
3729
3730        assert_eq!(
3731            format_file_skeleton("src/columnview.h", &syms, Some(60), 0),
3732            "// File: src/columnview.h (Lines 1-60)\n\
3733             \n\
3734             class ColumnViewAttached : public QObject {\n\
3735             \x20   void indexChanged(); // event L47-47\n\
3736             } // L11-52\n\
3737             \n\
3738             signal clicked(); // L60-60\n"
3739        );
3740    }
3741
3742    fn labeled_site(
3743        label: SiteLabel,
3744        path: &str,
3745        line: usize,
3746        caller: &str,
3747        basis: &str,
3748    ) -> LabeledSite {
3749        LabeledSite {
3750            site: ReferenceSite {
3751                from_symbol_name: caller.into(),
3752                from_symbol_id: "c1".into(),
3753                to_symbol_name: "target".into(),
3754                kind: "calls".into(),
3755                path: path.into(),
3756                start_line: Some(line),
3757                start_column: Some(4),
3758                occurrences: None,
3759                target: None,
3760                enclosing_symbol_name: None,
3761            },
3762            label,
3763            basis: basis.into(),
3764            end_line: None,
3765            arg_count: None,
3766        }
3767    }
3768
3769    fn report_of(sites: Vec<LabeledSite>) -> ReferenceReport {
3770        let count = |label| sites.iter().filter(|site| site.label == label).count();
3771        ReferenceReport {
3772            target: ReferenceTarget {
3773                symbol_id: "t1".into(),
3774                name: "from_mapping".into(),
3775                display_name: "Config.from_mapping".into(),
3776                kind: "method".into(),
3777                path: "src/flask/config.py".into(),
3778                line: 304,
3779                definitions_with_name: 1,
3780            },
3781            counts: ReferenceCounts {
3782                sites: sites.len(),
3783                proven: count(SiteLabel::Proven),
3784                likely: count(SiteLabel::Likely),
3785                unresolved: count(SiteLabel::Unresolved),
3786                excluded: count(SiteLabel::Excluded),
3787                text_lines: sites.len(),
3788                site_lines: sites.len(),
3789                ..ReferenceCounts::default()
3790            },
3791            sites,
3792            text_only: Vec::new(),
3793            text_scan: TextScanSummary {
3794                files_scanned: 12,
3795                files_total: 12,
3796                complete: true,
3797                elapsed_ms: 40,
3798                skipped: None,
3799            },
3800            cannot_see: Vec::new(),
3801            imports: Vec::new(),
3802            overrides: Vec::new(),
3803            overridden: Vec::new(),
3804            constructions: Vec::new(),
3805            variant_uses: Vec::new(),
3806            registrations: Vec::new(),
3807            tests: Vec::new(),
3808        }
3809    }
3810
3811    #[test]
3812    fn the_report_header_has_three_lines_with_plural_counts() {
3813        let mut report = report_of(vec![
3814            labeled_site(
3815                SiteLabel::Proven,
3816                "src/flask/config.py",
3817                302,
3818                "Config.from_file",
3819                "same file",
3820            ),
3821            labeled_site(
3822                SiteLabel::Likely,
3823                "tests/test_config.py",
3824                112,
3825                "test_config",
3826                "name only",
3827            ),
3828        ]);
3829        report.counts.text_lines = 17;
3830        report.counts.site_lines = 2;
3831        report.counts.definition_lines = 2;
3832        report.counts.import_lines = 0;
3833        report.counts.text_only_lines = 13;
3834
3835        let text = format_reference_report(&report, 20);
3836
3837        assert_eq!(
3838            text.lines().take(3).collect::<Vec<_>>(),
3839            [
3840                "References to method `Config.from_mapping` (src/flask/config.py:304)",
3841                "Sites: 2 · proven 1 · likely 1 · unresolved 0 · excluded 0",
3842                "Text: `from_mapping` is on 17 lines: 2 sites, 2 definitions, 0 imports, 13 text only.",
3843            ]
3844        );
3845    }
3846
3847    #[test]
3848    fn the_report_header_uses_singular_forms_for_a_count_of_one() {
3849        let mut report = report_of(vec![labeled_site(
3850            SiteLabel::Proven,
3851            "a.py",
3852            3,
3853            "caller",
3854            "same file",
3855        )]);
3856        report.counts.text_lines = 1;
3857        report.counts.site_lines = 1;
3858        report.counts.definition_lines = 1;
3859        report.counts.import_lines = 1;
3860        report.counts.text_only_lines = 0;
3861
3862        let text = format_reference_report(&report, 20);
3863
3864        assert_eq!(
3865            text.lines().nth(2),
3866            Some("Text: `from_mapping` is on 1 line: 1 site, 1 definition, 1 import, 0 text only.")
3867        );
3868    }
3869
3870    #[test]
3871    fn the_report_lists_each_group_in_label_order_with_its_basis() {
3872        let report = report_of(vec![
3873            labeled_site(SiteLabel::Proven, "a.py", 2, "first", "same file"),
3874            labeled_site(SiteLabel::Likely, "l.py", 6, "second", "name only"),
3875            labeled_site(SiteLabel::Unresolved, "u.py", 4, "third", "3 definitions"),
3876            labeled_site(SiteLabel::Excluded, "x.py", 9, "other", "other language"),
3877        ]);
3878
3879        let text = format_reference_report(&report, 20);
3880
3881        let body = text.split_once("\n\n").unwrap().1;
3882        assert_eq!(
3883            body,
3884            "Proven (1):\n\
3885             - `first` [a.py:2] (kind: calls; same file)\n\
3886             Likely (1):\n\
3887             - `second` [l.py:6] (kind: calls; name only)\n\
3888             Unresolved (1):\n\
3889             - `third` [u.py:4] (kind: calls; 3 definitions)\n\
3890             Excluded (1):\n\
3891             - `other` [x.py:9] (kind: calls; other language)\n"
3892        );
3893    }
3894
3895    #[test]
3896    fn a_group_with_no_sites_is_left_out() {
3897        let report = report_of(vec![labeled_site(
3898            SiteLabel::Likely,
3899            "l.py",
3900            6,
3901            "second",
3902            "name only",
3903        )]);
3904
3905        let text = format_reference_report(&report, 20);
3906
3907        assert!(!text.contains("Proven ("), "{text}");
3908        assert!(!text.contains("Excluded ("), "{text}");
3909        assert!(text.contains("Likely (1):\n"), "{text}");
3910    }
3911
3912    #[test]
3913    fn a_report_without_sites_has_only_the_header() {
3914        let report = report_of(Vec::new());
3915
3916        let text = format_reference_report(&report, 20);
3917
3918        assert_eq!(
3919            text,
3920            "References to method `Config.from_mapping` (src/flask/config.py:304)\n\
3921             Sites: 0 · proven 0 · likely 0 · unresolved 0 · excluded 0\n\
3922             Text: `from_mapping` is on 0 lines: 0 sites, 0 definitions, 0 imports, 0 text only.\n"
3923        );
3924    }
3925
3926    #[test]
3927    fn caller_names_follow_the_lambda_and_no_enclosing_symbol_rules() {
3928        let mut lambda = labeled_site(SiteLabel::Proven, "a.py", 1, "lambda_7", "same file");
3929        lambda.site.enclosing_symbol_name = Some("outer".into());
3930        let callerless = labeled_site(SiteLabel::Proven, "a.py", 2, "", "same file");
3931        let report = report_of(vec![lambda, callerless]);
3932
3933        let text = format_reference_report(&report, 20);
3934
3935        assert!(
3936            text.contains("- `<lambda> in outer` [a.py:1] (kind: calls; same file)\n"),
3937            "{text}"
3938        );
3939        assert!(
3940            text.contains("- (no enclosing symbol) [a.py:2] (kind: calls; same file)\n"),
3941            "{text}"
3942        );
3943    }
3944
3945    #[test]
3946    fn rows_past_the_limit_fold_into_one_line_per_label_with_per_file_counts() {
3947        let mut sites = vec![labeled_site(
3948            SiteLabel::Proven,
3949            "a.py",
3950            1,
3951            "p1",
3952            "same file",
3953        )];
3954        for (index, path) in ["b.py", "b.py", "b.py", "c.py", "c.py", "d.py"]
3955            .iter()
3956            .enumerate()
3957        {
3958            sites.push(labeled_site(
3959                SiteLabel::Likely,
3960                path,
3961                10 + index,
3962                "caller",
3963                "name only",
3964            ));
3965        }
3966        sites.push(labeled_site(
3967            SiteLabel::Excluded,
3968            "z.py",
3969            1,
3970            "other",
3971            "other language",
3972        ));
3973        let report = report_of(sites);
3974
3975        let text = format_reference_report(&report, 3);
3976
3977        let body = text.split_once("\n\n").unwrap().1;
3978        assert_eq!(
3979            body,
3980            "Proven (1):\n\
3981             - `p1` [a.py:1] (kind: calls; same file)\n\
3982             Likely (6):\n\
3983             - `caller` [b.py:10] (kind: calls; name only)\n\
3984             - `caller` [b.py:11] (kind: calls; name only)\n\
3985             … 4 more likely sites in 3 files: c.py (2), b.py (1), d.py (1)\n\
3986             Excluded (1):\n\
3987             … 1 more excluded site in 1 file: z.py (1)\n"
3988        );
3989    }
3990
3991    #[test]
3992    fn a_fold_line_names_at_most_five_files() {
3993        let sites = ["a", "b", "c", "d", "e", "f", "g"]
3994            .iter()
3995            .map(|stem| {
3996                labeled_site(
3997                    SiteLabel::Likely,
3998                    &format!("{stem}.py"),
3999                    1,
4000                    "caller",
4001                    "name only",
4002                )
4003            })
4004            .collect();
4005        let report = report_of(sites);
4006
4007        let text = format_reference_report(&report, 1);
4008
4009        assert!(
4010            text.contains(
4011                "… 6 more likely sites in 6 files: b.py (1), c.py (1), d.py (1), e.py (1), f.py (1), …\n"
4012            ),
4013            "{text}"
4014        );
4015    }
4016
4017    #[test]
4018    fn text_only_lines_list_per_file_and_fold_past_ten_files_and_twelve_lines() {
4019        let mut report = report_of(Vec::new());
4020        report.text_only = (0..12)
4021            .map(|index| TextOnlyLines {
4022                path: format!("docs/f{index:02}.rst"),
4023                lines: if index == 0 {
4024                    (1..=15).collect()
4025                } else {
4026                    vec![index + 1, index + 2]
4027                },
4028            })
4029            .collect();
4030        report.counts.text_only_lines = report.text_only.iter().map(|f| f.lines.len()).sum();
4031
4032        let text = format_reference_report(&report, 20);
4033
4034        let section = text.split_once("Text only (").unwrap().1;
4035        let mut lines = section.lines();
4036        assert_eq!(
4037            lines.next(),
4038            Some("37): docs, comments, strings, or code the index did not record")
4039        );
4040        assert_eq!(
4041            lines.next(),
4042            Some("  docs/f00.rst: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12")
4043        );
4044        assert_eq!(lines.next(), Some("  docs/f01.rst: 2, 3"));
4045        assert_eq!(lines.nth(7), Some("  docs/f09.rst: 10, 11"));
4046        assert_eq!(lines.next(), Some("  … 7 more lines in 3 files"));
4047        assert_eq!(lines.next(), None);
4048    }
4049
4050    #[test]
4051    fn a_text_scan_cut_by_its_budget_adds_the_stopped_line() {
4052        let mut report = report_of(Vec::new());
4053        report.text_scan = TextScanSummary {
4054            files_scanned: 410,
4055            files_total: 17334,
4056            complete: false,
4057            elapsed_ms: 3012,
4058            skipped: None,
4059        };
4060
4061        let text = format_reference_report(&report, 20);
4062
4063        assert_eq!(
4064            text.lines().nth(3),
4065            Some("Text scan stopped after 3 s: 410 of 17334 files read; counts cover those files.")
4066        );
4067    }
4068
4069    #[test]
4070    fn a_registration_without_a_verb_or_template_or_route_pattern_prints_what_it_has() {
4071        let registration =
4072            |pattern_id: &str, verb: Option<&str>, template: Option<&str>| Registration {
4073                pattern_id: pattern_id.into(),
4074                verb: verb.map(Into::into),
4075                template: template.map(Into::into),
4076                path: "app.py".into(),
4077                line: 3,
4078                by_name: false,
4079            };
4080        let mut report = report_of(Vec::new());
4081        report.registrations = vec![
4082            registration("flask.route.v1", Some("GET"), Some("/a")),
4083            registration("axum.route.v1", None, Some("/b")),
4084            registration("hapi.route.v1", Some("PUT"), None),
4085            registration("rocket.mount.v1", Some("GET"), Some("/c")),
4086        ];
4087
4088        let text = format_reference_report(&report, 20);
4089
4090        assert!(
4091            text.contains(
4092                "Registrations (4):\n\
4093                 - route GET /a [app.py:3]\n\
4094                 - route /b [app.py:3]\n\
4095                 - route PUT hapi.route.v1 [app.py:3]\n\
4096                 - rocket.mount.v1 [app.py:3]\n"
4097            ),
4098            "{text}"
4099        );
4100    }
4101
4102    #[test]
4103    fn a_constructor_report_replaces_the_text_line_with_the_skip_line() {
4104        let mut report = report_of(Vec::new());
4105        report.text_scan.skipped = Some("Flask".into());
4106
4107        let text = format_reference_report(&report, 20);
4108
4109        assert_eq!(
4110            text.lines().nth(2),
4111            Some(
4112                "Text: skipped for a constructor; its call sites spell the class name. Use find_references on `Flask`."
4113            )
4114        );
4115        assert!(!text.contains("is on"), "{text}");
4116        assert!(!text.contains("Text scan stopped"), "{text}");
4117    }
4118
4119    #[test]
4120    fn the_report_ends_with_the_blind_spots_and_the_import_summary() {
4121        let mut report = report_of(vec![labeled_site(
4122            SiteLabel::Proven,
4123            "a.py",
4124            2,
4125            "first",
4126            "same file",
4127        )]);
4128        report.cannot_see = vec![
4129            "calls through getattr() or names built at runtime".into(),
4130            "calls inside macro bodies".into(),
4131        ];
4132        report.imports = vec![("b.py".into(), 1), ("c.py".into(), 4)];
4133
4134        let text = format_reference_report(&report, 20);
4135
4136        assert!(
4137            text.ends_with(
4138                "Not visible to this index: calls through getattr() or names built at runtime; calls inside macro bodies.\n\
4139                 Imported 2 times in 2 files: b.py:1, c.py:4 (lookup_symbol with kind=\"import\" lists them)\n"
4140            ),
4141            "{text}"
4142        );
4143    }
4144}