1pub struct OutputLimits {
8 pub max_files: usize,
10 pub max_per_file: usize,
12 pub total_hits: usize,
14}
15
16impl OutputLimits {
17 pub fn new(max_files: u32, max_per_file: u32) -> Self {
18 Self {
19 max_files: max_files.min(100) as usize,
20 max_per_file: max_per_file.min(50) as usize,
21 total_hits: (max_files.min(100) * max_per_file.min(50)) as usize,
22 }
23 }
24}
25
26impl Default for OutputLimits {
27 fn default() -> Self {
28 Self {
29 max_files: 20,
30 max_per_file: 10,
31 total_hits: 200,
32 }
33 }
34}
35
36use crate::domain::index::{AdmissionTier, SkippedFile};
37use crate::live_index::{
38 ContextBundleFoundView, ContextBundleSectionView, ContextBundleView, FileContentView,
39 FileOutlineView, FindDependentsView, FindImplementationsView, FindReferencesView, HealthStats,
40 ImplBlockSuggestionView, IndexedFile, InspectMatchView, LiveIndex, PublishedIndexState,
41 RepoOutlineFileView, RepoOutlineView, ResolvePathView, SearchFilesTier, SearchFilesView,
42 SymbolDetailView, TypeDependencyView, WhatChangedTimestampView, search,
43};
44use crate::{cli::hook::HookAdoptionSnapshot, sidecar::StatsSnapshot};
45
46pub fn file_outline(index: &LiveIndex, path: &str) -> String {
52 match index.capture_shared_file(path) {
53 Some(file) => file_outline_from_indexed_file(file.as_ref()),
54 None => not_found_file(path),
55 }
56}
57
58pub fn file_outline_from_indexed_file(file: &IndexedFile) -> String {
59 render_file_outline(&file.relative_path, &file.symbols)
60}
61
62fn render_file_outline(relative_path: &str, symbols: &[crate::domain::SymbolRecord]) -> String {
63 let mut lines = Vec::new();
64 lines.push(format!("{} ({} symbols)", relative_path, symbols.len()));
65
66 for sym in symbols {
67 let indent = " ".repeat(sym.depth as usize);
68 let kind_str = sym.kind.to_string();
69 lines.push(format!(
70 "{}{:<12} {:<30} {}-{}",
71 indent,
72 kind_str,
73 sym.name,
74 sym.line_range.0 + 1,
75 sym.line_range.1 + 1
76 ));
77 }
78
79 lines.join("\n")
80}
81
82pub fn file_outline_view(view: &FileOutlineView) -> String {
86 render_file_outline(&view.relative_path, &view.symbols)
87}
88
89pub fn symbol_detail(
94 index: &LiveIndex,
95 path: &str,
96 name: &str,
97 kind_filter: Option<&str>,
98) -> String {
99 match index.capture_shared_file(path) {
100 Some(file) => symbol_detail_from_indexed_file(file.as_ref(), name, kind_filter),
101 None => not_found_file(path),
102 }
103}
104
105pub fn symbol_detail_from_indexed_file(
106 file: &IndexedFile,
107 name: &str,
108 kind_filter: Option<&str>,
109) -> String {
110 render_symbol_detail(
111 &file.relative_path,
112 &file.content,
113 &file.symbols,
114 name,
115 kind_filter,
116 )
117}
118
119pub fn symbol_detail_view(
123 view: &SymbolDetailView,
124 name: &str,
125 kind_filter: Option<&str>,
126) -> String {
127 render_symbol_detail(
128 &view.relative_path,
129 &view.content,
130 &view.symbols,
131 name,
132 kind_filter,
133 )
134}
135
136fn render_symbol_detail(
137 relative_path: &str,
138 content: &[u8],
139 symbols: &[crate::domain::SymbolRecord],
140 name: &str,
141 kind_filter: Option<&str>,
142) -> String {
143 let sym = symbols.iter().find(|s| {
144 s.name == name
145 && kind_filter
146 .map(|k| s.kind.to_string().eq_ignore_ascii_case(k))
147 .unwrap_or(true)
148 });
149
150 match sym {
151 None => render_not_found_symbol(relative_path, symbols, name),
152 Some(s) => {
153 let start = s.effective_start() as usize;
154 let end = s.byte_range.1 as usize;
155 let clamped_end = end.min(content.len());
156 let clamped_start = start.min(clamped_end);
157 let body = String::from_utf8_lossy(&content[clamped_start..clamped_end]).into_owned();
158 let byte_count = end.saturating_sub(start);
159 format!(
160 "{}\n[{}, lines {}-{}, {} bytes]",
161 body,
162 s.kind,
163 s.line_range.0 + 1,
164 s.line_range.1 + 1,
165 byte_count
166 )
167 }
168 }
169}
170
171pub fn code_slice_view(path: &str, slice: &[u8]) -> String {
172 let text = String::from_utf8_lossy(slice).into_owned();
173 format!("{path}\n{text}")
174}
175
176pub fn code_slice_from_indexed_file(
177 file: &IndexedFile,
178 start_byte: usize,
179 end_byte: Option<usize>,
180) -> String {
181 let end = end_byte
182 .unwrap_or(file.content.len())
183 .min(file.content.len());
184 let start = start_byte.min(end);
185 code_slice_view(&file.relative_path, &file.content[start..end])
186}
187
188pub fn search_symbols_result(index: &LiveIndex, query: &str) -> String {
204 search_symbols_result_with_kind(index, query, None)
205}
206
207pub fn search_symbols_result_with_kind(
208 index: &LiveIndex,
209 query: &str,
210 kind_filter: Option<&str>,
211) -> String {
212 let result = search::search_symbols(
213 index,
214 query,
215 kind_filter,
216 search::ResultLimit::symbol_search_default().get(),
217 );
218 search_symbols_result_view(&result, query)
219}
220
221pub fn search_symbols_result_view(result: &search::SymbolSearchResult, query: &str) -> String {
222 if result.hits.is_empty() {
223 return format!("No symbols matching '{query}'");
224 }
225
226 let mut lines = vec![format!(
227 "{} matches in {} files",
228 result.hits.len(),
229 result.file_count
230 )];
231
232 let mut last_tier: Option<search::SymbolMatchTier> = None;
233 for hit in &result.hits {
234 if last_tier != Some(hit.tier) {
235 last_tier = Some(hit.tier);
236 let header = match hit.tier {
237 search::SymbolMatchTier::Exact => "\u{2500}\u{2500} Exact matches \u{2500}\u{2500}",
238 search::SymbolMatchTier::Prefix => {
239 "\u{2500}\u{2500} Prefix matches \u{2500}\u{2500}"
240 }
241 search::SymbolMatchTier::Substring => {
242 "\u{2500}\u{2500} Substring matches \u{2500}\u{2500}"
243 }
244 };
245 if lines.len() > 1 {
246 lines.push(String::new());
247 }
248 lines.push(header.to_string());
249 }
250 lines.push(format!(
251 " {}: {} {} ({})",
252 hit.line, hit.kind, hit.name, hit.path
253 ));
254 }
255
256 lines.join("\n")
257}
258
259pub fn search_text_result(index: &LiveIndex, query: &str) -> String {
268 search_text_result_with_options(index, Some(query), None, false)
269}
270
271pub fn search_text_result_with_options(
272 index: &LiveIndex,
273 query: Option<&str>,
274 terms: Option<&[String]>,
275 regex: bool,
276) -> String {
277 let result = search::search_text(index, query, terms, regex);
278 search_text_result_view(result, None, None)
279}
280
281pub fn is_noise_line(line: &str) -> bool {
283 let trimmed = line.trim();
284 if trimmed.starts_with("///") || trimmed.starts_with("//!") || trimmed.starts_with("/**") {
285 return false;
286 }
287 trimmed.starts_with("use ")
288 || trimmed.starts_with("import ")
289 || trimmed.starts_with("from ")
290 || trimmed.starts_with("require(")
291 || trimmed.starts_with("#include")
292 || trimmed.starts_with("//")
293 || trimmed.starts_with('#')
294 || trimmed.starts_with("/*")
295 || trimmed.starts_with('*')
296 || trimmed.starts_with("--")
297 || line.contains("require(")
298}
299
300pub fn search_text_result_view(
301 result: Result<search::TextSearchResult, search::TextSearchError>,
302 group_by: Option<&str>,
303 terms: Option<&[String]>,
304) -> String {
305 let result = match result {
306 Ok(result) => result,
307 Err(search::TextSearchError::EmptyRegexQuery) => {
308 return "Regex search requires a non-empty query.".to_string();
309 }
310 Err(search::TextSearchError::EmptyQueryOrTerms) => {
311 return "Search requires a non-empty query or terms.".to_string();
312 }
313 Err(search::TextSearchError::InvalidRegex { pattern, error }) => {
314 return format!("Invalid regex '{pattern}': {error}");
315 }
316 Err(search::TextSearchError::InvalidGlob {
317 field,
318 pattern,
319 error,
320 }) => {
321 return format!("Invalid glob for `{field}` ('{pattern}'): {error}");
322 }
323 Err(search::TextSearchError::UnsupportedWholeWordRegex) => {
324 return "whole_word is not supported when `regex=true`.".to_string();
325 }
326 };
327
328 let annotate_term = |line: &str| -> String {
329 match &terms {
330 Some(ts) if ts.len() > 1 => {
331 let lower = line.to_lowercase();
332 for term in *ts {
333 if lower.contains(&term.to_lowercase()) {
334 return format!(" [term: {term}]");
335 }
336 }
337 String::new()
338 }
339 _ => String::new(),
340 }
341 };
342
343 if result.files.is_empty() {
344 if result.suppressed_by_noise > 0 {
345 return format!(
346 "No matches for {} in source code. {} match(es) found in test modules — set include_tests=true to include them.",
347 result.label, result.suppressed_by_noise
348 );
349 }
350 return format!("No matches for {}", result.label);
351 }
352
353 let mut lines = vec![format!(
354 "{} matches in {} files",
355 result.total_matches,
356 result.files.len()
357 )];
358 for file in &result.files {
359 lines.push(file.path.clone());
360 if let Some(rendered_lines) = &file.rendered_lines {
361 for rendered_line in rendered_lines {
363 match rendered_line {
364 search::TextDisplayLine::Separator => lines.push(" ...".to_string()),
365 search::TextDisplayLine::Line(rendered_line) => lines.push(format!(
366 "{} {}: {}",
367 if rendered_line.is_match { ">" } else { " " },
368 rendered_line.line_number,
369 rendered_line.line
370 )),
371 }
372 }
373 } else {
374 match group_by {
375 Some("symbol") => {
376 let mut symbol_order: Vec<String> = Vec::new();
379 let mut symbol_counts: std::collections::HashMap<
380 String,
381 (usize, String, u32, u32),
382 > = std::collections::HashMap::new();
383 let mut no_symbol_count = 0usize;
384 for line_match in &file.matches {
385 if let Some(ref enc) = line_match.enclosing_symbol {
386 let key = enc.name.clone();
387 if !symbol_counts.contains_key(&key) {
388 symbol_order.push(key.clone());
389 symbol_counts.insert(
390 key,
391 (
392 1,
393 enc.kind.clone(),
394 enc.line_range.0 + 1,
395 enc.line_range.1 + 1,
396 ),
397 );
398 } else {
399 symbol_counts.get_mut(&enc.name).unwrap().0 += 1;
400 }
401 } else {
402 no_symbol_count += 1;
403 }
404 }
405 for sym_name in &symbol_order {
406 if let Some((count, kind, start, end)) = symbol_counts.get(sym_name) {
407 let match_word = if *count == 1 { "match" } else { "matches" };
408 lines.push(format!(
409 " {} {} (lines {}-{}): {} {}",
410 kind, sym_name, start, end, count, match_word
411 ));
412 }
413 }
414 if no_symbol_count > 0 {
415 let match_word = if no_symbol_count == 1 {
416 "match"
417 } else {
418 "matches"
419 };
420 lines.push(format!(" (top-level): {} {}", no_symbol_count, match_word));
421 }
422 }
423 Some("usage") | Some("purpose") => {
424 let mut last_symbol: Option<String> = None;
425 let mut filtered_count = 0usize;
426 for line_match in &file.matches {
427 if is_noise_line(&line_match.line) {
428 filtered_count += 1;
429 continue;
430 }
431 if let Some(ref enc) = line_match.enclosing_symbol {
432 if last_symbol.as_deref() != Some(enc.name.as_str()) {
433 lines.push(format!(
434 " in {} {} (lines {}-{}):",
435 enc.kind,
436 enc.name,
437 enc.line_range.0 + 1,
438 enc.line_range.1 + 1
439 ));
440 last_symbol = Some(enc.name.clone());
441 }
442 lines.push(format!(
443 " > {}: {}{}",
444 line_match.line_number,
445 line_match.line,
446 annotate_term(&line_match.line)
447 ));
448 } else {
449 last_symbol = None;
450 lines.push(format!(
451 " {}: {}{}",
452 line_match.line_number,
453 line_match.line,
454 annotate_term(&line_match.line)
455 ));
456 }
457 }
458 if filtered_count > 0 {
459 lines.push(format!(
460 " ({filtered_count} import/comment match(es) excluded by usage filter)"
461 ));
462 }
463 }
464 _ => {
466 let mut last_symbol: Option<String> = None;
467 for line_match in &file.matches {
468 if let Some(ref enc) = line_match.enclosing_symbol {
469 if last_symbol.as_deref() != Some(enc.name.as_str()) {
470 lines.push(format!(
471 " in {} {} (lines {}-{}):",
472 enc.kind,
473 enc.name,
474 enc.line_range.0 + 1,
475 enc.line_range.1 + 1
476 ));
477 last_symbol = Some(enc.name.clone());
478 }
479 lines.push(format!(
480 " > {}: {}{}",
481 line_match.line_number,
482 line_match.line,
483 annotate_term(&line_match.line)
484 ));
485 } else {
486 last_symbol = None;
487 lines.push(format!(
488 " {}: {}{}",
489 line_match.line_number,
490 line_match.line,
491 annotate_term(&line_match.line)
492 ));
493 }
494 }
495 }
496 }
497 }
498 if let Some(ref callers) = file.callers {
499 if callers.is_empty() {
500 lines.push(" (no cross-references found)".to_string());
501 } else {
502 let caller_strs: Vec<String> = callers
503 .iter()
504 .map(|c| format!("{} ({}:{})", c.symbol, c.file, c.line))
505 .collect();
506 lines.push(format!(" Called by: {}", caller_strs.join(", ")));
507 }
508 }
509 }
510 lines.join("\n")
511}
512
513pub fn file_tree(index: &LiveIndex, path: &str, depth: u32) -> String {
527 let view = index.capture_repo_outline_view();
528 file_tree_view(&view.files, path, depth)
529}
530
531pub fn file_tree_view(files: &[RepoOutlineFileView], path: &str, depth: u32) -> String {
532 let depth = depth.min(5);
533 let prefix = path.trim_matches('/');
534
535 let matching_files: Vec<&RepoOutlineFileView> = files
537 .iter()
538 .filter(|file| {
539 let p = file.relative_path.as_str();
540 if prefix.is_empty() {
541 true
542 } else {
543 p.starts_with(prefix)
544 && (p.len() == prefix.len() || p.as_bytes().get(prefix.len()) == Some(&b'/'))
545 }
546 })
547 .collect();
548
549 if matching_files.is_empty() {
550 return format!(
551 "No source files found under '{}'",
552 if prefix.is_empty() { "." } else { prefix }
553 );
554 }
555
556 use std::collections::BTreeMap;
559
560 let strip_len = if prefix.is_empty() {
562 0
563 } else {
564 prefix.len() + 1
565 };
566 let stripped: Vec<(&str, &RepoOutlineFileView)> = matching_files
567 .into_iter()
568 .map(|file| {
569 let p = file.relative_path.as_str();
570 (
571 if p.len() >= strip_len {
572 &p[strip_len..]
573 } else {
574 p
575 },
576 file,
577 )
578 })
579 .collect();
580
581 fn build_lines(
583 entries: &[(&str, &RepoOutlineFileView)],
584 current_depth: u32,
585 max_depth: u32,
586 indent: usize,
587 ) -> Vec<String> {
588 let mut dirs: BTreeMap<&str, Vec<(&str, &RepoOutlineFileView)>> = BTreeMap::new();
590 let mut files_here: Vec<(&str, &RepoOutlineFileView)> = Vec::new();
591
592 for (rel, file) in entries {
593 if let Some(slash) = rel.find('/') {
594 let dir_part = &rel[..slash];
595 let rest = &rel[slash + 1..];
596 dirs.entry(dir_part).or_default().push((rest, file));
597 } else {
598 files_here.push((rel, file));
599 }
600 }
601
602 let pad = " ".repeat(indent);
603 let mut lines = Vec::new();
604
605 files_here.sort_by_key(|(name, _)| *name);
607 for (name, file) in &files_here {
608 let sym_count = file.symbol_count;
609 let sym_label = if sym_count == 1 { "symbol" } else { "symbols" };
610 let tag = file.noise_class.tag();
611 if tag.is_empty() {
612 lines.push(format!(
613 "{}{} [{}] ({} {})",
614 pad, name, file.language, sym_count, sym_label
615 ));
616 } else {
617 lines.push(format!(
618 "{}{} [{}] ({} {}) {}",
619 pad, name, file.language, sym_count, sym_label, tag
620 ));
621 }
622 }
623
624 for (dir_name, children) in &dirs {
626 let file_count = count_files(children);
627 let sym_count: usize = children.iter().map(|(_, f)| f.symbol_count).sum();
628 let sym_label = if sym_count == 1 { "symbol" } else { "symbols" };
629
630 if current_depth >= max_depth {
631 lines.push(format!(
633 "{}{}/ ({} files, {} {})",
634 pad, dir_name, file_count, sym_count, sym_label
635 ));
636 } else {
637 lines.push(format!(
638 "{}{}/ ({} files, {} {})",
639 pad, dir_name, file_count, sym_count, sym_label
640 ));
641 let sub_lines = build_lines(children, current_depth + 1, max_depth, indent + 1);
642 lines.extend(sub_lines);
643 }
644 }
645
646 lines
647 }
648
649 fn count_files(entries: &[(&str, &RepoOutlineFileView)]) -> usize {
650 let mut count = 0;
651 for (rel, _) in entries {
652 if rel.contains('/') {
653 } else {
655 count += 1;
656 }
657 }
658 let mut dirs: std::collections::HashMap<&str, Vec<(&str, &RepoOutlineFileView)>> =
660 std::collections::HashMap::new();
661 for (rel, file) in entries {
662 if let Some(slash) = rel.find('/') {
663 dirs.entry(&rel[..slash])
664 .or_default()
665 .push((&rel[slash + 1..], file));
666 }
667 }
668 for children in dirs.values() {
669 count += count_files(children);
670 }
671 count
672 }
673
674 fn count_dirs(entries: &[(&str, &RepoOutlineFileView)]) -> usize {
675 let mut dirs: std::collections::HashSet<&str> = std::collections::HashSet::new();
676 let mut sub_entries: std::collections::HashMap<&str, Vec<(&str, &RepoOutlineFileView)>> =
677 std::collections::HashMap::new();
678 for (rel, file) in entries {
679 if let Some(slash) = rel.find('/') {
680 let dir_name = &rel[..slash];
681 dirs.insert(dir_name);
682 sub_entries
683 .entry(dir_name)
684 .or_default()
685 .push((&rel[slash + 1..], file));
686 }
687 }
688 let mut total = dirs.len();
689 for children in sub_entries.values() {
690 total += count_dirs(children);
691 }
692 total
693 }
694
695 let body_lines = build_lines(&stripped, 1, depth, 0);
696
697 let total_files = stripped.len();
698 let total_dirs = count_dirs(&stripped);
699 let total_symbols: usize = stripped.iter().map(|(_, f)| f.symbol_count).sum();
700 let sym_label = if total_symbols == 1 {
701 "symbol"
702 } else {
703 "symbols"
704 };
705
706 let mut output = body_lines;
707 output.push(format!(
708 "{} directories, {} files, {} {}",
709 total_dirs, total_files, total_symbols, sym_label
710 ));
711
712 output.join("\n")
713}
714
715fn human_size(bytes: u64) -> String {
717 if bytes >= 1_073_741_824 {
718 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
719 } else if bytes >= 1_048_576 {
720 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
721 } else if bytes >= 1024 {
722 format!("{:.0} KB", bytes as f64 / 1024.0)
723 } else {
724 format!("{} B", bytes)
725 }
726}
727
728pub fn file_tree_view_with_skipped(
733 files: &[RepoOutlineFileView],
734 skipped: &[SkippedFile],
735 path: &str,
736 depth: u32,
737) -> String {
738 let prefix = path.trim_matches('/');
740 let tier2: Vec<&SkippedFile> = skipped
741 .iter()
742 .filter(|sf| {
743 sf.decision.tier == AdmissionTier::MetadataOnly
744 && (prefix.is_empty()
745 || sf.path.starts_with(prefix)
746 && (sf.path.len() == prefix.len()
747 || sf.path.as_bytes().get(prefix.len()) == Some(&b'/')))
748 })
749 .collect();
750 let tier3_count = skipped
751 .iter()
752 .filter(|sf| {
753 sf.decision.tier == AdmissionTier::HardSkip
754 && (prefix.is_empty()
755 || sf.path.starts_with(prefix)
756 && (sf.path.len() == prefix.len()
757 || sf.path.as_bytes().get(prefix.len()) == Some(&b'/')))
758 })
759 .count();
760
761 let base = if tier2.is_empty() && files.is_empty() {
763 file_tree_view(files, path, depth)
764 } else {
765 file_tree_view(files, path, depth)
769 };
770
771 let mut lines: Vec<String> = base.lines().map(String::from).collect();
778 let footer = if lines.len() > 1 { lines.pop() } else { None };
779
780 let strip_len = if prefix.is_empty() {
782 0
783 } else {
784 prefix.len() + 1
785 };
786 let mut tier2_lines: Vec<(String, String)> = tier2
787 .iter()
788 .map(|sf| {
789 let p = sf.path.as_str();
790 let rel = if p.len() >= strip_len {
791 &p[strip_len..]
792 } else {
793 p
794 };
795 let reason = sf
796 .decision
797 .reason
798 .as_ref()
799 .map(|r| r.to_string())
800 .unwrap_or_else(|| "skipped".to_string());
801 let tag = format!("[skipped: {}, {}]", reason, human_size(sf.size));
802 (rel.to_string(), tag)
803 })
804 .collect();
805 tier2_lines.sort_by(|a, b| a.0.cmp(&b.0));
806
807 for (rel, tag) in &tier2_lines {
808 let depth_level = rel.chars().filter(|&c| c == '/').count();
810 let pad = " ".repeat(depth_level);
811 let filename = rel.rsplit('/').next().unwrap_or(rel.as_str());
812 lines.push(format!("{}{} {}", pad, filename, tag));
813 }
814
815 if let Some(f) = footer {
817 lines.push(f);
818 }
819 if tier3_count > 0 {
820 let artifact_label = if tier3_count == 1 {
821 "artifact"
822 } else {
823 "artifacts"
824 };
825 lines.push(format!(
826 "{} hard-skipped {} not shown (>100MB)",
827 tier3_count, artifact_label
828 ));
829 }
830
831 lines.join("\n")
832}
833
834pub fn repo_outline(index: &LiveIndex, project_name: &str) -> String {
839 let view = index.capture_repo_outline_view();
840 repo_outline_view(&view, project_name)
841}
842
843pub fn repo_outline_view(view: &RepoOutlineView, project_name: &str) -> String {
844 let mut lines = Vec::new();
845 lines.push(format!(
846 "{project_name} ({} files, {} symbols)",
847 view.total_files, view.total_symbols
848 ));
849
850 let path_width = view
852 .files
853 .iter()
854 .map(|f| f.relative_path.len())
855 .max()
856 .unwrap_or(20)
857 .clamp(20, 50);
858
859 for file in &view.files {
860 lines.push(format!(
861 " {:<width$} {:<12} {} symbols",
862 file.relative_path,
863 file.language.to_string(),
864 file.symbol_count,
865 width = path_width
866 ));
867 }
868
869 lines.join("\n")
870}
871
872pub fn health_report(index: &LiveIndex) -> String {
888 use crate::live_index::IndexState;
889
890 let state = index.index_state();
891 let status = match state {
892 IndexState::Empty => "Empty",
893 IndexState::Ready => "Ready",
894 IndexState::Loading => "Loading",
895 IndexState::CircuitBreakerTripped { .. } => "Degraded",
896 };
897 let stats = index.health_stats();
898 health_report_from_stats(status, &stats)
899}
900
901pub fn health_report_with_watcher(
906 index: &LiveIndex,
907 watcher: &crate::watcher::WatcherInfo,
908) -> String {
909 use crate::live_index::IndexState;
910
911 let state = index.index_state();
912 let status = match state {
913 IndexState::Empty => "Empty",
914 IndexState::Ready => "Ready",
915 IndexState::Loading => "Loading",
916 IndexState::CircuitBreakerTripped { .. } => "Degraded",
917 };
918 let stats = index.health_stats_with_watcher(watcher);
919 health_report_from_stats(status, &stats)
920}
921
922pub fn health_report_from_published_state(
923 published: &PublishedIndexState,
924 watcher: &crate::watcher::WatcherInfo,
925) -> String {
926 let mut stats = HealthStats {
927 file_count: published.file_count,
928 symbol_count: published.symbol_count,
929 parsed_count: published.parsed_count,
930 partial_parse_count: published.partial_parse_count,
931 failed_count: published.failed_count,
932 load_duration: published.load_duration,
933 watcher_state: watcher.state.clone(),
934 events_processed: watcher.events_processed,
935 last_event_at: watcher.last_event_at,
936 debounce_window_ms: watcher.debounce_window_ms,
937 overflow_count: watcher.overflow_count,
938 last_overflow_at: watcher.last_overflow_at,
939 stale_files_found: watcher.stale_files_found,
940 last_reconcile_at: watcher.last_reconcile_at,
941 partial_parse_files: vec![],
942 failed_files: vec![],
943 tier_counts: published.tier_counts,
944 };
945 if matches!(stats.watcher_state, crate::watcher::WatcherState::Off) {
947 stats.events_processed = 0;
948 stats.last_event_at = None;
949 }
950 health_report_from_stats(published.status_label(), &stats)
951}
952
953pub fn health_report_from_stats(status: &str, stats: &HealthStats) -> String {
954 use crate::watcher::WatcherState;
955
956 let relative_age = |time: Option<std::time::SystemTime>| -> String {
957 match time {
958 None => "never".to_string(),
959 Some(t) => {
960 let secs = t.elapsed().map(|d| d.as_secs()).unwrap_or(0);
961 format!("{secs}s ago")
962 }
963 }
964 };
965
966 let watcher_line = match &stats.watcher_state {
967 WatcherState::Active => format!(
968 "Watcher: active ({} events, last: {}, debounce: {}ms, overflows: {}, stale reconciled: {}, last overflow: {}, last reconcile: {})",
969 stats.events_processed,
970 relative_age(stats.last_event_at),
971 stats.debounce_window_ms,
972 stats.overflow_count,
973 stats.stale_files_found,
974 relative_age(stats.last_overflow_at),
975 relative_age(stats.last_reconcile_at)
976 ),
977 WatcherState::Degraded => format!(
978 "Watcher: degraded ({} events processed before failure, overflows: {}, stale reconciled: {}, last overflow: {}, last reconcile: {})",
979 stats.events_processed,
980 stats.overflow_count,
981 stats.stale_files_found,
982 relative_age(stats.last_overflow_at),
983 relative_age(stats.last_reconcile_at)
984 ),
985 WatcherState::Off => "Watcher: off".to_string(),
986 };
987
988 let (tier1, tier2, tier3) = stats.tier_counts;
989 let total_discovered = tier1 + tier2 + tier3;
990 let admission_section = format!(
991 "\nAdmission: {} files discovered\n Tier 1 (indexed): {}\n Tier 2 (metadata only): {}\n Tier 3 (hard-skipped): {}",
992 total_discovered, tier1, tier2, tier3
993 );
994
995 let mut output = format!(
996 "Status: {}\nFiles: {} indexed ({} parsed, {} partial, {} failed)\nSymbols: {}\nLoaded in: {}ms\n{}{}",
997 status,
998 stats.file_count,
999 stats.parsed_count,
1000 stats.partial_parse_count,
1001 stats.failed_count,
1002 stats.symbol_count,
1003 stats.load_duration.as_millis(),
1004 watcher_line,
1005 admission_section
1006 );
1007
1008 if !stats.partial_parse_files.is_empty() {
1009 output.push_str(&format!(
1010 "\nPartial parse files ({}):\n",
1011 stats.partial_parse_files.len()
1012 ));
1013 for (i, path) in stats.partial_parse_files.iter().take(10).enumerate() {
1014 output.push_str(&format!(" {}. {}\n", i + 1, path));
1015 }
1016 if stats.partial_parse_files.len() > 10 {
1017 output.push_str(&format!(
1018 " ... and {} more partial files\n",
1019 stats.partial_parse_files.len() - 10
1020 ));
1021 }
1022 }
1023
1024 if !stats.failed_files.is_empty() {
1025 output.push_str(&format!("\nFailed files ({}):\n", stats.failed_files.len()));
1026 for (i, (path, error)) in stats.failed_files.iter().take(10).enumerate() {
1027 output.push_str(&format!(" {}. {} — {}\n", i + 1, path, error));
1028 }
1029 if stats.failed_files.len() > 10 {
1030 output.push_str(&format!(
1031 " ... and {} more failed files\n",
1032 stats.failed_files.len() - 10
1033 ));
1034 }
1035 }
1036
1037 output
1038}
1039
1040pub fn what_changed_result(index: &LiveIndex, since_ts: i64) -> String {
1045 let view = index.capture_what_changed_timestamp_view();
1046 what_changed_timestamp_view(&view, since_ts)
1047}
1048
1049pub fn what_changed_timestamp_view(view: &WhatChangedTimestampView, since_ts: i64) -> String {
1050 if since_ts < view.loaded_secs {
1051 if view.paths.is_empty() {
1053 return "Index is empty — no files tracked.".to_string();
1054 }
1055 view.paths.join("\n")
1056 } else {
1057 "No changes detected since last index load.".to_string()
1058 }
1059}
1060
1061pub fn what_changed_paths_result(paths: &[String], empty_message: &str) -> String {
1062 let mut normalized_paths: Vec<String> =
1063 paths.iter().map(|path| path.replace('\\', "/")).collect();
1064 normalized_paths.sort();
1065 normalized_paths.dedup();
1066
1067 if normalized_paths.is_empty() {
1068 return empty_message.to_string();
1069 }
1070
1071 normalized_paths.join("\n")
1072}
1073
1074pub fn resolve_path_result(index: &LiveIndex, hint: &str) -> String {
1075 let view = index.capture_resolve_path_view(hint);
1076 resolve_path_result_view(&view)
1077}
1078
1079pub fn resolve_path_result_view(view: &ResolvePathView) -> String {
1080 match view {
1081 ResolvePathView::EmptyHint => "Path hint must not be empty.".to_string(),
1082 ResolvePathView::Resolved { path } => path.clone(),
1083 ResolvePathView::NotFound { hint } => {
1084 format!("No indexed source path matched '{hint}'")
1085 }
1086 ResolvePathView::Ambiguous {
1087 hint,
1088 matches,
1089 overflow_count,
1090 } => {
1091 let mut lines = vec![format!(
1092 "Ambiguous path hint '{hint}' ({} matches)",
1093 matches.len() + overflow_count
1094 )];
1095 lines.extend(matches.iter().map(|path| format!(" {path}")));
1096 if *overflow_count > 0 {
1097 lines.push(format!(" ... and {} more", overflow_count));
1098 }
1099 lines.join("\n")
1100 }
1101 }
1102}
1103
1104pub fn search_files(index: &LiveIndex, query: &str, limit: usize) -> String {
1105 let view = index.capture_search_files_view(query, limit, None);
1106 search_files_result_view(&view)
1107}
1108
1109pub fn search_files_result_view(view: &SearchFilesView) -> String {
1110 match view {
1111 SearchFilesView::EmptyQuery => "Path search requires a non-empty query.".to_string(),
1112 SearchFilesView::NotFound { query } => {
1113 format!("No indexed source files matching '{query}'")
1114 }
1115 SearchFilesView::Found {
1116 total_matches,
1117 overflow_count,
1118 hits,
1119 ..
1120 } => {
1121 let mut lines = vec![if *total_matches == 1 {
1122 "1 matching file".to_string()
1123 } else {
1124 format!("{total_matches} matching files")
1125 }];
1126
1127 let mut last_tier: Option<SearchFilesTier> = None;
1128 for hit in hits {
1129 if last_tier != Some(hit.tier) {
1130 last_tier = Some(hit.tier);
1131 let header = match hit.tier {
1132 SearchFilesTier::CoChange => {
1133 "── Co-changed files (git temporal coupling) ──"
1134 }
1135 SearchFilesTier::StrongPath => "── Strong path matches ──",
1136 SearchFilesTier::Basename => "── Basename matches ──",
1137 SearchFilesTier::LoosePath => "── Loose path matches ──",
1138 };
1139 if lines.len() > 1 {
1140 lines.push(String::new());
1141 }
1142 lines.push(header.to_string());
1143 }
1144 if let (Some(score), Some(shared)) = (hit.coupling_score, hit.shared_commits) {
1145 lines.push(format!(
1146 " {} ({:.0}% coupled, {} shared commits)",
1147 hit.path,
1148 score * 100.0,
1149 shared
1150 ));
1151 } else {
1152 lines.push(format!(" {}", hit.path));
1153 }
1154 }
1155
1156 if *overflow_count > 0 {
1157 lines.push(format!("... and {} more", overflow_count));
1158 }
1159
1160 lines.join("\n")
1161 }
1162 }
1163}
1164
1165pub fn file_content(
1169 index: &LiveIndex,
1170 path: &str,
1171 start_line: Option<u32>,
1172 end_line: Option<u32>,
1173) -> String {
1174 let options = search::FileContentOptions::for_explicit_path_read(path, start_line, end_line);
1175 match index.capture_shared_file_for_scope(&options.path_scope) {
1176 Some(file) => {
1177 file_content_from_indexed_file_with_context(file.as_ref(), options.content_context)
1178 }
1179 None => not_found_file(path),
1180 }
1181}
1182
1183pub fn file_content_from_indexed_file(
1184 file: &IndexedFile,
1185 start_line: Option<u32>,
1186 end_line: Option<u32>,
1187) -> String {
1188 file_content_from_indexed_file_with_context(
1189 file,
1190 search::ContentContext::line_range(start_line, end_line),
1191 )
1192}
1193
1194pub fn file_content_from_indexed_file_with_context(
1195 file: &IndexedFile,
1196 context: search::ContentContext,
1197) -> String {
1198 if let Some(chunk_index) = context.chunk_index {
1199 let max_lines = match context.max_lines {
1200 Some(ml) => ml,
1201 None => {
1202 return format!(
1203 "{} [error: chunked read requires max_lines parameter]",
1204 file.relative_path
1205 );
1206 }
1207 };
1208 return render_numbered_chunk_excerpt(file, chunk_index, max_lines);
1209 }
1210
1211 if let Some(around_symbol) = context.around_symbol.as_deref() {
1212 return render_numbered_around_symbol_excerpt(
1213 file,
1214 around_symbol,
1215 context.symbol_line,
1216 context.context_lines.unwrap_or(0),
1217 context.max_lines,
1218 );
1219 }
1220
1221 if let Some(around_match) = context.around_match.as_deref() {
1222 return render_numbered_around_match_excerpt(
1223 file,
1224 around_match,
1225 context.match_occurrence.unwrap_or(1),
1226 context
1227 .context_lines
1228 .unwrap_or(DEFAULT_AROUND_LINE_CONTEXT_LINES),
1229 );
1230 }
1231
1232 render_file_content_bytes(&file.relative_path, &file.content, context)
1233}
1234
1235pub fn file_content_view(
1239 view: &FileContentView,
1240 start_line: Option<u32>,
1241 end_line: Option<u32>,
1242) -> String {
1243 render_file_content_bytes(
1244 &view.relative_path,
1245 &view.content,
1246 search::ContentContext::line_range(start_line, end_line),
1247 )
1248}
1249
1250pub fn validate_file_syntax_result(path: &str, file: &IndexedFile) -> String {
1251 let mut lines = vec![
1252 format!("Syntax validation: {path}"),
1253 format!("Language: {}", file.language),
1254 ];
1255
1256 match &file.parse_status {
1257 crate::live_index::ParseStatus::Parsed => {
1258 lines.push("Status: ok".to_string());
1259 }
1260 crate::live_index::ParseStatus::PartialParse { warning } => {
1261 lines.push("Status: partial".to_string());
1262 if let Some(diagnostic) = &file.parse_diagnostic {
1263 lines.push(format!("Diagnostic: {}", diagnostic.summary()));
1264 if let Some((start, end)) = diagnostic.byte_span {
1265 lines.push(format!("Byte span: {start}..{end}"));
1266 }
1267 } else {
1268 lines.push(format!("Diagnostic: {warning}"));
1269 }
1270 }
1271 crate::live_index::ParseStatus::Failed { error } => {
1272 lines.push("Status: failed".to_string());
1273 if let Some(diagnostic) = &file.parse_diagnostic {
1274 lines.push(format!("Diagnostic: {}", diagnostic.summary()));
1275 if let Some((start, end)) = diagnostic.byte_span {
1276 lines.push(format!("Byte span: {start}..{end}"));
1277 }
1278 } else {
1279 lines.push(format!("Diagnostic: {error}"));
1280 }
1281 }
1282 }
1283
1284 lines.push(format!("Symbols extracted: {}", file.symbols.len()));
1285 lines.join("\n")
1286}
1287
1288const DEFAULT_AROUND_LINE_CONTEXT_LINES: u32 = 2;
1289
1290pub(crate) fn render_file_content_bytes(
1291 path: &str,
1292 content: &[u8],
1293 context: search::ContentContext,
1294) -> String {
1295 let content = String::from_utf8_lossy(content);
1296 let lines: Vec<&str> = content.lines().collect();
1297 let line_count = lines.len() as u32;
1298
1299 if let Some(start) = context.start_line {
1301 if start > line_count {
1302 return format!(
1303 "{path} [error: requested range (lines {start}-{}) exceeds file length ({line_count} lines)]",
1304 context.end_line.unwrap_or(start),
1305 );
1306 }
1307 }
1308
1309 if let Some(around_line) = context.around_line {
1310 if around_line > line_count {
1311 return format!(
1312 "{path} [error: around_line={around_line} exceeds file length ({line_count} lines)]",
1313 );
1314 }
1315 return render_numbered_around_line_excerpt(
1316 &lines,
1317 around_line,
1318 context
1319 .context_lines
1320 .unwrap_or(DEFAULT_AROUND_LINE_CONTEXT_LINES),
1321 );
1322 }
1323
1324 if !context.show_line_numbers && !context.header {
1325 return match (context.start_line, context.end_line) {
1326 (None, None) => content.into_owned(),
1327 (start, end) => render_raw_line_slice(&lines, start, end),
1328 };
1329 }
1330
1331 render_ordinary_read(
1332 path,
1333 &lines,
1334 context.start_line,
1335 context.end_line,
1336 context.show_line_numbers,
1337 context.header,
1338 )
1339}
1340
1341fn render_raw_line_slice(lines: &[&str], start_line: Option<u32>, end_line: Option<u32>) -> String {
1342 slice_lines(lines, start_line, end_line)
1343 .into_iter()
1344 .map(|(_, line)| line)
1345 .collect::<Vec<_>>()
1346 .join("\n")
1347}
1348
1349fn render_ordinary_read(
1350 path: &str,
1351 lines: &[&str],
1352 start_line: Option<u32>,
1353 end_line: Option<u32>,
1354 show_line_numbers: bool,
1355 header: bool,
1356) -> String {
1357 let selected = slice_lines(lines, start_line, end_line);
1358 let body = if show_line_numbers {
1359 selected
1360 .iter()
1361 .map(|(line_number, line)| format!("{line_number}: {line}"))
1362 .collect::<Vec<_>>()
1363 .join("\n")
1364 } else {
1365 selected
1366 .iter()
1367 .map(|(_, line)| *line)
1368 .collect::<Vec<_>>()
1369 .join("\n")
1370 };
1371
1372 if !header {
1373 return body;
1374 }
1375
1376 let header_line = if start_line.is_some() || end_line.is_some() {
1377 render_ordinary_read_header(path, &selected)
1378 } else {
1379 path.to_string()
1380 };
1381
1382 if body.is_empty() {
1383 header_line
1384 } else {
1385 format!("{header_line}\n{body}")
1386 }
1387}
1388
1389fn slice_lines<'a>(
1390 lines: &'a [&'a str],
1391 start_line: Option<u32>,
1392 end_line: Option<u32>,
1393) -> Vec<(u32, &'a str)> {
1394 let start_idx = start_line
1395 .map(|start| start.saturating_sub(1) as usize)
1396 .unwrap_or(0);
1397 let end_idx = end_line.map(|end| end as usize).unwrap_or(usize::MAX);
1398
1399 lines
1400 .iter()
1401 .enumerate()
1402 .filter_map(|(idx, line)| {
1403 if idx >= start_idx && idx < end_idx {
1404 Some((idx as u32 + 1, *line))
1405 } else {
1406 None
1407 }
1408 })
1409 .collect()
1410}
1411
1412fn render_ordinary_read_header(path: &str, selected: &[(u32, &str)]) -> String {
1413 match (selected.first(), selected.last()) {
1414 (Some((first, _)), Some((last, _))) => format!("{path} [lines {first}-{last}]"),
1415 _ => format!("{path} [lines empty]"),
1416 }
1417}
1418
1419fn render_numbered_chunk_excerpt(file: &IndexedFile, chunk_index: u32, max_lines: u32) -> String {
1420 let content = String::from_utf8_lossy(&file.content);
1421 let lines: Vec<&str> = content.lines().collect();
1422 let chunk_size = max_lines as usize;
1423
1424 if chunk_index == 0 || chunk_size == 0 {
1425 return out_of_range_file_chunk(&file.relative_path, chunk_index, 0);
1426 }
1427
1428 let total_chunks = lines.len().div_ceil(chunk_size);
1429 if total_chunks == 0 {
1430 return out_of_range_file_chunk(&file.relative_path, chunk_index, 0);
1431 }
1432
1433 let chunk_number = chunk_index as usize;
1434 if chunk_number > total_chunks {
1435 return out_of_range_file_chunk(&file.relative_path, chunk_index, total_chunks);
1436 }
1437
1438 let start_idx = (chunk_number - 1) * chunk_size;
1439 let end_idx = (start_idx + chunk_size).min(lines.len());
1440 let start_line = start_idx + 1;
1441 let end_line = end_idx;
1442
1443 let body = lines[start_idx..end_idx]
1444 .iter()
1445 .enumerate()
1446 .map(|(offset, line)| format!("{}: {line}", start_line + offset))
1447 .collect::<Vec<_>>()
1448 .join("\n");
1449
1450 format!(
1451 "{} [chunk {}/{}, lines {}-{}]\n{}",
1452 file.relative_path, chunk_index, total_chunks, start_line, end_line, body
1453 )
1454}
1455
1456fn render_numbered_around_symbol_excerpt(
1457 file: &IndexedFile,
1458 around_symbol: &str,
1459 symbol_line: Option<u32>,
1460 context_lines: u32,
1461 max_lines: Option<u32>,
1462) -> String {
1463 let content = String::from_utf8_lossy(&file.content);
1464 let lines: Vec<&str> = content.lines().collect();
1465
1466 match resolve_around_symbol_range(file, around_symbol, symbol_line) {
1467 Ok((sym_start, sym_end)) => render_numbered_symbol_range_excerpt(
1468 &lines,
1469 sym_start,
1470 sym_end,
1471 context_lines,
1472 max_lines,
1473 ),
1474 Err(AroundSymbolResolutionError::NotFound) => {
1475 render_not_found_symbol(&file.relative_path, &file.symbols, around_symbol)
1476 }
1477 Err(AroundSymbolResolutionError::SelectorNotFound(symbol_line)) => {
1478 format!(
1479 "Symbol not found in {}: {} at line {}",
1480 file.relative_path, around_symbol, symbol_line
1481 )
1482 }
1483 Err(AroundSymbolResolutionError::Ambiguous(candidate_lines)) => {
1484 let candidate_lines = candidate_lines
1485 .iter()
1486 .map(u32::to_string)
1487 .collect::<Vec<_>>()
1488 .join(", ");
1489 format!(
1490 "Ambiguous symbol selector for {around_symbol} in {}; pass `symbol_line` to disambiguate. Candidates: {candidate_lines}",
1491 file.relative_path
1492 )
1493 }
1494 }
1495}
1496
1497fn render_numbered_symbol_range_excerpt(
1501 lines: &[&str],
1502 sym_start: u32,
1503 sym_end: u32,
1504 context_lines: u32,
1505 max_lines: Option<u32>,
1506) -> String {
1507 if lines.is_empty() {
1508 return String::new();
1509 }
1510
1511 let total = lines.len();
1512 let start = (sym_start as usize)
1513 .saturating_sub(context_lines as usize)
1514 .max(1);
1515 let end = ((sym_end as usize).saturating_add(context_lines as usize)).min(total);
1516
1517 if start > end || start > total {
1518 return String::new();
1519 }
1520
1521 let full_range_len = end - start + 1;
1522
1523 if let Some(ml) = max_lines {
1524 let ml = ml as usize;
1525 if ml > 0 && full_range_len > ml {
1526 let truncated_end = start + ml - 1;
1527 let mut result: Vec<String> = (start..=truncated_end)
1528 .map(|n| format!("{n}: {}", lines[n - 1]))
1529 .collect();
1530 result.push(format!(
1531 "... truncated (symbol is {} lines, showing first {})",
1532 sym_end.saturating_sub(sym_start) + 1,
1533 ml
1534 ));
1535 return result.join("\n");
1536 }
1537 }
1538
1539 (start..=end)
1540 .map(|n| format!("{n}: {}", lines[n - 1]))
1541 .collect::<Vec<_>>()
1542 .join("\n")
1543}
1544
1545#[derive(Debug, PartialEq, Eq)]
1546enum AroundSymbolResolutionError {
1547 NotFound,
1548 SelectorNotFound(u32),
1549 Ambiguous(Vec<u32>),
1550}
1551
1552fn resolve_around_symbol_range(
1555 file: &IndexedFile,
1556 around_symbol: &str,
1557 symbol_line: Option<u32>,
1558) -> Result<(u32, u32), AroundSymbolResolutionError> {
1559 let matching_symbols: Vec<&crate::domain::SymbolRecord> = file
1560 .symbols
1561 .iter()
1562 .filter(|symbol| symbol.name == around_symbol)
1563 .collect();
1564
1565 if matching_symbols.is_empty() {
1566 return Err(AroundSymbolResolutionError::NotFound);
1567 }
1568
1569 if let Some(symbol_line) = symbol_line {
1570 let exact_matches: Vec<&crate::domain::SymbolRecord> = matching_symbols
1572 .iter()
1573 .copied()
1574 .filter(|symbol| symbol.line_range.0 + 1 == symbol_line)
1575 .collect();
1576
1577 return match exact_matches.as_slice() {
1578 [symbol] => Ok((
1579 symbol.line_range.0.saturating_add(1),
1580 symbol.line_range.1.saturating_add(1),
1581 )),
1582 [] => Err(AroundSymbolResolutionError::SelectorNotFound(symbol_line)),
1583 _ => Err(AroundSymbolResolutionError::Ambiguous(
1584 dedup_symbol_candidate_lines(&exact_matches),
1585 )),
1586 };
1587 }
1588
1589 match matching_symbols.as_slice() {
1590 [symbol] => Ok((
1591 symbol.line_range.0.saturating_add(1),
1592 symbol.line_range.1.saturating_add(1),
1593 )),
1594 _ => Err(AroundSymbolResolutionError::Ambiguous(
1595 dedup_symbol_candidate_lines(&matching_symbols),
1596 )),
1597 }
1598}
1599
1600fn dedup_symbol_candidate_lines(symbols: &[&crate::domain::SymbolRecord]) -> Vec<u32> {
1601 let mut candidate_lines: Vec<u32> = symbols.iter().map(|symbol| symbol.line_range.0).collect();
1602 candidate_lines.sort_unstable();
1603 candidate_lines.dedup();
1604 candidate_lines
1605}
1606
1607fn render_numbered_around_match_excerpt(
1608 file: &IndexedFile,
1609 around_match: &str,
1610 match_occurrence: u32,
1611 context_lines: u32,
1612) -> String {
1613 let content = String::from_utf8_lossy(&file.content);
1614 let lines: Vec<&str> = content.lines().collect();
1615
1616 let candidate_lines = find_case_insensitive_match_lines(&lines, around_match);
1617 if candidate_lines.is_empty() {
1618 return not_found_file_match(&file.relative_path, around_match);
1619 }
1620
1621 let occurrence_index = match_occurrence.saturating_sub(1) as usize;
1622 let Some(&around_line) = candidate_lines.get(occurrence_index) else {
1623 let available_lines = candidate_lines
1624 .iter()
1625 .map(u32::to_string)
1626 .collect::<Vec<_>>()
1627 .join(", ");
1628 return format!(
1629 "Match occurrence {match_occurrence} for '{around_match}' not found in {}; {} match(es) available at lines {available_lines}",
1630 file.relative_path,
1631 candidate_lines.len()
1632 );
1633 };
1634
1635 render_numbered_around_line_excerpt(&lines, around_line, context_lines)
1636}
1637
1638fn find_case_insensitive_match_lines(lines: &[&str], around_match: &str) -> Vec<u32> {
1639 let needle = around_match.to_lowercase();
1640
1641 lines
1642 .iter()
1643 .enumerate()
1644 .filter_map(|(index, line)| {
1645 line.to_lowercase()
1646 .contains(&needle)
1647 .then_some((index + 1) as u32)
1648 })
1649 .collect()
1650}
1651
1652fn render_numbered_around_line_excerpt(
1653 lines: &[&str],
1654 around_line: u32,
1655 context_lines: u32,
1656) -> String {
1657 if lines.is_empty() {
1658 return String::new();
1659 }
1660
1661 let anchor = around_line.max(1) as usize;
1662 let context = context_lines as usize;
1663 let start = anchor.saturating_sub(context).max(1);
1664 let end = anchor.saturating_add(context).min(lines.len());
1665
1666 if start > end || start > lines.len() {
1667 return String::new();
1668 }
1669
1670 (start..=end)
1671 .map(|line_number| format!("{line_number}: {}", lines[line_number - 1]))
1672 .collect::<Vec<_>>()
1673 .join("\n")
1674}
1675
1676pub fn not_found_file(path: &str) -> String {
1678 format!("File not found: {path}")
1679}
1680
1681pub fn not_found_file_match(path: &str, query: &str) -> String {
1683 format!("No matches for '{query}' in {path}")
1684}
1685
1686fn out_of_range_file_chunk(path: &str, chunk_index: u32, total_chunks: usize) -> String {
1687 format!("Chunk {chunk_index} out of range for {path} ({total_chunks} chunks)")
1688}
1689
1690pub fn not_found_symbol(index: &LiveIndex, path: &str, name: &str) -> String {
1692 match index.capture_shared_file(path) {
1693 None => not_found_file(path),
1694 Some(file) => render_not_found_symbol(&file.relative_path, &file.symbols, name),
1695 }
1696}
1697
1698fn render_not_found_symbol(
1699 relative_path: &str,
1700 symbols: &[crate::domain::SymbolRecord],
1701 name: &str,
1702) -> String {
1703 let symbol_names: Vec<String> = symbols.iter().map(|s| s.name.clone()).collect();
1704 not_found_symbol_names(relative_path, &symbol_names, name)
1705}
1706
1707fn fuzzy_distance(a: &str, b: &str) -> usize {
1709 let a_lower = a.to_lowercase();
1710 let b_lower = b.to_lowercase();
1711
1712 if b_lower.contains(&a_lower) || a_lower.contains(&b_lower) {
1714 return 0;
1715 }
1716
1717 let prefix_len = a_lower
1719 .chars()
1720 .zip(b_lower.chars())
1721 .take_while(|(x, y)| x == y)
1722 .count();
1723 if prefix_len > 0 {
1724 return a.len().max(b.len()) - prefix_len;
1725 }
1726
1727 let a_chars: std::collections::HashSet<char> = a_lower.chars().collect();
1729 let b_chars: std::collections::HashSet<char> = b_lower.chars().collect();
1730 let intersection = a_chars.intersection(&b_chars).count();
1731 if intersection == 0 {
1732 return usize::MAX;
1733 }
1734 a.len().max(b.len()) - intersection
1735}
1736
1737fn not_found_symbol_names(relative_path: &str, symbol_names: &[String], name: &str) -> String {
1738 if symbol_names.is_empty() {
1739 return format!(
1740 "No symbol {name} in {relative_path}. \
1741 This file has no indexed symbols — it may use top-level statements, \
1742 expression-bodied code, or a syntax not extracted by the parser. \
1743 Use get_file_content without around_symbol to read the raw file."
1744 );
1745 }
1746
1747 let min_name_len = 2.min(name.len());
1751 let mut scored: Vec<(&String, usize)> = symbol_names
1752 .iter()
1753 .filter(|s| s.len() >= min_name_len)
1754 .map(|s| (s, fuzzy_distance(name, s)))
1755 .collect();
1756 scored.sort_by_key(|(_, d)| *d);
1757
1758 let close_matches: Vec<&str> = scored
1759 .iter()
1760 .take(5)
1761 .filter(|(_, d)| *d < usize::MAX)
1762 .map(|(s, _)| s.as_str())
1763 .collect();
1764
1765 if close_matches.is_empty() {
1766 format!(
1767 "No symbol {name} in {relative_path}. No close matches found. \
1768 Use get_file_context with sections=['outline'] to see all {} symbols in this file.",
1769 symbol_names.len()
1770 )
1771 } else {
1772 format!(
1773 "No symbol {name} in {relative_path}. Close matches: {}. \
1774 Use get_file_context with sections=['outline'] for the full list ({} symbols).",
1775 close_matches.join(", "),
1776 symbol_names.len()
1777 )
1778 }
1779}
1780
1781pub fn find_references_result(index: &LiveIndex, name: &str, kind_filter: Option<&str>) -> String {
1786 let limits = OutputLimits::default();
1787 let view = index.capture_find_references_view(name, kind_filter, limits.total_hits);
1788 find_references_result_view(&view, name, &limits)
1789}
1790
1791pub fn find_references_result_view(
1792 view: &FindReferencesView,
1793 name: &str,
1794 limits: &OutputLimits,
1795) -> String {
1796 if view.total_refs == 0 {
1797 return format!("No references found for \"{name}\"");
1798 }
1799
1800 let total = view.total_refs;
1801 let total_files = view.total_files;
1802 let shown_files = view.files.len().min(limits.max_files);
1803 let mut lines = if shown_files < total_files {
1804 vec![format!(
1805 "{total} references across {total_files} files (showing {shown_files})"
1806 )]
1807 } else {
1808 vec![format!("{total} references in {total_files} files")]
1809 };
1810 if view.total_refs > 100 && name.len() <= 4 {
1811 lines.push(format!(
1812 "Note: '{}' is a very common identifier — results may include unrelated symbols. \
1813 Add path or symbol_kind to scope the search.",
1814 name
1815 ));
1816 }
1817 lines.push(String::new()); let mut total_emitted = 0usize;
1820 for file in view.files.iter().take(limits.max_files) {
1821 if total_emitted >= limits.total_hits {
1822 break;
1823 }
1824 lines.push(file.file_path.clone());
1825 let mut hit_count = 0usize;
1826 let mut truncated_hits = 0usize;
1827 for hit in &file.hits {
1828 if hit_count >= limits.max_per_file || total_emitted >= limits.total_hits {
1829 truncated_hits += 1;
1830 continue;
1831 }
1832 for line in &hit.context_lines {
1833 if line.is_reference_line {
1834 if let Some(annotation) = &line.enclosing_annotation {
1835 lines.push(format!(
1836 " {}: {:<40}{}",
1837 line.line_number, line.text, annotation
1838 ));
1839 } else {
1840 lines.push(format!(" {}: {}", line.line_number, line.text));
1841 }
1842 } else {
1843 lines.push(format!(" {}: {}", line.line_number, line.text));
1844 }
1845 }
1846 hit_count += 1;
1847 total_emitted += 1;
1848 }
1849 if truncated_hits > 0 {
1850 lines.push(format!(" ... and {truncated_hits} more references"));
1851 }
1852 lines.push(String::new()); }
1854
1855 let remaining_files = total_files.saturating_sub(shown_files);
1856 if remaining_files > 0 {
1857 lines.push(format!("... and {remaining_files} more files"));
1858 }
1859
1860 while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
1861 lines.pop();
1862 }
1863
1864 lines.join("\n")
1865}
1866
1867pub fn find_references_compact_view(
1869 view: &FindReferencesView,
1870 name: &str,
1871 limits: &OutputLimits,
1872) -> String {
1873 if view.total_refs == 0 {
1874 return format!("No references found for \"{name}\"");
1875 }
1876
1877 let total_files = view.total_files;
1878 let shown_files = view.files.len().min(limits.max_files);
1879 let mut lines = if shown_files < total_files {
1880 vec![format!(
1881 "{} references to \"{}\" across {} files (showing {})",
1882 view.total_refs, name, total_files, shown_files
1883 )]
1884 } else {
1885 vec![format!(
1886 "{} references to \"{}\" in {} files",
1887 view.total_refs, name, total_files
1888 )]
1889 };
1890 if view.total_refs > 100 && name.len() <= 4 {
1891 lines.push(format!(
1892 "Note: '{}' is a very common identifier — results may include unrelated symbols. \
1893 Add path or symbol_kind to scope the search.",
1894 name
1895 ));
1896 }
1897
1898 let mut total_emitted = 0usize;
1899 for file in view.files.iter().take(limits.max_files) {
1900 if total_emitted >= limits.total_hits {
1901 break;
1902 }
1903 lines.push(file.file_path.clone());
1904 let mut hit_count = 0usize;
1905 let mut truncated_hits = 0usize;
1906 for hit in &file.hits {
1907 if hit_count >= limits.max_per_file || total_emitted >= limits.total_hits {
1908 truncated_hits += 1;
1909 continue;
1910 }
1911 for line in &hit.context_lines {
1912 if line.is_reference_line {
1913 let annotation = line.enclosing_annotation.as_deref().unwrap_or("");
1914 lines.push(format!(" :{} {}", line.line_number, annotation));
1915 }
1916 }
1917 hit_count += 1;
1918 total_emitted += 1;
1919 }
1920 if truncated_hits > 0 {
1921 lines.push(format!(" ... and {truncated_hits} more"));
1922 }
1923 }
1924
1925 let remaining_files = total_files.saturating_sub(shown_files);
1926 if remaining_files > 0 {
1927 lines.push(format!("... and {remaining_files} more files"));
1928 }
1929
1930 lines.join("\n")
1931}
1932
1933pub fn find_implementations_result_view(
1935 view: &FindImplementationsView,
1936 name: &str,
1937 limits: &OutputLimits,
1938) -> String {
1939 if view.entries.is_empty() {
1940 return format!("No implementations found for \"{name}\"");
1941 }
1942
1943 let total = view.entries.len();
1944 let shown = total.min(limits.max_files * limits.max_per_file);
1945 let mut lines = vec![format!("{total} implementation(s) found for \"{name}\"")];
1946 lines.push(String::new());
1947
1948 let mut current_trait: Option<&str> = None;
1950 for (i, entry) in view.entries.iter().enumerate() {
1951 if i >= shown {
1952 break;
1953 }
1954 if current_trait != Some(&entry.trait_name) {
1955 if current_trait.is_some() {
1956 lines.push(String::new());
1957 }
1958 lines.push(format!("trait/interface {}:", entry.trait_name));
1959 current_trait = Some(&entry.trait_name);
1960 }
1961 lines.push(format!(
1962 " {} ({}:{})",
1963 entry.implementor,
1964 entry.file_path,
1965 entry.line + 1
1966 ));
1967 }
1968
1969 let remaining = total.saturating_sub(shown);
1970 if remaining > 0 {
1971 lines.push(String::new());
1972 lines.push(format!("... and {remaining} more"));
1973 }
1974
1975 lines.join("\n")
1976}
1977
1978pub fn find_dependents_result(index: &LiveIndex, path: &str) -> String {
1982 let view = index.capture_find_dependents_view(path);
1983 find_dependents_result_view(&view, path, &OutputLimits::default())
1984}
1985
1986pub fn find_dependents_result_view(
1987 view: &FindDependentsView,
1988 path: &str,
1989 limits: &OutputLimits,
1990) -> String {
1991 if view.files.is_empty() {
1992 return format!("No dependents found for \"{path}\"");
1993 }
1994
1995 let total_files = view.files.len();
1996 let shown_files = total_files.min(limits.max_files);
1997 let mut lines = vec![format!("{total_files} files depend on {path}")];
1998 lines.push(String::new()); for file in view.files.iter().take(limits.max_files) {
2001 lines.push(file.file_path.clone());
2002 let total_refs = file.lines.len();
2003 let shown_refs = total_refs.min(limits.max_per_file);
2004 for line in file.lines.iter().take(limits.max_per_file) {
2005 lines.push(format!(
2006 " {}: {} [{}]",
2007 line.line_number, line.line_content, line.kind
2008 ));
2009 }
2010 let remaining_refs = total_refs.saturating_sub(shown_refs);
2011 if remaining_refs > 0 {
2012 lines.push(format!(" ... and {remaining_refs} more references"));
2013 }
2014 lines.push(String::new()); }
2016
2017 let remaining_files = total_files.saturating_sub(shown_files);
2018 if remaining_files > 0 {
2019 lines.push(format!("... and {remaining_files} more files"));
2020 }
2021
2022 while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
2024 lines.pop();
2025 }
2026
2027 lines.join("\n")
2028}
2029
2030pub fn find_dependents_compact_view(
2032 view: &FindDependentsView,
2033 path: &str,
2034 limits: &OutputLimits,
2035) -> String {
2036 if view.files.is_empty() {
2037 return format!("No dependents found for \"{path}\"");
2038 }
2039
2040 let total_files = view.files.len();
2041 let shown_files = total_files.min(limits.max_files);
2042 let mut lines = vec![format!("{total_files} files depend on {path}")];
2043
2044 for file in view.files.iter().take(limits.max_files) {
2045 let total_refs = file.lines.len();
2046 let shown_refs = total_refs.min(limits.max_per_file);
2047 let kinds: Vec<&str> = file
2048 .lines
2049 .iter()
2050 .take(limits.max_per_file)
2051 .map(|l| l.kind.as_str())
2052 .collect();
2053 let summary = if kinds.is_empty() {
2054 file.file_path.clone()
2055 } else {
2056 let unique_kinds: Vec<&str> = {
2057 let mut k = kinds.clone();
2058 k.sort_unstable();
2059 k.dedup();
2060 k
2061 };
2062 format!(
2063 " {} ({} refs: {})",
2064 file.file_path,
2065 total_refs,
2066 unique_kinds.join(", ")
2067 )
2068 };
2069 lines.push(summary);
2070 let remaining = total_refs.saturating_sub(shown_refs);
2071 if remaining > 0 {
2072 }
2074 }
2075
2076 let remaining_files = total_files.saturating_sub(shown_files);
2077 if remaining_files > 0 {
2078 lines.push(format!("... and {remaining_files} more files"));
2079 }
2080
2081 lines.join("\n")
2082}
2083
2084pub fn find_dependents_mermaid(
2086 view: &FindDependentsView,
2087 path: &str,
2088 limits: &OutputLimits,
2089) -> String {
2090 if view.files.is_empty() {
2091 return format!("No dependents found for \"{path}\"");
2092 }
2093
2094 let mut lines = vec!["flowchart LR".to_string()];
2095 let target_id = mermaid_node_id(path);
2096 lines.push(format!(" {target_id}[\"{path}\"]"));
2097
2098 for file in view.files.iter().take(limits.max_files) {
2099 let dep_id = mermaid_node_id(&file.file_path);
2100 let ref_count = file.lines.len();
2101
2102 let mut names: Vec<&str> = Vec::new();
2103 for line in &file.lines {
2104 if !names.contains(&line.name.as_str()) {
2105 names.push(&line.name);
2106 if names.len() >= 3 {
2107 break;
2108 }
2109 }
2110 }
2111 let remaining = ref_count.saturating_sub(names.len());
2112 let label = if names.is_empty() {
2113 format!("{ref_count} refs")
2114 } else if remaining > 0 {
2115 format!("{} +{remaining}", names.join(", "))
2116 } else {
2117 names.join(", ")
2118 };
2119 lines.push(format!(
2120 " {dep_id}[\"{}\"] -->|\"{label}\"| {target_id}",
2121 file.file_path
2122 ));
2123 }
2124
2125 let remaining = view.files.len().saturating_sub(limits.max_files);
2126 if remaining > 0 {
2127 lines.push(format!(
2128 " more[\"... and {remaining} more files\"] --> {target_id}"
2129 ));
2130 }
2131
2132 lines.join("\n")
2133}
2134
2135pub fn find_dependents_dot(view: &FindDependentsView, path: &str, limits: &OutputLimits) -> String {
2137 if view.files.is_empty() {
2138 return format!("No dependents found for \"{path}\"");
2139 }
2140
2141 let mut lines = vec!["digraph dependents {".to_string()];
2142 lines.push(" rankdir=LR;".to_string());
2143 lines.push(format!(
2144 " \"{}\" [shape=box, style=bold];",
2145 dot_escape(path)
2146 ));
2147
2148 for file in view.files.iter().take(limits.max_files) {
2149 let mut names: Vec<&str> = Vec::new();
2150 for line in &file.lines {
2151 if !names.contains(&line.name.as_str()) {
2152 names.push(&line.name);
2153 if names.len() >= 3 {
2154 break;
2155 }
2156 }
2157 }
2158 let remaining = file.lines.len().saturating_sub(names.len());
2159 let label = if names.is_empty() {
2160 format!("{} refs", file.lines.len())
2161 } else if remaining > 0 {
2162 format!("{} +{remaining}", names.join(", "))
2163 } else {
2164 names.join(", ")
2165 };
2166 lines.push(format!(
2167 " \"{}\" -> \"{}\" [label=\"{}\"];",
2168 dot_escape(&file.file_path),
2169 dot_escape(path),
2170 label
2171 ));
2172 }
2173
2174 let remaining = view.files.len().saturating_sub(limits.max_files);
2175 if remaining > 0 {
2176 lines.push(format!(
2177 " \"... and {} more\" -> \"{}\" [style=dashed];",
2178 remaining,
2179 dot_escape(path)
2180 ));
2181 }
2182
2183 lines.push("}".to_string());
2184 lines.join("\n")
2185}
2186
2187fn mermaid_node_id(path: &str) -> String {
2189 path.chars()
2190 .map(|c| if c.is_alphanumeric() { c } else { '_' })
2191 .collect()
2192}
2193
2194fn dot_escape(s: &str) -> String {
2196 s.replace('\\', "\\\\").replace('"', "\\\"")
2197}
2198
2199pub fn context_bundle_result(
2203 index: &LiveIndex,
2204 path: &str,
2205 name: &str,
2206 kind_filter: Option<&str>,
2207) -> String {
2208 let view = index.capture_context_bundle_view(path, name, kind_filter, None);
2209 context_bundle_result_view(&view, "full")
2210}
2211
2212pub fn context_bundle_result_view(view: &ContextBundleView, verbosity: &str) -> String {
2213 context_bundle_result_view_with_max_tokens(view, verbosity, None)
2214}
2215
2216pub fn context_bundle_result_view_with_max_tokens(
2217 view: &ContextBundleView,
2218 verbosity: &str,
2219 max_tokens: Option<u64>,
2220) -> String {
2221 match view {
2222 ContextBundleView::FileNotFound { path } => not_found_file(path),
2223 ContextBundleView::AmbiguousSymbol {
2224 path,
2225 name,
2226 candidate_lines,
2227 } => format!(
2228 "Ambiguous symbol selector for {name} in {path}; pass `symbol_line` to disambiguate. Candidates: {}",
2229 candidate_lines
2230 .iter()
2231 .map(u32::to_string)
2232 .collect::<Vec<_>>()
2233 .join(", ")
2234 ),
2235 ContextBundleView::SymbolNotFound {
2236 relative_path,
2237 symbol_names,
2238 name,
2239 } => not_found_symbol_names(relative_path, symbol_names, name),
2240 ContextBundleView::Found(view) => {
2241 render_context_bundle_found_with_max_tokens(view, verbosity, max_tokens)
2242 }
2243 }
2244}
2245
2246fn render_context_bundle_found_with_max_tokens(
2247 view: &ContextBundleFoundView,
2248 verbosity: &str,
2249 max_tokens: Option<u64>,
2250) -> String {
2251 let body = apply_verbosity(&view.body, verbosity);
2252 let mut output = format!(
2253 "{}\n[{}, {}:{}-{}, {} bytes]\n",
2254 body,
2255 view.kind_label,
2256 view.file_path,
2257 view.line_range.0 + 1,
2258 view.line_range.1 + 1,
2259 view.byte_count
2260 );
2261 output.push_str(&format_context_bundle_section("Callers", &view.callers));
2262 output.push_str(&format_context_bundle_section("Callees", &view.callees));
2263 output.push_str(&format_context_bundle_section(
2264 "Type usages",
2265 &view.type_usages,
2266 ));
2267 match max_tokens {
2268 Some(max_tokens) => {
2269 let max_bytes = (max_tokens as usize).saturating_mul(4);
2270 if max_bytes > 0 && output.len() > max_bytes {
2271 let mut truncated = truncate_text_at_line_boundary(&output, max_bytes);
2272 truncated.push_str(&format_bundle_truncation_notice(max_tokens, None));
2273 if !view.implementation_suggestions.is_empty() {
2274 truncated.push_str(&format_impl_block_suggestions(view));
2275 }
2276 return truncated;
2277 }
2278
2279 let (dep_text, omitted) =
2280 format_type_dependencies_with_budget(&view.dependencies, max_bytes, output.len());
2281 output.push_str(&dep_text);
2282 if omitted > 0 {
2283 output.push_str(&format_bundle_truncation_notice(max_tokens, Some(omitted)));
2284 }
2285 }
2286 None => {
2287 if !view.dependencies.is_empty() {
2288 output.push_str(&format_type_dependencies(&view.dependencies));
2289 }
2290 }
2291 }
2292 if !view.implementation_suggestions.is_empty() {
2293 output.push_str(&format_impl_block_suggestions(view));
2294 }
2295 output
2296}
2297
2298pub fn trace_symbol_result_view(
2300 view: &crate::live_index::TraceSymbolView,
2301 name: &str,
2302 verbosity: &str,
2303) -> String {
2304 match view {
2305 crate::live_index::TraceSymbolView::FileNotFound { path } => not_found_file(path),
2306 crate::live_index::TraceSymbolView::AmbiguousSymbol {
2307 path,
2308 name,
2309 candidate_lines,
2310 } => format!(
2311 "Ambiguous symbol selector for {name} in {path}; pass `symbol_line` to disambiguate. Candidates: {}",
2312 candidate_lines
2313 .iter()
2314 .map(u32::to_string)
2315 .collect::<Vec<_>>()
2316 .join(", ")
2317 ),
2318 crate::live_index::TraceSymbolView::SymbolNotFound {
2319 relative_path,
2320 symbol_names,
2321 name,
2322 } => not_found_symbol_names(relative_path, symbol_names, name),
2323 crate::live_index::TraceSymbolView::Found(found) => {
2324 let mut output =
2325 render_context_bundle_found_with_max_tokens(&found.context_bundle, verbosity, None);
2326
2327 if !found.siblings.is_empty() {
2328 output.push_str(&format_siblings(&found.siblings, 0));
2329 }
2330
2331 if !found.dependents.files.is_empty() {
2332 output.push_str("\n\n");
2333 let dependents_fn = if verbosity == "full" {
2334 find_dependents_result_view
2335 } else {
2336 find_dependents_compact_view
2337 };
2338 output.push_str(&dependents_fn(
2339 &found.dependents,
2340 &found.context_bundle.file_path,
2341 &OutputLimits::default(),
2342 ));
2343 }
2344
2345 if !found.implementations.entries.is_empty() {
2346 output.push_str("\n\n");
2347 output.push_str(&find_implementations_result_view(
2348 &found.implementations,
2349 name,
2350 &OutputLimits::default(),
2351 ));
2352 }
2353
2354 if let Some(git) = &found.git_activity {
2355 output.push_str(&format_trace_git_activity(git));
2356 }
2357
2358 output
2359 }
2360 }
2361}
2362
2363fn format_siblings(siblings: &[crate::live_index::SiblingSymbolView], overflow: usize) -> String {
2364 let mut lines = vec!["\nNearby siblings:".to_string()];
2365 for sib in siblings {
2366 lines.push(format!(
2367 " {:<12} {:<30} {}-{}",
2368 sib.kind_label, sib.name, sib.line_range.0, sib.line_range.1
2369 ));
2370 }
2371 if overflow > 0 {
2372 lines.push(format!(" ... and {overflow} more siblings"));
2373 }
2374 lines.join("\n")
2375}
2376
2377fn format_trace_git_activity(git: &crate::live_index::GitActivityView) -> String {
2378 let mut lines = vec![String::new()];
2379 lines.push(format!(
2380 "Git activity: {} {:.2} ({}) {} commits, last {}",
2381 git.churn_bar, git.churn_score, git.churn_label, git.commit_count, git.last_relative,
2382 ));
2383 lines.push(format!(
2384 " Last: {} \"{}\" ({}, {})",
2385 git.last_hash, git.last_message, git.last_author, git.last_timestamp,
2386 ));
2387 if !git.owners.is_empty() {
2388 lines.push(format!(" Owners: {}", git.owners.join(", ")));
2389 }
2390 if !git.co_changes.is_empty() {
2391 lines.push(" Co-changes:".to_string());
2392 for (path, coupling, shared) in &git.co_changes {
2393 lines.push(format!(
2394 " {} ({:.2} coupling, {} shared commits)",
2395 path, coupling, shared,
2396 ));
2397 }
2398 }
2399 lines.join("\n")
2400}
2401
2402pub fn inspect_match_result_view(view: &InspectMatchView) -> String {
2404 match view {
2405 InspectMatchView::FileNotFound { path } => not_found_file(path),
2406 InspectMatchView::LineOutOfBounds {
2407 path,
2408 line,
2409 total_lines,
2410 } => {
2411 format!("Line {line} is out of bounds for {path} (file has {total_lines} lines).")
2412 }
2413 InspectMatchView::Found(found) => {
2414 let mut output = String::new();
2415
2416 output.push_str(&found.excerpt);
2418 output.push('\n');
2419
2420 if found.parent_chain.len() > 1 {
2422 output.push_str("\nScope: ");
2423 let chain: Vec<String> = found
2424 .parent_chain
2425 .iter()
2426 .map(|p| format!("{} {}", p.kind_label, p.name))
2427 .collect();
2428 output.push_str(&chain.join(" → "));
2429 }
2430
2431 if let Some(enclosing) = &found.enclosing {
2433 output.push_str(&format_enclosing(enclosing));
2434 } else {
2435 output.push_str("\n(No enclosing symbol)");
2436 }
2437
2438 if !found.siblings.is_empty() || found.siblings_overflow > 0 {
2440 output.push_str(&format_siblings(&found.siblings, found.siblings_overflow));
2441 }
2442
2443 output
2444 }
2445 }
2446}
2447
2448fn format_enclosing(enclosing: &crate::live_index::EnclosingSymbolView) -> String {
2449 format!(
2450 "\nEnclosing symbol: {} {} (lines {}-{})",
2451 enclosing.kind_label, enclosing.name, enclosing.line_range.0, enclosing.line_range.1
2452 )
2453}
2454
2455fn format_context_bundle_section(title: &str, section: &ContextBundleSectionView) -> String {
2456 let has_dedup = section.entries.iter().any(|e| e.occurrence_count > 1);
2458
2459 let header =
2460 if has_dedup && section.unique_count > 0 && section.unique_count < section.total_count {
2461 format!(
2462 "\n{title} ({} total, {} unique):",
2463 section.total_count, section.unique_count
2464 )
2465 } else {
2466 format!("\n{title} ({}):", section.total_count)
2467 };
2468
2469 let mut lines = vec![header];
2470
2471 let mut external_count = 0usize;
2472
2473 for entry in §ion.entries {
2474 if is_external_symbol(&entry.display_name, &entry.file_path) {
2475 external_count += 1;
2476 }
2477
2478 let name_part = if entry.occurrence_count > 1 {
2480 format!("{} (×{})", entry.display_name, entry.occurrence_count)
2481 } else {
2482 entry.display_name.clone()
2483 };
2484
2485 if let Some(enclosing) = &entry.enclosing {
2486 lines.push(format!(
2487 " {:<30} {}:{} {}",
2488 name_part, entry.file_path, entry.line_number, enclosing
2489 ));
2490 } else {
2491 lines.push(format!(
2492 " {:<30} {}:{}",
2493 name_part, entry.file_path, entry.line_number
2494 ));
2495 }
2496 }
2497
2498 if section.overflow_count > 0 {
2499 let shown = section.entries.len();
2501 let est_external = if shown > 0 {
2502 (external_count as f64 / shown as f64 * section.overflow_count as f64).round() as usize
2503 } else {
2504 0
2505 };
2506 let est_project = section.overflow_count.saturating_sub(est_external);
2507 if has_dedup {
2508 lines.push(format!(
2510 " ...and {} more unique {}",
2511 section.overflow_count,
2512 title.to_lowercase()
2513 ));
2514 } else if est_external > 0 {
2515 lines.push(format!(
2516 " ...and {} more {} ({} project, ~{} stdlib/framework)",
2517 section.overflow_count,
2518 title.to_lowercase(),
2519 est_project,
2520 est_external
2521 ));
2522 } else {
2523 lines.push(format!(
2524 " ...and {} more {}",
2525 section.overflow_count,
2526 title.to_lowercase()
2527 ));
2528 }
2529 }
2530
2531 lines.join("\n")
2532}
2533
2534fn is_external_symbol(name: &str, file_path: &str) -> bool {
2536 if file_path.is_empty() {
2538 return true;
2539 }
2540 let external_prefixes = [
2542 "std::",
2543 "core::",
2544 "alloc::",
2545 "System.",
2546 "Microsoft.",
2547 "java.",
2548 "javax.",
2549 "kotlin.",
2550 "android.",
2551 "console.",
2552 "JSON.",
2553 "Math.",
2554 "Object.",
2555 "Array.",
2556 "String.",
2557 "Promise.",
2558 "Map.",
2559 "Set.",
2560 "Error.",
2561 ];
2562 for prefix in &external_prefixes {
2563 if name.starts_with(prefix) {
2564 return true;
2565 }
2566 }
2567 let common_builtins = [
2569 "println",
2570 "print",
2571 "eprintln",
2572 "format",
2573 "vec",
2574 "to_string",
2575 "clone",
2576 "unwrap",
2577 "expect",
2578 "push",
2579 "pop",
2580 "len",
2581 "is_empty",
2582 "iter",
2583 "map",
2584 "filter",
2585 "collect",
2586 "into",
2587 "from",
2588 "default",
2589 "new",
2590 "Add",
2591 "Sub",
2592 "Display",
2593 "Debug",
2594 "ToString",
2595 "log",
2596 "warn",
2597 "error",
2598 "info",
2599 "LogWarning",
2600 "LogError",
2601 "LogInformation",
2602 "Console",
2603 ];
2604 common_builtins.contains(&name)
2605}
2606
2607fn extract_signature(body: &str) -> String {
2615 let mut sig_lines: Vec<&str> = Vec::new();
2616 let mut in_sig = false;
2617
2618 for line in body.lines() {
2619 let trimmed = line.trim();
2620
2621 if !in_sig {
2622 if trimmed.is_empty()
2624 || trimmed.starts_with("///")
2625 || trimmed.starts_with("//!")
2626 || trimmed.starts_with("//")
2627 || trimmed.starts_with("/**")
2628 || trimmed.starts_with("/*")
2629 || trimmed.starts_with('*')
2630 || trimmed.starts_with('#')
2631 {
2632 continue;
2633 }
2634 in_sig = true;
2635 }
2636
2637 sig_lines.push(trimmed);
2638
2639 if trimmed.ends_with('{')
2645 || trimmed.ends_with("where")
2646 || trimmed.ends_with(';')
2647 || trimmed == "{"
2648 {
2649 break;
2650 }
2651 if sig_lines.len() >= 10 {
2655 break;
2656 }
2657 }
2658
2659 if sig_lines.is_empty() {
2660 return body.lines().next().unwrap_or("").to_string();
2661 }
2662
2663 let joined = sig_lines.join(" ");
2665 let result = joined
2668 .trim_end_matches(" {")
2669 .trim_end_matches('{')
2670 .trim_end_matches(';')
2671 .trim();
2672 result.to_string()
2673}
2674
2675fn extract_first_doc_line(body: &str) -> Option<String> {
2680 for line in body.lines() {
2681 let trimmed = line.trim();
2682 if trimmed.is_empty() {
2683 continue;
2684 }
2685 if let Some(rest) = trimmed.strip_prefix("///") {
2687 let doc = rest.trim();
2688 if !doc.is_empty() {
2689 return Some(doc.to_string());
2690 }
2691 }
2692 if let Some(rest) = trimmed.strip_prefix("//!") {
2694 let doc = rest.trim();
2695 if !doc.is_empty() {
2696 return Some(doc.to_string());
2697 }
2698 }
2699 if let Some(rest) = trimmed.strip_prefix("/**") {
2701 let doc = rest.trim_end_matches("*/").trim();
2702 if !doc.is_empty() {
2703 return Some(doc.to_string());
2704 }
2705 }
2706 if trimmed.starts_with("/// <summary>") || trimmed.starts_with("/// <remarks>") {
2708 continue; }
2710 if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
2712 let doc = trimmed
2713 .trim_start_matches("\"\"\"")
2714 .trim_start_matches("'''")
2715 .trim_end_matches("\"\"\"")
2716 .trim_end_matches("'''")
2717 .trim();
2718 if !doc.is_empty() {
2719 return Some(doc.to_string());
2720 }
2721 }
2722 if !trimmed.starts_with("//")
2724 && !trimmed.starts_with("/*")
2725 && !trimmed.starts_with('*')
2726 && !trimmed.starts_with('#')
2727 {
2728 break;
2729 }
2730 }
2731 None
2732}
2733
2734pub(crate) fn apply_verbosity(body: &str, verbosity: &str) -> String {
2740 match verbosity {
2741 "signature" => extract_signature(body),
2742 "compact" => {
2743 let sig = extract_signature(body);
2744 if let Some(doc) = extract_first_doc_line(body) {
2745 format!("{sig}\n // {doc}")
2746 } else {
2747 sig
2748 }
2749 }
2750 _ => body.to_string(),
2751 }
2752}
2753
2754fn format_type_dependencies(deps: &[TypeDependencyView]) -> String {
2755 let mut output = format!("\nDependencies ({}):", deps.len());
2756 for dep in deps {
2757 output.push_str(&format_type_dependency(dep));
2758 }
2759 output
2760}
2761
2762fn format_type_dependencies_with_budget(
2763 deps: &[TypeDependencyView],
2764 max_bytes: usize,
2765 base_len: usize,
2766) -> (String, usize) {
2767 if deps.is_empty() || max_bytes == 0 {
2768 return (String::new(), 0);
2769 }
2770
2771 let mut rendered = String::new();
2772 let header = format!("\nDependencies ({}):", deps.len());
2773 let mut header_added = false;
2774 let mut included = 0usize;
2775
2776 for dep in deps {
2777 let dep_block = format_type_dependency(dep);
2778 let header_cost = if header_added { 0 } else { header.len() };
2779 if base_len + rendered.len() + header_cost + dep_block.len() > max_bytes {
2780 break;
2781 }
2782 if !header_added {
2783 rendered.push_str(&header);
2784 header_added = true;
2785 }
2786 rendered.push_str(&dep_block);
2787 included += 1;
2788 }
2789
2790 if included == deps.len() {
2791 return (rendered, 0);
2792 }
2793 if included == 0 {
2794 return (String::new(), deps.len());
2795 }
2796 (rendered, deps.len().saturating_sub(included))
2797}
2798
2799fn format_type_dependency(dep: &TypeDependencyView) -> String {
2800 let depth_marker = if dep.depth > 0 {
2801 format!(" (depth {})", dep.depth)
2802 } else {
2803 String::new()
2804 };
2805 format!(
2806 "\n── {} [{}, {}:{}-{}{}] ──\n{}",
2807 dep.name,
2808 dep.kind_label,
2809 dep.file_path,
2810 dep.line_range.0 + 1,
2811 dep.line_range.1 + 1,
2812 depth_marker,
2813 dep.body
2814 )
2815}
2816
2817fn format_impl_block_suggestions(view: &ContextBundleFoundView) -> String {
2818 let is_type_definition = matches!(view.kind_label.as_str(), "struct" | "enum");
2819 if !is_type_definition
2820 || view.callers.total_count != 0
2821 || view.implementation_suggestions.is_empty()
2822 {
2823 return String::new();
2824 }
2825
2826 let mut output = format!(
2827 "\nTip: This {} has 0 direct callers. Try `get_symbol_context` on one of its impl blocks:",
2828 view.kind_label
2829 );
2830 for suggestion in &view.implementation_suggestions {
2831 output.push_str(&format_impl_block_suggestion(suggestion));
2832 }
2833 output.push('\n');
2834 output
2835}
2836
2837fn format_impl_block_suggestion(suggestion: &ImplBlockSuggestionView) -> String {
2838 format!(
2839 "\n- {} ({}:{})",
2840 suggestion.display_name, suggestion.file_path, suggestion.line_number
2841 )
2842}
2843
2844fn format_bundle_truncation_notice(max_tokens: u64, omitted_dependencies: Option<usize>) -> String {
2845 match omitted_dependencies {
2846 Some(count) => format!(
2847 "\nTruncated at ~{max_tokens} tokens. {count} additional type dependencies not shown.\n"
2848 ),
2849 None => format!("\nTruncated at ~{max_tokens} tokens.\n"),
2850 }
2851}
2852
2853pub fn enforce_token_budget(output: String, max_tokens: Option<u64>) -> String {
2859 let max_tokens = match max_tokens {
2860 Some(t) if t > 0 => t,
2861 _ => return output,
2862 };
2863 let max_bytes = (max_tokens as usize).saturating_mul(4);
2864 if output.len() <= max_bytes {
2865 return output;
2866 }
2867 let actual_tokens_est = output.len() / 4;
2868 let mut truncated = truncate_text_at_line_boundary(&output, max_bytes);
2869 truncated.push_str(&format!(
2870 "\n\n[truncated — output is ~{} tokens, budget is {} tokens]\n",
2871 actual_tokens_est, max_tokens
2872 ));
2873 truncated
2874}
2875
2876fn truncate_text_at_line_boundary(text: &str, max_bytes: usize) -> String {
2877 if text.len() <= max_bytes {
2878 return text.to_string();
2879 }
2880
2881 let mut last_char_end = 0usize;
2882 let mut last_newline_end = None;
2883 for (idx, ch) in text.char_indices() {
2884 let char_end = idx + ch.len_utf8();
2885 if char_end > max_bytes {
2886 break;
2887 }
2888 last_char_end = char_end;
2889 if ch == '\n' {
2890 last_newline_end = Some(char_end);
2891 }
2892 }
2893
2894 let end = last_newline_end.unwrap_or(last_char_end);
2895 text[..end].to_string()
2896}
2897
2898pub fn loading_guard_message() -> String {
2900 "Index is loading... try again shortly.".to_string()
2901}
2902
2903pub fn empty_guard_message() -> String {
2905 "Index not loaded. Call index_folder to index a directory.".to_string()
2906}
2907
2908pub fn format_token_savings(snap: &StatsSnapshot) -> String {
2925 let total_saved = snap.read_saved_tokens + snap.edit_saved_tokens + snap.grep_saved_tokens;
2926
2927 let any_fires =
2929 snap.read_fires > 0 || snap.edit_fires > 0 || snap.write_fires > 0 || snap.grep_fires > 0;
2930
2931 if !any_fires {
2932 return String::new();
2933 }
2934
2935 let mut lines = vec!["── Token Savings (this session) ──".to_string()];
2936
2937 if snap.read_fires > 0 {
2938 lines.push(format!(
2939 "Read: {} fires, ~{} tokens saved",
2940 snap.read_fires, snap.read_saved_tokens
2941 ));
2942 }
2943 if snap.edit_fires > 0 {
2944 lines.push(format!(
2945 "Edit: {} fires, ~{} tokens saved",
2946 snap.edit_fires, snap.edit_saved_tokens
2947 ));
2948 }
2949 if snap.write_fires > 0 {
2950 lines.push(format!("Write: {} fires", snap.write_fires));
2951 }
2952 if snap.grep_fires > 0 {
2953 lines.push(format!(
2954 "Grep: {} fires, ~{} tokens saved",
2955 snap.grep_fires, snap.grep_saved_tokens
2956 ));
2957 }
2958
2959 lines.push(format!("Total: ~{} tokens saved", total_saved));
2960
2961 lines.join("\n")
2962}
2963
2964pub fn format_tool_call_counts(counts: &[(String, usize)]) -> String {
2976 if counts.is_empty() {
2977 return String::new();
2978 }
2979
2980 let mut lines = vec!["── Tool Call Counts (this session) ──".to_string()];
2981 let max_name_len = counts.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
2983 for (name, count) in counts {
2984 lines.push(format!("{:<width$} {}", name, count, width = max_name_len));
2985 }
2986
2987 lines.join("\n")
2988}
2989
2990pub fn compact_savings_footer(response_chars: usize, raw_chars: usize) -> String {
2993 if raw_chars <= response_chars || raw_chars < 200 {
2994 return String::new();
2995 }
2996 let response_tokens = response_chars / 4;
2998 let raw_tokens = raw_chars / 4;
2999 let saved = raw_tokens.saturating_sub(response_tokens);
3000 if saved < 50 {
3001 return String::new();
3002 }
3003 format!("\n\n~{saved} tokens saved vs raw file read")
3004}
3005
3006pub(crate) fn format_hook_adoption(snap: &HookAdoptionSnapshot) -> String {
3008 if snap.is_empty() {
3009 return String::new();
3010 }
3011
3012 let total = snap.total_attempts();
3013 let routed = snap.total_routed();
3014 let percent = if total == 0 {
3015 0
3016 } else {
3017 ((routed as f64 / total as f64) * 100.0).round() as usize
3018 };
3019
3020 let mut lines = vec![
3021 "── Hook Adoption (current session) ──".to_string(),
3022 format!("Owned workflows routed: {routed}/{total} ({percent}%)"),
3023 format!("Fail-open outcomes: {}", snap.total_fail_open()),
3024 ];
3025
3026 let total_daemon = snap.source_read.daemon_fallback
3028 + snap.source_search.daemon_fallback
3029 + snap.repo_start.daemon_fallback
3030 + snap.prompt_context.daemon_fallback
3031 + snap.post_edit_impact.daemon_fallback;
3032 if total_daemon > 0 {
3033 lines.push(format!("Daemon fallback routed: {total_daemon}"));
3034 }
3035
3036 let mut push_workflow_line =
3037 |label: &str, counts: &crate::cli::hook::WorkflowAdoptionCounts| {
3038 if counts.total() == 0 {
3039 return;
3040 }
3041 let mut parts = vec![format!("routed {}", counts.routed)];
3042 if counts.daemon_fallback > 0 {
3043 parts.push(format!("daemon fallback {}", counts.daemon_fallback));
3044 }
3045 if counts.fail_open() > 0 && counts.no_sidecar > 0 {
3046 parts.push(format!("no sidecar {}", counts.no_sidecar));
3047 }
3048 if counts.fail_open() > 0 && counts.sidecar_error > 0 {
3049 parts.push(format!("sidecar errors {}", counts.sidecar_error));
3050 }
3051 lines.push(format!("{label}: {}", parts.join(", ")));
3052 };
3053
3054 push_workflow_line("Source read", &snap.source_read);
3055 push_workflow_line("Source search", &snap.source_search);
3056 push_workflow_line("Repo start", &snap.repo_start);
3057 push_workflow_line("Prompt context", &snap.prompt_context);
3058 push_workflow_line("Post-edit impact", &snap.post_edit_impact);
3059
3060 if let Some(first) = snap.first_repo_start {
3061 lines.push(format!("First repo start: {}", first.label()));
3062 }
3063
3064 if snap.total_fail_open() > 0 && snap.total_routed() == 0 && total_daemon == 0 {
3066 lines.push(String::new());
3067 lines.push("⚠ All hook attempts failed open (no sidecar found).".to_string());
3068 lines.push(" Start SymForge as an MCP server or run 'symforge daemon start'.".to_string());
3069 }
3070
3071 lines.join("\n")
3072}
3073
3074pub fn compact_next_step_hint(items: &[&str]) -> String {
3076 let items: Vec<&str> = items
3077 .iter()
3078 .copied()
3079 .filter(|item| !item.trim().is_empty())
3080 .collect();
3081 if items.is_empty() {
3082 return String::new();
3083 }
3084 format!("\nTip: {}", items.join(" | "))
3085}
3086
3087pub fn git_temporal_health_line(
3089 temporal: &crate::live_index::git_temporal::GitTemporalIndex,
3090) -> String {
3091 use crate::live_index::git_temporal::GitTemporalState;
3092
3093 match &temporal.state {
3094 GitTemporalState::Pending => "Git temporal: pending".to_string(),
3095 GitTemporalState::Computing => "Git temporal: computing...".to_string(),
3096 GitTemporalState::Unavailable(reason) => {
3097 format!("Git temporal: unavailable ({reason})")
3098 }
3099 GitTemporalState::Ready => {
3100 let stats = &temporal.stats;
3101 let mut lines = vec![format!(
3102 "Git temporal: ready ({} commits over {}d, computed in {}ms)",
3103 stats.total_commits_analyzed,
3104 stats.analysis_window_days,
3105 stats.compute_duration.as_millis(),
3106 )];
3107
3108 if !stats.hotspots.is_empty() {
3109 let top: Vec<String> = stats
3110 .hotspots
3111 .iter()
3112 .take(5)
3113 .map(|(path, score)| format!("{path} ({score:.2})"))
3114 .collect();
3115 lines.push(format!(" Hotspots: {}", top.join(", ")));
3116 }
3117
3118 if !stats.most_coupled.is_empty() {
3119 let (a, b, score) = &stats.most_coupled[0];
3120 lines.push(format!(
3121 " Strongest coupling: {a} \u{2194} {b} ({score:.2})"
3122 ));
3123 }
3124
3125 lines.join("\n")
3126 }
3127 }
3128}
3129
3130#[cfg(test)]
3131mod tests {
3132 use super::*;
3133 use crate::domain::{LanguageId, SymbolKind, SymbolRecord};
3134 use crate::live_index::store::{CircuitBreakerState, IndexedFile, LiveIndex, ParseStatus};
3135 use std::collections::HashMap;
3136 use std::time::{Duration, Instant};
3137
3138 fn make_symbol(
3141 name: &str,
3142 kind: SymbolKind,
3143 depth: u32,
3144 line_start: u32,
3145 line_end: u32,
3146 ) -> SymbolRecord {
3147 let byte_range = (0, 10);
3148 SymbolRecord {
3149 name: name.to_string(),
3150 kind,
3151 depth,
3152 sort_order: 0,
3153 byte_range,
3154 item_byte_range: Some(byte_range),
3155 line_range: (line_start, line_end),
3156 doc_byte_range: None,
3157 }
3158 }
3159
3160 fn make_symbol_with_bytes(
3161 name: &str,
3162 kind: SymbolKind,
3163 depth: u32,
3164 line_start: u32,
3165 line_end: u32,
3166 byte_start: u32,
3167 byte_end: u32,
3168 ) -> SymbolRecord {
3169 let byte_range = (byte_start, byte_end);
3170 SymbolRecord {
3171 name: name.to_string(),
3172 kind,
3173 depth,
3174 sort_order: 0,
3175 byte_range,
3176 item_byte_range: Some(byte_range),
3177 line_range: (line_start, line_end),
3178 doc_byte_range: None,
3179 }
3180 }
3181
3182 fn make_file(path: &str, content: &[u8], symbols: Vec<SymbolRecord>) -> (String, IndexedFile) {
3183 (
3184 path.to_string(),
3185 IndexedFile {
3186 relative_path: path.to_string(),
3187 language: LanguageId::Rust,
3188 classification: crate::domain::FileClassification::for_code_path(path),
3189 content: content.to_vec(),
3190 symbols,
3191 parse_status: ParseStatus::Parsed,
3192 parse_diagnostic: None,
3193 byte_len: content.len() as u64,
3194 content_hash: "test".to_string(),
3195 references: vec![],
3196 alias_map: std::collections::HashMap::new(),
3197 mtime_secs: 0,
3198 },
3199 )
3200 }
3201
3202 fn make_index(files: Vec<(String, IndexedFile)>) -> LiveIndex {
3203 use crate::live_index::trigram::TrigramIndex;
3204 let cb = CircuitBreakerState::new(0.20);
3205 let files_map = files
3206 .into_iter()
3207 .map(|(path, file)| (path, std::sync::Arc::new(file)))
3208 .collect::<HashMap<_, _>>();
3209 let trigram_index = TrigramIndex::build_from_files(&files_map);
3210 let mut index = LiveIndex {
3211 files: files_map,
3212 loaded_at: Instant::now(),
3213 loaded_at_system: std::time::SystemTime::now(),
3214 load_duration: Duration::from_millis(42),
3215 cb_state: cb,
3216 is_empty: false,
3217 load_source: crate::live_index::store::IndexLoadSource::FreshLoad,
3218 snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
3219 reverse_index: HashMap::new(),
3220 files_by_basename: HashMap::new(),
3221 files_by_dir_component: HashMap::new(),
3222 trigram_index,
3223 gitignore: None,
3224 skipped_files: Vec::new(),
3225 };
3226 index.rebuild_path_indices();
3227 index
3228 }
3229
3230 fn empty_index() -> LiveIndex {
3231 make_index(vec![])
3232 }
3233
3234 #[test]
3237 fn test_file_outline_header_shows_path_and_count() {
3238 let (key, file) = make_file(
3239 "src/main.rs",
3240 b"fn main() {}",
3241 vec![make_symbol("main", SymbolKind::Function, 0, 1, 1)],
3242 );
3243 let index = make_index(vec![(key, file)]);
3244 let result = file_outline(&index, "src/main.rs");
3245 assert!(
3246 result.starts_with("src/main.rs (1 symbols)"),
3247 "header should show path and count, got: {result}"
3248 );
3249 }
3250
3251 #[test]
3252 fn test_file_outline_symbol_line_with_kind_and_range() {
3253 let (key, file) = make_file(
3254 "src/main.rs",
3255 b"fn main() {}",
3256 vec![make_symbol("main", SymbolKind::Function, 0, 0, 4)],
3257 );
3258 let index = make_index(vec![(key, file)]);
3259 let result = file_outline(&index, "src/main.rs");
3260 assert!(result.contains("fn"), "should contain fn kind");
3261 assert!(result.contains("main"), "should contain symbol name");
3262 assert!(result.contains("1-5"), "should contain 1-based line range");
3263 }
3264
3265 #[test]
3266 fn test_file_outline_depth_indentation() {
3267 let symbols = vec![
3268 make_symbol("MyStruct", SymbolKind::Struct, 0, 1, 10),
3269 make_symbol("my_method", SymbolKind::Method, 1, 2, 5),
3270 ];
3271 let (key, file) = make_file(
3272 "src/lib.rs",
3273 b"struct MyStruct { fn my_method() {} }",
3274 symbols,
3275 );
3276 let index = make_index(vec![(key, file)]);
3277 let result = file_outline(&index, "src/lib.rs");
3278 let lines: Vec<&str> = result.lines().collect();
3279 let method_line = lines.iter().find(|l| l.contains("my_method")).unwrap();
3281 assert!(
3282 method_line.starts_with(" "),
3283 "depth-1 symbol should be indented by 2 spaces"
3284 );
3285 }
3286
3287 #[test]
3288 fn test_file_outline_not_found() {
3289 let index = empty_index();
3290 let result = file_outline(&index, "nonexistent.rs");
3291 assert_eq!(result, "File not found: nonexistent.rs");
3292 }
3293
3294 #[test]
3295 fn test_file_outline_empty_symbols() {
3296 let (key, file) = make_file("src/main.rs", b"", vec![]);
3297 let index = make_index(vec![(key, file)]);
3298 let result = file_outline(&index, "src/main.rs");
3299 assert!(result.contains("(0 symbols)"), "should show 0 symbols");
3300 }
3301
3302 #[test]
3303 fn test_file_outline_view_matches_live_index_output() {
3304 let (key, file) = make_file(
3305 "src/main.rs",
3306 b"fn main() {}",
3307 vec![make_symbol("main", SymbolKind::Function, 0, 1, 5)],
3308 );
3309 let index = make_index(vec![(key, file)]);
3310
3311 let live_result = file_outline(&index, "src/main.rs");
3312 let captured_result =
3313 file_outline_view(&index.capture_file_outline_view("src/main.rs").unwrap());
3314
3315 assert_eq!(captured_result, live_result);
3316 }
3317
3318 #[test]
3321 fn test_symbol_detail_returns_body_and_footer() {
3322 let content = b"fn hello() { println!(\"hi\"); }";
3323 let sym = make_symbol_with_bytes("hello", SymbolKind::Function, 0, 0, 0, 0, 30);
3324 let (key, file) = make_file("src/lib.rs", content, vec![sym]);
3325 let index = make_index(vec![(key, file)]);
3326 let result = symbol_detail(&index, "src/lib.rs", "hello", None);
3327 assert!(result.contains("fn hello"), "should contain body");
3328 assert!(
3329 result.contains("[fn, lines 1-1, 30 bytes]"),
3330 "should contain footer (0-based line_range 0-0 displayed as 1-based 1-1)"
3331 );
3332 }
3333
3334 #[test]
3335 fn test_symbol_detail_not_found_lists_available_symbols() {
3336 let sym = make_symbol("real_fn", SymbolKind::Function, 0, 1, 5);
3337 let (key, file) = make_file("src/lib.rs", b"fn real_fn() {}", vec![sym]);
3338 let index = make_index(vec![(key, file)]);
3339 let result = symbol_detail(&index, "src/lib.rs", "missing_fn", None);
3340 assert!(result.contains("No symbol missing_fn in src/lib.rs"));
3341 assert!(result.contains("real_fn"), "should list available symbols");
3342 }
3343
3344 #[test]
3345 fn test_symbol_detail_file_not_found() {
3346 let index = empty_index();
3347 let result = symbol_detail(&index, "nonexistent.rs", "foo", None);
3348 assert_eq!(result, "File not found: nonexistent.rs");
3349 }
3350
3351 #[test]
3352 fn test_symbol_detail_kind_filter_matches() {
3353 let symbols = vec![
3354 make_symbol("foo", SymbolKind::Function, 0, 0, 0),
3355 make_symbol("foo", SymbolKind::Struct, 0, 4, 9),
3356 ];
3357 let content = b"fn foo() {} struct foo {}";
3358 let (key, file) = make_file("src/lib.rs", content, symbols);
3359 let index = make_index(vec![(key, file)]);
3360 let result = symbol_detail(&index, "src/lib.rs", "foo", Some("struct"));
3362 assert!(
3363 result.contains("[struct, lines 5-10"),
3364 "footer should show struct kind"
3365 );
3366 }
3367
3368 #[test]
3369 fn test_symbol_detail_view_matches_live_index_output() {
3370 let content = b"fn hello() { println!(\"hi\"); }";
3371 let sym = make_symbol_with_bytes("hello", SymbolKind::Function, 0, 1, 1, 0, 30);
3372 let (key, file) = make_file("src/lib.rs", content, vec![sym]);
3373 let index = make_index(vec![(key, file)]);
3374
3375 let live_result = symbol_detail(&index, "src/lib.rs", "hello", None);
3376 let captured_result = symbol_detail_view(
3377 &index.capture_symbol_detail_view("src/lib.rs").unwrap(),
3378 "hello",
3379 None,
3380 );
3381
3382 assert_eq!(captured_result, live_result);
3383 }
3384
3385 #[test]
3386 fn test_code_slice_view_formats_path_and_slice_text() {
3387 let result = code_slice_view("src/lib.rs", b"fn foo()");
3388 assert_eq!(result, "src/lib.rs\nfn foo()");
3389 }
3390
3391 #[test]
3392 fn test_code_slice_from_indexed_file_clamps_and_formats() {
3393 let (key, file) = make_file("src/lib.rs", b"fn foo() { bar(); }", vec![]);
3394 let index = make_index(vec![(key, file)]);
3395
3396 let result = code_slice_from_indexed_file(
3397 index.capture_shared_file("src/lib.rs").unwrap().as_ref(),
3398 0,
3399 Some(200),
3400 );
3401
3402 assert_eq!(result, "src/lib.rs\nfn foo() { bar(); }");
3403 }
3404
3405 #[test]
3408 fn test_search_symbols_summary_header() {
3409 let symbols = vec![
3410 make_symbol("get_user", SymbolKind::Function, 0, 1, 5),
3411 make_symbol("get_role", SymbolKind::Function, 0, 6, 10),
3412 ];
3413 let (key, file) = make_file("src/lib.rs", b"fn get_user() {} fn get_role() {}", symbols);
3414 let index = make_index(vec![(key, file)]);
3415 let result = search_symbols_result(&index, "get");
3416 assert!(
3417 result.starts_with("2 matches in 1 files"),
3418 "should start with summary"
3419 );
3420 }
3421
3422 #[test]
3423 fn test_search_symbols_case_insensitive() {
3424 let sym = make_symbol("GetUser", SymbolKind::Function, 0, 1, 5);
3425 let (key, file) = make_file("src/lib.rs", b"fn GetUser() {}", vec![sym]);
3426 let index = make_index(vec![(key, file)]);
3427 let result = search_symbols_result(&index, "getuser");
3428 assert!(
3429 !result.starts_with("No symbols"),
3430 "should find case-insensitive match"
3431 );
3432 }
3433
3434 #[test]
3435 fn test_search_symbols_no_match() {
3436 let sym = make_symbol("unrelated", SymbolKind::Function, 0, 1, 5);
3437 let (key, file) = make_file("src/lib.rs", b"fn unrelated() {}", vec![sym]);
3438 let index = make_index(vec![(key, file)]);
3439 let result = search_symbols_result(&index, "xyz_no_match");
3440 assert_eq!(result, "No symbols matching 'xyz_no_match'");
3441 }
3442
3443 #[test]
3444 fn test_search_symbols_result_view_matches_live_index_output() {
3445 let symbols = vec![
3446 make_symbol("get_user", SymbolKind::Function, 0, 1, 5),
3447 make_symbol("get_role", SymbolKind::Function, 0, 6, 10),
3448 ];
3449 let (key, file) = make_file("src/lib.rs", b"fn get_user() {} fn get_role() {}", symbols);
3450 let index = make_index(vec![(key, file)]);
3451
3452 let live_result = search_symbols_result(&index, "get");
3453 let captured_result =
3454 search_symbols_result_view(&search::search_symbols(&index, "get", None, 50), "get");
3455
3456 assert_eq!(captured_result, live_result);
3457 }
3458
3459 #[test]
3460 fn test_search_symbols_grouped_by_file() {
3461 let sym1 = make_symbol("foo", SymbolKind::Function, 0, 1, 5);
3462 let sym2 = make_symbol("foo_bar", SymbolKind::Function, 0, 1, 5);
3463 let (key1, file1) = make_file("a.rs", b"fn foo() {}", vec![sym1]);
3464 let (key2, file2) = make_file("b.rs", b"fn foo_bar() {}", vec![sym2]);
3465 let index = make_index(vec![(key1, file1), (key2, file2)]);
3466 let result = search_symbols_result(&index, "foo");
3467 assert!(
3468 result.contains("2 matches in 2 files"),
3469 "should show 2 files"
3470 );
3471 assert!(result.contains("a.rs"), "should contain file a.rs");
3472 assert!(result.contains("b.rs"), "should contain file b.rs");
3473 }
3474
3475 #[test]
3476 fn test_search_symbols_kind_filter_limits_results() {
3477 let function = make_symbol("JobRunner", SymbolKind::Function, 0, 1, 5);
3478 let class = make_symbol("Job", SymbolKind::Class, 0, 6, 10);
3479 let (key, file) = make_file(
3480 "src/lib.rs",
3481 b"fn JobRunner() {} struct Job {}",
3482 vec![function, class],
3483 );
3484 let index = make_index(vec![(key, file)]);
3485 let result = search_symbols_result_with_kind(&index, "job", Some("class"));
3486 assert!(
3487 result.contains("class Job"),
3488 "class result should remain visible: {result}"
3489 );
3490 assert!(
3491 !result.contains("fn JobRunner"),
3492 "function result should be filtered out: {result}"
3493 );
3494 }
3495
3496 #[test]
3499 fn test_search_text_summary_header() {
3500 let (key, file) = make_file("src/lib.rs", b"let x = 1;\nlet y = 2;", vec![]);
3501 let index = make_index(vec![(key, file)]);
3502 let result = search_text_result(&index, "let");
3503 assert!(result.starts_with("2 matches in 1 files"), "got: {result}");
3504 }
3505
3506 #[test]
3507 fn test_search_text_shows_line_numbers() {
3508 let content = b"line one\nline two\nline three";
3509 let (key, file) = make_file("src/lib.rs", content, vec![]);
3510 let index = make_index(vec![(key, file)]);
3511 let result = search_text_result(&index, "line two");
3512 assert!(
3513 result.contains(" 2:"),
3514 "should show 1-indexed line number 2"
3515 );
3516 }
3517
3518 #[test]
3519 fn test_search_text_case_insensitive() {
3520 let (key, file) = make_file("src/lib.rs", b"Hello World", vec![]);
3521 let index = make_index(vec![(key, file)]);
3522 let result = search_text_result(&index, "hello world");
3523 assert!(
3524 !result.starts_with("No matches"),
3525 "should find case-insensitive"
3526 );
3527 }
3528
3529 #[test]
3530 fn test_search_text_no_match() {
3531 let (key, file) = make_file("src/lib.rs", b"fn main() {}", vec![]);
3532 let index = make_index(vec![(key, file)]);
3533 let result = search_text_result(&index, "xyz_totally_absent");
3534 assert_eq!(result, "No matches for 'xyz_totally_absent'");
3535 }
3536
3537 #[test]
3538 fn test_search_text_result_view_matches_live_index_output() {
3539 let (key, file) = make_file("src/lib.rs", b"let x = 1;\nlet y = 2;", vec![]);
3540 let index = make_index(vec![(key, file)]);
3541
3542 let live_result = search_text_result(&index, "let");
3543 let captured_result = search_text_result_view(
3544 search::search_text(&index, Some("let"), None, false),
3545 None,
3546 None,
3547 );
3548
3549 assert_eq!(captured_result, live_result);
3550 }
3551
3552 #[test]
3553 fn test_search_text_crlf_handling() {
3554 let content = b"fn foo() {\r\n let x = 1;\r\n}";
3555 let (key, file) = make_file("src/lib.rs", content, vec![]);
3556 let index = make_index(vec![(key, file)]);
3557 let result = search_text_result(&index, "let x");
3558 assert!(
3559 result.contains("let x = 1"),
3560 "should find content without \\r"
3561 );
3562 }
3563
3564 #[test]
3565 fn test_search_text_terms_or_matches_multiple_needles() {
3566 let (key, file) = make_file(
3567 "src/lib.rs",
3568 b"// TODO: first\n// FIXME: second\n// NOTE: ignored",
3569 vec![],
3570 );
3571 let index = make_index(vec![(key, file)]);
3572 let terms = vec!["TODO".to_string(), "FIXME".to_string()];
3573 let result = search_text_result_with_options(&index, None, Some(&terms), false);
3574 assert!(
3575 result.contains("TODO: first"),
3576 "TODO line should match: {result}"
3577 );
3578 assert!(
3579 result.contains("FIXME: second"),
3580 "FIXME line should match: {result}"
3581 );
3582 assert!(
3583 !result.contains("NOTE: ignored"),
3584 "non-matching line should be absent: {result}"
3585 );
3586 }
3587
3588 #[test]
3589 fn test_search_text_regex_mode_matches_pattern() {
3590 let (key, file) = make_file(
3591 "src/lib.rs",
3592 b"// TODO: first\n// FIXME: second\n// NOTE: ignored",
3593 vec![],
3594 );
3595 let index = make_index(vec![(key, file)]);
3596 let result = search_text_result_with_options(&index, Some("TODO|FIXME"), None, true);
3597 assert!(
3598 result.contains("TODO: first"),
3599 "TODO line should match regex: {result}"
3600 );
3601 assert!(
3602 result.contains("FIXME: second"),
3603 "FIXME line should match regex: {result}"
3604 );
3605 assert!(
3606 !result.contains("NOTE: ignored"),
3607 "non-matching line should be absent: {result}"
3608 );
3609 }
3610
3611 #[test]
3612 fn test_search_text_result_view_renders_context_windows_with_separators() {
3613 let (key, file) = make_file(
3614 "src/lib.rs",
3615 b"line 1\nline 2\nneedle 3\nline 4\nneedle 5\nline 6\nline 7\nline 8\nneedle 9\nline 10\n",
3616 vec![],
3617 );
3618 let index = make_index(vec![(key, file)]);
3619 let result = search::search_text_with_options(
3620 &index,
3621 Some("needle"),
3622 None,
3623 false,
3624 &search::TextSearchOptions {
3625 context: Some(1),
3626 ..search::TextSearchOptions::for_current_code_search()
3627 },
3628 );
3629
3630 let rendered = search_text_result_view(result, None, None);
3631
3632 assert!(
3633 rendered.contains("src/lib.rs"),
3634 "file header missing: {rendered}"
3635 );
3636 assert!(
3637 rendered.contains(" 2: line 2"),
3638 "context line missing: {rendered}"
3639 );
3640 assert!(
3641 rendered.contains("> 3: needle 3"),
3642 "match marker missing: {rendered}"
3643 );
3644 assert!(
3645 rendered.contains(" ..."),
3646 "window separator missing: {rendered}"
3647 );
3648 assert!(
3649 rendered.contains("> 9: needle 9"),
3650 "later match missing: {rendered}"
3651 );
3652 }
3653
3654 #[test]
3657 fn test_repo_outline_header_totals() {
3658 let sym = make_symbol("main", SymbolKind::Function, 0, 1, 5);
3659 let (key, file) = make_file("src/main.rs", b"fn main() {}", vec![sym]);
3660 let index = make_index(vec![(key, file)]);
3661 let result = repo_outline(&index, "myproject");
3662 assert!(
3663 result.starts_with("myproject (1 files, 1 symbols)"),
3664 "got: {result}"
3665 );
3666 }
3667
3668 #[test]
3669 fn test_repo_outline_shows_filename_language_count() {
3670 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3671 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
3672 let index = make_index(vec![(key, file)]);
3673 let result = repo_outline(&index, "proj");
3674 assert!(result.contains("lib.rs"), "should show filename");
3675 assert!(result.contains("Rust"), "should show language");
3676 assert!(result.contains("1 symbols"), "should show symbol count");
3677 }
3678
3679 #[test]
3680 fn test_repo_outline_repeated_basenames_use_shortest_unique_suffixes() {
3681 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3682 let index = make_index(vec![
3683 make_file("src/live_index/mod.rs", b"fn foo() {}", vec![sym.clone()]),
3684 make_file("src/protocol/mod.rs", b"fn foo() {}", vec![sym.clone()]),
3685 make_file("src/parsing/languages/mod.rs", b"fn foo() {}", vec![sym]),
3686 ]);
3687
3688 let result = repo_outline(&index, "proj");
3689
3690 assert!(result.contains("live_index/mod.rs"), "got: {result}");
3691 assert!(result.contains("protocol/mod.rs"), "got: {result}");
3692 assert!(result.contains("languages/mod.rs"), "got: {result}");
3693 assert!(!result.contains("\n mod.rs"), "got: {result}");
3694 }
3695
3696 #[test]
3697 fn test_repo_outline_deeper_collisions_expand_beyond_one_parent() {
3698 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3699 let index = make_index(vec![
3700 make_file("src/alpha/shared/mod.rs", b"fn foo() {}", vec![sym.clone()]),
3701 make_file("tests/beta/shared/mod.rs", b"fn foo() {}", vec![sym]),
3702 ]);
3703
3704 let result = repo_outline(&index, "proj");
3705
3706 assert!(result.contains("alpha/shared/mod.rs"), "got: {result}");
3707 assert!(result.contains("beta/shared/mod.rs"), "got: {result}");
3708 }
3709
3710 #[test]
3711 fn test_repo_outline_view_matches_live_index_output() {
3712 let alpha = make_symbol("alpha", SymbolKind::Function, 0, 1, 3);
3713 let beta = make_symbol("beta", SymbolKind::Function, 0, 5, 7);
3714 let (k1, f1) = make_file("src/zeta.rs", b"fn beta() {}", vec![beta]);
3715 let (k2, f2) = make_file("src/alpha.rs", b"fn alpha() {}", vec![alpha]);
3716 let index = make_index(vec![(k1, f1), (k2, f2)]);
3717
3718 let live_result = repo_outline(&index, "proj");
3719 let captured_result = repo_outline_view(&index.capture_repo_outline_view(), "proj");
3720
3721 assert_eq!(captured_result, live_result);
3722 }
3723
3724 #[test]
3727 fn test_health_report_ready_state() {
3728 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3729 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
3730 let index = make_index(vec![(key, file)]);
3731 let result = health_report(&index);
3732 assert!(result.contains("Status: Ready"), "got: {result}");
3733 assert!(result.contains("Files:"), "should have Files line");
3734 assert!(result.contains("Symbols:"), "should have Symbols line");
3735 assert!(result.contains("Loaded in:"), "should have Loaded in line");
3736 assert!(
3737 result.contains("Watcher: off"),
3738 "should have Watcher: off line (no watcher active)"
3739 );
3740 }
3741
3742 #[test]
3743 fn test_health_report_empty_state() {
3744 let index = LiveIndex {
3745 files: HashMap::new(),
3746 loaded_at: Instant::now(),
3747 loaded_at_system: std::time::SystemTime::now(),
3748 load_duration: Duration::from_millis(0),
3749 cb_state: CircuitBreakerState::new(0.20),
3750 is_empty: true,
3751 load_source: crate::live_index::store::IndexLoadSource::EmptyBootstrap,
3752 snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
3753 reverse_index: HashMap::new(),
3754 files_by_basename: HashMap::new(),
3755 files_by_dir_component: HashMap::new(),
3756 trigram_index: crate::live_index::trigram::TrigramIndex::new(),
3757 gitignore: None,
3758 skipped_files: Vec::new(),
3759 };
3760 let result = health_report(&index);
3761 assert!(result.contains("Status: Empty"), "got: {result}");
3762 }
3763
3764 #[test]
3765 fn test_health_report_shows_watcher_off() {
3766 let index = make_index(vec![]);
3768 let result = health_report(&index);
3769 assert!(result.contains("Watcher: off"), "got: {result}");
3770 assert!(
3771 !result.contains("events"),
3772 "off watcher should not mention events"
3773 );
3774 }
3775
3776 #[test]
3777 fn test_health_report_shows_watcher_active() {
3778 use crate::watcher::{WatcherInfo, WatcherState};
3779 let index = make_index(vec![]);
3784 let watcher = WatcherInfo {
3785 state: WatcherState::Active,
3786 events_processed: 7,
3787 last_event_at: None,
3788 debounce_window_ms: 200,
3789 ..WatcherInfo::default()
3790 };
3791 let stats = index.health_stats_with_watcher(&watcher);
3792 assert_eq!(stats.watcher_state, WatcherState::Active);
3793 assert_eq!(stats.events_processed, 7);
3794 assert_eq!(stats.overflow_count, 0);
3795 }
3796
3797 #[test]
3798 fn test_health_report_from_stats_matches_live_index_output() {
3799 use crate::watcher::{WatcherInfo, WatcherState};
3800
3801 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3802 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
3803 let index = make_index(vec![(key, file)]);
3804 let watcher = WatcherInfo {
3805 state: WatcherState::Active,
3806 events_processed: 7,
3807 last_event_at: None,
3808 debounce_window_ms: 200,
3809 ..WatcherInfo::default()
3810 };
3811
3812 let live_result = health_report_with_watcher(&index, &watcher);
3813 let captured_result =
3814 health_report_from_stats("Ready", &index.health_stats_with_watcher(&watcher));
3815
3816 assert_eq!(captured_result, live_result);
3817 }
3818
3819 #[test]
3820 fn test_health_report_from_published_state_matches_live_index_output() {
3821 use crate::watcher::{WatcherInfo, WatcherState};
3822
3823 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
3824 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
3825 let index = make_index(vec![(key, file)]);
3826 let watcher = WatcherInfo {
3827 state: WatcherState::Active,
3828 events_processed: 7,
3829 last_event_at: None,
3830 debounce_window_ms: 200,
3831 ..WatcherInfo::default()
3832 };
3833
3834 let live_result = health_report_with_watcher(&index, &watcher);
3835 let shared = crate::live_index::SharedIndexHandle::shared(index);
3836 let captured_result =
3837 health_report_from_published_state(&shared.published_state(), &watcher);
3838
3839 assert_eq!(captured_result, live_result);
3840 }
3841
3842 #[test]
3843 fn test_health_report_lists_partial_parse_files() {
3844 use crate::watcher::WatcherState;
3845 use std::time::Duration;
3846
3847 let stats = HealthStats {
3848 file_count: 3,
3849 symbol_count: 0,
3850 parsed_count: 0,
3851 partial_parse_count: 3,
3852 failed_count: 0,
3853 load_duration: Duration::from_millis(0),
3854 watcher_state: WatcherState::Off,
3855 events_processed: 0,
3856 last_event_at: None,
3857 debounce_window_ms: 200,
3858 overflow_count: 0,
3859 last_overflow_at: None,
3860 stale_files_found: 0,
3861 last_reconcile_at: None,
3862 partial_parse_files: vec![
3863 "src/a.rs".to_string(),
3864 "src/b.rs".to_string(),
3865 "src/c.rs".to_string(),
3866 ],
3867 failed_files: vec![],
3868 tier_counts: (3, 0, 0),
3869 };
3870 let report = health_report_from_stats("Ready", &stats);
3871 assert!(
3872 report.contains("Partial parse files (3):"),
3873 "should contain header"
3874 );
3875 assert!(report.contains(" 1. src/a.rs"), "should list first file");
3876 assert!(report.contains(" 2. src/b.rs"), "should list second file");
3877 assert!(report.contains(" 3. src/c.rs"), "should list third file");
3878 assert!(
3879 !report.contains("... and"),
3880 "should not show overflow hint for 3 files"
3881 );
3882 }
3883
3884 #[test]
3885 fn test_health_report_caps_partial_list_at_10() {
3886 use crate::watcher::WatcherState;
3887 use std::time::Duration;
3888
3889 let partial_parse_files: Vec<String> =
3890 (1..=50).map(|i| format!("src/file{:02}.rs", i)).collect();
3891 let stats = HealthStats {
3892 file_count: 50,
3893 symbol_count: 0,
3894 parsed_count: 0,
3895 partial_parse_count: 50,
3896 failed_count: 0,
3897 load_duration: Duration::from_millis(0),
3898 watcher_state: WatcherState::Off,
3899 events_processed: 0,
3900 last_event_at: None,
3901 debounce_window_ms: 200,
3902 overflow_count: 0,
3903 last_overflow_at: None,
3904 stale_files_found: 0,
3905 last_reconcile_at: None,
3906 partial_parse_files,
3907 failed_files: vec![],
3908 tier_counts: (50, 0, 0),
3909 };
3910 let report = health_report_from_stats("Ready", &stats);
3911 assert!(
3912 report.contains("Partial parse files (50):"),
3913 "should show count of 50"
3914 );
3915 assert!(report.contains(" 10."), "should list up to entry 10");
3916 assert!(!report.contains(" 11."), "should not list entry 11");
3917 assert!(
3918 report.contains("... and 40 more partial files"),
3919 "should show overflow hint for 40 remaining"
3920 );
3921 }
3922
3923 #[test]
3924 fn test_health_report_shows_tier_breakdown() {
3925 use crate::watcher::WatcherState;
3926 use std::time::Duration;
3927
3928 let stats = HealthStats {
3929 file_count: 8200,
3930 symbol_count: 10000,
3931 parsed_count: 8180,
3932 partial_parse_count: 15,
3933 failed_count: 5,
3934 load_duration: Duration::from_millis(120),
3935 watcher_state: WatcherState::Off,
3936 events_processed: 0,
3937 last_event_at: None,
3938 debounce_window_ms: 200,
3939 overflow_count: 0,
3940 last_overflow_at: None,
3941 stale_files_found: 0,
3942 last_reconcile_at: None,
3943 partial_parse_files: vec![],
3944 failed_files: vec![],
3945 tier_counts: (8200, 1280, 20),
3946 };
3947 let report = health_report_from_stats("Ready", &stats);
3948 assert!(
3949 report.contains("Admission: 9500 files discovered"),
3950 "should show total discovered count; got:\n{report}"
3951 );
3952 assert!(
3953 report.contains("Tier 1 (indexed): 8200"),
3954 "should show Tier 1 count; got:\n{report}"
3955 );
3956 assert!(
3957 report.contains("Tier 2 (metadata only): 1280"),
3958 "should show Tier 2 count; got:\n{report}"
3959 );
3960 assert!(
3961 report.contains("Tier 3 (hard-skipped): 20"),
3962 "should show Tier 3 count; got:\n{report}"
3963 );
3964 }
3965
3966 #[test]
3967 fn test_health_report_shows_reconciliation_and_overflow_stats() {
3968 use crate::watcher::WatcherState;
3969 use std::time::{Duration, SystemTime};
3970
3971 let stats = HealthStats {
3972 file_count: 1,
3973 symbol_count: 0,
3974 parsed_count: 1,
3975 partial_parse_count: 0,
3976 failed_count: 0,
3977 load_duration: Duration::from_millis(10),
3978 watcher_state: WatcherState::Active,
3979 events_processed: 7,
3980 last_event_at: Some(SystemTime::now()),
3981 debounce_window_ms: 200,
3982 overflow_count: 2,
3983 last_overflow_at: Some(SystemTime::now()),
3984 stale_files_found: 5,
3985 last_reconcile_at: Some(SystemTime::now()),
3986 partial_parse_files: vec![],
3987 failed_files: vec![],
3988 tier_counts: (1, 0, 0),
3989 };
3990
3991 let report = health_report_from_stats("Ready", &stats);
3992 assert!(report.contains("overflows: 2"), "got: {report}");
3993 assert!(report.contains("stale reconciled: 5"), "got: {report}");
3994 assert!(report.contains("last overflow:"), "got: {report}");
3995 assert!(report.contains("last reconcile:"), "got: {report}");
3996 }
3997
3998 #[test]
3999 fn test_around_match_occurrence_selects_requested_match() {
4000 let content = b"line one\nTODO first\nline three\nTODO second\nline five";
4001 let (key, file) = make_file("src/main.rs", content, vec![]);
4002 let index = make_index(vec![(key, file)]);
4003
4004 let result = file_content_from_indexed_file_with_context(
4005 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4006 search::ContentContext::around_match_occurrence("todo", Some(2), Some(1), false, false),
4007 );
4008
4009 assert_eq!(result, "3: line three\n4: TODO second\n5: line five");
4010 }
4011
4012 #[test]
4013 fn test_around_match_occurrence_reports_available_lines() {
4014 let content = b"line one\nTODO first\nline three";
4015 let (key, file) = make_file("src/main.rs", content, vec![]);
4016 let index = make_index(vec![(key, file)]);
4017
4018 let result = file_content_from_indexed_file_with_context(
4019 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4020 search::ContentContext::around_match_occurrence("todo", Some(2), Some(1), false, false),
4021 );
4022
4023 assert_eq!(
4024 result,
4025 "Match occurrence 2 for 'todo' not found in src/main.rs; 1 match(es) available at lines 2"
4026 );
4027 }
4028
4029 #[test]
4032 fn test_what_changed_since_far_past_lists_all_files() {
4033 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
4034 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
4035 let index = make_index(vec![(key, file)]);
4036 let result = what_changed_result(&index, 0);
4038 assert!(
4039 result.contains("src/lib.rs"),
4040 "should list all files: {result}"
4041 );
4042 }
4043
4044 #[test]
4045 fn test_what_changed_since_far_future_returns_no_changes() {
4046 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
4047 let index = make_index(vec![(key, file)]);
4048 let result = what_changed_result(&index, i64::MAX);
4050 assert_eq!(result, "No changes detected since last index load.");
4051 }
4052
4053 #[test]
4054 fn test_what_changed_timestamp_view_matches_live_index_output() {
4055 let (k1, f1) = make_file("src/z.rs", b"fn z() {}", vec![]);
4056 let (k2, f2) = make_file("src/a.rs", b"fn a() {}", vec![]);
4057 let index = make_index(vec![(k1, f1), (k2, f2)]);
4058
4059 let live_result = what_changed_result(&index, 0);
4060 let captured_result =
4061 what_changed_timestamp_view(&index.capture_what_changed_timestamp_view(), 0);
4062
4063 assert_eq!(captured_result, live_result);
4064 }
4065
4066 #[test]
4067 fn test_what_changed_paths_result_sorts_and_deduplicates() {
4068 let result = what_changed_paths_result(
4069 &[
4070 "src\\b.rs".to_string(),
4071 "src/a.rs".to_string(),
4072 "src/a.rs".to_string(),
4073 ],
4074 "No git changes detected.",
4075 );
4076 assert_eq!(result, "src/a.rs\nsrc/b.rs");
4077 }
4078
4079 #[test]
4080 fn test_resolve_path_result_view_returns_exact_path() {
4081 let view = ResolvePathView::Resolved {
4082 path: "src/protocol/tools.rs".to_string(),
4083 };
4084
4085 assert_eq!(resolve_path_result_view(&view), "src/protocol/tools.rs");
4086 }
4087
4088 #[test]
4089 fn test_resolve_path_result_view_formats_ambiguous_output() {
4090 let view = ResolvePathView::Ambiguous {
4091 hint: "lib.rs".to_string(),
4092 matches: vec!["src/lib.rs".to_string(), "tests/lib.rs".to_string()],
4093 overflow_count: 1,
4094 };
4095
4096 let result = resolve_path_result_view(&view);
4097
4098 assert!(result.contains("Ambiguous path hint 'lib.rs' (3 matches)"));
4099 assert!(result.contains(" src/lib.rs"));
4100 assert!(result.contains(" tests/lib.rs"));
4101 assert!(result.contains(" ... and 1 more"));
4102 }
4103
4104 #[test]
4105 fn test_resolve_path_result_view_not_found() {
4106 let view = ResolvePathView::NotFound {
4107 hint: "README.md".to_string(),
4108 };
4109
4110 assert_eq!(
4111 resolve_path_result_view(&view),
4112 "No indexed source path matched 'README.md'"
4113 );
4114 }
4115
4116 #[test]
4117 fn test_search_files_result_view_groups_ranked_paths() {
4118 let view = SearchFilesView::Found {
4119 query: "tools.rs".to_string(),
4120 total_matches: 3,
4121 overflow_count: 1,
4122 hits: vec![
4123 crate::live_index::SearchFilesHit {
4124 tier: SearchFilesTier::StrongPath,
4125 path: "src/protocol/tools.rs".to_string(),
4126 coupling_score: None,
4127 shared_commits: None,
4128 },
4129 crate::live_index::SearchFilesHit {
4130 tier: SearchFilesTier::Basename,
4131 path: "src/sidecar/tools.rs".to_string(),
4132 coupling_score: None,
4133 shared_commits: None,
4134 },
4135 crate::live_index::SearchFilesHit {
4136 tier: SearchFilesTier::LoosePath,
4137 path: "src/protocol/tools_helper.rs".to_string(),
4138 coupling_score: None,
4139 shared_commits: None,
4140 },
4141 ],
4142 };
4143
4144 let result = search_files_result_view(&view);
4145
4146 assert!(result.contains("3 matching files"));
4147 assert!(result.contains("── Strong path matches ──"));
4148 assert!(result.contains(" src/protocol/tools.rs"));
4149 assert!(result.contains("── Basename matches ──"));
4150 assert!(result.contains(" src/sidecar/tools.rs"));
4151 assert!(result.contains("── Loose path matches ──"));
4152 assert!(result.contains(" src/protocol/tools_helper.rs"));
4153 assert!(result.contains("... and 1 more"));
4154 }
4155
4156 #[test]
4157 fn test_search_files_result_view_not_found() {
4158 let view = SearchFilesView::NotFound {
4159 query: "README.md".to_string(),
4160 };
4161
4162 assert_eq!(
4163 search_files_result_view(&view),
4164 "No indexed source files matching 'README.md'"
4165 );
4166 }
4167
4168 #[test]
4171 fn test_file_content_full() {
4172 let content = b"fn main() {\n println!(\"hi\");\n}";
4173 let (key, file) = make_file("src/main.rs", content, vec![]);
4174 let index = make_index(vec![(key, file)]);
4175 let result = file_content(&index, "src/main.rs", None, None);
4176 assert!(result.contains("fn main()"), "should return full content");
4177 assert!(result.contains("println!"), "should return full content");
4178 }
4179
4180 #[test]
4181 fn test_file_content_line_range() {
4182 let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
4183 let (key, file) = make_file("src/main.rs", content, vec![]);
4184 let index = make_index(vec![(key, file)]);
4185 let result = file_content(&index, "src/main.rs", Some(2), Some(4));
4187 assert!(!result.contains("line 1"), "should not include line 1");
4188 assert!(result.contains("line 2"), "should include line 2");
4189 assert!(result.contains("line 3"), "should include line 3");
4190 assert!(result.contains("line 4"), "should include line 4");
4191 assert!(!result.contains("line 5"), "should not include line 5");
4192 }
4193
4194 #[test]
4195 fn test_file_content_not_found() {
4196 let index = empty_index();
4197 let result = file_content(&index, "nonexistent.rs", None, None);
4198 assert_eq!(result, "File not found: nonexistent.rs");
4199 }
4200
4201 #[test]
4202 fn test_file_outline_from_indexed_file_matches_live_index_output() {
4203 let (key, file) = make_file(
4204 "src/main.rs",
4205 b"fn main() {}",
4206 vec![
4207 make_symbol("main", SymbolKind::Function, 0, 0, 0),
4208 make_symbol("helper", SymbolKind::Function, 1, 1, 1),
4209 ],
4210 );
4211 let index = make_index(vec![(key, file)]);
4212
4213 let live_result = file_outline(&index, "src/main.rs");
4214 let shared_result = file_outline_from_indexed_file(
4215 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4216 );
4217
4218 assert_eq!(shared_result, live_result);
4219 }
4220
4221 #[test]
4222 fn test_symbol_detail_from_indexed_file_matches_live_index_output() {
4223 let content = b"fn helper() {}\nfn target() {}\n";
4224 let (key, file) = make_file(
4225 "src/main.rs",
4226 content,
4227 vec![
4228 make_symbol_with_bytes("helper", SymbolKind::Function, 0, 0, 0, 0, 13),
4229 make_symbol_with_bytes("target", SymbolKind::Function, 0, 1, 1, 14, 27),
4230 ],
4231 );
4232 let index = make_index(vec![(key, file)]);
4233
4234 let live_result = symbol_detail(&index, "src/main.rs", "target", None);
4235 let shared_result = symbol_detail_from_indexed_file(
4236 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4237 "target",
4238 None,
4239 );
4240
4241 assert_eq!(shared_result, live_result);
4242 }
4243
4244 #[test]
4245 fn test_file_content_view_matches_live_index_output() {
4246 let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
4247 let (key, file) = make_file("src/main.rs", content, vec![]);
4248 let index = make_index(vec![(key, file)]);
4249
4250 let live_result = file_content(&index, "src/main.rs", Some(2), Some(4));
4251 let captured_result = file_content_view(
4252 &index.capture_file_content_view("src/main.rs").unwrap(),
4253 Some(2),
4254 Some(4),
4255 );
4256
4257 assert_eq!(captured_result, live_result);
4258 }
4259
4260 #[test]
4261 fn test_file_content_from_indexed_file_matches_live_index_output() {
4262 let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
4263 let (key, file) = make_file("src/main.rs", content, vec![]);
4264 let index = make_index(vec![(key, file)]);
4265
4266 let live_result = file_content(&index, "src/main.rs", Some(2), Some(4));
4267 let shared_result = file_content_from_indexed_file(
4268 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4269 Some(2),
4270 Some(4),
4271 );
4272
4273 assert_eq!(shared_result, live_result);
4274 }
4275
4276 #[test]
4277 fn test_file_content_from_indexed_file_with_context_renders_numbered_full_read() {
4278 let content = b"line 1\nline 2\nline 3";
4279 let (key, file) = make_file("src/main.rs", content, vec![]);
4280 let index = make_index(vec![(key, file)]);
4281
4282 let result = file_content_from_indexed_file_with_context(
4283 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4284 search::ContentContext::line_range_with_format(None, None, true, false),
4285 );
4286
4287 assert_eq!(result, "1: line 1\n2: line 2\n3: line 3");
4288 }
4289
4290 #[test]
4291 fn test_file_content_from_indexed_file_with_context_renders_headered_range_read() {
4292 let content = b"line 1\nline 2\nline 3\nline 4";
4293 let (key, file) = make_file("src/main.rs", content, vec![]);
4294 let index = make_index(vec![(key, file)]);
4295
4296 let result = file_content_from_indexed_file_with_context(
4297 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4298 search::ContentContext::line_range_with_format(Some(2), Some(3), true, true),
4299 );
4300
4301 assert_eq!(result, "src/main.rs [lines 2-3]\n2: line 2\n3: line 3");
4302 }
4303
4304 #[test]
4305 fn test_file_content_from_indexed_file_with_context_renders_numbered_around_line_excerpt() {
4306 let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
4307 let (key, file) = make_file("src/main.rs", content, vec![]);
4308 let index = make_index(vec![(key, file)]);
4309
4310 let result = file_content_from_indexed_file_with_context(
4311 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4312 search::ContentContext::around_line(3, Some(1), false, false),
4313 );
4314
4315 assert_eq!(result, "2: line 2\n3: line 3\n4: line 4");
4316 }
4317
4318 #[test]
4319 fn test_file_content_from_indexed_file_with_context_renders_numbered_around_match_excerpt() {
4320 let content = b"line 1\nTODO first\nline 3\nTODO second\nline 5";
4321 let (key, file) = make_file("src/main.rs", content, vec![]);
4322 let index = make_index(vec![(key, file)]);
4323
4324 let result = file_content_from_indexed_file_with_context(
4325 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4326 search::ContentContext::around_match("todo", Some(1), false, false),
4327 );
4328
4329 assert_eq!(result, "1: line 1\n2: TODO first\n3: line 3");
4330 }
4331
4332 #[test]
4333 fn test_file_content_from_indexed_file_with_context_renders_chunked_excerpt_header() {
4334 let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
4335 let (key, file) = make_file("src/main.rs", content, vec![]);
4336 let index = make_index(vec![(key, file)]);
4337
4338 let result = file_content_from_indexed_file_with_context(
4339 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4340 search::ContentContext::chunk(2, 2),
4341 );
4342
4343 assert_eq!(
4344 result,
4345 "src/main.rs [chunk 2/3, lines 3-4]\n3: line 3\n4: line 4"
4346 );
4347 }
4348
4349 #[test]
4350 fn test_file_content_from_indexed_file_with_context_reports_out_of_range_chunk() {
4351 let content = b"line 1\nline 2\nline 3";
4352 let (key, file) = make_file("src/main.rs", content, vec![]);
4353 let index = make_index(vec![(key, file)]);
4354
4355 let result = file_content_from_indexed_file_with_context(
4356 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4357 search::ContentContext::chunk(3, 2),
4358 );
4359
4360 assert_eq!(result, "Chunk 3 out of range for src/main.rs (2 chunks)");
4361 }
4362
4363 #[test]
4364 fn test_file_content_from_indexed_file_with_context_renders_around_symbol_excerpt() {
4365 let content = b"line 1\nfn connect() {}\nline 3";
4366 let (key, file) = make_file(
4367 "src/main.rs",
4368 content,
4369 vec![make_symbol("connect", SymbolKind::Function, 0, 1, 1)],
4370 );
4371 let index = make_index(vec![(key, file)]);
4372
4373 let result = file_content_from_indexed_file_with_context(
4374 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4375 search::ContentContext::around_symbol("connect", None, Some(1)),
4376 );
4377
4378 assert_eq!(result, "1: line 1\n2: fn connect() {}\n3: line 3");
4379 }
4380
4381 #[test]
4382 fn test_file_content_from_indexed_file_with_context_reports_ambiguous_around_symbol() {
4383 let content = b"fn connect() {}\nline 2\nfn connect() {}";
4384 let (key, file) = make_file(
4385 "src/main.rs",
4386 content,
4387 vec![
4388 make_symbol("connect", SymbolKind::Function, 0, 0, 0),
4389 make_symbol("connect", SymbolKind::Function, 0, 2, 2),
4390 ],
4391 );
4392 let index = make_index(vec![(key, file)]);
4393
4394 let result = file_content_from_indexed_file_with_context(
4395 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4396 search::ContentContext::around_symbol("connect", None, Some(1)),
4397 );
4398
4399 assert_eq!(
4400 result,
4401 "Ambiguous symbol selector for connect in src/main.rs; pass `symbol_line` to disambiguate. Candidates: 0, 2"
4402 );
4403 }
4404
4405 #[test]
4406 fn test_file_content_from_indexed_file_with_context_around_symbol_line_selects_exact_match() {
4407 let content = b"fn connect() {}\nline 2\nfn connect() {}";
4408 let (key, file) = make_file(
4409 "src/main.rs",
4410 content,
4411 vec![
4412 make_symbol("connect", SymbolKind::Function, 0, 0, 0),
4413 make_symbol("connect", SymbolKind::Function, 0, 2, 2),
4414 ],
4415 );
4416 let index = make_index(vec![(key, file)]);
4417
4418 let result = file_content_from_indexed_file_with_context(
4419 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4420 search::ContentContext::around_symbol("connect", Some(3), Some(0)),
4421 );
4422
4423 assert_eq!(result, "3: fn connect() {}");
4424 }
4425
4426 #[test]
4429 fn test_around_symbol_returns_full_multiline_body() {
4430 let mut lines_vec: Vec<String> = Vec::new();
4432 lines_vec.push("// preamble".to_string());
4433 lines_vec.push("fn big_function() {".to_string());
4434 for i in 0..20 {
4435 lines_vec.push(format!(" let x{i} = {i};"));
4436 }
4437 lines_vec.push("}".to_string());
4438 lines_vec.push("// postamble".to_string());
4439 let content_str = lines_vec.join("\n");
4440 let content = content_str.as_bytes();
4441
4442 let (key, file) = make_file(
4444 "src/main.rs",
4445 content,
4446 vec![make_symbol("big_function", SymbolKind::Function, 0, 1, 22)],
4447 );
4448 let index = make_index(vec![(key, file)]);
4449
4450 let result = file_content_from_indexed_file_with_context(
4451 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4452 search::ContentContext::around_symbol("big_function", None, None),
4453 );
4454
4455 let result_lines: Vec<&str> = result.lines().collect();
4456 assert_eq!(
4458 result_lines.len(),
4459 22,
4460 "should return all 22 lines of the symbol"
4461 );
4462 assert!(result_lines[0].contains("fn big_function()"));
4463 assert!(result_lines[21].contains("}"));
4464 }
4465
4466 #[test]
4467 fn test_around_symbol_with_max_lines_truncates() {
4468 let content =
4469 b"line 1\nfn connect() {\n let a = 1;\n let b = 2;\n let c = 3;\n}\nline 7";
4470 let (key, file) = make_file(
4471 "src/main.rs",
4472 content,
4473 vec![make_symbol("connect", SymbolKind::Function, 0, 1, 5)],
4475 );
4476 let index = make_index(vec![(key, file)]);
4477
4478 let result = file_content_from_indexed_file_with_context(
4479 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4480 search::ContentContext::around_symbol_with_max_lines(
4481 "connect",
4482 None,
4483 None,
4484 Some(3),
4485 false,
4486 false,
4487 ),
4488 );
4489
4490 let result_lines: Vec<&str> = result.lines().collect();
4491 assert_eq!(result_lines.len(), 4); assert!(result_lines[0].contains("fn connect()"));
4493 assert!(result_lines[3].contains("truncated"));
4494 assert!(result_lines[3].contains("showing first 3"));
4495 }
4496
4497 #[test]
4498 fn test_around_symbol_context_lines_extends_range() {
4499 let content = b"line 1\nline 2\nfn connect() {\n body;\n}\nline 6\nline 7";
4500 let (key, file) = make_file(
4501 "src/main.rs",
4502 content,
4503 vec![make_symbol("connect", SymbolKind::Function, 0, 2, 4)],
4505 );
4506 let index = make_index(vec![(key, file)]);
4507
4508 let result = file_content_from_indexed_file_with_context(
4510 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4511 search::ContentContext::around_symbol("connect", None, Some(2)),
4512 );
4513
4514 let result_lines: Vec<&str> = result.lines().collect();
4515 assert_eq!(result_lines.len(), 7);
4517 assert!(result_lines[0].contains("line 1"));
4518 assert!(result_lines[6].contains("line 7"));
4519 }
4520
4521 #[test]
4522 fn test_around_symbol_not_found_returns_error() {
4523 let content = b"fn connect() {}\nline 2";
4524 let (key, file) = make_file(
4525 "src/main.rs",
4526 content,
4527 vec![make_symbol("connect", SymbolKind::Function, 0, 0, 0)],
4528 );
4529 let index = make_index(vec![(key, file)]);
4530
4531 let result = file_content_from_indexed_file_with_context(
4532 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4533 search::ContentContext::around_symbol("nonexistent", None, None),
4534 );
4535
4536 assert!(
4537 result.contains("No symbol")
4538 || result.contains("not found")
4539 || result.contains("Not found"),
4540 "should indicate symbol not found, got: {result}"
4541 );
4542 assert!(
4543 result.contains("nonexistent"),
4544 "error should name the missing symbol, got: {result}"
4545 );
4546 }
4547
4548 #[test]
4549 fn test_around_symbol_includes_doc_comments_in_indexed_range() {
4550 let content = b"/// Doc comment\nfn connect() {\n body;\n}\nline 5";
4552 let (key, file) = make_file(
4553 "src/main.rs",
4554 content,
4555 vec![make_symbol("connect", SymbolKind::Function, 0, 0, 3)],
4557 );
4558 let index = make_index(vec![(key, file)]);
4559
4560 let result = file_content_from_indexed_file_with_context(
4561 index.capture_shared_file("src/main.rs").unwrap().as_ref(),
4562 search::ContentContext::around_symbol("connect", None, None),
4563 );
4564
4565 let result_lines: Vec<&str> = result.lines().collect();
4566 assert_eq!(result_lines.len(), 4);
4567 assert!(result_lines[0].contains("/// Doc comment"));
4568 assert!(result_lines[3].contains("}"));
4569 }
4570
4571 #[test]
4574 fn test_loading_guard_message() {
4575 assert_eq!(
4576 loading_guard_message(),
4577 "Index is loading... try again shortly."
4578 );
4579 }
4580
4581 #[test]
4582 fn test_empty_guard_message() {
4583 assert_eq!(
4584 empty_guard_message(),
4585 "Index not loaded. Call index_folder to index a directory."
4586 );
4587 }
4588
4589 #[test]
4592 fn test_not_found_file_format() {
4593 assert_eq!(not_found_file("src/foo.rs"), "File not found: src/foo.rs");
4594 }
4595
4596 #[test]
4597 fn test_not_found_symbol_lists_available() {
4598 let sym = make_symbol("existing_fn", SymbolKind::Function, 0, 1, 5);
4599 let (key, file) = make_file("src/lib.rs", b"fn existing_fn() {}", vec![sym]);
4600 let index = make_index(vec![(key, file)]);
4601 let result = not_found_symbol(&index, "src/lib.rs", "missing_fn");
4602 assert!(result.contains("No symbol missing_fn in src/lib.rs"));
4603 assert!(result.contains("existing_fn"));
4604 }
4605
4606 #[test]
4607 fn test_not_found_symbol_no_symbols_in_file() {
4608 let (key, file) = make_file("src/lib.rs", b"", vec![]);
4609 let index = make_index(vec![(key, file)]);
4610 let result = not_found_symbol(&index, "src/lib.rs", "foo");
4611 assert!(result.contains("no indexed symbols"));
4612 }
4613
4614 use crate::domain::{ReferenceKind, ReferenceRecord};
4617
4618 fn make_ref(
4619 name: &str,
4620 kind: ReferenceKind,
4621 line: u32,
4622 enclosing: Option<u32>,
4623 ) -> ReferenceRecord {
4624 ReferenceRecord {
4625 name: name.to_string(),
4626 qualified_name: None,
4627 kind,
4628 byte_range: (0, 1),
4629 line_range: (line, line),
4630 enclosing_symbol_index: enclosing,
4631 }
4632 }
4633
4634 fn make_file_with_refs(
4635 path: &str,
4636 content: &[u8],
4637 symbols: Vec<SymbolRecord>,
4638 references: Vec<ReferenceRecord>,
4639 ) -> (String, IndexedFile) {
4640 (
4641 path.to_string(),
4642 IndexedFile {
4643 relative_path: path.to_string(),
4644 language: LanguageId::Rust,
4645 classification: crate::domain::FileClassification::for_code_path(path),
4646 content: content.to_vec(),
4647 symbols,
4648 parse_status: ParseStatus::Parsed,
4649 parse_diagnostic: None,
4650 byte_len: content.len() as u64,
4651 content_hash: "test".to_string(),
4652 references,
4653 alias_map: std::collections::HashMap::new(),
4654 mtime_secs: 0,
4655 },
4656 )
4657 }
4658
4659 fn make_index_with_reverse(files: Vec<(String, IndexedFile)>) -> LiveIndex {
4660 use crate::live_index::trigram::TrigramIndex;
4661 let cb = CircuitBreakerState::new(0.20);
4662 let files_map = files
4663 .into_iter()
4664 .map(|(path, file)| (path, std::sync::Arc::new(file)))
4665 .collect::<HashMap<_, _>>();
4666 let trigram_index = TrigramIndex::build_from_files(&files_map);
4667 let mut index = LiveIndex {
4668 files: files_map,
4669 loaded_at: Instant::now(),
4670 loaded_at_system: std::time::SystemTime::now(),
4671 load_duration: Duration::from_millis(42),
4672 cb_state: cb,
4673 is_empty: false,
4674 load_source: crate::live_index::store::IndexLoadSource::FreshLoad,
4675 snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
4676 reverse_index: HashMap::new(),
4677 files_by_basename: HashMap::new(),
4678 files_by_dir_component: HashMap::new(),
4679 trigram_index,
4680 gitignore: None,
4681 skipped_files: Vec::new(),
4682 };
4683 index.rebuild_reverse_index();
4684 index.rebuild_path_indices();
4685 index
4686 }
4687
4688 #[test]
4689 fn test_find_references_result_groups_by_file_and_shows_context() {
4690 let content = b"fn handle() {\n process(x);\n}\n";
4692 let sym = make_symbol_with_bytes("handle", SymbolKind::Function, 0, 1, 3, 0, 30);
4693 let r = make_ref("process", ReferenceKind::Call, 2, Some(0));
4694 let (key, file) = make_file_with_refs("src/handler.rs", content, vec![sym], vec![r]);
4695 let index = make_index_with_reverse(vec![(key, file)]);
4696 let result = find_references_result(&index, "process", None);
4697 assert!(
4698 result.contains("1 references in 1 files"),
4699 "header missing, got: {result}"
4700 );
4701 assert!(
4702 result.contains("src/handler.rs"),
4703 "file path missing, got: {result}"
4704 );
4705 assert!(
4706 result.contains("process"),
4707 "reference name missing, got: {result}"
4708 );
4709 assert!(
4710 result.contains("[in fn handle]"),
4711 "enclosing annotation missing, got: {result}"
4712 );
4713 }
4714
4715 #[test]
4716 fn test_find_references_result_zero_results() {
4717 let index = make_index_with_reverse(vec![]);
4718 let result = find_references_result(&index, "nobody", None);
4719 assert_eq!(result, "No references found for \"nobody\"");
4720 }
4721
4722 #[test]
4723 fn test_find_references_result_kind_filter_call_only() {
4724 let content = b"use foo;\nfoo();\n";
4725 let r_import = make_ref("foo", ReferenceKind::Import, 1, None);
4726 let r_call = make_ref("foo", ReferenceKind::Call, 2, None);
4727 let (key, file) =
4728 make_file_with_refs("src/lib.rs", content, vec![], vec![r_import, r_call]);
4729 let index = make_index_with_reverse(vec![(key, file)]);
4730 let result = find_references_result(&index, "foo", Some("call"));
4731 assert!(
4733 result.contains("1 references"),
4734 "expected only 1 reference, got: {result}"
4735 );
4736 }
4737
4738 #[test]
4739 fn test_find_references_result_view_matches_live_index_output() {
4740 let content = b"fn handle() {\n process(x);\n}\n";
4741 let sym = make_symbol_with_bytes("handle", SymbolKind::Function, 0, 1, 3, 0, 30);
4742 let r = make_ref("process", ReferenceKind::Call, 2, Some(0));
4743 let (key, file) = make_file_with_refs("src/handler.rs", content, vec![sym], vec![r]);
4744 let index = make_index_with_reverse(vec![(key, file)]);
4745
4746 let live_result = find_references_result(&index, "process", None);
4747 let limits = OutputLimits::default();
4748 let captured_result = find_references_result_view(
4749 &index.capture_find_references_view("process", None, limits.total_hits),
4750 "process",
4751 &limits,
4752 );
4753
4754 assert_eq!(captured_result, live_result);
4755 }
4756
4757 #[test]
4758 fn test_find_references_result_view_total_limit_caps_across_files() {
4759 let mut all_files = Vec::new();
4761 for i in 0..3 {
4762 let path = format!("src/file_{i}.rs");
4763 let content = b"fn f() {}\nfn g() {}\nfn h() {}\n";
4764 let refs: Vec<ReferenceRecord> = (0..10)
4765 .map(|j| make_ref("target", ReferenceKind::Call, (j % 3) + 1, None))
4766 .collect();
4767 let (key, file) = make_file_with_refs(&path, content, vec![], refs);
4768 all_files.push((key, file));
4769 }
4770 let index = make_index_with_reverse(all_files);
4771 let view = index.capture_find_references_view("target", None, 200);
4772
4773 let unlimited = OutputLimits {
4775 max_files: 100,
4776 max_per_file: 100,
4777 total_hits: usize::MAX,
4778 };
4779 let unlimited_result = find_references_result_view(&view, "target", &unlimited);
4780 assert!(
4781 !unlimited_result.contains("more references"),
4782 "unlimited should show all refs"
4783 );
4784
4785 let limits = OutputLimits {
4787 max_files: 100,
4788 max_per_file: 100,
4789 total_hits: 15,
4790 };
4791 let result = find_references_result_view(&view, "target", &limits);
4792
4793 assert!(
4796 result.contains("... and 5 more references"),
4797 "file_1 should show 5 truncated hits, got:\n{result}"
4798 );
4799 assert!(
4801 !result.contains("src/file_2.rs"),
4802 "file_2 should be skipped, got:\n{result}"
4803 );
4804 }
4805
4806 #[test]
4807 fn test_find_references_result_view_per_file_limit_within_total() {
4808 let content = b"fn a() {}\nfn b() {}\nfn c() {}\n";
4810 let refs: Vec<ReferenceRecord> = (0..20)
4811 .map(|j| make_ref("target", ReferenceKind::Call, (j % 3) + 1, None))
4812 .collect();
4813 let (key, file) = make_file_with_refs("src/lib.rs", content, vec![], refs);
4814 let index = make_index_with_reverse(vec![(key, file)]);
4815 let view = index.capture_find_references_view("target", None, 200);
4816
4817 let limits = OutputLimits {
4818 max_files: 100,
4819 max_per_file: 5,
4820 total_hits: 100,
4821 };
4822 let result = find_references_result_view(&view, "target", &limits);
4823
4824 assert!(
4826 result.contains("... and 15 more references"),
4827 "expected per-file truncation, got:\n{result}"
4828 );
4829 }
4830
4831 #[test]
4832 fn test_find_references_compact_view_total_limit_caps_across_files() {
4833 let mut all_files = Vec::new();
4834 for i in 0..3 {
4835 let path = format!("src/file_{i}.rs");
4836 let content = b"fn f() {}\nfn g() {}\nfn h() {}\n";
4837 let refs: Vec<ReferenceRecord> = (0..10)
4838 .map(|j| make_ref("target", ReferenceKind::Call, (j % 3) + 1, None))
4839 .collect();
4840 let (key, file) = make_file_with_refs(&path, content, vec![], refs);
4841 all_files.push((key, file));
4842 }
4843 let index = make_index_with_reverse(all_files);
4844 let view = index.capture_find_references_view("target", None, 200);
4845
4846 let limits = OutputLimits {
4847 max_files: 100,
4848 max_per_file: 100,
4849 total_hits: 15,
4850 };
4851 let result = find_references_compact_view(&view, "target", &limits);
4852
4853 assert!(
4855 result.contains("... and 5 more"),
4856 "file_1 should show 5 truncated in compact view, got:\n{result}"
4857 );
4858 assert!(
4859 !result.contains("src/file_2.rs"),
4860 "file_2 should be skipped in compact view, got:\n{result}"
4861 );
4862 }
4863
4864 #[test]
4867 fn test_find_dependents_result_shows_importers() {
4868 let content_b = b"use crate::db;\n";
4869 let r = make_ref("db", ReferenceKind::Import, 1, None);
4870 let (key_b, file_b) = make_file_with_refs("src/handler.rs", content_b, vec![], vec![r]);
4871 let (key_a, file_a) = make_file("src/db.rs", b"pub fn connect() {}", vec![]);
4873 let index = make_index_with_reverse(vec![(key_a, file_a), (key_b, file_b)]);
4874 let result = find_dependents_result(&index, "src/db.rs");
4875 assert!(
4876 result.contains("1 files depend on src/db.rs"),
4877 "header wrong, got: {result}"
4878 );
4879 assert!(
4880 result.contains("src/handler.rs"),
4881 "importer missing, got: {result}"
4882 );
4883 assert!(
4884 result.contains("[import]"),
4885 "import annotation missing, got: {result}"
4886 );
4887 }
4888
4889 #[test]
4890 fn test_find_dependents_result_zero_dependents() {
4891 let (key, file) = make_file("src/db.rs", b"", vec![]);
4892 let index = make_index_with_reverse(vec![(key, file)]);
4893 let result = find_dependents_result(&index, "src/db.rs");
4894 assert_eq!(result, "No dependents found for \"src/db.rs\"");
4895 }
4896
4897 #[test]
4898 fn test_find_dependents_result_view_matches_live_index_output() {
4899 let content_b = b"use crate::db;\n";
4900 let r = make_ref("db", ReferenceKind::Import, 1, None);
4901 let (key_b, file_b) = make_file_with_refs("src/handler.rs", content_b, vec![], vec![r]);
4902 let (key_a, file_a) = make_file("src/db.rs", b"pub fn connect() {}", vec![]);
4903 let index = make_index_with_reverse(vec![(key_a, file_a), (key_b, file_b)]);
4904
4905 let live_result = find_dependents_result(&index, "src/db.rs");
4906 let captured_result = find_dependents_result_view(
4907 &index.capture_find_dependents_view("src/db.rs"),
4908 "src/db.rs",
4909 &OutputLimits::default(),
4910 );
4911
4912 assert_eq!(captured_result, live_result);
4913 }
4914
4915 #[test]
4916 fn test_find_dependents_mermaid_shows_flowchart() {
4917 let content_b = b"use crate::db;\n";
4918 let r = make_ref("db", ReferenceKind::Import, 1, None);
4919 let (key_b, file_b) = make_file_with_refs("src/handler.rs", content_b, vec![], vec![r]);
4920 let (key_a, file_a) = make_file("src/db.rs", b"pub fn connect() {}", vec![]);
4921 let index = make_index_with_reverse(vec![(key_a, file_a), (key_b, file_b)]);
4922 let view = index.capture_find_dependents_view("src/db.rs");
4923 let result = find_dependents_mermaid(&view, "src/db.rs", &OutputLimits::default());
4924 assert!(
4925 result.starts_with("flowchart LR"),
4926 "should start with flowchart, got: {result}"
4927 );
4928 assert!(result.contains("src/db.rs"), "should mention target file");
4929 assert!(
4930 result.contains("src/handler.rs"),
4931 "should mention dependent"
4932 );
4933 assert!(
4934 result.contains("db"),
4935 "should show symbol name in edge label"
4936 );
4937 }
4938
4939 #[test]
4940 fn test_find_dependents_mermaid_empty() {
4941 let (key, file) = make_file("src/db.rs", b"", vec![]);
4942 let index = make_index_with_reverse(vec![(key, file)]);
4943 let view = index.capture_find_dependents_view("src/db.rs");
4944 let result = find_dependents_mermaid(&view, "src/db.rs", &OutputLimits::default());
4945 assert_eq!(result, "No dependents found for \"src/db.rs\"");
4946 }
4947
4948 #[test]
4949 fn test_find_dependents_dot_shows_digraph() {
4950 let content_b = b"use crate::db;\n";
4951 let r = make_ref("db", ReferenceKind::Import, 1, None);
4952 let (key_b, file_b) = make_file_with_refs("src/handler.rs", content_b, vec![], vec![r]);
4953 let (key_a, file_a) = make_file("src/db.rs", b"pub fn connect() {}", vec![]);
4954 let index = make_index_with_reverse(vec![(key_a, file_a), (key_b, file_b)]);
4955 let view = index.capture_find_dependents_view("src/db.rs");
4956 let result = find_dependents_dot(&view, "src/db.rs", &OutputLimits::default());
4957 assert!(
4958 result.starts_with("digraph dependents {"),
4959 "should start with digraph, got: {result}"
4960 );
4961 assert!(result.contains("src/db.rs"), "should mention target file");
4962 assert!(
4963 result.contains("src/handler.rs"),
4964 "should mention dependent"
4965 );
4966 assert!(result.ends_with('}'), "should end with closing brace");
4967 }
4968
4969 #[test]
4970 fn test_find_dependents_dot_empty() {
4971 let (key, file) = make_file("src/db.rs", b"", vec![]);
4972 let index = make_index_with_reverse(vec![(key, file)]);
4973 let view = index.capture_find_dependents_view("src/db.rs");
4974 let result = find_dependents_dot(&view, "src/db.rs", &OutputLimits::default());
4975 assert_eq!(result, "No dependents found for \"src/db.rs\"");
4976 }
4977
4978 #[test]
4979 fn test_find_dependents_mermaid_shows_true_ref_count_not_capped() {
4980 use crate::live_index::query::{DependentFileView, DependentLineView, FindDependentsView};
4983 let lines: Vec<DependentLineView> = (1..=5)
4984 .map(|i| DependentLineView {
4985 line_number: i,
4986 line_content: format!("use crate::db; // ref {i}"),
4987 kind: "import".to_string(),
4988 name: "db".to_string(),
4989 })
4990 .collect();
4991 let view = FindDependentsView {
4992 files: vec![DependentFileView {
4993 file_path: "src/handler.rs".to_string(),
4994 lines,
4995 }],
4996 };
4997 let limits = OutputLimits::new(20, 2); let result = find_dependents_mermaid(&view, "src/db.rs", &limits);
4999 assert!(
5000 result.contains("db"),
5001 "mermaid label should include symbol name 'db'. Got: {result}"
5002 );
5003 }
5004
5005 #[test]
5006 fn test_find_dependents_dot_shows_true_ref_count_not_capped() {
5007 use crate::live_index::query::{DependentFileView, DependentLineView, FindDependentsView};
5008 let lines: Vec<DependentLineView> = (1..=5)
5009 .map(|i| DependentLineView {
5010 line_number: i,
5011 line_content: format!("use crate::db; // ref {i}"),
5012 kind: "import".to_string(),
5013 name: "db".to_string(),
5014 })
5015 .collect();
5016 let view = FindDependentsView {
5017 files: vec![DependentFileView {
5018 file_path: "src/handler.rs".to_string(),
5019 lines,
5020 }],
5021 };
5022 let limits = OutputLimits::new(20, 2);
5023 let result = find_dependents_dot(&view, "src/db.rs", &limits);
5024 assert!(
5025 result.contains("db"),
5026 "dot label should include symbol name 'db'. Got: {result}"
5027 );
5028 }
5029
5030 #[test]
5033 fn test_context_bundle_result_includes_body_and_sections() {
5034 let content = b"fn process(x: i32) -> i32 {\n x + 1\n}\n";
5035 let sym = make_symbol_with_bytes("process", SymbolKind::Function, 0, 1, 3, 0, 41);
5036 let (key, file) = make_file_with_refs("src/lib.rs", content, vec![sym], vec![]);
5037 let index = make_index_with_reverse(vec![(key, file)]);
5038 let result = context_bundle_result(&index, "src/lib.rs", "process", None);
5039 assert!(result.contains("fn process"), "body missing, got: {result}");
5040 assert!(
5041 result.contains("[fn, src/lib.rs:"),
5042 "footer missing, got: {result}"
5043 );
5044 assert!(
5045 result.contains("Callers"),
5046 "Callers section missing, got: {result}"
5047 );
5048 assert!(
5049 result.contains("Callees"),
5050 "Callees section missing, got: {result}"
5051 );
5052 assert!(
5053 result.contains("Type usages"),
5054 "Type usages section missing, got: {result}"
5055 );
5056 }
5057
5058 #[test]
5059 fn test_context_bundle_result_caps_callers_at_20() {
5060 let refs: Vec<ReferenceRecord> = (0u32..25)
5062 .map(|i| make_ref("process", ReferenceKind::Call, i + 100, None))
5063 .collect();
5064 let content = b"fn caller() {} fn process() {}";
5065 let sym_caller = make_symbol_with_bytes("caller", SymbolKind::Function, 0, 1, 1, 0, 14);
5066 let sym_process = make_symbol_with_bytes("process", SymbolKind::Function, 0, 1, 1, 15, 30);
5067 let (key, file) =
5069 make_file_with_refs("src/lib.rs", content, vec![sym_caller, sym_process], refs);
5070 let index = make_index_with_reverse(vec![(key, file)]);
5071 let result = context_bundle_result(&index, "src/lib.rs", "process", None);
5072 assert!(
5073 result.contains("...and"),
5074 "overflow message missing, got: {result}"
5075 );
5076 assert!(
5077 result.contains("more callers"),
5078 "overflow count missing, got: {result}"
5079 );
5080 }
5081
5082 #[test]
5083 fn test_context_bundle_result_symbol_not_found() {
5084 let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
5085 let index = make_index_with_reverse(vec![(key, file)]);
5086 let result = context_bundle_result(&index, "src/lib.rs", "nonexistent", None);
5087 assert!(
5088 result.contains("No symbol nonexistent in src/lib.rs"),
5089 "got: {result}"
5090 );
5091 }
5092
5093 #[test]
5094 fn test_context_bundle_result_empty_sections_show_zero() {
5095 let content = b"fn process() {}";
5096 let sym = make_symbol_with_bytes("process", SymbolKind::Function, 0, 1, 1, 0, 15);
5097 let (key, file) = make_file_with_refs("src/lib.rs", content, vec![sym], vec![]);
5098 let index = make_index_with_reverse(vec![(key, file)]);
5099 let result = context_bundle_result(&index, "src/lib.rs", "process", None);
5100 assert!(
5101 result.contains("Callers (0)"),
5102 "zero callers section missing, got: {result}"
5103 );
5104 assert!(
5105 result.contains("Callees (0)"),
5106 "zero callees section missing, got: {result}"
5107 );
5108 assert!(
5109 result.contains("Type usages (0)"),
5110 "zero type usages section missing, got: {result}"
5111 );
5112 }
5113
5114 #[test]
5115 fn test_context_bundle_result_view_matches_live_index_output() {
5116 let content = b"fn process(x: i32) -> i32 {\n x + 1\n}\n";
5117 let sym = make_symbol_with_bytes("process", SymbolKind::Function, 0, 1, 3, 0, 41);
5118 let (key, file) = make_file_with_refs("src/lib.rs", content, vec![sym], vec![]);
5119 let index = make_index_with_reverse(vec![(key, file)]);
5120
5121 let live_result = context_bundle_result(&index, "src/lib.rs", "process", None);
5122 let captured_result = context_bundle_result_view(
5123 &index.capture_context_bundle_view("src/lib.rs", "process", None, None),
5124 "full",
5125 );
5126
5127 assert_eq!(captured_result, live_result);
5128 }
5129
5130 #[test]
5131 fn test_context_bundle_result_view_ambiguous_symbol() {
5132 let result = context_bundle_result_view(
5133 &ContextBundleView::AmbiguousSymbol {
5134 path: "src/lib.rs".to_string(),
5135 name: "process".to_string(),
5136 candidate_lines: vec![1, 10],
5137 },
5138 "full",
5139 );
5140
5141 assert!(
5142 result.contains("Ambiguous symbol selector"),
5143 "got: {result}"
5144 );
5145 assert!(result.contains("1"), "got: {result}");
5146 assert!(result.contains("10"), "got: {result}");
5147 }
5148
5149 #[test]
5150 fn test_context_bundle_result_view_suggests_impl_blocks_for_zero_caller_struct() {
5151 let empty_section = ContextBundleSectionView {
5152 total_count: 0,
5153 overflow_count: 0,
5154 entries: vec![],
5155 unique_count: 0,
5156 };
5157 let result = context_bundle_result_view(
5158 &ContextBundleView::Found(ContextBundleFoundView {
5159 file_path: "src/actors.rs".to_string(),
5160 body: "struct MyActor;".to_string(),
5161 kind_label: "struct".to_string(),
5162 line_range: (0, 0),
5163 byte_count: 15,
5164 callers: empty_section.clone(),
5165 callees: empty_section.clone(),
5166 type_usages: empty_section,
5167 dependencies: vec![],
5168 implementation_suggestions: vec![
5169 ImplBlockSuggestionView {
5170 display_name: "impl MyActor".to_string(),
5171 file_path: "src/actors.rs".to_string(),
5172 line_number: 3,
5173 },
5174 ImplBlockSuggestionView {
5175 display_name: "impl Actor for MyActor".to_string(),
5176 file_path: "src/actors.rs".to_string(),
5177 line_number: 7,
5178 },
5179 ],
5180 }),
5181 "full",
5182 );
5183
5184 assert!(
5185 result.contains("0 direct callers"),
5186 "missing zero-caller tip: {result}"
5187 );
5188 assert!(
5189 result.contains("impl MyActor (src/actors.rs:3)"),
5190 "missing inherent impl suggestion: {result}"
5191 );
5192 assert!(
5193 result.contains("impl Actor for MyActor (src/actors.rs:7)"),
5194 "missing trait impl suggestion: {result}"
5195 );
5196 }
5197
5198 #[test]
5199 fn test_context_bundle_result_view_with_max_tokens_truncates_dependencies_in_priority_order() {
5200 let empty_section = ContextBundleSectionView {
5201 total_count: 0,
5202 overflow_count: 0,
5203 entries: vec![],
5204 unique_count: 0,
5205 };
5206 let result = context_bundle_result_view_with_max_tokens(
5207 &ContextBundleView::Found(ContextBundleFoundView {
5208 file_path: "src/lib.rs".to_string(),
5209 body: "fn plan(alpha: Alpha) -> Output { todo!() }".to_string(),
5210 kind_label: "fn".to_string(),
5211 line_range: (0, 0),
5212 byte_count: 44,
5213 callers: empty_section.clone(),
5214 callees: empty_section.clone(),
5215 type_usages: empty_section,
5216 dependencies: vec![
5217 TypeDependencyView {
5218 name: "Alpha".to_string(),
5219 kind_label: "struct".to_string(),
5220 file_path: "src/types.rs".to_string(),
5221 line_range: (10, 12),
5222 body: "struct Alpha {\n value: i32,\n}\n".to_string(),
5223 depth: 0,
5224 },
5225 TypeDependencyView {
5226 name: "Gamma".to_string(),
5227 kind_label: "struct".to_string(),
5228 file_path: "src/types.rs".to_string(),
5229 line_range: (20, 40),
5230 body: format!(
5231 "struct Gamma {{\n{}\n}}\n",
5232 " payload: [u8; 64],\n".repeat(10)
5233 ),
5234 depth: 1,
5235 },
5236 ],
5237 implementation_suggestions: vec![],
5238 }),
5239 "full",
5240 Some(100),
5241 );
5242
5243 assert!(
5244 result.contains("── Alpha [struct, src/types.rs:11-13]"),
5245 "expected direct dependency to fit the budget: {result}"
5246 );
5247 assert!(
5248 !result.contains("── Gamma [struct, src/types.rs:21-41"),
5249 "transitive dependency should be omitted once the budget is exhausted: {result}"
5250 );
5251 assert!(
5252 result.contains("Truncated at ~100 tokens."),
5253 "expected truncation footer: {result}"
5254 );
5255 assert!(
5256 result.contains("1 additional type dependencies not shown."),
5257 "expected omitted dependency count: {result}"
5258 );
5259 }
5260
5261 #[test]
5264 fn test_format_token_savings_all_zeros_returns_empty() {
5265 let snap = crate::sidecar::StatsSnapshot {
5266 read_fires: 0,
5267 read_saved_tokens: 0,
5268 edit_fires: 0,
5269 edit_saved_tokens: 0,
5270 write_fires: 0,
5271 grep_fires: 0,
5272 grep_saved_tokens: 0,
5273 };
5274 let result = format_token_savings(&snap);
5275 assert!(
5276 result.is_empty(),
5277 "all-zero snapshot should return empty string; got: {result}"
5278 );
5279 }
5280
5281 #[test]
5282 fn test_format_token_savings_shows_section_header() {
5283 let snap = crate::sidecar::StatsSnapshot {
5284 read_fires: 1,
5285 read_saved_tokens: 250,
5286 edit_fires: 0,
5287 edit_saved_tokens: 0,
5288 write_fires: 0,
5289 grep_fires: 0,
5290 grep_saved_tokens: 0,
5291 };
5292 let result = format_token_savings(&snap);
5293 assert!(
5294 result.contains("Token Savings"),
5295 "result must contain 'Token Savings' header; got: {result}"
5296 );
5297 }
5298
5299 #[test]
5300 fn test_format_token_savings_read_fires_and_tokens() {
5301 let snap = crate::sidecar::StatsSnapshot {
5302 read_fires: 3,
5303 read_saved_tokens: 750,
5304 edit_fires: 0,
5305 edit_saved_tokens: 0,
5306 write_fires: 0,
5307 grep_fires: 0,
5308 grep_saved_tokens: 0,
5309 };
5310 let result = format_token_savings(&snap);
5311 assert!(
5312 result.contains("Read"),
5313 "should show Read line; got: {result}"
5314 );
5315 assert!(
5316 result.contains("3 fires"),
5317 "should show fire count; got: {result}"
5318 );
5319 assert!(
5320 result.contains("750"),
5321 "should show saved tokens; got: {result}"
5322 );
5323 }
5324
5325 #[test]
5326 fn test_format_token_savings_total_is_sum_of_parts() {
5327 let snap = crate::sidecar::StatsSnapshot {
5328 read_fires: 2,
5329 read_saved_tokens: 100,
5330 edit_fires: 1,
5331 edit_saved_tokens: 50,
5332 write_fires: 0,
5333 grep_fires: 3,
5334 grep_saved_tokens: 200,
5335 };
5336 let result = format_token_savings(&snap);
5337 assert!(
5339 result.contains("350"),
5340 "total should be sum of read+edit+grep savings (350); got: {result}"
5341 );
5342 assert!(
5343 result.contains("Total:"),
5344 "should have Total line; got: {result}"
5345 );
5346 }
5347
5348 #[test]
5349 fn test_format_token_savings_write_fires_no_savings_field() {
5350 let snap = crate::sidecar::StatsSnapshot {
5351 read_fires: 0,
5352 read_saved_tokens: 0,
5353 edit_fires: 0,
5354 edit_saved_tokens: 0,
5355 write_fires: 2,
5356 grep_fires: 0,
5357 grep_saved_tokens: 0,
5358 };
5359 let result = format_token_savings(&snap);
5360 assert!(
5361 result.contains("Write"),
5362 "should show Write line; got: {result}"
5363 );
5364 assert!(
5365 result.contains("2 fires"),
5366 "should show write fire count; got: {result}"
5367 );
5368 assert!(
5370 !result.contains("tokens saved\nTotal"),
5371 "write line should not show saved tokens"
5372 );
5373 }
5374
5375 #[test]
5376 fn test_format_token_savings_omits_zero_hook_types() {
5377 let snap = crate::sidecar::StatsSnapshot {
5379 read_fires: 1,
5380 read_saved_tokens: 100,
5381 edit_fires: 0,
5382 edit_saved_tokens: 0,
5383 write_fires: 0,
5384 grep_fires: 0,
5385 grep_saved_tokens: 0,
5386 };
5387 let result = format_token_savings(&snap);
5388 assert!(result.contains("Read"), "should show Read; got: {result}");
5389 assert!(
5390 !result.contains("Edit:"),
5391 "Edit should be omitted when zero; got: {result}"
5392 );
5393 assert!(
5394 !result.contains("Grep:"),
5395 "Grep should be omitted when zero; got: {result}"
5396 );
5397 assert!(
5398 !result.contains("Write:"),
5399 "Write should be omitted when zero; got: {result}"
5400 );
5401 }
5402
5403 #[test]
5404 fn test_format_hook_adoption_returns_empty_for_no_attempts() {
5405 let snap = crate::cli::hook::HookAdoptionSnapshot::default();
5406 assert!(format_hook_adoption(&snap).is_empty());
5407 }
5408
5409 #[test]
5410 fn test_format_hook_adoption_shows_workflow_totals_and_first_repo_start() {
5411 let snap = crate::cli::hook::HookAdoptionSnapshot {
5412 source_read: crate::cli::hook::WorkflowAdoptionCounts {
5413 routed: 3,
5414 no_sidecar: 1,
5415 sidecar_error: 0,
5416 daemon_fallback: 0,
5417 },
5418 source_search: crate::cli::hook::WorkflowAdoptionCounts {
5419 routed: 2,
5420 no_sidecar: 0,
5421 sidecar_error: 1,
5422 daemon_fallback: 0,
5423 },
5424 repo_start: crate::cli::hook::WorkflowAdoptionCounts {
5425 routed: 1,
5426 no_sidecar: 0,
5427 sidecar_error: 0,
5428 daemon_fallback: 0,
5429 },
5430 prompt_context: crate::cli::hook::WorkflowAdoptionCounts::default(),
5431 post_edit_impact: crate::cli::hook::WorkflowAdoptionCounts {
5432 routed: 0,
5433 no_sidecar: 1,
5434 sidecar_error: 0,
5435 daemon_fallback: 0,
5436 },
5437 first_repo_start: Some(crate::cli::hook::HookOutcome::Routed),
5438 };
5439
5440 let result = format_hook_adoption(&snap);
5441 assert!(result.contains("Hook Adoption"), "missing header: {result}");
5442 assert!(
5443 result.contains("Owned workflows routed: 6/9 (67%)"),
5444 "missing totals line: {result}"
5445 );
5446 assert!(
5447 result.contains("Source read: routed 3, no sidecar 1"),
5448 "missing source-read line: {result}"
5449 );
5450 assert!(
5451 result.contains("Source search: routed 2, sidecar errors 1"),
5452 "missing source-search line: {result}"
5453 );
5454 assert!(
5455 result.contains("Post-edit impact: routed 0, no sidecar 1"),
5456 "missing post-edit line: {result}"
5457 );
5458 assert!(
5459 result.contains("First repo start: routed"),
5460 "missing first repo-start line: {result}"
5461 );
5462 }
5463
5464 #[test]
5467 fn test_compact_savings_footer_shows_savings() {
5468 let footer = compact_savings_footer(200, 2000);
5469 assert!(footer.contains("tokens saved"), "got: {footer}");
5470 }
5471
5472 #[test]
5473 fn test_compact_savings_footer_empty_when_no_savings() {
5474 let footer = compact_savings_footer(2000, 200);
5475 assert!(footer.is_empty());
5476 }
5477
5478 #[test]
5479 fn test_compact_savings_footer_empty_for_small_files() {
5480 let footer = compact_savings_footer(50, 100);
5481 assert!(footer.is_empty());
5482 }
5483
5484 #[test]
5485 fn test_compact_next_step_hint_formats_joined_items() {
5486 let hint = compact_next_step_hint(&["get_symbol (body)", "find_references (usages)"]);
5487 assert_eq!(hint, "\nTip: get_symbol (body) | find_references (usages)");
5488 }
5489
5490 #[test]
5491 fn test_compact_next_step_hint_ignores_empty_items() {
5492 let hint = compact_next_step_hint(&["", "search_text"]);
5493 assert_eq!(hint, "\nTip: search_text");
5494 }
5495
5496 #[test]
5499 fn test_search_symbols_exact_match_tier_header() {
5500 let sym = make_symbol("parse", SymbolKind::Function, 0, 1, 5);
5501 let (key, file) = make_file("src/lib.rs", b"fn parse() {}", vec![sym]);
5502 let index = make_index(vec![(key, file)]);
5503 let result = search_symbols_result(&index, "parse");
5504 assert!(
5505 result.contains("Exact matches"),
5506 "should show 'Exact matches' tier header; got: {result}"
5507 );
5508 }
5509
5510 #[test]
5511 fn test_search_symbols_prefix_match_tier_header() {
5512 let sym = make_symbol("parse_file", SymbolKind::Function, 0, 1, 5);
5513 let (key, file) = make_file("src/lib.rs", b"fn parse_file() {}", vec![sym]);
5514 let index = make_index(vec![(key, file)]);
5515 let result = search_symbols_result(&index, "parse");
5516 assert!(
5517 result.contains("Prefix matches"),
5518 "should show 'Prefix matches' tier header; got: {result}"
5519 );
5520 }
5521
5522 #[test]
5523 fn test_search_symbols_substring_match_tier_header() {
5524 let sym = make_symbol("do_parse_now", SymbolKind::Function, 0, 1, 5);
5525 let (key, file) = make_file("src/lib.rs", b"fn do_parse_now() {}", vec![sym]);
5526 let index = make_index(vec![(key, file)]);
5527 let result = search_symbols_result(&index, "parse");
5528 assert!(
5529 result.contains("Substring matches"),
5530 "should show 'Substring matches' tier header; got: {result}"
5531 );
5532 }
5533
5534 #[test]
5535 fn test_search_symbols_exact_before_prefix_before_substring() {
5536 let symbols = vec![
5538 make_symbol("do_parse", SymbolKind::Function, 0, 1, 2),
5539 make_symbol("parse_file", SymbolKind::Function, 0, 3, 4),
5540 make_symbol("parse", SymbolKind::Function, 0, 5, 6),
5541 ];
5542 let (key, file) = make_file(
5543 "src/lib.rs",
5544 b"fn do_parse() {} fn parse_file() {} fn parse() {}",
5545 symbols,
5546 );
5547 let index = make_index(vec![(key, file)]);
5548 let result = search_symbols_result(&index, "parse");
5549
5550 let exact_pos = result
5551 .find("Exact matches")
5552 .expect("missing Exact matches header");
5553 let prefix_pos = result
5554 .find("Prefix matches")
5555 .expect("missing Prefix matches header");
5556 let substr_pos = result
5557 .find("Substring matches")
5558 .expect("missing Substring matches header");
5559
5560 assert!(exact_pos < prefix_pos, "Exact must appear before Prefix");
5561 assert!(
5562 prefix_pos < substr_pos,
5563 "Prefix must appear before Substring"
5564 );
5565
5566 let parse_pos = result[exact_pos..]
5568 .find("\n ")
5569 .map(|p| exact_pos + p)
5570 .expect("no symbol line after Exact header");
5571 assert!(
5572 parse_pos < prefix_pos,
5573 "exact match 'parse' must be in Exact section"
5574 );
5575 }
5576
5577 #[test]
5578 fn test_search_symbols_omits_empty_tier_sections() {
5579 let sym = make_symbol("search", SymbolKind::Function, 0, 1, 5);
5581 let (key, file) = make_file("src/lib.rs", b"fn search() {}", vec![sym]);
5582 let index = make_index(vec![(key, file)]);
5583 let result = search_symbols_result(&index, "search");
5584 assert!(
5585 !result.contains("Prefix matches"),
5586 "no prefix matches: header must be omitted; got: {result}"
5587 );
5588 assert!(
5589 !result.contains("Substring matches"),
5590 "no substring matches: header must be omitted; got: {result}"
5591 );
5592 }
5593
5594 #[test]
5595 fn test_search_symbols_within_exact_tier_alphabetical() {
5596 let symbols = vec![
5597 make_symbol("z_fn", SymbolKind::Function, 0, 1, 2),
5598 make_symbol("a_fn", SymbolKind::Function, 0, 3, 4),
5599 make_symbol("m_fn", SymbolKind::Function, 0, 5, 6),
5600 ];
5601 let (key, file) = make_file(
5602 "src/lib.rs",
5603 b"fn z_fn() {} fn a_fn() {} fn m_fn() {}",
5604 symbols,
5605 );
5606 let index = make_index(vec![(key, file)]);
5607 let result = search_symbols_result(&index, "a_fn");
5608 assert!(result.contains("Exact matches"), "got: {result}");
5610 assert!(result.contains("a_fn"), "got: {result}");
5611 }
5612
5613 #[test]
5614 fn test_search_symbols_within_prefix_tier_shorter_names_first() {
5615 let symbols = vec![
5617 make_symbol("parse_longer", SymbolKind::Function, 0, 1, 2),
5618 make_symbol("parse_x", SymbolKind::Function, 0, 3, 4),
5619 ];
5620 let (key, file) = make_file(
5621 "src/lib.rs",
5622 b"fn parse_longer() {} fn parse_x() {}",
5623 symbols,
5624 );
5625 let index = make_index(vec![(key, file)]);
5626 let result = search_symbols_result(&index, "parse");
5627
5628 let prefix_pos = result
5630 .find("Prefix matches")
5631 .expect("missing Prefix matches");
5632 let section_after = &result[prefix_pos..];
5633 let x_pos = section_after
5634 .find("parse_x")
5635 .expect("parse_x not in prefix section");
5636 let longer_pos = section_after
5637 .find("parse_longer")
5638 .expect("parse_longer not in prefix section");
5639 assert!(
5640 x_pos < longer_pos,
5641 "shorter prefix match 'parse_x' must appear before 'parse_longer'"
5642 );
5643 }
5644
5645 fn make_file_with_lang(
5648 path: &str,
5649 content: &[u8],
5650 symbols: Vec<SymbolRecord>,
5651 lang: crate::domain::LanguageId,
5652 ) -> (String, IndexedFile) {
5653 (
5654 path.to_string(),
5655 IndexedFile {
5656 relative_path: path.to_string(),
5657 language: lang,
5658 classification: crate::domain::FileClassification::for_code_path(path),
5659 content: content.to_vec(),
5660 symbols,
5661 parse_status: ParseStatus::Parsed,
5662 parse_diagnostic: None,
5663 byte_len: content.len() as u64,
5664 content_hash: "test".to_string(),
5665 references: vec![],
5666 alias_map: std::collections::HashMap::new(),
5667 mtime_secs: 0,
5668 },
5669 )
5670 }
5671
5672 #[test]
5673 fn test_file_tree_shows_files_with_symbol_count() {
5674 let sym = make_symbol("main", SymbolKind::Function, 0, 1, 5);
5675 let (key, file) = make_file_with_lang(
5676 "src/main.rs",
5677 b"fn main() {}",
5678 vec![sym],
5679 crate::domain::LanguageId::Rust,
5680 );
5681 let index = make_index(vec![(key, file)]);
5682 let result = file_tree(&index, "", 2);
5683 assert!(
5684 result.contains("main.rs"),
5685 "should show filename; got: {result}"
5686 );
5687 assert!(
5688 result.contains("1 symbol"),
5689 "should show symbol count; got: {result}"
5690 );
5691 }
5692
5693 #[test]
5694 fn test_file_tree_view_matches_live_index_output() {
5695 let sym1 = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5696 let sym2 = make_symbol("bar", SymbolKind::Function, 0, 1, 3);
5697 let (k1, f1) = make_file_with_lang(
5698 "src/a.rs",
5699 b"fn foo() {}",
5700 vec![sym1],
5701 crate::domain::LanguageId::Rust,
5702 );
5703 let (k2, f2) = make_file_with_lang(
5704 "tests/b.rs",
5705 b"fn bar() {}",
5706 vec![sym2],
5707 crate::domain::LanguageId::Rust,
5708 );
5709 let index = make_index(vec![(k1, f1), (k2, f2)]);
5710
5711 let live_result = file_tree(&index, "", 3);
5712 let captured_result = file_tree_view(&index.capture_repo_outline_view().files, "", 3);
5713
5714 assert_eq!(captured_result, live_result);
5715 }
5716
5717 #[test]
5718 fn test_file_tree_shows_directory_with_file_counts() {
5719 let sym1 = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5720 let sym2 = make_symbol("bar", SymbolKind::Function, 0, 1, 3);
5721 let (k1, f1) = make_file_with_lang(
5722 "src/a.rs",
5723 b"fn foo() {}",
5724 vec![sym1],
5725 crate::domain::LanguageId::Rust,
5726 );
5727 let (k2, f2) = make_file_with_lang(
5728 "src/b.rs",
5729 b"fn bar() {}",
5730 vec![sym2],
5731 crate::domain::LanguageId::Rust,
5732 );
5733 let index = make_index(vec![(k1, f1), (k2, f2)]);
5734 let result = file_tree(&index, "", 1);
5735 assert!(
5737 result.contains("src"),
5738 "should show src directory; got: {result}"
5739 );
5740 }
5741
5742 #[test]
5743 fn test_file_tree_footer_shows_totals() {
5744 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5745 let (k1, f1) = make_file_with_lang(
5746 "src/a.rs",
5747 b"fn foo() {}",
5748 vec![sym],
5749 crate::domain::LanguageId::Rust,
5750 );
5751 let (k2, f2) = make_file_with_lang(
5752 "lib/b.rs",
5753 b"fn bar() {}",
5754 vec![],
5755 crate::domain::LanguageId::Rust,
5756 );
5757 let index = make_index(vec![(k1, f1), (k2, f2)]);
5758 let result = file_tree(&index, "", 3);
5759 assert!(
5761 result.contains("files"),
5762 "footer should mention files; got: {result}"
5763 );
5764 assert!(
5765 result.contains("symbols"),
5766 "footer should mention symbols; got: {result}"
5767 );
5768 }
5769
5770 #[test]
5771 fn test_file_tree_respects_path_filter() {
5772 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5773 let (k1, f1) = make_file_with_lang(
5774 "src/a.rs",
5775 b"fn foo() {}",
5776 vec![sym],
5777 crate::domain::LanguageId::Rust,
5778 );
5779 let (k2, f2) = make_file_with_lang(
5780 "tests/b.rs",
5781 b"fn test_b() {}",
5782 vec![],
5783 crate::domain::LanguageId::Rust,
5784 );
5785 let index = make_index(vec![(k1, f1), (k2, f2)]);
5786 let result = file_tree(&index, "src", 3);
5787 assert!(
5788 result.contains("a.rs"),
5789 "src filter should show a.rs; got: {result}"
5790 );
5791 assert!(
5792 !result.contains("b.rs"),
5793 "src filter should not show tests/b.rs; got: {result}"
5794 );
5795 }
5796
5797 #[test]
5798 fn test_file_tree_repeated_basenames_remain_hierarchical() {
5799 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5800 let index = make_index(vec![
5801 make_file_with_lang(
5802 "src/live_index/mod.rs",
5803 b"fn foo() {}",
5804 vec![sym.clone()],
5805 crate::domain::LanguageId::Rust,
5806 ),
5807 make_file_with_lang(
5808 "src/protocol/mod.rs",
5809 b"fn foo() {}",
5810 vec![sym],
5811 crate::domain::LanguageId::Rust,
5812 ),
5813 ]);
5814 let result = file_tree(&index, "", 3);
5815 assert!(result.contains("live_index/"), "got: {result}");
5816 assert!(result.contains("protocol/"), "got: {result}");
5817 assert!(!result.contains("live_index/mod.rs"), "got: {result}");
5818 assert!(!result.contains("protocol/mod.rs"), "got: {result}");
5819 }
5820
5821 #[test]
5822 fn test_file_tree_depth_collapses_deep_directories() {
5823 let sym = make_symbol("deep", SymbolKind::Function, 0, 1, 3);
5825 let (k1, f1) = make_file_with_lang(
5826 "src/deep/nested/file.rs",
5827 b"fn deep() {}",
5828 vec![sym],
5829 crate::domain::LanguageId::Rust,
5830 );
5831 let index = make_index(vec![(k1, f1)]);
5832 let result = file_tree(&index, "", 1);
5833 assert!(
5835 !result.contains("file.rs"),
5836 "file.rs should be collapsed at depth=1; got: {result}"
5837 );
5838 }
5839
5840 #[test]
5841 fn test_file_tree_empty_index() {
5842 let index = make_index(vec![]);
5843 let result = file_tree(&index, "", 2);
5844 assert!(
5845 result.contains("0 files") || result.contains("No source files"),
5846 "got: {result}"
5847 );
5848 }
5849
5850 #[test]
5851 fn test_repo_map_shows_tier2_tagged() {
5852 use crate::domain::index::{AdmissionDecision, AdmissionTier, SkipReason, SkippedFile};
5853
5854 let skipped = vec![SkippedFile {
5856 path: "model.safetensors".to_string(),
5857 size: 4_509_715_456, extension: Some("safetensors".to_string()),
5859 decision: AdmissionDecision {
5860 tier: AdmissionTier::MetadataOnly,
5861 reason: Some(SkipReason::DenylistedExtension),
5862 },
5863 }];
5864
5865 let result = file_tree_view_with_skipped(&[], &skipped, "", 2);
5866 assert!(
5867 result.contains("[skipped:"),
5868 "expected [skipped: tag for Tier 2 file, got: {result}"
5869 );
5870 assert!(
5871 result.contains("model.safetensors"),
5872 "expected filename in output, got: {result}"
5873 );
5874 assert!(
5875 result.contains("artifact"),
5876 "expected SkipReason display 'artifact' in tag, got: {result}"
5877 );
5878 assert!(
5880 !result.contains("hard-skipped"),
5881 "should not have tier3 footer, got: {result}"
5882 );
5883 }
5884
5885 #[test]
5886 fn test_repo_map_tier3_footer_only() {
5887 use crate::domain::index::{AdmissionDecision, AdmissionTier, SkipReason, SkippedFile};
5888
5889 let skipped = vec![
5890 SkippedFile {
5891 path: "data/huge1.bin".to_string(),
5892 size: 200 * 1024 * 1024,
5893 extension: Some("bin".to_string()),
5894 decision: AdmissionDecision {
5895 tier: AdmissionTier::HardSkip,
5896 reason: Some(SkipReason::SizeCeiling),
5897 },
5898 },
5899 SkippedFile {
5900 path: "data/huge2.bin".to_string(),
5901 size: 300 * 1024 * 1024,
5902 extension: Some("bin".to_string()),
5903 decision: AdmissionDecision {
5904 tier: AdmissionTier::HardSkip,
5905 reason: Some(SkipReason::SizeCeiling),
5906 },
5907 },
5908 ];
5909
5910 let sym = make_symbol("foo", SymbolKind::Function, 0, 1, 3);
5911 let (k, f) = make_file_with_lang(
5912 "src/main.rs",
5913 b"fn foo() {}",
5914 vec![sym],
5915 crate::domain::LanguageId::Rust,
5916 );
5917 let index = make_index(vec![(k, f)]);
5918 let view = index.capture_repo_outline_view();
5919
5920 let result = file_tree_view_with_skipped(&view.files, &skipped, "", 2);
5921
5922 assert!(
5924 !result.contains("huge1.bin"),
5925 "Tier 3 file should not be in tree, got: {result}"
5926 );
5927 assert!(
5928 !result.contains("huge2.bin"),
5929 "Tier 3 file should not be in tree, got: {result}"
5930 );
5931 assert!(
5933 result.contains("2 hard-skipped"),
5934 "expected '2 hard-skipped' footer, got: {result}"
5935 );
5936 assert!(
5937 result.contains("not shown (>100MB)"),
5938 "expected '>100MB' in footer, got: {result}"
5939 );
5940 assert!(
5942 result.contains("main.rs"),
5943 "indexed file should appear, got: {result}"
5944 );
5945 }
5946
5947 #[test]
5948 fn test_format_type_dependencies_renders_bodies_and_depth() {
5949 let deps = vec![
5950 TypeDependencyView {
5951 name: "UserConfig".to_string(),
5952 kind_label: "struct".to_string(),
5953 file_path: "src/config.rs".to_string(),
5954 line_range: (0, 2),
5955 body: "pub struct UserConfig {\n pub name: String,\n}".to_string(),
5956 depth: 0,
5957 },
5958 TypeDependencyView {
5959 name: "Address".to_string(),
5960 kind_label: "struct".to_string(),
5961 file_path: "src/address.rs".to_string(),
5962 line_range: (0, 1),
5963 body: "pub struct Address {\n pub city: String,\n}".to_string(),
5964 depth: 1,
5965 },
5966 ];
5967 let result = format_type_dependencies(&deps);
5968 assert!(
5969 result.contains("Dependencies (2):"),
5970 "header missing, got: {result}"
5971 );
5972 assert!(
5973 result.contains("── UserConfig [struct, src/config.rs:1-3] ──"),
5974 "UserConfig entry missing (0-based 0-2 displayed as 1-based 1-3), got: {result}"
5975 );
5976 assert!(
5977 result.contains("pub struct UserConfig"),
5978 "UserConfig body missing, got: {result}"
5979 );
5980 assert!(
5981 result.contains("(depth 1)"),
5982 "depth marker missing for Address, got: {result}"
5983 );
5984 assert!(
5986 !result.contains("(depth 0)"),
5987 "depth 0 should have no marker, got: {result}"
5988 );
5989 }
5990
5991 #[test]
5992 fn test_extract_declaration_name_rust_fn() {
5993 assert_eq!(
5994 super::extract_declaration_name("pub fn hello_world() -> String {"),
5995 Some("hello_world".to_string())
5996 );
5997 assert_eq!(
5998 super::extract_declaration_name("fn main() {"),
5999 Some("main".to_string())
6000 );
6001 assert_eq!(
6002 super::extract_declaration_name("pub(crate) async fn process(x: u32) -> Result {"),
6003 Some("process".to_string())
6004 );
6005 }
6006
6007 #[test]
6008 fn test_extract_declaration_name_struct() {
6009 assert_eq!(
6010 super::extract_declaration_name("pub struct Config {"),
6011 Some("Config".to_string())
6012 );
6013 assert_eq!(
6014 super::extract_declaration_name("struct Inner;"),
6015 Some("Inner".to_string())
6016 );
6017 }
6018
6019 #[test]
6020 fn test_extract_declaration_name_non_declaration() {
6021 assert_eq!(super::extract_declaration_name("let x = 5;"), None);
6022 assert_eq!(
6023 super::extract_declaration_name("// fn commented_out()"),
6024 None
6025 );
6026 assert_eq!(
6027 super::extract_declaration_name("use std::collections::HashMap;"),
6028 None
6029 );
6030 }
6031
6032 #[test]
6033 fn test_extract_declaration_name_csharp_const() {
6034 assert_eq!(
6036 super::extract_declaration_name("const string ConnectionString = \"...\";"),
6037 Some("ConnectionString".to_string())
6038 );
6039 assert_eq!(
6040 super::extract_declaration_name("const int MaxRetries = 3;"),
6041 Some("MaxRetries".to_string())
6042 );
6043 assert_eq!(
6045 super::extract_declaration_name("const MAX_SIZE: usize = 100;"),
6046 Some("MAX_SIZE".to_string())
6047 );
6048 }
6049
6050 #[test]
6053 fn test_extract_signature_single_line_full_decl() {
6054 let body = "pub fn foo<T: Display>(x: T) -> Result<String> {\n todo!()\n}";
6056 assert_eq!(
6057 super::extract_signature(body),
6058 "pub fn foo<T: Display>(x: T) -> Result<String>"
6059 );
6060 }
6061
6062 #[test]
6063 fn test_extract_signature_pub_crate_visibility() {
6064 let body = "pub(crate) fn bar(x: i32) -> bool {\n x > 0\n}";
6065 assert_eq!(
6066 super::extract_signature(body),
6067 "pub(crate) fn bar(x: i32) -> bool"
6068 );
6069 }
6070
6071 #[test]
6072 fn test_extract_signature_multi_line_joins_to_one_line() {
6073 let body = "pub fn process<T>(\n input: T,\n verbose: bool,\n) -> Result<String> {\n todo!()\n}";
6075 let sig = super::extract_signature(body);
6076 assert!(
6078 !sig.contains('\n'),
6079 "signature must be one line, got: {sig:?}"
6080 );
6081 assert!(
6083 sig.contains("pub fn process"),
6084 "missing pub fn process: {sig:?}"
6085 );
6086 assert!(sig.contains("<T>"), "missing generic: {sig:?}");
6087 assert!(
6088 sig.contains("-> Result<String>"),
6089 "missing return type: {sig:?}"
6090 );
6091 }
6092
6093 #[test]
6094 fn test_extract_signature_skips_doc_comments() {
6095 let body = "/// Does something important\n/// Multi-line doc\npub fn documented() -> u32 {\n 42\n}";
6096 let sig = super::extract_signature(body);
6097 assert_eq!(sig, "pub fn documented() -> u32");
6098 }
6099
6100 #[test]
6101 fn test_extract_signature_struct_with_generics() {
6102 let body = "pub struct Wrapper<T: Clone> {\n inner: T,\n}";
6103 let sig = super::extract_signature(body);
6104 assert_eq!(sig, "pub struct Wrapper<T: Clone>");
6105 }
6106
6107 #[test]
6108 fn test_extract_signature_trait_decl() {
6109 let body = "pub trait Processor: Send + Sync {\n fn process(&self);\n}";
6110 let sig = super::extract_signature(body);
6111 assert_eq!(sig, "pub trait Processor: Send + Sync");
6112 }
6113
6114 #[test]
6115 fn test_apply_verbosity_signature_is_one_line() {
6116 let body = "pub fn foo<T: Display>(x: T) -> Result<String> {\n let a = 1;\n let b = 2;\n todo!()\n}";
6118 let result = super::apply_verbosity(body, "signature");
6119 assert!(
6120 !result.contains('\n'),
6121 "signature verbosity must produce one line, got: {result:?}"
6122 );
6123 assert!(
6124 result.contains("pub fn foo"),
6125 "must include pub fn foo: {result:?}"
6126 );
6127 assert!(
6128 result.contains("-> Result<String>"),
6129 "must include return type: {result:?}"
6130 );
6131 }
6132
6133 #[test]
6134 fn test_apply_verbosity_full_returns_whole_body() {
6135 let body = "pub fn foo() {\n let x = 1;\n}";
6136 assert_eq!(super::apply_verbosity(body, "full"), body);
6137 }
6138
6139 #[test]
6140 fn test_apply_verbosity_compact_includes_doc() {
6141 let body = "/// Does the thing\npub fn bar() -> u32 {\n 1\n}";
6142 let result = super::apply_verbosity(body, "compact");
6143 assert!(
6144 result.contains("pub fn bar() -> u32"),
6145 "missing sig: {result:?}"
6146 );
6147 assert!(result.contains("Does the thing"), "missing doc: {result:?}");
6148 assert!(
6149 !result.contains("1\n"),
6150 "body should not be in compact: {result:?}"
6151 );
6152 }
6153}
6154
6155pub fn explore_result_view(
6157 label: &str,
6158 symbol_hits: &[(String, String, String)], text_hits: &[(String, String, usize)], related_files: &[(String, usize)], enriched_symbols: &[(String, String, String, Option<String>, Vec<String>)],
6162 symbol_impls: &[(String, Vec<String>)],
6163 symbol_deps: &[(String, Vec<String>)],
6164 depth: u32,
6165) -> String {
6166 let mut lines = vec![format!("── Exploring: {label} ──")];
6167 lines.push(String::new());
6168
6169 if depth >= 2 && !enriched_symbols.is_empty() {
6170 lines.push(format!("Symbols ({} found):", symbol_hits.len()));
6172 for (name, kind, path, signature, dependents) in enriched_symbols {
6173 if let Some(sig) = signature {
6174 let first_line = sig.lines().next().unwrap_or(sig);
6176 lines.push(format!(" {first_line} [{kind}, {path}]"));
6177 } else {
6178 lines.push(format!(" {kind} {name} {path}"));
6179 }
6180 if !dependents.is_empty() {
6181 lines.push(format!(" <- used by: {}", dependents.join(", ")));
6182 }
6183 }
6184 if symbol_hits.len() > enriched_symbols.len() {
6186 for (name, kind, path) in &symbol_hits[enriched_symbols.len()..] {
6187 lines.push(format!(" {kind} {name} {path}"));
6188 }
6189 }
6190 lines.push(String::new());
6191 } else if !symbol_hits.is_empty() {
6192 lines.push(format!("Symbols ({} found):", symbol_hits.len()));
6194 for (name, kind, path) in symbol_hits {
6195 lines.push(format!(" {kind} {name} {path}"));
6196 }
6197 lines.push(String::new());
6198 }
6199
6200 if depth >= 3 && symbol_impls.is_empty() && symbol_deps.is_empty() {
6202 lines.push("No implementations or type dependencies found for top symbols.".to_string());
6203 lines.push(String::new());
6204 }
6205 if depth >= 3 && !symbol_impls.is_empty() {
6206 lines.push("Implementations:".to_string());
6207 for (name, impls) in symbol_impls {
6208 lines.push(format!(" {name}:"));
6209 for imp in impls {
6210 lines.push(format!(" -> {imp}"));
6211 }
6212 }
6213 lines.push(String::new());
6214 }
6215
6216 if depth >= 3 && !symbol_deps.is_empty() {
6217 lines.push("Type dependencies:".to_string());
6218 for (name, deps) in symbol_deps {
6219 lines.push(format!(" {name}:"));
6220 for dep in deps {
6221 lines.push(format!(" -> {dep}"));
6222 }
6223 }
6224 lines.push(String::new());
6225 }
6226
6227 if !text_hits.is_empty() {
6228 lines.push(format!("Code patterns ({} found):", text_hits.len()));
6229 let mut last_path: Option<&str> = None;
6230 for (path, line, line_number) in text_hits {
6231 if last_path != Some(path.as_str()) {
6232 lines.push(format!(" {path}"));
6233 last_path = Some(path.as_str());
6234 }
6235 lines.push(format!(" > {line_number}: {line}"));
6236 }
6237 lines.push(String::new());
6238 }
6239
6240 if !related_files.is_empty() {
6241 lines.push("Related files:".to_string());
6242 for (path, count) in related_files {
6243 lines.push(format!(" {path} ({count} matches)"));
6244 }
6245 }
6246
6247 if symbol_hits.is_empty() && text_hits.is_empty() {
6248 lines.push("No matches found.".to_string());
6249 }
6250
6251 lines.join("\n")
6252}
6253
6254pub fn get_co_changes_result_view(
6256 path: &str,
6257 history: &crate::live_index::git_temporal::GitFileHistory,
6258 limit: usize,
6259) -> String {
6260 let mut lines = Vec::new();
6261
6262 lines.push(format!("Git temporal data for {path}"));
6263 lines.push(String::new());
6264
6265 lines.push(format!(
6267 "Churn score: {:.2} ({} commits)",
6268 history.churn_score, history.commit_count
6269 ));
6270
6271 let c = &history.last_commit;
6273 lines.push(format!(
6274 "Last commit: {} {} — {} ({})",
6275 c.hash, c.timestamp, c.message_head, c.author
6276 ));
6277 lines.push(String::new());
6278
6279 if !history.contributors.is_empty() {
6281 lines.push("Ownership:".to_string());
6282 for contrib in &history.contributors {
6283 lines.push(format!(
6284 " {}: {} commits ({:.0}%)",
6285 contrib.author, contrib.commit_count, contrib.percentage
6286 ));
6287 }
6288 lines.push(String::new());
6289 }
6290
6291 if history.co_changes.is_empty() {
6293 lines.push("No co-changing files detected.".to_string());
6294 } else {
6295 lines.push(format!(
6296 "Co-changing files (top {}):",
6297 limit.min(history.co_changes.len())
6298 ));
6299 for entry in history.co_changes.iter().take(limit) {
6300 lines.push(format!(
6301 " {:<50} coupling: {:.3} ({} shared commits)",
6302 entry.path, entry.coupling_score, entry.shared_commits
6303 ));
6304 }
6305 }
6306
6307 lines.join("\n")
6308}
6309
6310pub fn diff_symbols_result_view(
6312 base: &str,
6313 target: &str,
6314 changed_files: &[&str],
6315 repo: &crate::git::GitRepo,
6316 compact: bool,
6317 summary_only: bool,
6318) -> String {
6319 use std::collections::HashMap;
6320
6321 let mut lines = Vec::new();
6322 lines.push(format!("Symbol diff: {base}...{target}"));
6323 lines.push(format!("{} files changed", changed_files.len()));
6324 lines.push(String::new());
6325
6326 let mut total_added = 0usize;
6327 let mut total_removed = 0usize;
6328 let mut total_modified = 0usize;
6329 let mut files_with_changes = 0usize;
6330
6331 for file_path in changed_files {
6332 let base_content = repo
6334 .file_at_ref(base, file_path)
6335 .unwrap_or_default()
6336 .unwrap_or_default();
6337
6338 let target_content = repo
6339 .file_at_ref(target, file_path)
6340 .unwrap_or_default()
6341 .unwrap_or_default();
6342
6343 let base_symbols = extract_symbol_signatures(&base_content);
6345 let target_symbols = extract_symbol_signatures(&target_content);
6346
6347 let base_names: HashMap<&str, &str> = base_symbols
6348 .iter()
6349 .map(|(n, s)| (n.as_str(), s.as_str()))
6350 .collect();
6351 let target_names: HashMap<&str, &str> = target_symbols
6352 .iter()
6353 .map(|(n, s)| (n.as_str(), s.as_str()))
6354 .collect();
6355
6356 let mut file_added = Vec::new();
6357 let mut file_removed = Vec::new();
6358 let mut file_modified = Vec::new();
6359
6360 for (name, sig) in &target_names {
6362 match base_names.get(name) {
6363 None => file_added.push(*name),
6364 Some(base_sig) if base_sig != sig => file_modified.push(*name),
6365 _ => {}
6366 }
6367 }
6368
6369 for name in base_names.keys() {
6371 if !target_names.contains_key(name) {
6372 file_removed.push(*name);
6373 }
6374 }
6375
6376 if file_added.is_empty() && file_removed.is_empty() && file_modified.is_empty() {
6377 continue; }
6379
6380 total_added += file_added.len();
6381 total_removed += file_removed.len();
6382 total_modified += file_modified.len();
6383 files_with_changes += 1;
6384
6385 if !summary_only {
6386 if compact {
6387 let mut parts = Vec::new();
6389 if !file_added.is_empty() {
6390 parts.push(format!("+{}", file_added.len()));
6391 }
6392 if !file_removed.is_empty() {
6393 parts.push(format!("-{}", file_removed.len()));
6394 }
6395 if !file_modified.is_empty() {
6396 parts.push(format!("~{}", file_modified.len()));
6397 }
6398 lines.push(format!(" {} ({})", file_path, parts.join(", ")));
6399 } else {
6400 lines.push(format!("── {} ──", file_path));
6401 if !file_added.is_empty() {
6402 let mut sorted = file_added.clone();
6403 sorted.sort_unstable();
6404 for name in &sorted {
6405 lines.push(format!(" + {name}"));
6406 }
6407 }
6408 if !file_removed.is_empty() {
6409 let mut sorted = file_removed.clone();
6410 sorted.sort_unstable();
6411 for name in &sorted {
6412 lines.push(format!(" - {name}"));
6413 }
6414 }
6415 if !file_modified.is_empty() {
6416 let mut sorted = file_modified.clone();
6417 sorted.sort_unstable();
6418 for name in &sorted {
6419 lines.push(format!(" ~ {name}"));
6420 }
6421 }
6422 lines.push(String::new());
6423 }
6424 }
6425 }
6426
6427 lines.push(format!(
6429 "Summary: +{total_added} added, -{total_removed} removed, ~{total_modified} modified"
6430 ));
6431 let files_with_symbol_changes = total_added + total_removed + total_modified;
6432 if files_with_symbol_changes == 0 && !changed_files.is_empty() {
6433 lines.push(format!(
6434 "Note: {} file(s) changed but no symbol boundaries were affected (changes in comments, whitespace, or non-symbol code).",
6435 changed_files.len()
6436 ));
6437 }
6438
6439 if compact && files_with_changes > 0 && changed_files.len() > files_with_changes {
6440 let omitted = changed_files.len() - files_with_changes;
6441 lines.push(format!(
6442 "({omitted} file(s) with only non-symbol changes omitted)"
6443 ));
6444 }
6445
6446 lines.join("\n")
6447}
6448
6449fn extract_symbol_signatures(content: &str) -> Vec<(String, String)> {
6452 let mut symbols = Vec::new();
6453 for line in content.lines() {
6454 let trimmed = line.trim();
6455 if trimmed.is_empty()
6457 || trimmed.starts_with("//")
6458 || trimmed.starts_with('#')
6459 || trimmed.starts_with("/*")
6460 || trimmed.starts_with('*')
6461 || trimmed.starts_with("use ")
6462 || trimmed.starts_with("import ")
6463 || trimmed.starts_with("from ")
6464 {
6465 continue;
6466 }
6467
6468 let name = extract_declaration_name(trimmed);
6470 if let Some(name) = name {
6471 symbols.push((name, trimmed.to_string()));
6472 }
6473 }
6474 symbols
6475}
6476
6477fn is_likely_type_keyword(word: &str) -> bool {
6480 matches!(
6481 word,
6482 "string"
6483 | "String"
6484 | "int"
6485 | "Int32"
6486 | "Int64"
6487 | "bool"
6488 | "Boolean"
6489 | "float"
6490 | "double"
6491 | "decimal"
6492 | "char"
6493 | "byte"
6494 | "long"
6495 | "short"
6496 | "uint"
6497 | "object"
6498 | "var"
6499 | "number"
6500 | "bigint"
6501 | "any"
6502 )
6503}
6504
6505pub(crate) fn extract_declaration_name(line: &str) -> Option<String> {
6507 let stripped = if let Some(rest) = line.strip_prefix("pub") {
6509 if let Some(after_paren) = rest.strip_prefix('(') {
6510 if let Some(close) = after_paren.find(')') {
6512 after_paren[close + 1..].trim_start()
6513 } else {
6514 rest.trim_start()
6515 }
6516 } else {
6517 rest.trim_start()
6518 }
6519 } else if let Some(rest) = line.strip_prefix("export default ") {
6520 rest
6521 } else if let Some(rest) = line.strip_prefix("export ") {
6522 rest
6523 } else {
6524 line
6525 };
6526
6527 let keywords = [
6528 "async fn ",
6529 "fn ",
6530 "struct ",
6531 "enum ",
6532 "trait ",
6533 "type ",
6534 "const ",
6535 "static ",
6536 "class ",
6537 "interface ",
6538 "function ",
6539 "async function ",
6540 "async def ",
6541 "def ",
6542 ];
6543
6544 for kw in &keywords {
6545 if let Some(rest) = stripped.strip_prefix(kw) {
6546 let name: String = rest
6547 .chars()
6548 .take_while(|c| c.is_alphanumeric() || *c == '_')
6549 .collect();
6550 if name.is_empty() {
6551 continue;
6552 }
6553 if *kw == "const " && is_likely_type_keyword(&name) {
6556 let after_type = &rest[name.len()..].trim_start();
6557 let real_name: String = after_type
6558 .chars()
6559 .take_while(|c| c.is_alphanumeric() || *c == '_')
6560 .collect();
6561 if !real_name.is_empty() {
6562 return Some(real_name);
6563 }
6564 }
6565 return Some(name);
6566 }
6567 }
6568 None
6569}