1use crate::graph::CallChain;
7use crate::graph::CallGraph;
8use crate::pagination::PaginationMode;
9use crate::test_detection::is_test_file;
10use crate::traversal::WalkEntry;
11use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
12use std::collections::{HashMap, HashSet};
13use std::fmt::Write;
14use std::path::Path;
15use thiserror::Error;
16use tracing::instrument;
17
18const MULTILINE_THRESHOLD: usize = 10;
19
20fn format_function_list_wrapped<'a>(
22 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
23 call_frequency: &std::collections::HashMap<String, usize>,
24) -> String {
25 let mut output = String::new();
26 let mut line = String::from(" ");
27 for (i, func) in functions.enumerate() {
28 let mut call_marker = func.compact_signature();
29
30 if let Some(&count) = call_frequency.get(&func.name)
31 && count > 3
32 {
33 call_marker.push_str(&format!("\u{2022}{}", count));
34 }
35
36 if i == 0 {
37 line.push_str(&call_marker);
38 } else if line.len() + call_marker.len() + 2 > 100 {
39 output.push_str(&line);
40 output.push('\n');
41 let mut new_line = String::with_capacity(2 + call_marker.len());
42 new_line.push_str(" ");
43 new_line.push_str(&call_marker);
44 line = new_line;
45 } else {
46 line.push_str(", ");
47 line.push_str(&call_marker);
48 }
49 }
50 if !line.trim().is_empty() {
51 output.push_str(&line);
52 output.push('\n');
53 }
54 output
55}
56
57fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
60 let mut parts = Vec::new();
61 if line_count > 0 {
62 parts.push(format!("{}L", line_count));
63 }
64 if fn_count > 0 {
65 parts.push(format!("{}F", fn_count));
66 }
67 if cls_count > 0 {
68 parts.push(format!("{}C", cls_count));
69 }
70 if parts.is_empty() {
71 None
72 } else {
73 Some(format!("[{}]", parts.join(", ")))
74 }
75}
76
77fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
79 match base_path {
80 Some(base) => {
81 if let Ok(rel_path) = path.strip_prefix(base) {
82 rel_path.display().to_string()
83 } else {
84 path.display().to_string()
85 }
86 }
87 None => path.display().to_string(),
88 }
89}
90
91#[derive(Debug, Error)]
92pub enum FormatterError {
93 #[error("Graph error: {0}")]
94 GraphError(#[from] crate::graph::GraphError),
95}
96
97#[instrument(skip_all)]
99pub fn format_structure(
100 entries: &[WalkEntry],
101 analysis_results: &[FileInfo],
102 max_depth: Option<u32>,
103 _base_path: Option<&Path>,
104) -> String {
105 let mut output = String::new();
106
107 let analysis_map: HashMap<String, &FileInfo> = analysis_results
109 .iter()
110 .map(|a| (a.path.clone(), a))
111 .collect();
112
113 let (prod_files, test_files): (Vec<_>, Vec<_>) =
115 analysis_results.iter().partition(|a| !a.is_test);
116
117 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
119 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
120 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
121
122 let mut lang_counts: HashMap<String, usize> = HashMap::new();
124 for analysis in analysis_results {
125 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
126 }
127 let total_files = analysis_results.len();
128
129 let primary_lang = lang_counts
131 .iter()
132 .max_by_key(|&(_, count)| count)
133 .map(|(name, count)| {
134 let percentage = if total_files > 0 {
135 (*count * 100) / total_files
136 } else {
137 0
138 };
139 format!("{} {}%", name, percentage)
140 })
141 .unwrap_or_else(|| "unknown 0%".to_string());
142
143 output.push_str(&format!(
144 "{} files, {}L, {}F, {}C ({})\n",
145 total_files, total_loc, total_functions, total_classes, primary_lang
146 ));
147
148 output.push_str("SUMMARY:\n");
150 let depth_label = match max_depth {
151 Some(n) if n > 0 => format!(" (max_depth={})", n),
152 _ => String::new(),
153 };
154 output.push_str(&format!(
155 "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
156 total_files,
157 prod_files.len(),
158 test_files.len(),
159 total_loc,
160 total_functions,
161 total_classes,
162 depth_label
163 ));
164
165 if !lang_counts.is_empty() {
166 output.push_str("Languages: ");
167 let mut langs: Vec<_> = lang_counts.iter().collect();
168 langs.sort_by_key(|&(name, _)| name);
169 let lang_strs: Vec<String> = langs
170 .iter()
171 .map(|(name, count)| {
172 let percentage = if total_files > 0 {
173 (**count * 100) / total_files
174 } else {
175 0
176 };
177 format!("{} ({}%)", name, percentage)
178 })
179 .collect();
180 output.push_str(&lang_strs.join(", "));
181 output.push('\n');
182 }
183
184 output.push('\n');
185
186 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
188
189 for entry in entries {
190 if entry.depth == 0 {
192 continue;
193 }
194
195 let indent = " ".repeat(entry.depth - 1);
197
198 let name = entry
200 .path
201 .file_name()
202 .and_then(|n| n.to_str())
203 .unwrap_or("?");
204
205 if !entry.is_dir {
207 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
208 if analysis.is_test {
210 continue;
211 }
212
213 if let Some(info_str) = format_file_info_parts(
214 analysis.line_count,
215 analysis.function_count,
216 analysis.class_count,
217 ) {
218 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
219 } else {
220 output.push_str(&format!("{}{}\n", indent, name));
221 }
222 }
223 } else {
225 output.push_str(&format!("{}{}/\n", indent, name));
226 }
227 }
228
229 if !test_files.is_empty() {
231 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
232
233 for entry in entries {
234 if entry.depth == 0 {
236 continue;
237 }
238
239 let indent = " ".repeat(entry.depth - 1);
241
242 let name = entry
244 .path
245 .file_name()
246 .and_then(|n| n.to_str())
247 .unwrap_or("?");
248
249 if !entry.is_dir
251 && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
252 {
253 if !analysis.is_test {
255 continue;
256 }
257
258 if let Some(info_str) = format_file_info_parts(
259 analysis.line_count,
260 analysis.function_count,
261 analysis.class_count,
262 ) {
263 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
264 } else {
265 output.push_str(&format!("{}{}\n", indent, name));
266 }
267 }
268 }
269 }
270
271 output
272}
273
274#[instrument(skip_all)]
276pub fn format_file_details(
277 path: &str,
278 analysis: &SemanticAnalysis,
279 line_count: usize,
280 is_test: bool,
281 base_path: Option<&Path>,
282) -> String {
283 let mut output = String::new();
284
285 let display_path = strip_base_path(Path::new(path), base_path);
287 if is_test {
288 output.push_str(&format!(
289 "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
290 display_path,
291 line_count,
292 analysis.functions.len(),
293 analysis.classes.len(),
294 analysis.imports.len()
295 ));
296 } else {
297 output.push_str(&format!(
298 "FILE: {}({}L, {}F, {}C, {}I)\n",
299 display_path,
300 line_count,
301 analysis.functions.len(),
302 analysis.classes.len(),
303 analysis.imports.len()
304 ));
305 }
306
307 output.push_str(&format_classes_section(&analysis.classes));
309
310 if !analysis.functions.is_empty() {
312 output.push_str("F:\n");
313 output.push_str(&format_function_list_wrapped(
314 analysis.functions.iter(),
315 &analysis.call_frequency,
316 ));
317 }
318
319 output.push_str(&format_imports_section(&analysis.imports));
321
322 output
323}
324
325fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
335 use std::collections::BTreeMap;
336
337 if chains.is_empty() {
338 return " (none)\n".to_string();
339 }
340
341 let mut output = String::new();
342
343 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
345 for (parent, child) in chains {
346 if !child.is_empty() {
348 *groups
349 .entry(parent.to_string())
350 .or_default()
351 .entry(child.to_string())
352 .or_insert(0) += 1;
353 } else {
354 groups.entry(parent.to_string()).or_default();
356 }
357 }
358
359 for (parent, children) in groups {
361 let _ = writeln!(output, " {} {} {}", focus_symbol, arrow, parent);
362 let mut sorted: Vec<_> = children.into_iter().collect();
364 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
365 for (child, count) in sorted {
366 if count > 1 {
367 let _ = writeln!(output, " {} {} (x{})", arrow, child, count);
368 } else {
369 let _ = writeln!(output, " {} {}", arrow, child);
370 }
371 }
372 }
373
374 output
375}
376
377#[instrument(skip_all)]
379pub fn format_focused(
380 graph: &CallGraph,
381 symbol: &str,
382 follow_depth: u32,
383 base_path: Option<&Path>,
384) -> Result<String, FormatterError> {
385 let mut output = String::new();
386
387 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
389 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
390 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
391
392 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
394 incoming_chains.clone().into_iter().partition(|chain| {
395 chain
396 .chain
397 .first()
398 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
399 });
400
401 let callers_count = prod_chains
403 .iter()
404 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
405 .collect::<std::collections::HashSet<_>>()
406 .len();
407
408 let callees_count = outgoing_chains
410 .iter()
411 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
412 .collect::<std::collections::HashSet<_>>()
413 .len();
414
415 output.push_str(&format!(
417 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
418 symbol, def_count, callers_count, callees_count
419 ));
420
421 output.push_str(&format!("DEPTH: {}\n", follow_depth));
423
424 if let Some(definitions) = graph.definitions.get(symbol) {
426 output.push_str("DEFINED:\n");
427 for (path, line) in definitions {
428 output.push_str(&format!(
429 " {}:{}\n",
430 strip_base_path(path, base_path),
431 line
432 ));
433 }
434 } else {
435 output.push_str("DEFINED: (not found)\n");
436 }
437
438 output.push_str("CALLERS:\n");
440
441 let prod_refs: Vec<_> = prod_chains
443 .iter()
444 .filter_map(|chain| {
445 if chain.chain.len() >= 2 {
446 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
447 } else if chain.chain.len() == 1 {
448 Some((chain.chain[0].0.as_str(), ""))
449 } else {
450 None
451 }
452 })
453 .collect();
454
455 if prod_refs.is_empty() {
456 output.push_str(" (none)\n");
457 } else {
458 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
459 }
460
461 if !test_chains.is_empty() {
463 let mut test_files: Vec<_> = test_chains
464 .iter()
465 .filter_map(|chain| {
466 chain
467 .chain
468 .first()
469 .map(|(_, path, _)| path.to_string_lossy().into_owned())
470 })
471 .collect();
472 test_files.sort();
473 test_files.dedup();
474
475 let display_files: Vec<_> = test_files
477 .iter()
478 .map(|f| strip_base_path(Path::new(f), base_path))
479 .collect();
480
481 let file_list = display_files.join(", ");
482 output.push_str(&format!(
483 "CALLERS (test): {} test functions (in {})\n",
484 test_chains.len(),
485 file_list
486 ));
487 }
488
489 output.push_str("CALLEES:\n");
491 let outgoing_refs: Vec<_> = outgoing_chains
492 .iter()
493 .filter_map(|chain| {
494 if chain.chain.len() >= 2 {
495 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
496 } else if chain.chain.len() == 1 {
497 Some((chain.chain[0].0.as_str(), ""))
498 } else {
499 None
500 }
501 })
502 .collect();
503
504 if outgoing_refs.is_empty() {
505 output.push_str(" (none)\n");
506 } else {
507 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
508 }
509
510 output.push_str("STATISTICS:\n");
512 let incoming_count = prod_refs
513 .iter()
514 .map(|(p, _)| p)
515 .collect::<std::collections::HashSet<_>>()
516 .len();
517 let outgoing_count = outgoing_refs
518 .iter()
519 .map(|(p, _)| p)
520 .collect::<std::collections::HashSet<_>>()
521 .len();
522 output.push_str(&format!(" Incoming calls: {}\n", incoming_count));
523 output.push_str(&format!(" Outgoing calls: {}\n", outgoing_count));
524
525 let mut files = HashSet::new();
527 for chain in &prod_chains {
528 for (_, path, _) in &chain.chain {
529 files.insert(path.clone());
530 }
531 }
532 for chain in &outgoing_chains {
533 for (_, path, _) in &chain.chain {
534 files.insert(path.clone());
535 }
536 }
537 if let Some(definitions) = graph.definitions.get(symbol) {
538 for (path, _) in definitions {
539 files.insert(path.clone());
540 }
541 }
542
543 let (prod_files, test_files): (Vec<_>, Vec<_>) =
545 files.into_iter().partition(|path| !is_test_file(path));
546
547 output.push_str("FILES:\n");
548 if prod_files.is_empty() && test_files.is_empty() {
549 output.push_str(" (none)\n");
550 } else {
551 if !prod_files.is_empty() {
553 let mut sorted_files = prod_files;
554 sorted_files.sort();
555 for file in sorted_files {
556 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
557 }
558 }
559
560 if !test_files.is_empty() {
562 output.push_str(" TEST FILES:\n");
563 let mut sorted_files = test_files;
564 sorted_files.sort();
565 for file in sorted_files {
566 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
567 }
568 }
569 }
570
571 Ok(output)
572}
573
574#[instrument(skip_all)]
577pub fn format_focused_summary(
578 graph: &CallGraph,
579 symbol: &str,
580 follow_depth: u32,
581 base_path: Option<&Path>,
582) -> Result<String, FormatterError> {
583 let mut output = String::new();
584
585 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
587 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
588 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
589
590 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
592 incoming_chains.into_iter().partition(|chain| {
593 chain
594 .chain
595 .first()
596 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
597 });
598
599 let callers_count = prod_chains
601 .iter()
602 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
603 .collect::<std::collections::HashSet<_>>()
604 .len();
605
606 let callees_count = outgoing_chains
608 .iter()
609 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
610 .collect::<std::collections::HashSet<_>>()
611 .len();
612
613 output.push_str(&format!(
615 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
616 symbol, def_count, callers_count, callees_count
617 ));
618
619 output.push_str(&format!("DEPTH: {}\n", follow_depth));
621
622 if let Some(definitions) = graph.definitions.get(symbol) {
624 output.push_str("DEFINED:\n");
625 for (path, line) in definitions {
626 output.push_str(&format!(
627 " {}:{}\n",
628 strip_base_path(path, base_path),
629 line
630 ));
631 }
632 } else {
633 output.push_str("DEFINED: (not found)\n");
634 }
635
636 output.push_str("CALLERS (top 10):\n");
638 if prod_chains.is_empty() {
639 output.push_str(" (none)\n");
640 } else {
641 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
643 std::collections::HashMap::new();
644 for chain in &prod_chains {
645 if let Some((name, path, _)) = chain.chain.first() {
646 let file_path = strip_base_path(path, base_path);
647 caller_freq
648 .entry(name.clone())
649 .and_modify(|(count, _)| *count += 1)
650 .or_insert((1, file_path));
651 }
652 }
653
654 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
656 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
657
658 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
659 output.push_str(&format!(" {} {}\n", name, file_path));
660 }
661 }
662
663 if !test_chains.is_empty() {
665 let mut test_files: Vec<_> = test_chains
666 .iter()
667 .filter_map(|chain| {
668 chain
669 .chain
670 .first()
671 .map(|(_, path, _)| path.to_string_lossy().into_owned())
672 })
673 .collect();
674 test_files.sort();
675 test_files.dedup();
676
677 output.push_str(&format!(
678 "CALLERS (test): {} test functions (in {} files)\n",
679 test_chains.len(),
680 test_files.len()
681 ));
682 }
683
684 output.push_str("CALLEES (top 10):\n");
686 if outgoing_chains.is_empty() {
687 output.push_str(" (none)\n");
688 } else {
689 let mut callee_freq: std::collections::HashMap<String, usize> =
691 std::collections::HashMap::new();
692 for chain in &outgoing_chains {
693 if let Some((name, _, _)) = chain.chain.first() {
694 *callee_freq.entry(name.clone()).or_insert(0) += 1;
695 }
696 }
697
698 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
700 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
701
702 for (name, _) in sorted_callees.into_iter().take(10) {
703 output.push_str(&format!(" {}\n", name));
704 }
705 }
706
707 output.push_str("SUGGESTION:\n");
709 output.push_str("Use summary=false with force=true for full output\n");
710
711 Ok(output)
712}
713
714#[instrument(skip_all)]
717pub fn format_summary(
718 entries: &[WalkEntry],
719 analysis_results: &[FileInfo],
720 max_depth: Option<u32>,
721 _base_path: Option<&Path>,
722) -> String {
723 let mut output = String::new();
724
725 let (prod_files, test_files): (Vec<_>, Vec<_>) =
727 analysis_results.iter().partition(|a| !a.is_test);
728
729 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
731 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
732 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
733
734 let mut lang_counts: HashMap<String, usize> = HashMap::new();
736 for analysis in analysis_results {
737 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
738 }
739 let total_files = analysis_results.len();
740
741 let primary_lang = lang_counts
743 .iter()
744 .max_by_key(|&(_, count)| count)
745 .map(|(name, count)| {
746 let percentage = if total_files > 0 {
747 (*count * 100) / total_files
748 } else {
749 0
750 };
751 format!("{} {}%", name, percentage)
752 })
753 .unwrap_or_else(|| "unknown 0%".to_string());
754
755 output.push_str(&format!(
756 "{} files, {}L, {}F, {}C ({})\n",
757 total_files, total_loc, total_functions, total_classes, primary_lang
758 ));
759
760 output.push_str("SUMMARY:\n");
762 let depth_label = match max_depth {
763 Some(n) if n > 0 => format!(" (max_depth={})", n),
764 _ => String::new(),
765 };
766 output.push_str(&format!(
767 "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
768 total_files,
769 prod_files.len(),
770 test_files.len(),
771 total_loc,
772 total_functions,
773 total_classes,
774 depth_label
775 ));
776
777 if !lang_counts.is_empty() {
778 output.push_str("Languages: ");
779 let mut langs: Vec<_> = lang_counts.iter().collect();
780 langs.sort_by_key(|&(name, _)| name);
781 let lang_strs: Vec<String> = langs
782 .iter()
783 .map(|(name, count)| {
784 let percentage = if total_files > 0 {
785 (**count * 100) / total_files
786 } else {
787 0
788 };
789 format!("{} ({}%)", name, percentage)
790 })
791 .collect();
792 output.push_str(&lang_strs.join(", "));
793 output.push('\n');
794 }
795
796 output.push('\n');
797
798 output.push_str("STRUCTURE (depth 1):\n");
800
801 let analysis_map: HashMap<String, &FileInfo> = analysis_results
803 .iter()
804 .map(|a| (a.path.clone(), a))
805 .collect();
806
807 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
809 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
810
811 for entry in depth1_entries {
812 let name = entry
813 .path
814 .file_name()
815 .and_then(|n| n.to_str())
816 .unwrap_or("?");
817
818 if entry.is_dir {
819 let dir_path_str = entry.path.display().to_string();
821 let files_in_dir: Vec<&FileInfo> = analysis_results
822 .iter()
823 .filter(|f| f.path.starts_with(&dir_path_str))
824 .collect();
825
826 if !files_in_dir.is_empty() {
827 let dir_file_count = files_in_dir.len();
828 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
829 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
830 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
831
832 output.push_str(&format!(
833 " {}/ [{} files, {}L, {}F, {}C]\n",
834 name, dir_file_count, dir_loc, dir_functions, dir_classes
835 ));
836 } else {
837 output.push_str(&format!(" {}/\n", name));
838 }
839 } else {
840 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
842 if let Some(info_str) = format_file_info_parts(
843 analysis.line_count,
844 analysis.function_count,
845 analysis.class_count,
846 ) {
847 output.push_str(&format!(" {} {}\n", name, info_str));
848 } else {
849 output.push_str(&format!(" {}\n", name));
850 }
851 }
852 }
853 }
854
855 output.push('\n');
856
857 output.push_str("SUGGESTION:\n");
859 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
860
861 output
862}
863
864#[instrument(skip_all)]
869pub fn format_file_details_summary(
870 semantic: &SemanticAnalysis,
871 path: &str,
872 line_count: usize,
873) -> String {
874 let mut output = String::new();
875
876 output.push_str("FILE:\n");
878 output.push_str(&format!(" path: {}\n", path));
879 output.push_str(&format!(
880 " {}L, {}F, {}C\n",
881 line_count,
882 semantic.functions.len(),
883 semantic.classes.len()
884 ));
885 output.push('\n');
886
887 if !semantic.functions.is_empty() {
889 output.push_str("TOP FUNCTIONS BY SIZE:\n");
890 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
891 let k = funcs.len().min(10);
892 if k > 0 {
893 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
894 let a_span = a.end_line.saturating_sub(a.line);
895 let b_span = b.end_line.saturating_sub(b.line);
896 b_span.cmp(&a_span)
897 });
898 funcs[..k].sort_by(|a, b| {
899 let a_span = a.end_line.saturating_sub(a.line);
900 let b_span = b.end_line.saturating_sub(b.line);
901 b_span.cmp(&a_span)
902 });
903 }
904
905 for func in &funcs[..k] {
906 let span = func.end_line.saturating_sub(func.line);
907 let params = if func.parameters.is_empty() {
908 String::new()
909 } else {
910 format!("({})", func.parameters.join(", "))
911 };
912 output.push_str(&format!(
913 " {}:{}: {} {} [{}L]\n",
914 func.line, func.end_line, func.name, params, span
915 ));
916 }
917 output.push('\n');
918 }
919
920 if !semantic.classes.is_empty() {
922 output.push_str("CLASSES:\n");
923 if semantic.classes.len() <= 10 {
924 for class in &semantic.classes {
926 let methods_count = class.methods.len();
927 output.push_str(&format!(" {}: {}M\n", class.name, methods_count));
928 }
929 } else {
930 output.push_str(&format!(" {} classes total\n", semantic.classes.len()));
932 for class in semantic.classes.iter().take(5) {
933 output.push_str(&format!(" {}\n", class.name));
934 }
935 if semantic.classes.len() > 5 {
936 output.push_str(&format!(
937 " ... and {} more\n",
938 semantic.classes.len() - 5
939 ));
940 }
941 }
942 output.push('\n');
943 }
944
945 output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
947 output.push('\n');
948
949 output.push_str("SUGGESTION:\n");
951 output.push_str("Use force=true for full output, or narrow your scope\n");
952
953 output
954}
955
956#[instrument(skip_all)]
958pub fn format_structure_paginated(
959 paginated_files: &[FileInfo],
960 total_files: usize,
961 max_depth: Option<u32>,
962 base_path: Option<&Path>,
963) -> String {
964 let mut output = String::new();
965
966 let depth_label = match max_depth {
967 Some(n) if n > 0 => format!(" (max_depth={})", n),
968 _ => String::new(),
969 };
970 output.push_str(&format!(
971 "PAGINATED: showing {} of {} files{}\n\n",
972 paginated_files.len(),
973 total_files,
974 depth_label
975 ));
976
977 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
978 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
979
980 if !prod_files.is_empty() {
981 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
982 for file in &prod_files {
983 output.push_str(&format_file_entry(file, base_path));
984 }
985 }
986
987 if !test_files.is_empty() {
988 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
989 for file in &test_files {
990 output.push_str(&format_file_entry(file, base_path));
991 }
992 }
993
994 output
995}
996
997#[instrument(skip_all)]
1001pub fn format_file_details_paginated(
1002 functions_page: &[FunctionInfo],
1003 total_functions: usize,
1004 semantic: &SemanticAnalysis,
1005 path: &str,
1006 line_count: usize,
1007 offset: usize,
1008) -> String {
1009 let mut output = String::new();
1010
1011 let start = offset + 1; let end = offset + functions_page.len();
1013
1014 output.push_str(&format!(
1015 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1016 path,
1017 line_count,
1018 start,
1019 end,
1020 total_functions,
1021 semantic.classes.len(),
1022 semantic.imports.len(),
1023 ));
1024
1025 if offset == 0 {
1027 output.push_str(&format_classes_section(&semantic.classes));
1028 output.push_str(&format_imports_section(&semantic.imports));
1029 }
1030
1031 if !functions_page.is_empty() {
1033 output.push_str("F:\n");
1034 output.push_str(&format_function_list_wrapped(
1035 functions_page.iter(),
1036 &semantic.call_frequency,
1037 ));
1038 }
1039
1040 output
1041}
1042
1043pub struct FocusedPaginatedParams<'a> {
1045 pub paginated_chains: &'a [CallChain],
1046 pub total: usize,
1047 pub mode: PaginationMode,
1048 pub symbol: &'a str,
1049 pub prod_chains: &'a [CallChain],
1050 pub test_chains: &'a [CallChain],
1051 pub outgoing_chains: &'a [CallChain],
1052 pub def_count: usize,
1053 pub offset: usize,
1054 pub base_path: Option<&'a Path>,
1055}
1056
1057#[instrument(skip_all)]
1062#[allow(clippy::too_many_arguments)]
1063pub fn format_focused_paginated(
1064 paginated_chains: &[CallChain],
1065 total: usize,
1066 mode: PaginationMode,
1067 symbol: &str,
1068 prod_chains: &[CallChain],
1069 test_chains: &[CallChain],
1070 outgoing_chains: &[CallChain],
1071 def_count: usize,
1072 offset: usize,
1073 base_path: Option<&Path>,
1074) -> String {
1075 let start = offset + 1; let end = offset + paginated_chains.len();
1077
1078 let callers_count = prod_chains
1079 .iter()
1080 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1081 .collect::<std::collections::HashSet<_>>()
1082 .len();
1083
1084 let callees_count = outgoing_chains
1085 .iter()
1086 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1087 .collect::<std::collections::HashSet<_>>()
1088 .len();
1089
1090 let mut output = String::new();
1091
1092 output.push_str(&format!(
1093 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1094 symbol, def_count, callers_count, callees_count
1095 ));
1096
1097 match mode {
1098 PaginationMode::Callers => {
1099 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1101
1102 let page_refs: Vec<_> = paginated_chains
1103 .iter()
1104 .filter_map(|chain| {
1105 if chain.chain.len() >= 2 {
1106 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1107 } else if chain.chain.len() == 1 {
1108 Some((chain.chain[0].0.as_str(), ""))
1109 } else {
1110 None
1111 }
1112 })
1113 .collect();
1114
1115 if page_refs.is_empty() {
1116 output.push_str(" (none)\n");
1117 } else {
1118 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1119 }
1120
1121 if !test_chains.is_empty() {
1123 let mut test_files: Vec<_> = test_chains
1124 .iter()
1125 .filter_map(|chain| {
1126 chain
1127 .chain
1128 .first()
1129 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1130 })
1131 .collect();
1132 test_files.sort();
1133 test_files.dedup();
1134
1135 let display_files: Vec<_> = test_files
1136 .iter()
1137 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1138 .collect();
1139
1140 output.push_str(&format!(
1141 "CALLERS (test): {} test functions (in {})\n",
1142 test_chains.len(),
1143 display_files.join(", ")
1144 ));
1145 }
1146
1147 let callee_names: Vec<_> = outgoing_chains
1149 .iter()
1150 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1151 .collect::<std::collections::HashSet<_>>()
1152 .into_iter()
1153 .collect();
1154 if callee_names.is_empty() {
1155 output.push_str("CALLEES: (none)\n");
1156 } else {
1157 output.push_str(&format!(
1158 "CALLEES: {} (use cursor for callee pagination)\n",
1159 callees_count
1160 ));
1161 }
1162 }
1163 PaginationMode::Callees => {
1164 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1166
1167 if !test_chains.is_empty() {
1169 output.push_str(&format!(
1170 "CALLERS (test): {} test functions\n",
1171 test_chains.len()
1172 ));
1173 }
1174
1175 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1177
1178 let page_refs: Vec<_> = paginated_chains
1179 .iter()
1180 .filter_map(|chain| {
1181 if chain.chain.len() >= 2 {
1182 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1183 } else if chain.chain.len() == 1 {
1184 Some((chain.chain[0].0.as_str(), ""))
1185 } else {
1186 None
1187 }
1188 })
1189 .collect();
1190
1191 if page_refs.is_empty() {
1192 output.push_str(" (none)\n");
1193 } else {
1194 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1195 }
1196 }
1197 PaginationMode::Default => {
1198 unreachable!("format_focused_paginated called with PaginationMode::Default")
1199 }
1200 }
1201
1202 output
1203}
1204
1205fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1206 let mut parts = Vec::new();
1207 if file.line_count > 0 {
1208 parts.push(format!("{}L", file.line_count));
1209 }
1210 if file.function_count > 0 {
1211 parts.push(format!("{}F", file.function_count));
1212 }
1213 if file.class_count > 0 {
1214 parts.push(format!("{}C", file.class_count));
1215 }
1216 let display_path = strip_base_path(Path::new(&file.path), base_path);
1217 if parts.is_empty() {
1218 format!("{}\n", display_path)
1219 } else {
1220 format!("{} [{}]\n", display_path, parts.join(", "))
1221 }
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::*;
1227
1228 #[test]
1229 fn test_strip_base_path_relative() {
1230 let path = Path::new("/home/user/project/src/main.rs");
1231 let base = Path::new("/home/user/project");
1232 let result = strip_base_path(path, Some(base));
1233 assert_eq!(result, "src/main.rs");
1234 }
1235
1236 #[test]
1237 fn test_strip_base_path_fallback_absolute() {
1238 let path = Path::new("/other/project/src/main.rs");
1239 let base = Path::new("/home/user/project");
1240 let result = strip_base_path(path, Some(base));
1241 assert_eq!(result, "/other/project/src/main.rs");
1242 }
1243
1244 #[test]
1245 fn test_strip_base_path_none() {
1246 let path = Path::new("/home/user/project/src/main.rs");
1247 let result = strip_base_path(path, None);
1248 assert_eq!(result, "/home/user/project/src/main.rs");
1249 }
1250
1251 #[test]
1252 fn test_format_file_details_summary_empty() {
1253 use crate::types::SemanticAnalysis;
1254 use std::collections::HashMap;
1255
1256 let semantic = SemanticAnalysis {
1257 functions: vec![],
1258 classes: vec![],
1259 imports: vec![],
1260 references: vec![],
1261 call_frequency: HashMap::new(),
1262 calls: vec![],
1263 assignments: vec![],
1264 field_accesses: vec![],
1265 };
1266
1267 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1268
1269 assert!(result.contains("FILE:"));
1271 assert!(result.contains("100L, 0F, 0C"));
1272 assert!(result.contains("src/main.rs"));
1273 assert!(result.contains("Imports: 0"));
1274 assert!(result.contains("SUGGESTION:"));
1275 }
1276
1277 #[test]
1278 fn test_format_file_details_summary_with_functions() {
1279 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1280 use std::collections::HashMap;
1281
1282 let semantic = SemanticAnalysis {
1283 functions: vec![
1284 FunctionInfo {
1285 name: "short".to_string(),
1286 line: 10,
1287 end_line: 12,
1288 parameters: vec![],
1289 return_type: None,
1290 },
1291 FunctionInfo {
1292 name: "long_function".to_string(),
1293 line: 20,
1294 end_line: 50,
1295 parameters: vec!["x".to_string(), "y".to_string()],
1296 return_type: Some("i32".to_string()),
1297 },
1298 ],
1299 classes: vec![ClassInfo {
1300 name: "MyClass".to_string(),
1301 line: 60,
1302 end_line: 80,
1303 methods: vec![],
1304 fields: vec![],
1305 inherits: vec![],
1306 }],
1307 imports: vec![],
1308 references: vec![],
1309 call_frequency: HashMap::new(),
1310 calls: vec![],
1311 assignments: vec![],
1312 field_accesses: vec![],
1313 };
1314
1315 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1316
1317 assert!(result.contains("FILE:"));
1319 assert!(result.contains("src/lib.rs"));
1320 assert!(result.contains("250L, 2F, 1C"));
1321
1322 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1324 let long_idx = result.find("long_function").unwrap_or(0);
1325 let short_idx = result.find("short").unwrap_or(0);
1326 assert!(
1327 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1328 "long_function should appear before short"
1329 );
1330
1331 assert!(result.contains("CLASSES:"));
1333 assert!(result.contains("MyClass:"));
1334
1335 assert!(result.contains("Imports: 0"));
1337 }
1338 #[test]
1339 fn test_format_file_info_parts_all_zero() {
1340 assert_eq!(format_file_info_parts(0, 0, 0), None);
1341 }
1342
1343 #[test]
1344 fn test_format_file_info_parts_partial() {
1345 assert_eq!(
1346 format_file_info_parts(42, 0, 3),
1347 Some("[42L, 3C]".to_string())
1348 );
1349 }
1350
1351 #[test]
1352 fn test_format_file_info_parts_all_nonzero() {
1353 assert_eq!(
1354 format_file_info_parts(100, 5, 2),
1355 Some("[100L, 5F, 2C]".to_string())
1356 );
1357 }
1358
1359 #[test]
1360 fn test_format_function_list_wrapped_empty() {
1361 let freq = std::collections::HashMap::new();
1362 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1363 assert_eq!(result, "");
1364 }
1365
1366 #[test]
1367 fn test_format_function_list_wrapped_bullet_annotation() {
1368 use crate::types::FunctionInfo;
1369 use std::collections::HashMap;
1370
1371 let mut freq = HashMap::new();
1372 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1375 name: "frequent".to_string(),
1376 line: 1,
1377 end_line: 10,
1378 parameters: vec![],
1379 return_type: Some("void".to_string()),
1380 }];
1381
1382 let result = format_function_list_wrapped(funcs.iter(), &freq);
1383 assert!(result.contains("\u{2022}5"));
1385 }
1386}
1387
1388fn format_classes_section(classes: &[ClassInfo]) -> String {
1389 let mut output = String::new();
1390 if classes.is_empty() {
1391 return output;
1392 }
1393 output.push_str("C:\n");
1394 if classes.len() <= MULTILINE_THRESHOLD {
1395 let class_strs: Vec<String> = classes
1396 .iter()
1397 .map(|class| {
1398 if class.inherits.is_empty() {
1399 format!("{}:{}", class.name, class.line)
1400 } else {
1401 format!(
1402 "{}:{} ({})",
1403 class.name,
1404 class.line,
1405 class.inherits.join(", ")
1406 )
1407 }
1408 })
1409 .collect();
1410 output.push_str(" ");
1411 output.push_str(&class_strs.join("; "));
1412 output.push('\n');
1413 } else {
1414 for class in classes {
1415 if class.inherits.is_empty() {
1416 output.push_str(&format!(" {}:{}\n", class.name, class.line));
1417 } else {
1418 output.push_str(&format!(
1419 " {}:{} ({})\n",
1420 class.name,
1421 class.line,
1422 class.inherits.join(", ")
1423 ));
1424 }
1425 }
1426 }
1427 output
1428}
1429
1430fn format_imports_section(imports: &[ImportInfo]) -> String {
1431 let mut output = String::new();
1432 if imports.is_empty() {
1433 return output;
1434 }
1435 output.push_str("I:\n");
1436 let mut module_map: HashMap<String, usize> = HashMap::new();
1437 for import in imports {
1438 module_map
1439 .entry(import.module.clone())
1440 .and_modify(|count| *count += 1)
1441 .or_insert(1);
1442 }
1443 let mut modules: Vec<_> = module_map.keys().cloned().collect();
1444 modules.sort();
1445 let formatted_modules: Vec<String> = modules
1446 .iter()
1447 .map(|module| format!("{}({})", module, module_map[module]))
1448 .collect();
1449 if formatted_modules.len() <= MULTILINE_THRESHOLD {
1450 output.push_str(" ");
1451 output.push_str(&formatted_modules.join("; "));
1452 output.push('\n');
1453 } else {
1454 for module_str in formatted_modules {
1455 output.push_str(" ");
1456 output.push_str(&module_str);
1457 output.push('\n');
1458 }
1459 }
1460 output
1461}