Skip to main content

aptu_coder_core/formatter/
summary.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Aggregate formatters for directory structure summaries and focused symbol summaries.
4
5use crate::formatter::emit;
6use crate::graph::{CallGraph, InternalCallChain};
7use crate::test_detection::is_test_file;
8use crate::traversal::WalkEntry;
9use crate::types::{DefUseKind, DefUseSite, FileInfo};
10use std::collections::{HashMap, HashSet};
11use std::fmt::Write;
12use std::path::{Path, PathBuf};
13use tracing::instrument;
14
15use super::render::FormatterError;
16
17/// Format directory structure analysis results.
18#[instrument(skip_all)]
19#[allow(clippy::too_many_lines)]
20pub fn format_structure(
21    entries: &[WalkEntry],
22    analysis_results: &[FileInfo],
23    max_depth: Option<u32>,
24) -> String {
25    let mut output = String::new();
26
27    let analysis_map: HashMap<String, &FileInfo> = analysis_results
28        .iter()
29        .map(|a| (a.path.clone(), a))
30        .collect();
31
32    let (prod_files, test_files): (Vec<_>, Vec<_>) =
33        analysis_results.iter().partition(|a| !a.is_test);
34
35    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
36    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
37    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
38
39    let mut lang_counts: HashMap<String, usize> = HashMap::new();
40    for analysis in analysis_results {
41        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
42    }
43    let total_files = analysis_results.len();
44
45    // Leading summary line with totals
46    let primary_lang = lang_counts
47        .iter()
48        .max_by_key(|&(_, count)| count)
49        .map_or_else(
50            || "unknown 0%".to_string(),
51            |(name, count)| {
52                let percentage = (*count * 100).checked_div(total_files).unwrap_or_default();
53                format!("{name} {percentage}%")
54            },
55        );
56
57    let _ = writeln!(
58        output,
59        "{total_files} files, {total_loc}L, {total_functions}F, {total_classes}C ({primary_lang})"
60    );
61
62    // SUMMARY block
63    output.push_str("SUMMARY:\n");
64    let depth_label = match max_depth {
65        Some(n) if n > 0 => format!(" (max_depth={n})"),
66        _ => String::new(),
67    };
68    let _ = writeln!(
69        output,
70        "Shown: {} files ({} prod, {} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}",
71        total_files,
72        prod_files.len(),
73        test_files.len()
74    );
75
76    if !lang_counts.is_empty() {
77        output.push_str("Languages: ");
78        let mut langs: Vec<_> = lang_counts.iter().collect();
79        langs.sort_by_key(|&(name, _)| name);
80        let lang_strs: Vec<String> = langs
81            .iter()
82            .map(|(name, count)| {
83                let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
84                format!("{name} ({percentage}%)")
85            })
86            .collect();
87        output.push_str(&lang_strs.join(", "));
88        output.push('\n');
89    }
90
91    output.push('\n');
92
93    // PATH block - tree structure
94    output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
95
96    let mut test_buf = String::new();
97
98    for entry in entries {
99        if entry.depth == 0 {
100            continue;
101        }
102
103        let indent = "  ".repeat(entry.depth - 1);
104
105        let name = entry
106            .path
107            .file_name()
108            .and_then(|n| n.to_str())
109            .unwrap_or("?");
110
111        if entry.is_dir {
112            let line = format!("{indent}{name}/\n");
113            output.push_str(&line);
114        } else if let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
115            && let Some(info_str) = emit::format_file_info_parts(
116                analysis.line_count,
117                analysis.function_count,
118                analysis.class_count,
119            )
120        {
121            let line = format!("{indent}{name} {info_str}\n");
122            if analysis.is_test {
123                test_buf.push_str(&line);
124            } else {
125                output.push_str(&line);
126            }
127        }
128    }
129
130    if !test_buf.is_empty() {
131        output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
132        output.push_str(&test_buf);
133    }
134
135    output
136}
137
138pub fn format_summary(
139    entries: &[WalkEntry],
140    analysis_results: &[FileInfo],
141    max_depth: Option<u32>,
142    subtree_counts: Option<&[(PathBuf, usize)]>,
143) -> String {
144    let mut output = String::new();
145
146    // Partition files into production and test
147    let (prod_files, test_files): (Vec<_>, Vec<_>) =
148        analysis_results.iter().partition(|a| !a.is_test);
149
150    // Calculate totals
151    let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
152    let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
153    let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
154
155    // Count files by language
156    let mut lang_counts: HashMap<String, usize> = HashMap::new();
157    for analysis in analysis_results {
158        *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
159    }
160    let total_files = analysis_results.len();
161
162    // SUMMARY block
163    output.push_str("SUMMARY:\n");
164    let depth_label = match max_depth {
165        Some(n) if n > 0 => format!(" (max_depth={n})"),
166        _ => String::new(),
167    };
168    let prod_count = prod_files.len();
169    let test_count = test_files.len();
170    let _ = writeln!(
171        output,
172        "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
173    );
174
175    if !lang_counts.is_empty() {
176        output.push_str("Languages: ");
177        let mut langs: Vec<_> = lang_counts.iter().collect();
178        langs.sort_unstable_by_key(|&(name, _)| name);
179        let lang_strs: Vec<String> = langs
180            .iter()
181            .map(|(name, count)| {
182                let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
183                format!("{name} ({percentage}%)")
184            })
185            .collect();
186        output.push_str(&lang_strs.join(", "));
187        output.push('\n');
188    }
189
190    output.push('\n');
191
192    // STRUCTURE (depth 1) block
193    output.push_str("STRUCTURE (depth 1):\n");
194
195    // Build a map of path -> analysis for quick lookup
196    let analysis_map: HashMap<String, &FileInfo> = analysis_results
197        .iter()
198        .map(|a| (a.path.clone(), a))
199        .collect();
200
201    // Collect depth-1 entries (directories and files at depth 1)
202    let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
203    depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
204
205    // Track largest non-excluded directory for SUGGESTION
206    let mut largest_dir_name: Option<String> = None;
207    let mut largest_dir_path: Option<String> = None;
208    let mut largest_dir_count: usize = 0;
209
210    for entry in depth1_entries {
211        let name = entry
212            .path
213            .file_name()
214            .and_then(|n| n.to_str())
215            .unwrap_or("?");
216
217        if entry.is_dir {
218            // For directories, aggregate stats from all files under this directory
219            let dir_path_str = entry.path.display().to_string();
220            let files_in_dir: Vec<&FileInfo> = analysis_results
221                .iter()
222                .filter(|f| Path::new(&f.path).starts_with(&entry.path))
223                .collect();
224
225            if files_in_dir.is_empty() {
226                // No analyzed files at this depth, but subtree_counts may have a true count
227                let entry_name_str = name.to_string();
228                if let Some(counts) = subtree_counts {
229                    let true_count = counts
230                        .binary_search_by_key(&&entry.path, |(p, _)| p)
231                        .ok()
232                        .map_or(0, |i| counts[i].1);
233                    if true_count > 0 {
234                        // Track for SUGGESTION
235                        if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
236                            && true_count > largest_dir_count
237                        {
238                            largest_dir_count = true_count;
239                            largest_dir_name = Some(entry_name_str);
240                            largest_dir_path = Some(
241                                entry
242                                    .path
243                                    .canonicalize()
244                                    .unwrap_or_else(|_| entry.path.clone())
245                                    .display()
246                                    .to_string(),
247                            );
248                        }
249                        let depth_val = max_depth.unwrap_or(0);
250                        let _ = writeln!(
251                            output,
252                            "  {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
253                        );
254                    } else {
255                        let _ = writeln!(output, "  {name}/");
256                    }
257                } else {
258                    let _ = writeln!(output, "  {name}/");
259                }
260            } else {
261                let dir_file_count = files_in_dir.len();
262                let (dir_loc, dir_functions, dir_classes) =
263                    emit::aggregate_dir_stats(&files_in_dir);
264
265                // Track largest non-excluded directory for SUGGESTION
266                let entry_name_str = name.to_string();
267                let effective_count = if let Some(counts) = subtree_counts {
268                    counts
269                        .binary_search_by_key(&&entry.path, |(p, _)| p)
270                        .ok()
271                        .map_or(dir_file_count, |i| counts[i].1)
272                } else {
273                    dir_file_count
274                };
275                if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
276                    && effective_count > largest_dir_count
277                {
278                    largest_dir_count = effective_count;
279                    largest_dir_name = Some(entry_name_str);
280                    largest_dir_path = Some(
281                        entry
282                            .path
283                            .canonicalize()
284                            .unwrap_or_else(|_| entry.path.clone())
285                            .display()
286                            .to_string(),
287                    );
288                }
289
290                // Build hint: top-N files sorted by class_count desc, fallback to function_count
291                let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
292                    let has_classes = files_in_dir.iter().any(|f| f.class_count > 0);
293                    let dir_path = Path::new(&dir_path_str);
294                    emit::render_top_files_section(&files_in_dir, dir_path, has_classes)
295                } else {
296                    String::new()
297                };
298
299                // Collect depth-2 sub-package directories (immediate children of this directory)
300                let mut subdirs: Vec<String> = entries
301                    .iter()
302                    .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
303                    .filter_map(|e| {
304                        e.path
305                            .file_name()
306                            .and_then(|n| n.to_str())
307                            .map(std::borrow::ToOwned::to_owned)
308                    })
309                    .collect();
310                subdirs.sort();
311                subdirs.dedup();
312                let subdir_suffix = if subdirs.is_empty() {
313                    String::new()
314                } else {
315                    let subdirs_capped: Vec<String> =
316                        subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
317                    let joined = subdirs_capped.join(", ");
318                    format!("  sub: {joined}")
319                };
320
321                let files_label = if let Some(counts) = subtree_counts {
322                    let true_count = counts
323                        .binary_search_by_key(&&entry.path, |(p, _)| p)
324                        .ok()
325                        .map_or(dir_file_count, |i| counts[i].1);
326                    if true_count == dir_file_count {
327                        format!(
328                            "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
329                        )
330                    } else {
331                        let depth_val = max_depth.unwrap_or(0);
332                        format!(
333                            "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
334                        )
335                    }
336                } else {
337                    format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
338                };
339                let _ = writeln!(output, "  {name}/ [{files_label}]{hint}{subdir_suffix}");
340            }
341        } else {
342            // For files, show individual stats
343            if let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
344                && let Some(info_str) = emit::format_file_info_parts(
345                    analysis.line_count,
346                    analysis.function_count,
347                    analysis.class_count,
348                )
349            {
350                let _ = writeln!(output, "  {name} {info_str}");
351            } else if analysis_map.contains_key(&entry.path.display().to_string()) {
352                let _ = writeln!(output, "  {name}");
353            }
354        }
355    }
356
357    output.push('\n');
358
359    // SUGGESTION block
360    if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
361        let _ = writeln!(
362            output,
363            "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
364        );
365    } else {
366        output.push_str("SUGGESTION:\n");
367        output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
368    }
369
370    output
371}
372
373/// Format a compact summary of file details for large `FileDetails` output.
374///
375/// Returns `FILE` header with path/LOC/counts, top 10 functions by line span descending,
376/// classes inline if <=10, import count, and suggestion block.
377#[instrument(skip_all)]
378
379/// Full-format focused symbol output (callers/callees with chain trees).
380pub(crate) fn format_focused_internal(
381    graph: &CallGraph,
382    symbol: &str,
383    follow_depth: u32,
384    base_path: Option<&Path>,
385    incoming_chains: Option<&[InternalCallChain]>,
386    outgoing_chains: Option<&[InternalCallChain]>,
387    def_use_sites: &[DefUseSite],
388) -> Result<String, FormatterError> {
389    let mut output = String::new();
390
391    // Compute all counts BEFORE output begins
392    let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
393
394    // Use pre-computed chains if provided, otherwise compute them
395    let (incoming_chains_vec, outgoing_chains_vec);
396    let (incoming_chains_ref, outgoing_chains_ref) =
397        if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
398            (inc, out)
399        } else {
400            incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
401            outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
402            (
403                incoming_chains_vec.as_slice(),
404                outgoing_chains_vec.as_slice(),
405            )
406        };
407
408    // Partition incoming_chains into production and test callers
409    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
410        incoming_chains_ref.iter().cloned().partition(|chain| {
411            chain
412                .chain
413                .first()
414                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
415        });
416
417    // Count unique callers
418    let callers_count = prod_chains
419        .iter()
420        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
421        .collect::<std::collections::HashSet<_>>()
422        .len();
423
424    // Count unique callees
425    let callees_count = outgoing_chains_ref
426        .iter()
427        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
428        .collect::<std::collections::HashSet<_>>()
429        .len();
430
431    // FOCUS section - with inline counts
432    let _ = writeln!(
433        output,
434        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
435    );
436
437    // DEPTH section
438    let _ = writeln!(output, "DEPTH: {follow_depth}");
439
440    // DEFINED section - show where the symbol is defined
441    if let Some(definitions) = graph.definitions.get(symbol) {
442        output.push_str("DEFINED:\n");
443        for (path, line) in definitions {
444            let display = emit::strip_base_path(path, base_path);
445            let _ = writeln!(output, "  {display}:{line}");
446        }
447    } else {
448        output.push_str("DEFINED: (not found)\n");
449    }
450
451    // CALLERS section - who calls this symbol
452    output.push_str("CALLERS:\n");
453
454    // Render production callers
455    let prod_refs: Vec<_> = prod_chains
456        .iter()
457        .filter_map(|chain| {
458            if chain.chain.len() >= 2 {
459                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
460            } else if chain.chain.len() == 1 {
461                Some((chain.chain[0].0.as_str(), ""))
462            } else {
463                None
464            }
465        })
466        .collect();
467
468    if prod_refs.is_empty() {
469        output.push_str("  (none)\n");
470    } else {
471        output.push_str(&crate::formatter::pagination::format_chains_as_tree(
472            &prod_refs, "<-", symbol,
473        ));
474    }
475
476    // Render test callers summary if any
477    if !test_chains.is_empty() {
478        let mut test_files: Vec<_> = test_chains
479            .iter()
480            .filter_map(|chain| {
481                chain
482                    .chain
483                    .first()
484                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
485            })
486            .collect();
487        test_files.sort();
488        test_files.dedup();
489
490        // Strip base path for display
491        let display_files: Vec<_> = test_files
492            .iter()
493            .map(|f| emit::strip_base_path(Path::new(f), base_path))
494            .collect();
495
496        let file_list = display_files.join(", ");
497        let test_count = test_chains.len();
498        let _ = writeln!(
499            output,
500            "CALLERS (test): {test_count} test functions (in {file_list})"
501        );
502    }
503
504    // CALLEES section - what this symbol calls
505    output.push_str("CALLEES:\n");
506    let outgoing_refs: Vec<_> = outgoing_chains_ref
507        .iter()
508        .filter_map(|chain| {
509            if chain.chain.len() >= 2 {
510                Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
511            } else if chain.chain.len() == 1 {
512                Some((chain.chain[0].0.as_str(), ""))
513            } else {
514                None
515            }
516        })
517        .collect();
518
519    if outgoing_refs.is_empty() {
520        output.push_str("  (none)\n");
521    } else {
522        output.push_str(&crate::formatter::pagination::format_chains_as_tree(
523            &outgoing_refs,
524            "->",
525            symbol,
526        ));
527    }
528
529    // FILES section - collect unique files from production chains
530    let mut files: HashSet<PathBuf> = HashSet::new();
531    for chain in &prod_chains {
532        for (_, path, _) in &chain.chain {
533            files.insert(path.clone());
534        }
535    }
536    for chain in outgoing_chains_ref {
537        for (_, path, _) in &chain.chain {
538            files.insert(path.clone());
539        }
540    }
541    if let Some(definitions) = graph.definitions.get(symbol) {
542        for (path, _) in definitions {
543            files.insert(path.clone());
544        }
545    }
546
547    // Partition files into production and test
548    let (prod_files, test_files): (Vec<_>, Vec<_>) =
549        files.into_iter().partition(|path| !is_test_file(path));
550
551    output.push_str("FILES:\n");
552    if prod_files.is_empty() && test_files.is_empty() {
553        output.push_str("  (none)\n");
554    } else {
555        // Show production files first
556        if !prod_files.is_empty() {
557            let mut sorted_files = prod_files;
558            sorted_files.sort();
559            for file in sorted_files {
560                let display = emit::strip_base_path(&file, base_path);
561                let _ = writeln!(output, "  {display}");
562            }
563        }
564
565        // Show test files in separate subsection
566        if !test_files.is_empty() {
567            output.push_str("  TEST FILES:\n");
568            let mut sorted_files = test_files;
569            sorted_files.sort();
570            for file in sorted_files {
571                let display = emit::strip_base_path(&file, base_path);
572                let _ = writeln!(output, "    {display}");
573            }
574        }
575    }
576
577    // DEF-USE SITES section - show writes and reads of the symbol
578    if !def_use_sites.is_empty() {
579        let (write_count, read_count) = emit::def_use_write_read_counts(def_use_sites);
580        let total = def_use_sites.len();
581        let _ = writeln!(
582            output,
583            "DEF-USE SITES  {symbol}  ({total} total: {write_count} writes, {read_count} reads)"
584        );
585
586        emit::render_def_use_group(
587            &mut output,
588            def_use_sites,
589            "WRITES",
590            |s| matches!(s.kind, DefUseKind::Write | DefUseKind::WriteRead),
591            base_path,
592        );
593        emit::render_def_use_group(
594            &mut output,
595            def_use_sites,
596            "READS",
597            |s| s.kind == DefUseKind::Read,
598            base_path,
599        );
600    }
601
602    Ok(output)
603}
604
605/// Format a compact summary of focused symbol analysis.
606/// Used when output would exceed the size threshold or when explicitly requested.
607/// Internal helper that accepts pre-computed chains.
608#[instrument(skip_all)]
609#[allow(clippy::too_many_lines)] // exhaustive symbol summary formatting; splitting harms readability
610#[allow(clippy::similar_names)] // domain pairs: callers_count/callees_count are intentionally similar
611pub(crate) fn format_focused_summary_internal(
612    graph: &CallGraph,
613    symbol: &str,
614    follow_depth: u32,
615    base_path: Option<&Path>,
616    incoming_chains: Option<&[InternalCallChain]>,
617    outgoing_chains: Option<&[InternalCallChain]>,
618    def_use_sites: &[DefUseSite],
619) -> Result<String, FormatterError> {
620    let mut output = String::new();
621
622    // Compute all counts BEFORE output begins
623    let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
624
625    // Use pre-computed chains if provided, otherwise compute them
626    let (incoming_chains_vec, outgoing_chains_vec);
627    let (incoming_chains_ref, outgoing_chains_ref) =
628        if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
629            (inc, out)
630        } else {
631            incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
632            outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
633            (
634                incoming_chains_vec.as_slice(),
635                outgoing_chains_vec.as_slice(),
636            )
637        };
638
639    // Partition incoming_chains into production and test callers
640    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
641        incoming_chains_ref.iter().cloned().partition(|chain| {
642            chain
643                .chain
644                .first()
645                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
646        });
647
648    // Count unique production callers
649    let callers_count = prod_chains
650        .iter()
651        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
652        .collect::<std::collections::HashSet<_>>()
653        .len();
654
655    // Count unique callees
656    let callees_count = outgoing_chains_ref
657        .iter()
658        .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
659        .collect::<std::collections::HashSet<_>>()
660        .len();
661
662    // FOCUS header
663    let _ = writeln!(
664        output,
665        "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
666    );
667
668    // DEPTH line
669    let _ = writeln!(output, "DEPTH: {follow_depth}");
670
671    // DEFINED section
672    if let Some(definitions) = graph.definitions.get(symbol) {
673        output.push_str("DEFINED:\n");
674        for (path, line) in definitions {
675            let display = emit::strip_base_path(path, base_path);
676            let _ = writeln!(output, "  {display}:{line}");
677        }
678    } else {
679        output.push_str("DEFINED: (not found)\n");
680    }
681
682    // CALLERS (production, top 10 by frequency)
683    output.push_str("CALLERS (top 10):\n");
684    if prod_chains.is_empty() {
685        output.push_str("  (none)\n");
686    } else {
687        // Collect caller names with their file paths (from chain.chain.first())
688        let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
689            std::collections::HashMap::new();
690        for chain in &prod_chains {
691            if let Some((name, path, _)) = chain.chain.first() {
692                let file_path = emit::strip_base_path(path, base_path);
693                caller_freq
694                    .entry(name.clone())
695                    .and_modify(|(count, _)| *count += 1)
696                    .or_insert((1, file_path));
697            }
698        }
699
700        // Sort by frequency descending, take top 10
701        let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
702        sorted_callers.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.0));
703
704        for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
705            let _ = writeln!(output, "  {name} {file_path}");
706        }
707    }
708
709    // CALLERS (test) - summary only
710    if !test_chains.is_empty() {
711        let mut test_files: Vec<_> = test_chains
712            .iter()
713            .filter_map(|chain| {
714                chain
715                    .chain
716                    .first()
717                    .map(|(_, path, _)| path.to_string_lossy().into_owned())
718            })
719            .collect();
720        test_files.sort();
721        test_files.dedup();
722
723        let test_count = test_chains.len();
724        let test_file_count = test_files.len();
725        let _ = writeln!(
726            output,
727            "CALLERS (test): {test_count} test functions (in {test_file_count} files)"
728        );
729    }
730
731    // CALLEES (top 10 by frequency)
732    output.push_str("CALLEES (top 10):\n");
733    if outgoing_chains_ref.is_empty() {
734        output.push_str("  (none)\n");
735    } else {
736        // Collect callee names and count frequency
737        let mut callee_freq: std::collections::HashMap<String, usize> =
738            std::collections::HashMap::new();
739        for chain in outgoing_chains_ref {
740            if let Some((name, _, _)) = chain.chain.first() {
741                *callee_freq.entry(name.clone()).or_insert(0) += 1;
742            }
743        }
744
745        // Sort by frequency descending, take top 10
746        let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
747        sorted_callees.sort_unstable_by_key(|b| std::cmp::Reverse(b.1));
748
749        for (name, _) in sorted_callees.into_iter().take(10) {
750            let _ = writeln!(output, "  {name}");
751        }
752    }
753
754    // SUGGESTION section
755    output.push_str("SUGGESTION:\n");
756    output.push_str("Use summary=false with force=true for full output\n");
757
758    // DEF-USE SITES brief summary
759    if !def_use_sites.is_empty() {
760        let (write_count, read_count) = emit::def_use_write_read_counts(def_use_sites);
761        let total = def_use_sites.len();
762        let _ = writeln!(
763            output,
764            "DEF-USE SITES: {total} total ({write_count} writes, {read_count} reads)",
765        );
766    }
767
768    Ok(output)
769}
770
771/// Format a compact summary of focused symbol analysis.
772/// Public wrapper that computes chains if not provided.
773pub fn format_focused_summary(
774    graph: &CallGraph,
775    symbol: &str,
776    follow_depth: u32,
777    base_path: Option<&Path>,
778) -> Result<String, FormatterError> {
779    format_focused_summary_internal(graph, symbol, follow_depth, base_path, None, None, &[])
780}