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