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