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 verbose: bool,
964) -> String {
965 let mut output = String::new();
966
967 let depth_label = match max_depth {
968 Some(n) if n > 0 => format!(" (max_depth={})", n),
969 _ => String::new(),
970 };
971 output.push_str(&format!(
972 "PAGINATED: showing {} of {} files{}\n\n",
973 paginated_files.len(),
974 total_files,
975 depth_label
976 ));
977
978 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
979 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
980
981 if !prod_files.is_empty() {
982 if verbose {
983 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
984 }
985 for file in &prod_files {
986 output.push_str(&format_file_entry(file, base_path));
987 }
988 }
989
990 if !test_files.is_empty() {
991 if verbose {
992 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
993 } else if !prod_files.is_empty() {
994 output.push('\n');
995 }
996 for file in &test_files {
997 output.push_str(&format_file_entry(file, base_path));
998 }
999 }
1000
1001 output
1002}
1003
1004#[instrument(skip_all)]
1009pub fn format_file_details_paginated(
1010 functions_page: &[FunctionInfo],
1011 total_functions: usize,
1012 semantic: &SemanticAnalysis,
1013 path: &str,
1014 line_count: usize,
1015 offset: usize,
1016 verbose: bool,
1017) -> String {
1018 let mut output = String::new();
1019
1020 let start = offset + 1; let end = offset + functions_page.len();
1022
1023 output.push_str(&format!(
1024 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1025 path,
1026 line_count,
1027 start,
1028 end,
1029 total_functions,
1030 semantic.classes.len(),
1031 semantic.imports.len(),
1032 ));
1033
1034 if offset == 0 && !semantic.classes.is_empty() {
1036 output.push_str(&format_classes_section(&semantic.classes));
1037 }
1038
1039 if offset == 0 && verbose {
1041 output.push_str(&format_imports_section(&semantic.imports));
1042 }
1043
1044 if !functions_page.is_empty() {
1046 output.push_str("F:\n");
1047 output.push_str(&format_function_list_wrapped(
1048 functions_page.iter(),
1049 &semantic.call_frequency,
1050 ));
1051 }
1052
1053 output
1054}
1055
1056pub struct FocusedPaginatedParams<'a> {
1058 pub paginated_chains: &'a [CallChain],
1059 pub total: usize,
1060 pub mode: PaginationMode,
1061 pub symbol: &'a str,
1062 pub prod_chains: &'a [CallChain],
1063 pub test_chains: &'a [CallChain],
1064 pub outgoing_chains: &'a [CallChain],
1065 pub def_count: usize,
1066 pub offset: usize,
1067 pub base_path: Option<&'a Path>,
1068}
1069
1070#[instrument(skip_all)]
1075#[allow(clippy::too_many_arguments)]
1076pub fn format_focused_paginated(
1077 paginated_chains: &[CallChain],
1078 total: usize,
1079 mode: PaginationMode,
1080 symbol: &str,
1081 prod_chains: &[CallChain],
1082 test_chains: &[CallChain],
1083 outgoing_chains: &[CallChain],
1084 def_count: usize,
1085 offset: usize,
1086 base_path: Option<&Path>,
1087 _verbose: bool,
1088) -> String {
1089 let start = offset + 1; let end = offset + paginated_chains.len();
1091
1092 let callers_count = prod_chains
1093 .iter()
1094 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1095 .collect::<std::collections::HashSet<_>>()
1096 .len();
1097
1098 let callees_count = outgoing_chains
1099 .iter()
1100 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1101 .collect::<std::collections::HashSet<_>>()
1102 .len();
1103
1104 let mut output = String::new();
1105
1106 output.push_str(&format!(
1107 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1108 symbol, def_count, callers_count, callees_count
1109 ));
1110
1111 match mode {
1112 PaginationMode::Callers => {
1113 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1115
1116 let page_refs: Vec<_> = paginated_chains
1117 .iter()
1118 .filter_map(|chain| {
1119 if chain.chain.len() >= 2 {
1120 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1121 } else if chain.chain.len() == 1 {
1122 Some((chain.chain[0].0.as_str(), ""))
1123 } else {
1124 None
1125 }
1126 })
1127 .collect();
1128
1129 if page_refs.is_empty() {
1130 output.push_str(" (none)\n");
1131 } else {
1132 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1133 }
1134
1135 if !test_chains.is_empty() {
1137 let mut test_files: Vec<_> = test_chains
1138 .iter()
1139 .filter_map(|chain| {
1140 chain
1141 .chain
1142 .first()
1143 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1144 })
1145 .collect();
1146 test_files.sort();
1147 test_files.dedup();
1148
1149 let display_files: Vec<_> = test_files
1150 .iter()
1151 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1152 .collect();
1153
1154 output.push_str(&format!(
1155 "CALLERS (test): {} test functions (in {})\n",
1156 test_chains.len(),
1157 display_files.join(", ")
1158 ));
1159 }
1160
1161 let callee_names: Vec<_> = outgoing_chains
1163 .iter()
1164 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1165 .collect::<std::collections::HashSet<_>>()
1166 .into_iter()
1167 .collect();
1168 if callee_names.is_empty() {
1169 output.push_str("CALLEES: (none)\n");
1170 } else {
1171 output.push_str(&format!(
1172 "CALLEES: {} (use cursor for callee pagination)\n",
1173 callees_count
1174 ));
1175 }
1176 }
1177 PaginationMode::Callees => {
1178 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1180
1181 if !test_chains.is_empty() {
1183 output.push_str(&format!(
1184 "CALLERS (test): {} test functions\n",
1185 test_chains.len()
1186 ));
1187 }
1188
1189 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1191
1192 let page_refs: Vec<_> = paginated_chains
1193 .iter()
1194 .filter_map(|chain| {
1195 if chain.chain.len() >= 2 {
1196 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1197 } else if chain.chain.len() == 1 {
1198 Some((chain.chain[0].0.as_str(), ""))
1199 } else {
1200 None
1201 }
1202 })
1203 .collect();
1204
1205 if page_refs.is_empty() {
1206 output.push_str(" (none)\n");
1207 } else {
1208 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1209 }
1210 }
1211 PaginationMode::Default => {
1212 unreachable!("format_focused_paginated called with PaginationMode::Default")
1213 }
1214 }
1215
1216 output
1217}
1218
1219fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1220 let mut parts = Vec::new();
1221 if file.line_count > 0 {
1222 parts.push(format!("{}L", file.line_count));
1223 }
1224 if file.function_count > 0 {
1225 parts.push(format!("{}F", file.function_count));
1226 }
1227 if file.class_count > 0 {
1228 parts.push(format!("{}C", file.class_count));
1229 }
1230 let display_path = strip_base_path(Path::new(&file.path), base_path);
1231 if parts.is_empty() {
1232 format!("{}\n", display_path)
1233 } else {
1234 format!("{} [{}]\n", display_path, parts.join(", "))
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241
1242 #[test]
1243 fn test_strip_base_path_relative() {
1244 let path = Path::new("/home/user/project/src/main.rs");
1245 let base = Path::new("/home/user/project");
1246 let result = strip_base_path(path, Some(base));
1247 assert_eq!(result, "src/main.rs");
1248 }
1249
1250 #[test]
1251 fn test_strip_base_path_fallback_absolute() {
1252 let path = Path::new("/other/project/src/main.rs");
1253 let base = Path::new("/home/user/project");
1254 let result = strip_base_path(path, Some(base));
1255 assert_eq!(result, "/other/project/src/main.rs");
1256 }
1257
1258 #[test]
1259 fn test_strip_base_path_none() {
1260 let path = Path::new("/home/user/project/src/main.rs");
1261 let result = strip_base_path(path, None);
1262 assert_eq!(result, "/home/user/project/src/main.rs");
1263 }
1264
1265 #[test]
1266 fn test_format_file_details_summary_empty() {
1267 use crate::types::SemanticAnalysis;
1268 use std::collections::HashMap;
1269
1270 let semantic = SemanticAnalysis {
1271 functions: vec![],
1272 classes: vec![],
1273 imports: vec![],
1274 references: vec![],
1275 call_frequency: HashMap::new(),
1276 calls: vec![],
1277 assignments: vec![],
1278 field_accesses: vec![],
1279 };
1280
1281 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1282
1283 assert!(result.contains("FILE:"));
1285 assert!(result.contains("100L, 0F, 0C"));
1286 assert!(result.contains("src/main.rs"));
1287 assert!(result.contains("Imports: 0"));
1288 assert!(result.contains("SUGGESTION:"));
1289 }
1290
1291 #[test]
1292 fn test_format_file_details_summary_with_functions() {
1293 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1294 use std::collections::HashMap;
1295
1296 let semantic = SemanticAnalysis {
1297 functions: vec![
1298 FunctionInfo {
1299 name: "short".to_string(),
1300 line: 10,
1301 end_line: 12,
1302 parameters: vec![],
1303 return_type: None,
1304 },
1305 FunctionInfo {
1306 name: "long_function".to_string(),
1307 line: 20,
1308 end_line: 50,
1309 parameters: vec!["x".to_string(), "y".to_string()],
1310 return_type: Some("i32".to_string()),
1311 },
1312 ],
1313 classes: vec![ClassInfo {
1314 name: "MyClass".to_string(),
1315 line: 60,
1316 end_line: 80,
1317 methods: vec![],
1318 fields: vec![],
1319 inherits: vec![],
1320 }],
1321 imports: vec![],
1322 references: vec![],
1323 call_frequency: HashMap::new(),
1324 calls: vec![],
1325 assignments: vec![],
1326 field_accesses: vec![],
1327 };
1328
1329 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1330
1331 assert!(result.contains("FILE:"));
1333 assert!(result.contains("src/lib.rs"));
1334 assert!(result.contains("250L, 2F, 1C"));
1335
1336 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1338 let long_idx = result.find("long_function").unwrap_or(0);
1339 let short_idx = result.find("short").unwrap_or(0);
1340 assert!(
1341 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1342 "long_function should appear before short"
1343 );
1344
1345 assert!(result.contains("CLASSES:"));
1347 assert!(result.contains("MyClass:"));
1348
1349 assert!(result.contains("Imports: 0"));
1351 }
1352 #[test]
1353 fn test_format_file_info_parts_all_zero() {
1354 assert_eq!(format_file_info_parts(0, 0, 0), None);
1355 }
1356
1357 #[test]
1358 fn test_format_file_info_parts_partial() {
1359 assert_eq!(
1360 format_file_info_parts(42, 0, 3),
1361 Some("[42L, 3C]".to_string())
1362 );
1363 }
1364
1365 #[test]
1366 fn test_format_file_info_parts_all_nonzero() {
1367 assert_eq!(
1368 format_file_info_parts(100, 5, 2),
1369 Some("[100L, 5F, 2C]".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn test_format_function_list_wrapped_empty() {
1375 let freq = std::collections::HashMap::new();
1376 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1377 assert_eq!(result, "");
1378 }
1379
1380 #[test]
1381 fn test_format_function_list_wrapped_bullet_annotation() {
1382 use crate::types::FunctionInfo;
1383 use std::collections::HashMap;
1384
1385 let mut freq = HashMap::new();
1386 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1389 name: "frequent".to_string(),
1390 line: 1,
1391 end_line: 10,
1392 parameters: vec![],
1393 return_type: Some("void".to_string()),
1394 }];
1395
1396 let result = format_function_list_wrapped(funcs.iter(), &freq);
1397 assert!(result.contains("\u{2022}5"));
1399 }
1400
1401 #[test]
1402 fn test_compact_format_omits_sections() {
1403 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1404 use std::collections::HashMap;
1405
1406 let funcs: Vec<FunctionInfo> = (0..10)
1407 .map(|i| FunctionInfo {
1408 name: format!("fn_{}", i),
1409 line: i * 5 + 1,
1410 end_line: i * 5 + 4,
1411 parameters: vec![format!("x: u32")],
1412 return_type: Some("bool".to_string()),
1413 })
1414 .collect();
1415 let imports: Vec<ImportInfo> = vec![ImportInfo {
1416 module: "std::collections".to_string(),
1417 items: vec!["HashMap".to_string()],
1418 line: 1,
1419 }];
1420 let classes: Vec<ClassInfo> = vec![ClassInfo {
1421 name: "MyStruct".to_string(),
1422 line: 5,
1423 end_line: 50,
1424 methods: vec![],
1425 fields: vec![],
1426 inherits: vec![],
1427 }];
1428 let semantic = SemanticAnalysis {
1429 functions: funcs,
1430 classes,
1431 imports,
1432 references: vec![],
1433 call_frequency: HashMap::new(),
1434 calls: vec![],
1435 assignments: vec![],
1436 field_accesses: vec![],
1437 };
1438
1439 let verbose_out = format_file_details_paginated(
1440 &semantic.functions,
1441 semantic.functions.len(),
1442 &semantic,
1443 "src/lib.rs",
1444 100,
1445 0,
1446 true,
1447 );
1448 let compact_out = format_file_details_paginated(
1449 &semantic.functions,
1450 semantic.functions.len(),
1451 &semantic,
1452 "src/lib.rs",
1453 100,
1454 0,
1455 false,
1456 );
1457
1458 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1460 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1461 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1462
1463 assert!(
1465 compact_out.contains("C:\n"),
1466 "compact must have C: section (restored)"
1467 );
1468 assert!(
1469 !compact_out.contains("I:\n"),
1470 "compact must not have I: section (imports omitted)"
1471 );
1472 assert!(
1473 compact_out.contains("F:\n"),
1474 "compact must have F: section with wrapped formatting"
1475 );
1476
1477 assert!(compact_out.contains("fn_0"), "compact must list functions");
1479 let has_two_on_same_line = compact_out
1480 .lines()
1481 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1482 assert!(
1483 has_two_on_same_line,
1484 "compact must render multiple functions per line (wrapped), not one-per-line"
1485 );
1486 }
1487
1488 #[test]
1490 fn test_compact_mode_consistent_token_reduction() {
1491 use crate::types::{FunctionInfo, SemanticAnalysis};
1492 use std::collections::HashMap;
1493
1494 let funcs: Vec<FunctionInfo> = (0..50)
1495 .map(|i| FunctionInfo {
1496 name: format!("function_name_{}", i),
1497 line: i * 10 + 1,
1498 end_line: i * 10 + 8,
1499 parameters: vec![
1500 "arg1: u32".to_string(),
1501 "arg2: String".to_string(),
1502 "arg3: Option<bool>".to_string(),
1503 ],
1504 return_type: Some("Result<Vec<String>, Error>".to_string()),
1505 })
1506 .collect();
1507
1508 let semantic = SemanticAnalysis {
1509 functions: funcs,
1510 classes: vec![],
1511 imports: vec![],
1512 references: vec![],
1513 call_frequency: HashMap::new(),
1514 calls: vec![],
1515 assignments: vec![],
1516 field_accesses: vec![],
1517 };
1518
1519 let verbose_out = format_file_details_paginated(
1520 &semantic.functions,
1521 semantic.functions.len(),
1522 &semantic,
1523 "src/large_file.rs",
1524 1000,
1525 0,
1526 true,
1527 );
1528 let compact_out = format_file_details_paginated(
1529 &semantic.functions,
1530 semantic.functions.len(),
1531 &semantic,
1532 "src/large_file.rs",
1533 1000,
1534 0,
1535 false,
1536 );
1537
1538 assert!(
1539 compact_out.len() <= verbose_out.len(),
1540 "compact ({} chars) must be <= verbose ({} chars)",
1541 compact_out.len(),
1542 verbose_out.len(),
1543 );
1544 }
1545
1546 #[test]
1548 fn test_compact_mode_empty_classes_no_header() {
1549 use crate::types::{FunctionInfo, SemanticAnalysis};
1550 use std::collections::HashMap;
1551
1552 let funcs: Vec<FunctionInfo> = (0..5)
1553 .map(|i| FunctionInfo {
1554 name: format!("fn_{}", i),
1555 line: i * 5 + 1,
1556 end_line: i * 5 + 4,
1557 parameters: vec![],
1558 return_type: None,
1559 })
1560 .collect();
1561
1562 let semantic = SemanticAnalysis {
1563 functions: funcs,
1564 classes: vec![], imports: vec![],
1566 references: vec![],
1567 call_frequency: HashMap::new(),
1568 calls: vec![],
1569 assignments: vec![],
1570 field_accesses: vec![],
1571 };
1572
1573 let compact_out = format_file_details_paginated(
1574 &semantic.functions,
1575 semantic.functions.len(),
1576 &semantic,
1577 "src/simple.rs",
1578 100,
1579 0,
1580 false,
1581 );
1582
1583 assert!(
1585 !compact_out.contains("C:\n"),
1586 "compact mode must not emit C: header when classes are empty"
1587 );
1588 }
1589}
1590
1591fn format_classes_section(classes: &[ClassInfo]) -> String {
1592 let mut output = String::new();
1593 if classes.is_empty() {
1594 return output;
1595 }
1596 output.push_str("C:\n");
1597 if classes.len() <= MULTILINE_THRESHOLD {
1598 let class_strs: Vec<String> = classes
1599 .iter()
1600 .map(|class| {
1601 if class.inherits.is_empty() {
1602 format!("{}:{}", class.name, class.line)
1603 } else {
1604 format!(
1605 "{}:{} ({})",
1606 class.name,
1607 class.line,
1608 class.inherits.join(", ")
1609 )
1610 }
1611 })
1612 .collect();
1613 output.push_str(" ");
1614 output.push_str(&class_strs.join("; "));
1615 output.push('\n');
1616 } else {
1617 for class in classes {
1618 if class.inherits.is_empty() {
1619 output.push_str(&format!(" {}:{}\n", class.name, class.line));
1620 } else {
1621 output.push_str(&format!(
1622 " {}:{} ({})\n",
1623 class.name,
1624 class.line,
1625 class.inherits.join(", ")
1626 ));
1627 }
1628 }
1629 }
1630 output
1631}
1632
1633fn format_imports_section(imports: &[ImportInfo]) -> String {
1634 let mut output = String::new();
1635 if imports.is_empty() {
1636 return output;
1637 }
1638 output.push_str("I:\n");
1639 let mut module_map: HashMap<String, usize> = HashMap::new();
1640 for import in imports {
1641 module_map
1642 .entry(import.module.clone())
1643 .and_modify(|count| *count += 1)
1644 .or_insert(1);
1645 }
1646 let mut modules: Vec<_> = module_map.keys().cloned().collect();
1647 modules.sort();
1648 let formatted_modules: Vec<String> = modules
1649 .iter()
1650 .map(|module| format!("{}({})", module, module_map[module]))
1651 .collect();
1652 if formatted_modules.len() <= MULTILINE_THRESHOLD {
1653 output.push_str(" ");
1654 output.push_str(&formatted_modules.join("; "));
1655 output.push('\n');
1656 } else {
1657 for module_str in formatted_modules {
1658 output.push_str(" ");
1659 output.push_str(&module_str);
1660 output.push('\n');
1661 }
1662 }
1663 output
1664}