Skip to main content

code_kb_core/
formatters.rs

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