Skip to main content

symforge/protocol/
format.rs

1/// Pure formatting functions for all 10 tool responses.
2///
3/// All functions take `&LiveIndex` (or data derived from it) and return `String`.
4/// No I/O, no async. Output matches the locked formats defined in CONTEXT.md.
5
6/// Budget limits for reference/dependent output to prevent unbounded token usage.
7pub struct OutputLimits {
8    /// Maximum number of files to include in the output.
9    pub max_files: usize,
10    /// Maximum number of reference/hit lines per file.
11    pub max_per_file: usize,
12    /// Maximum total hits across all files (max_files * max_per_file).
13    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
46/// Format the file outline for a given path.
47///
48/// Header: `{path}  ({N} symbols)`
49/// Body: each symbol indented by `depth * 2` spaces, then `{kind:<12} {name:<30} {start}-{end}`
50/// Not-found: "File not found: {path}"
51pub 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
82/// Compatibility renderer for `FileOutlineView`.
83///
84/// Main hot-path readers should prefer `file_outline_from_indexed_file()`.
85pub fn file_outline_view(view: &FileOutlineView) -> String {
86    render_file_outline(&view.relative_path, &view.symbols)
87}
88
89/// Return the full source body for a named symbol plus a footer.
90///
91/// Footer: `[{kind}, lines {start}-{end}, {byte_count} bytes]`
92/// Not-found: see `not_found_symbol`
93pub 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
119/// Compatibility renderer for `SymbolDetailView`.
120///
121/// Main hot-path readers should prefer `symbol_detail_from_indexed_file()`.
122pub 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
188/// Search for symbols matching a query (case-insensitive), with 3-tier scored ranking.
189///
190/// Output sections (only non-empty tiers shown):
191/// ```text
192/// ── Exact matches ──
193///   {line}: {kind} {name}  ({file})
194///
195/// ── Prefix matches ──
196///   ...
197///
198/// ── Substring matches ──
199///   ...
200/// ```
201/// Header: `{N} matches in {M} files`
202/// Empty: "No symbols matching '{query}'"
203pub 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
259/// Search for text content matches (case-insensitive substring).
260///
261/// For queries >= 3 chars, uses the TrigramIndex to select candidate files before scanning.
262/// For queries < 3 chars, falls back to scanning all files (trigram search handles this internally).
263///
264/// Header: `{N} matches in {M} files`
265/// Body: grouped by file, each match: `  {line_number}: {line_content}`
266/// Empty: "No matches for '{query}'"
267pub 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
281/// Returns true if the line looks like an import statement or a non-doc comment.
282pub 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            // Context mode: don't apply grouping — context windows don't compose well with it
362            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                    // One entry per unique enclosing symbol, showing match count
377                    // Preserve insertion order by tracking symbol names in order
378                    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                // None or Some("file") — default behavior
465                _ => {
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
513/// Generate a depth-limited source file tree with symbol counts per file and directory.
514///
515/// - `path`: subtree prefix filter (empty/blank = project root).
516/// - `depth`: maximum depth levels to expand (default 2, max 5).
517///
518/// Output format:
519/// ```text
520/// {dir}/  ({N} files, {M} symbols)
521///   {file} [{lang}]  ({K} symbols)
522///   {subdir}/  ({N} files, {M} symbols)
523/// ...
524/// {D} directories, {F} files, {S} symbols
525/// ```
526pub 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    // Collect all files whose relative_path starts with the path prefix.
536    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    // Build a tree: BTreeMap from directory path -> Vec<(filename, lang, symbol_count)>
557    // Node entries are keyed by their path component at each level.
558    use std::collections::BTreeMap;
559
560    // Strip the prefix from all paths before building the tree.
561    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    // Recursively build tree lines.
582    fn build_lines(
583        entries: &[(&str, &RepoOutlineFileView)],
584        current_depth: u32,
585        max_depth: u32,
586        indent: usize,
587    ) -> Vec<String> {
588        // Group by first path component.
589        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 at this level
606        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        // Directories at this level
625        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                // Collapsed — just show summary line
632                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                // nested
654            } else {
655                count += 1;
656            }
657        }
658        // also count files in sub-directories
659        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
715/// Format a byte count as a human-readable size string.
716fn 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
728/// Like `file_tree_view` but also incorporates skipped files:
729/// - Tier 2 (MetadataOnly) files appear in the tree with a `[skipped: {reason}, {size}]` tag.
730/// - Tier 3 (HardSkip) files do NOT appear in the tree; instead a footer line is appended:
731///   `{N} hard-skipped artifacts not shown (>100MB)`
732pub fn file_tree_view_with_skipped(
733    files: &[RepoOutlineFileView],
734    skipped: &[SkippedFile],
735    path: &str,
736    depth: u32,
737) -> String {
738    // Separate Tier 2 and Tier 3 skipped files, filtered to the path prefix.
739    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    // Build the base tree from indexed files.
762    let base = if tier2.is_empty() && files.is_empty() {
763        file_tree_view(files, path, depth)
764    } else {
765        // Build augmented file list: convert Tier 2 skipped files into synthetic
766        // RepoOutlineFileView entries so they appear in the tree with the skip tag appended.
767        // We render the base tree first, then inject Tier 2 entries separately.
768        file_tree_view(files, path, depth)
769    };
770
771    // If there are no indexed files and no Tier 2 skipped files, the base already handles it.
772    // We need to inject Tier 2 entries into the output.
773    // Strategy: build Tier 2 lines separately and splice into base before the footer.
774    // Simpler approach: re-render with Tier 2 files appended as extra lines after the base tree body.
775
776    // Split base output into body lines and footer (last line is always the summary).
777    let mut lines: Vec<String> = base.lines().map(String::from).collect();
778    let footer = if lines.len() > 1 { lines.pop() } else { None };
779
780    // Build Tier 2 file lines. Each gets placed at the correct indentation by stripping the prefix.
781    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        // Compute indentation from path depth.
809        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    // Re-append footer, then add Tier 3 footer if needed.
816    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
834/// Generate a directory-tree overview of the repo.
835///
836/// Header: `{project_name}  ({N} files, {M} symbols)`
837/// Body: sorted paths, each: `  {filename:<20} {language:<12} {symbol_count} symbols`
838pub 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    // Always show full relative paths for orientation (not just disambiguated basenames).
851    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
872/// Generate a health report for the index.
873///
874/// Watcher state is read from `health_stats()` (Off defaults when no watcher is active).
875/// Use `health_report_with_watcher` when the live `WatcherInfo` should be reflected.
876///
877/// Format:
878/// ```text
879/// Status: {Ready|Empty|Degraded}
880/// Files:  {N} indexed ({P} parsed, {PP} partial, {F} failed)
881/// Symbols: {S}
882/// Loaded in: {D}ms
883/// Watcher: active ({E} events, last: {T}, debounce: {D}ms)
884///     or: degraded ({E} events processed before failure)
885///     or: off
886/// ```
887pub 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
901/// Generate a health report for the index with live watcher state.
902///
903/// Uses `health_stats_with_watcher` to incorporate the live `WatcherInfo` into the report.
904/// Called by the `health` tool handler in production (watcher is always available there).
905pub 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    // Preserve the existing formatter shape by reusing HealthStats.
946    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
1040/// List files changed since the given Unix timestamp.
1041///
1042/// If since_ts < loaded_at: return list of all files (entire index is "newer")
1043/// If since_ts >= loaded_at: return "No changes detected since last index load."
1044pub 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        // Entire index is newer — list all files
1052        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
1165/// Return raw file content, optionally sliced by 1-indexed line range.
1166///
1167/// Not-found: "File not found: {path}"
1168pub 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
1235/// Compatibility renderer for `FileContentView`.
1236///
1237/// Main hot-path readers should prefer `file_content_from_indexed_file()`.
1238pub 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    // Validate explicit line range against file length.
1300    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
1497/// Render a numbered excerpt covering the full symbol range `sym_start..=sym_end`
1498/// (1-indexed inclusive), extended by `context_lines` on each side.
1499/// When `max_lines` is set and the total exceeds it, truncate with a hint.
1500fn 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
1552/// Resolve an `around_symbol` selector to the symbol's full 1-indexed line range
1553/// `(start_line, end_line)`.  Both bounds are inclusive.
1554fn 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        // symbol_line is 1-based (from search_symbols output); line_range is 0-based.
1571        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
1676/// "File not found: {path}"
1677pub fn not_found_file(path: &str) -> String {
1678    format!("File not found: {path}")
1679}
1680
1681/// "No matches for '{query}' in {path}"
1682pub 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
1690/// "No symbol {name} in {path}. Close matches: {top 5 fuzzy matches}. Use get_file_context with sections=['outline'] for the full list."
1691pub 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
1707/// Simple edit-distance score for fuzzy matching (lower is closer).
1708fn fuzzy_distance(a: &str, b: &str) -> usize {
1709    let a_lower = a.to_lowercase();
1710    let b_lower = b.to_lowercase();
1711
1712    // Substring match gets highest priority (distance 0).
1713    if b_lower.contains(&a_lower) || a_lower.contains(&b_lower) {
1714        return 0;
1715    }
1716
1717    // Prefix match gets second priority.
1718    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    // Fall back to simple character overlap distance.
1728    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    // Rank by fuzzy distance and take top 5.
1748    // Filter out very short names (1-2 chars like "i", "d") that are usually
1749    // loop variables and produce unhelpful suggestions.
1750    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
1781/// Find all references for a name across the repo, grouped by file with 3-line context.
1782///
1783/// kind_filter: "call" | "import" | "type_usage" | "all" | None (all)
1784/// Output format matches CONTEXT.md decision AD-6 (compact human-readable).
1785pub 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()); // blank line
1818
1819    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()); // blank line between files
1853    }
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
1867/// Render a compact find_references result: file:line [kind] in symbol — no source text.
1868pub 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
1933/// Format results of `find_implementations`.
1934pub 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    // Group by trait name for readable output
1949    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
1978/// Find all files that import (depend on) the given path.
1979///
1980/// Output format: compact list grouped by importing file, each with import line.
1981pub 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()); // blank line
1999
2000    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()); // blank line between files
2015    }
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    // Remove trailing blank line
2023    while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
2024        lines.pop();
2025    }
2026
2027    lines.join("\n")
2028}
2029
2030/// Render a compact find_dependents result: file:line [kind] without source text.
2031pub 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            // still count but don't show individual lines
2073        }
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
2084/// Render a find_dependents result as a Mermaid flowchart.
2085pub 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
2135/// Render a find_dependents result as a Graphviz DOT digraph.
2136pub 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
2187/// Sanitize a file path into a valid Mermaid node ID (alphanumeric + underscores).
2188fn mermaid_node_id(path: &str) -> String {
2189    path.chars()
2190        .map(|c| if c.is_alphanumeric() { c } else { '_' })
2191        .collect()
2192}
2193
2194/// Escape a string for DOT label/node usage.
2195fn dot_escape(s: &str) -> String {
2196    s.replace('\\', "\\\\").replace('"', "\\\"")
2197}
2198
2199/// Get full context bundle for a symbol: definition body + callers + callees + type usages.
2200///
2201/// Each section is capped at 20 entries with "...and N more" overflow.
2202pub 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
2298/// Format results of `trace_symbol`.
2299pub 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
2402/// Format results of `inspect_match`.
2403pub 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            // 1. Excerpt
2417            output.push_str(&found.excerpt);
2418            output.push('\n');
2419
2420            // 2. Parent chain (shows full nesting context when deeper than 1 level)
2421            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            // 3. Enclosing symbol (deepest)
2432            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            // 4. Siblings
2439            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    // Detect if this section has deduplicated entries (any occurrence_count > 1).
2457    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 &section.entries {
2474        if is_external_symbol(&entry.display_name, &entry.file_path) {
2475            external_count += 1;
2476        }
2477
2478        // Build the name part, appending ×N for deduplicated entries.
2479        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        // Estimate external ratio from shown entries and extrapolate
2500        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            // For deduplicated sections, overflow is in unique callee names
2509            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
2534/// Heuristic: classify a symbol reference as external (stdlib/framework) vs project-defined.
2535fn is_external_symbol(name: &str, file_path: &str) -> bool {
2536    // No file path means it's a builtin/external
2537    if file_path.is_empty() {
2538        return true;
2539    }
2540    // Common stdlib/framework patterns across languages
2541    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    // Single-word lowercase names that are very common builtins
2568    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
2607/// Extract the full signature from a symbol body.
2608///
2609/// Handles common patterns: `fn foo(...)`, `pub struct Foo`, `class Bar`, etc.
2610/// Skips leading doc comments, then collects lines until the declaration is
2611/// complete (opening brace `{`, `where` clause, or terminating `;`).
2612/// Multi-line signatures are joined on one line with spaces, preserving
2613/// visibility, generic parameters, and return type.
2614fn 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            // Skip leading empty lines and doc/attribute comments
2623            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        // Stop once the signature is terminated:
2640        // - opens a body block: `{`
2641        // - `where` clause (generics constraints) — include the where line then stop at `{`
2642        // - declaration terminator: `;` (abstract methods, type aliases, extern fns)
2643        // - `=>` (match arm / single-expr lambda — stop collecting)
2644        if trimmed.ends_with('{')
2645            || trimmed.ends_with("where")
2646            || trimmed.ends_with(';')
2647            || trimmed == "{"
2648        {
2649            break;
2650        }
2651        // A line that IS just a where clause body — keep collecting until `{`
2652        // A plain `)` or `->` line means multi-line sig still continuing — keep going
2653        // But cap at 10 lines to avoid pulling in the entire body for edge cases
2654        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    // Join multi-line signatures onto a single line, collapsing extra whitespace
2664    let joined = sig_lines.join(" ");
2665    // Strip trailing ` {` or ` ;` from the end — the signature line should not
2666    // include the opening brace or semicolon
2667    let result = joined
2668        .trim_end_matches(" {")
2669        .trim_end_matches('{')
2670        .trim_end_matches(';')
2671        .trim();
2672    result.to_string()
2673}
2674
2675/// Extract the first doc-comment line from a symbol body.
2676///
2677/// Looks for `///`, `//!`, `/** ... */`, `# ...` (Python docstring-adjacent),
2678/// or `/* ... */` style comments immediately before/after the signature.
2679fn 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        // Rust doc comments
2686        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        // Rust inner doc comments
2693        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        // C-style block doc comments
2700        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        // XML doc comments (C#)
2707        if trimmed.starts_with("/// <summary>") || trimmed.starts_with("/// <remarks>") {
2708            continue; // skip XML tags, look for actual text
2709        }
2710        // Python/JS docstrings
2711        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 we hit a non-comment line, stop looking
2723        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
2734/// Apply verbosity filter to a symbol body.
2735///
2736/// - `"signature"`: full declaration line — visibility, name, generics, params, return type (~80% smaller).
2737/// - `"compact"`: signature + first doc-comment line.
2738/// - `"full"` or anything else: complete body (default).
2739pub(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
2853/// Enforce a max-token budget on an already-assembled output string.
2854///
2855/// If the output exceeds `max_tokens * 4` bytes it is truncated at a line
2856/// boundary and a clear notice is appended.  Returns the original string
2857/// unchanged when no budget is set or the output fits within the budget.
2858pub 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
2898/// "Index is loading... try again shortly."
2899pub fn loading_guard_message() -> String {
2900    "Index is loading... try again shortly.".to_string()
2901}
2902
2903/// "Index not loaded. Call index_folder to index a directory."
2904pub fn empty_guard_message() -> String {
2905    "Index not loaded. Call index_folder to index a directory.".to_string()
2906}
2907
2908/// Format a "Token Savings (this session)" section from a `StatsSnapshot`.
2909///
2910/// Input: `snap` — the `StatsSnapshot` from `TokenStats::summary()`.
2911/// Output: a multi-line string listing per-hook-type fire counts and token savings.
2912///
2913/// If all counters are zero, returns an empty string (no savings section shown).
2914/// This is a fail-open function — callers can append the result without checking emptiness.
2915///
2916/// ```text
2917/// ── Token Savings (this session) ──
2918/// Read:  N fires, ~M tokens saved
2919/// Edit:  N fires, ~M tokens saved
2920/// Write: N fires
2921/// Grep:  N fires, ~M tokens saved
2922/// Total: ~T tokens saved
2923/// ```
2924pub 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    // Show section only when at least one hook has fired.
2928    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
2964/// Format a "Tool Call Counts (this session)" section from per-tool invocation counts.
2965///
2966/// Input: `counts` — sorted slice of `(tool_name, count)` from `TokenStats::tool_call_counts()`.
2967/// Output: a multi-line string. Returns empty string when `counts` is empty.
2968///
2969/// ```text
2970/// ── Tool Call Counts (this session) ──
2971/// search_text:        12
2972/// get_file_context:    7
2973/// get_symbol:          3
2974/// ```
2975pub 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    // Align counts by padding tool names to the width of the longest name.
2982    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
2990/// Estimate tokens saved by a structured response vs raw file content.
2991/// Returns a one-line footer string, or empty string if no meaningful savings.
2992pub 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    // Rough token estimate: ~4 chars per token for code
2997    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
3006/// Format a "Hook Adoption (current session)" section from hook-time workflow counters.
3007pub(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    // Show daemon fallback total if any occurred.
3027    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    // Show a hint when all fail-open outcomes are due to no-sidecar.
3065    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
3074/// Format a compact "what next" hint line for tool outputs.
3075pub 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
3087/// Format a one-line git temporal summary for the health report.
3088pub 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    // --- Test helpers ---
3139
3140    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    // --- file_outline tests ---
3235
3236    #[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        // Method at depth 1 should be indented by 2 spaces
3280        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    // --- symbol_detail tests ---
3319
3320    #[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        // Filter for struct kind (0-based 4-9 displays as 1-based 5-10)
3361        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    // --- search_symbols_result tests ---
3406
3407    #[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    // --- search_text_result tests ---
3497
3498    #[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    // --- repo_outline tests ---
3655
3656    #[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    // --- health_report tests ---
3725
3726    #[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        // health_report with no watcher active should show "Watcher: off"
3767        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        // Verify health_stats_with_watcher populates Active state correctly;
3780        // we test the stats fields here since health_report calls health_stats() not
3781        // health_stats_with_watcher(). The format function is fully tested via the
3782        // watcher_state field on HealthStats.
3783        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    // --- what_changed_result tests ---
4030
4031    #[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        // since_ts=0 (epoch) is before index was loaded
4037        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        // since_ts=far future — no changes
4049        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    // --- file_content tests ---
4169
4170    #[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        // Lines 2-4 (1-indexed)
4186        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    // --- B2: around_symbol returns full indexed span ---
4427
4428    #[test]
4429    fn test_around_symbol_returns_full_multiline_body() {
4430        // 25-line function to verify we get the full body, not just 3-7 lines
4431        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        // Symbol spans lines 1..22 (0-indexed), i.e. "fn big_function() {" through "}"
4443        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        // Symbol is lines 2..23 (1-indexed), default context_lines=0
4457        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            // Symbol spans lines 1..5 (0-indexed), i.e. 6 lines: "fn connect() {" through "}"
4474            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); // 3 content lines + truncation hint
4492        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            // Symbol spans lines 2..4 (0-indexed)
4504            vec![make_symbol("connect", SymbolKind::Function, 0, 2, 4)],
4505        );
4506        let index = make_index(vec![(key, file)]);
4507
4508        // context_lines=2 should add 2 lines before and after the symbol
4509        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        // Symbol is lines 3-5 (1-indexed), context extends to 1-7
4516        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        // Doc comment is on line 0, function signature on line 1, body on lines 2-3
4551        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            // Symbol range includes the doc comment line (0..3)
4556            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    // --- guard messages ---
4572
4573    #[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    // --- not_found helpers ---
4590
4591    #[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    // ─── find_references_result tests ─────────────────────────────────────
4615
4616    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        // Content: 3 lines so we can test context extraction
4691        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        // Should only show the call reference, not the import
4732        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        // 3 files, each with 10 references → 30 total, but total_limit=15
4760        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        // Without total_hits limit, all 30 refs would be shown (max_per_file is high)
4774        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        // With total_hits=15, only 15 refs should be emitted
4786        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        // file_0 gets 10 hits, file_1 gets 5 hits before total_limit reached,
4794        // file_1 has 5 truncated, file_2 is skipped entirely
4795        assert!(
4796            result.contains("... and 5 more references"),
4797            "file_1 should show 5 truncated hits, got:\n{result}"
4798        );
4799        // file_2 should not appear (total_limit already reached before it)
4800        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        // 1 file with 20 references, max_per_file=5, total_hits=100
4809        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        // Should show 5 refs and truncate 15
4825        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        // file_0 gets 10 hits, file_1 gets 5 hits, file_1 truncates 5, file_2 skipped
4854        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    // ─── find_dependents_result tests ─────────────────────────────────────
4865
4866    #[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        // Also need "src/db.rs" in the index for find_dependents_for_file to work
4872        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        // Construct a view directly with 5 lines, but set max_per_file=2.
4981        // The mermaid label should show symbol names (all "db"), not just "5 refs".
4982        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); // max_per_file=2, but 5 actual refs
4998        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    // ─── context_bundle_result tests ──────────────────────────────────────
5031
5032    #[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        // Build 25 Call references to "process" from different positions
5061        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        // Add a process symbol as the target
5068        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    // --- format_token_savings tests ---
5262
5263    #[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        // Total = 100 + 50 + 200 = 350
5338        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        // Write has no savings — just fire count
5369        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        // Only read fired — edit and grep should not appear.
5378        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    // --- compact_savings_footer tests ---
5465
5466    #[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    // ── search_symbols tier ordering tests ───────────────────────────────────
5497
5498    #[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        // exact: "parse", prefix: "parse_file", substring: "do_parse"
5537        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        // "parse" must appear after "Exact matches" and before "Prefix matches"
5567        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        // Only exact match — prefix and substring headers must NOT appear
5580        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        // Only "a_fn" matches exactly — just verify it shows up in Exact
5609        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        // "parse" is query, "parse_x" (7 chars) should come before "parse_longer" (12 chars)
5616        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        // In the prefix section, parse_x must appear before parse_longer
5629        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    // ── file_tree tests ───────────────────────────────────────────────────────
5646
5647    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        // At depth 1, "src" directory should be shown collapsed with file/symbol counts
5736        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        // Footer must show directories, files, symbols totals
5760        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        // At depth=1, nested directories beyond root level should be collapsed
5824        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        // file.rs should not be individually listed at depth=1
5834        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        // No indexed files — only a Tier 2 skipped file.
5855        let skipped = vec![SkippedFile {
5856            path: "model.safetensors".to_string(),
5857            size: 4_509_715_456, // ~4.2 GB
5858            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        // Tier 3 footer should NOT appear.
5879        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        // Tier 3 files must NOT appear in the tree body.
5923        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        // Footer must appear.
5932        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        // The indexed file must still appear.
5941        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        // Direct dependency (depth 0) should NOT have depth marker.
5985        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        // C# const: `const string Foo = "bar"` — name is Foo, not string
6035        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        // Rust const should still work (type after colon, not before name)
6044        assert_eq!(
6045            super::extract_declaration_name("const MAX_SIZE: usize = 100;"),
6046            Some("MAX_SIZE".to_string())
6047        );
6048    }
6049
6050    // ─── extract_signature / apply_verbosity tests (U6) ──────────────────────
6051
6052    #[test]
6053    fn test_extract_signature_single_line_full_decl() {
6054        // Full single-line Rust fn — visibility, generics, params, return type all preserved
6055        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        // Multi-line fn signature — params on separate lines
6074        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        // Must be a single line
6077        assert!(
6078            !sig.contains('\n'),
6079            "signature must be one line, got: {sig:?}"
6080        );
6081        // Must include visibility, generics, return type
6082        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        // Verifies output stability — always one line regardless of body size
6117        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
6155/// Format the output of the `explore` tool.
6156pub fn explore_result_view(
6157    label: &str,
6158    symbol_hits: &[(String, String, String)], // (name, kind, path)
6159    text_hits: &[(String, String, usize)],    // (path, line, line_number)
6160    related_files: &[(String, usize)],        // (path, count)
6161    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        // Depth 2+: show enriched symbols with signatures
6171        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                // Show first line of signature only to keep it compact
6175                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        // Show remaining non-enriched symbols in compact form
6185        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        // Depth 1: original compact format
6193        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    // Depth 3: implementations + type dependencies
6201    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
6254/// Format git temporal data for a single file: churn, ownership, co-changes, last commit.
6255pub 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    // Churn
6266    lines.push(format!(
6267        "Churn score: {:.2} ({} commits)",
6268        history.churn_score, history.commit_count
6269    ));
6270
6271    // Last commit
6272    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    // Ownership
6280    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    // Co-changes
6292    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
6310/// Format symbol-level diff between two git refs.
6311pub 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        // Get content at base and target refs
6333        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        // Extract symbol names from both versions
6344        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        // Find added and modified
6361        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        // Find removed
6370        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; // No symbol-level changes
6378        }
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                // Compact mode: one line per file with counts only
6388                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    // Summary
6428    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
6449/// Extract symbol name → signature pairs from source code using simple pattern matching.
6450/// Returns Vec<(name, signature_line)> for functions, classes, structs, enums, traits, interfaces.
6451fn 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        // Skip empty, comments, imports
6456        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        // Match common symbol declaration patterns
6469        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
6477/// Check if a word is a well-known type keyword that would appear between
6478/// `const` and the actual variable name in C#, Java, or TypeScript.
6479fn 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
6505/// Try to extract a declaration name from a line of code.
6506pub(crate) fn extract_declaration_name(line: &str) -> Option<String> {
6507    // Strip leading visibility modifier generically: pub, pub(crate), pub(super), pub(in path).
6508    let stripped = if let Some(rest) = line.strip_prefix("pub") {
6509        if let Some(after_paren) = rest.strip_prefix('(') {
6510            // Skip balanced parens: pub(crate), pub(super), pub(in crate::foo)
6511            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            // For `const`, the first word might be a type name (C#: `const string Foo`).
6554            // If it looks like a well-known type, skip it and take the next identifier.
6555            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}