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, ModuleInfo, SemanticAnalysis};
12use std::collections::{HashMap, HashSet};
13use std::fmt::Write;
14use std::path::Path;
15use thiserror::Error;
16use tracing::instrument;
17
18pub(crate) const EXCLUDED_DIRS: &[&str] = &[
19 "node_modules",
20 "vendor",
21 ".git",
22 "__pycache__",
23 "target",
24 "dist",
25 "build",
26 ".venv",
27];
28
29const MULTILINE_THRESHOLD: usize = 10;
30
31fn format_function_list_wrapped<'a>(
33 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
34 call_frequency: &std::collections::HashMap<String, usize>,
35) -> String {
36 let mut output = String::new();
37 let mut line = String::from(" ");
38 for (i, func) in functions.enumerate() {
39 let mut call_marker = func.compact_signature();
40
41 if let Some(&count) = call_frequency.get(&func.name)
42 && count > 3
43 {
44 call_marker.push_str(&format!("\u{2022}{}", count));
45 }
46
47 if i == 0 {
48 line.push_str(&call_marker);
49 } else if line.len() + call_marker.len() + 2 > 100 {
50 output.push_str(&line);
51 output.push('\n');
52 let mut new_line = String::with_capacity(2 + call_marker.len());
53 new_line.push_str(" ");
54 new_line.push_str(&call_marker);
55 line = new_line;
56 } else {
57 line.push_str(", ");
58 line.push_str(&call_marker);
59 }
60 }
61 if !line.trim().is_empty() {
62 output.push_str(&line);
63 output.push('\n');
64 }
65 output
66}
67
68fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
71 let mut parts = Vec::new();
72 if line_count > 0 {
73 parts.push(format!("{}L", line_count));
74 }
75 if fn_count > 0 {
76 parts.push(format!("{}F", fn_count));
77 }
78 if cls_count > 0 {
79 parts.push(format!("{}C", cls_count));
80 }
81 if parts.is_empty() {
82 None
83 } else {
84 Some(format!("[{}]", parts.join(", ")))
85 }
86}
87
88fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
90 match base_path {
91 Some(base) => {
92 if let Ok(rel_path) = path.strip_prefix(base) {
93 rel_path.display().to_string()
94 } else {
95 path.display().to_string()
96 }
97 }
98 None => path.display().to_string(),
99 }
100}
101
102#[derive(Debug, Error)]
103pub enum FormatterError {
104 #[error("Graph error: {0}")]
105 GraphError(#[from] crate::graph::GraphError),
106}
107
108#[instrument(skip_all)]
110pub fn format_structure(
111 entries: &[WalkEntry],
112 analysis_results: &[FileInfo],
113 max_depth: Option<u32>,
114 _base_path: Option<&Path>,
115) -> String {
116 let mut output = String::new();
117
118 let analysis_map: HashMap<String, &FileInfo> = analysis_results
120 .iter()
121 .map(|a| (a.path.clone(), a))
122 .collect();
123
124 let (prod_files, test_files): (Vec<_>, Vec<_>) =
126 analysis_results.iter().partition(|a| !a.is_test);
127
128 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
130 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
131 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
132
133 let mut lang_counts: HashMap<String, usize> = HashMap::new();
135 for analysis in analysis_results {
136 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
137 }
138 let total_files = analysis_results.len();
139
140 let primary_lang = lang_counts
142 .iter()
143 .max_by_key(|&(_, count)| count)
144 .map(|(name, count)| {
145 let percentage = if total_files > 0 {
146 (*count * 100) / total_files
147 } else {
148 0
149 };
150 format!("{} {}%", name, percentage)
151 })
152 .unwrap_or_else(|| "unknown 0%".to_string());
153
154 output.push_str(&format!(
155 "{} files, {}L, {}F, {}C ({})\n",
156 total_files, total_loc, total_functions, total_classes, primary_lang
157 ));
158
159 output.push_str("SUMMARY:\n");
161 let depth_label = match max_depth {
162 Some(n) if n > 0 => format!(" (max_depth={})", n),
163 _ => String::new(),
164 };
165 output.push_str(&format!(
166 "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
167 total_files,
168 prod_files.len(),
169 test_files.len(),
170 total_loc,
171 total_functions,
172 total_classes,
173 depth_label
174 ));
175
176 if !lang_counts.is_empty() {
177 output.push_str("Languages: ");
178 let mut langs: Vec<_> = lang_counts.iter().collect();
179 langs.sort_by_key(|&(name, _)| name);
180 let lang_strs: Vec<String> = langs
181 .iter()
182 .map(|(name, count)| {
183 let percentage = if total_files > 0 {
184 (**count * 100) / total_files
185 } else {
186 0
187 };
188 format!("{} ({}%)", name, percentage)
189 })
190 .collect();
191 output.push_str(&lang_strs.join(", "));
192 output.push('\n');
193 }
194
195 output.push('\n');
196
197 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
199
200 for entry in entries {
201 if entry.depth == 0 {
203 continue;
204 }
205
206 let indent = " ".repeat(entry.depth - 1);
208
209 let name = entry
211 .path
212 .file_name()
213 .and_then(|n| n.to_str())
214 .unwrap_or("?");
215
216 if !entry.is_dir {
218 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
219 if analysis.is_test {
221 continue;
222 }
223
224 if let Some(info_str) = format_file_info_parts(
225 analysis.line_count,
226 analysis.function_count,
227 analysis.class_count,
228 ) {
229 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
230 } else {
231 output.push_str(&format!("{}{}\n", indent, name));
232 }
233 }
234 } else {
236 output.push_str(&format!("{}{}/\n", indent, name));
237 }
238 }
239
240 if !test_files.is_empty() {
242 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
243
244 for entry in entries {
245 if entry.depth == 0 {
247 continue;
248 }
249
250 let indent = " ".repeat(entry.depth - 1);
252
253 let name = entry
255 .path
256 .file_name()
257 .and_then(|n| n.to_str())
258 .unwrap_or("?");
259
260 if !entry.is_dir
262 && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
263 {
264 if !analysis.is_test {
266 continue;
267 }
268
269 if let Some(info_str) = format_file_info_parts(
270 analysis.line_count,
271 analysis.function_count,
272 analysis.class_count,
273 ) {
274 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
275 } else {
276 output.push_str(&format!("{}{}\n", indent, name));
277 }
278 }
279 }
280 }
281
282 output
283}
284
285#[instrument(skip_all)]
287pub fn format_file_details(
288 path: &str,
289 analysis: &SemanticAnalysis,
290 line_count: usize,
291 is_test: bool,
292 base_path: Option<&Path>,
293) -> String {
294 let mut output = String::new();
295
296 let display_path = strip_base_path(Path::new(path), base_path);
298 if is_test {
299 output.push_str(&format!(
300 "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
301 display_path,
302 line_count,
303 analysis.functions.len(),
304 analysis.classes.len(),
305 analysis.imports.len()
306 ));
307 } else {
308 output.push_str(&format!(
309 "FILE: {}({}L, {}F, {}C, {}I)\n",
310 display_path,
311 line_count,
312 analysis.functions.len(),
313 analysis.classes.len(),
314 analysis.imports.len()
315 ));
316 }
317
318 output.push_str(&format_classes_section(&analysis.classes));
320
321 if !analysis.functions.is_empty() {
323 output.push_str("F:\n");
324 output.push_str(&format_function_list_wrapped(
325 analysis.functions.iter(),
326 &analysis.call_frequency,
327 ));
328 }
329
330 output.push_str(&format_imports_section(&analysis.imports));
332
333 output
334}
335
336fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
346 use std::collections::BTreeMap;
347
348 if chains.is_empty() {
349 return " (none)\n".to_string();
350 }
351
352 let mut output = String::new();
353
354 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
356 for (parent, child) in chains {
357 if !child.is_empty() {
359 *groups
360 .entry(parent.to_string())
361 .or_default()
362 .entry(child.to_string())
363 .or_insert(0) += 1;
364 } else {
365 groups.entry(parent.to_string()).or_default();
367 }
368 }
369
370 for (parent, children) in groups {
372 let _ = writeln!(output, " {} {} {}", focus_symbol, arrow, parent);
373 let mut sorted: Vec<_> = children.into_iter().collect();
375 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
376 for (child, count) in sorted {
377 if count > 1 {
378 let _ = writeln!(output, " {} {} (x{})", arrow, child, count);
379 } else {
380 let _ = writeln!(output, " {} {}", arrow, child);
381 }
382 }
383 }
384
385 output
386}
387
388#[instrument(skip_all)]
390pub fn format_focused(
391 graph: &CallGraph,
392 symbol: &str,
393 follow_depth: u32,
394 base_path: Option<&Path>,
395) -> Result<String, FormatterError> {
396 let mut output = String::new();
397
398 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
400 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
401 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
402
403 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
405 incoming_chains.clone().into_iter().partition(|chain| {
406 chain
407 .chain
408 .first()
409 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
410 });
411
412 let callers_count = prod_chains
414 .iter()
415 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
416 .collect::<std::collections::HashSet<_>>()
417 .len();
418
419 let callees_count = outgoing_chains
421 .iter()
422 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
423 .collect::<std::collections::HashSet<_>>()
424 .len();
425
426 output.push_str(&format!(
428 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
429 symbol, def_count, callers_count, callees_count
430 ));
431
432 output.push_str(&format!("DEPTH: {}\n", follow_depth));
434
435 if let Some(definitions) = graph.definitions.get(symbol) {
437 output.push_str("DEFINED:\n");
438 for (path, line) in definitions {
439 output.push_str(&format!(
440 " {}:{}\n",
441 strip_base_path(path, base_path),
442 line
443 ));
444 }
445 } else {
446 output.push_str("DEFINED: (not found)\n");
447 }
448
449 output.push_str("CALLERS:\n");
451
452 let prod_refs: Vec<_> = prod_chains
454 .iter()
455 .filter_map(|chain| {
456 if chain.chain.len() >= 2 {
457 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
458 } else if chain.chain.len() == 1 {
459 Some((chain.chain[0].0.as_str(), ""))
460 } else {
461 None
462 }
463 })
464 .collect();
465
466 if prod_refs.is_empty() {
467 output.push_str(" (none)\n");
468 } else {
469 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
470 }
471
472 if !test_chains.is_empty() {
474 let mut test_files: Vec<_> = test_chains
475 .iter()
476 .filter_map(|chain| {
477 chain
478 .chain
479 .first()
480 .map(|(_, path, _)| path.to_string_lossy().into_owned())
481 })
482 .collect();
483 test_files.sort();
484 test_files.dedup();
485
486 let display_files: Vec<_> = test_files
488 .iter()
489 .map(|f| strip_base_path(Path::new(f), base_path))
490 .collect();
491
492 let file_list = display_files.join(", ");
493 output.push_str(&format!(
494 "CALLERS (test): {} test functions (in {})\n",
495 test_chains.len(),
496 file_list
497 ));
498 }
499
500 output.push_str("CALLEES:\n");
502 let outgoing_refs: Vec<_> = outgoing_chains
503 .iter()
504 .filter_map(|chain| {
505 if chain.chain.len() >= 2 {
506 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
507 } else if chain.chain.len() == 1 {
508 Some((chain.chain[0].0.as_str(), ""))
509 } else {
510 None
511 }
512 })
513 .collect();
514
515 if outgoing_refs.is_empty() {
516 output.push_str(" (none)\n");
517 } else {
518 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
519 }
520
521 output.push_str("STATISTICS:\n");
523 let incoming_count = prod_refs
524 .iter()
525 .map(|(p, _)| p)
526 .collect::<std::collections::HashSet<_>>()
527 .len();
528 let outgoing_count = outgoing_refs
529 .iter()
530 .map(|(p, _)| p)
531 .collect::<std::collections::HashSet<_>>()
532 .len();
533 output.push_str(&format!(" Incoming calls: {}\n", incoming_count));
534 output.push_str(&format!(" Outgoing calls: {}\n", outgoing_count));
535
536 let mut files = HashSet::new();
538 for chain in &prod_chains {
539 for (_, path, _) in &chain.chain {
540 files.insert(path.clone());
541 }
542 }
543 for chain in &outgoing_chains {
544 for (_, path, _) in &chain.chain {
545 files.insert(path.clone());
546 }
547 }
548 if let Some(definitions) = graph.definitions.get(symbol) {
549 for (path, _) in definitions {
550 files.insert(path.clone());
551 }
552 }
553
554 let (prod_files, test_files): (Vec<_>, Vec<_>) =
556 files.into_iter().partition(|path| !is_test_file(path));
557
558 output.push_str("FILES:\n");
559 if prod_files.is_empty() && test_files.is_empty() {
560 output.push_str(" (none)\n");
561 } else {
562 if !prod_files.is_empty() {
564 let mut sorted_files = prod_files;
565 sorted_files.sort();
566 for file in sorted_files {
567 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
568 }
569 }
570
571 if !test_files.is_empty() {
573 output.push_str(" TEST FILES:\n");
574 let mut sorted_files = test_files;
575 sorted_files.sort();
576 for file in sorted_files {
577 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
578 }
579 }
580 }
581
582 Ok(output)
583}
584
585#[instrument(skip_all)]
588pub fn format_focused_summary(
589 graph: &CallGraph,
590 symbol: &str,
591 follow_depth: u32,
592 base_path: Option<&Path>,
593) -> Result<String, FormatterError> {
594 let mut output = String::new();
595
596 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
598 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
599 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
600
601 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
603 incoming_chains.into_iter().partition(|chain| {
604 chain
605 .chain
606 .first()
607 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
608 });
609
610 let callers_count = prod_chains
612 .iter()
613 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
614 .collect::<std::collections::HashSet<_>>()
615 .len();
616
617 let callees_count = outgoing_chains
619 .iter()
620 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
621 .collect::<std::collections::HashSet<_>>()
622 .len();
623
624 output.push_str(&format!(
626 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
627 symbol, def_count, callers_count, callees_count
628 ));
629
630 output.push_str(&format!("DEPTH: {}\n", follow_depth));
632
633 if let Some(definitions) = graph.definitions.get(symbol) {
635 output.push_str("DEFINED:\n");
636 for (path, line) in definitions {
637 output.push_str(&format!(
638 " {}:{}\n",
639 strip_base_path(path, base_path),
640 line
641 ));
642 }
643 } else {
644 output.push_str("DEFINED: (not found)\n");
645 }
646
647 output.push_str("CALLERS (top 10):\n");
649 if prod_chains.is_empty() {
650 output.push_str(" (none)\n");
651 } else {
652 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
654 std::collections::HashMap::new();
655 for chain in &prod_chains {
656 if let Some((name, path, _)) = chain.chain.first() {
657 let file_path = strip_base_path(path, base_path);
658 caller_freq
659 .entry(name.clone())
660 .and_modify(|(count, _)| *count += 1)
661 .or_insert((1, file_path));
662 }
663 }
664
665 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
667 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
668
669 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
670 output.push_str(&format!(" {} {}\n", name, file_path));
671 }
672 }
673
674 if !test_chains.is_empty() {
676 let mut test_files: Vec<_> = test_chains
677 .iter()
678 .filter_map(|chain| {
679 chain
680 .chain
681 .first()
682 .map(|(_, path, _)| path.to_string_lossy().into_owned())
683 })
684 .collect();
685 test_files.sort();
686 test_files.dedup();
687
688 output.push_str(&format!(
689 "CALLERS (test): {} test functions (in {} files)\n",
690 test_chains.len(),
691 test_files.len()
692 ));
693 }
694
695 output.push_str("CALLEES (top 10):\n");
697 if outgoing_chains.is_empty() {
698 output.push_str(" (none)\n");
699 } else {
700 let mut callee_freq: std::collections::HashMap<String, usize> =
702 std::collections::HashMap::new();
703 for chain in &outgoing_chains {
704 if let Some((name, _, _)) = chain.chain.first() {
705 *callee_freq.entry(name.clone()).or_insert(0) += 1;
706 }
707 }
708
709 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
711 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
712
713 for (name, _) in sorted_callees.into_iter().take(10) {
714 output.push_str(&format!(" {}\n", name));
715 }
716 }
717
718 output.push_str("SUGGESTION:\n");
720 output.push_str("Use summary=false with force=true for full output\n");
721
722 Ok(output)
723}
724
725#[instrument(skip_all)]
728pub fn format_summary(
729 entries: &[WalkEntry],
730 analysis_results: &[FileInfo],
731 max_depth: Option<u32>,
732 _base_path: Option<&Path>,
733) -> String {
734 let mut output = String::new();
735
736 let (prod_files, test_files): (Vec<_>, Vec<_>) =
738 analysis_results.iter().partition(|a| !a.is_test);
739
740 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
742 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
743 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
744
745 let mut lang_counts: HashMap<String, usize> = HashMap::new();
747 for analysis in analysis_results {
748 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
749 }
750 let total_files = analysis_results.len();
751
752 output.push_str("SUMMARY:\n");
754 let depth_label = match max_depth {
755 Some(n) if n > 0 => format!(" (max_depth={})", n),
756 _ => String::new(),
757 };
758 output.push_str(&format!(
759 "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
760 total_files,
761 prod_files.len(),
762 test_files.len(),
763 total_loc,
764 total_functions,
765 total_classes,
766 depth_label
767 ));
768
769 if !lang_counts.is_empty() {
770 output.push_str("Languages: ");
771 let mut langs: Vec<_> = lang_counts.iter().collect();
772 langs.sort_by_key(|&(name, _)| name);
773 let lang_strs: Vec<String> = langs
774 .iter()
775 .map(|(name, count)| {
776 let percentage = if total_files > 0 {
777 (**count * 100) / total_files
778 } else {
779 0
780 };
781 format!("{} ({}%)", name, percentage)
782 })
783 .collect();
784 output.push_str(&lang_strs.join(", "));
785 output.push('\n');
786 }
787
788 output.push('\n');
789
790 output.push_str("STRUCTURE (depth 1):\n");
792
793 let analysis_map: HashMap<String, &FileInfo> = analysis_results
795 .iter()
796 .map(|a| (a.path.clone(), a))
797 .collect();
798
799 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
801 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
802
803 let mut largest_dir_name: Option<String> = None;
805 let mut largest_dir_path: Option<String> = None;
806 let mut largest_dir_count: usize = 0;
807
808 for entry in depth1_entries {
809 let name = entry
810 .path
811 .file_name()
812 .and_then(|n| n.to_str())
813 .unwrap_or("?");
814
815 if entry.is_dir {
816 let dir_path_str = entry.path.display().to_string();
818 let files_in_dir: Vec<&FileInfo> = analysis_results
819 .iter()
820 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
821 .collect();
822
823 if !files_in_dir.is_empty() {
824 let dir_file_count = files_in_dir.len();
825 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
826 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
827 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
828
829 let entry_name_str = name.to_string();
831 if !EXCLUDED_DIRS.contains(&entry_name_str.as_str())
832 && files_in_dir.len() > largest_dir_count
833 {
834 largest_dir_count = files_in_dir.len();
835 largest_dir_name = Some(entry_name_str);
836 largest_dir_path = Some(
837 entry
838 .path
839 .canonicalize()
840 .unwrap_or_else(|_| entry.path.clone())
841 .display()
842 .to_string(),
843 );
844 }
845
846 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
848 let mut top_files = files_in_dir.clone();
849 top_files.sort_unstable_by(|a, b| {
850 b.class_count
851 .cmp(&a.class_count)
852 .then(b.function_count.cmp(&a.function_count))
853 .then(a.path.cmp(&b.path))
854 });
855
856 let has_classes = top_files.iter().any(|f| f.class_count > 0);
857
858 if !has_classes {
860 top_files.sort_unstable_by(|a, b| {
861 b.function_count
862 .cmp(&a.function_count)
863 .then(a.path.cmp(&b.path))
864 });
865 }
866
867 let dir_path = Path::new(&dir_path_str);
868 let top_n: Vec<String> = top_files
869 .iter()
870 .take(3)
871 .filter(|f| {
872 if has_classes {
873 f.class_count > 0
874 } else {
875 f.function_count > 0
876 }
877 })
878 .map(|f| {
879 let rel = Path::new(&f.path)
880 .strip_prefix(dir_path)
881 .map(|p| p.to_string_lossy().into_owned())
882 .unwrap_or_else(|_| {
883 Path::new(&f.path)
884 .file_name()
885 .and_then(|n| n.to_str())
886 .map(|s| s.to_owned())
887 .unwrap_or_else(|| "?".to_owned())
888 });
889 let count = if has_classes {
890 f.class_count
891 } else {
892 f.function_count
893 };
894 let suffix = if has_classes { 'C' } else { 'F' };
895 format!("{}({}{})", rel, count, suffix)
896 })
897 .collect();
898 if top_n.is_empty() {
899 String::new()
900 } else {
901 format!(" top: {}", top_n.join(", "))
902 }
903 } else {
904 String::new()
905 };
906
907 let mut subdirs: Vec<String> = entries
909 .iter()
910 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
911 .filter_map(|e| {
912 e.path
913 .file_name()
914 .and_then(|n| n.to_str())
915 .map(|s| s.to_owned())
916 })
917 .collect();
918 subdirs.sort();
919 subdirs.dedup();
920 let subdir_suffix = if subdirs.is_empty() {
921 String::new()
922 } else {
923 let subdirs_capped: Vec<String> =
924 subdirs.iter().take(5).map(|s| format!("{}/", s)).collect();
925 format!(" sub: {}", subdirs_capped.join(", "))
926 };
927
928 output.push_str(&format!(
929 " {}/ [{} files, {}L, {}F, {}C]{}{}\n",
930 name, dir_file_count, dir_loc, dir_functions, dir_classes, hint, subdir_suffix
931 ));
932 } else {
933 output.push_str(&format!(" {}/\n", name));
934 }
935 } else {
936 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
938 if let Some(info_str) = format_file_info_parts(
939 analysis.line_count,
940 analysis.function_count,
941 analysis.class_count,
942 ) {
943 output.push_str(&format!(" {} {}\n", name, info_str));
944 } else {
945 output.push_str(&format!(" {}\n", name));
946 }
947 }
948 }
949 }
950
951 output.push('\n');
952
953 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
955 output.push_str(&format!(
956 "SUGGESTION: Largest source directory: {}/ ({} files total). For module details, re-run with path={} and max_depth=2.\n",
957 name, largest_dir_count, path
958 ));
959 } else {
960 output.push_str("SUGGESTION:\n");
961 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
962 }
963
964 output
965}
966
967#[instrument(skip_all)]
972pub fn format_file_details_summary(
973 semantic: &SemanticAnalysis,
974 path: &str,
975 line_count: usize,
976) -> String {
977 let mut output = String::new();
978
979 output.push_str("FILE:\n");
981 output.push_str(&format!(" path: {}\n", path));
982 output.push_str(&format!(
983 " {}L, {}F, {}C\n",
984 line_count,
985 semantic.functions.len(),
986 semantic.classes.len()
987 ));
988 output.push('\n');
989
990 if !semantic.functions.is_empty() {
992 output.push_str("TOP FUNCTIONS BY SIZE:\n");
993 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
994 let k = funcs.len().min(10);
995 if k > 0 {
996 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
997 let a_span = a.end_line.saturating_sub(a.line);
998 let b_span = b.end_line.saturating_sub(b.line);
999 b_span.cmp(&a_span)
1000 });
1001 funcs[..k].sort_by(|a, b| {
1002 let a_span = a.end_line.saturating_sub(a.line);
1003 let b_span = b.end_line.saturating_sub(b.line);
1004 b_span.cmp(&a_span)
1005 });
1006 }
1007
1008 for func in &funcs[..k] {
1009 let span = func.end_line.saturating_sub(func.line);
1010 let params = if func.parameters.is_empty() {
1011 String::new()
1012 } else {
1013 format!("({})", func.parameters.join(", "))
1014 };
1015 output.push_str(&format!(
1016 " {}:{}: {} {} [{}L]\n",
1017 func.line, func.end_line, func.name, params, span
1018 ));
1019 }
1020 output.push('\n');
1021 }
1022
1023 if !semantic.classes.is_empty() {
1025 output.push_str("CLASSES:\n");
1026 if semantic.classes.len() <= 10 {
1027 for class in &semantic.classes {
1029 let methods_count = class.methods.len();
1030 output.push_str(&format!(" {}: {}M\n", class.name, methods_count));
1031 }
1032 } else {
1033 output.push_str(&format!(" {} classes total\n", semantic.classes.len()));
1035 for class in semantic.classes.iter().take(5) {
1036 output.push_str(&format!(" {}\n", class.name));
1037 }
1038 if semantic.classes.len() > 5 {
1039 output.push_str(&format!(
1040 " ... and {} more\n",
1041 semantic.classes.len() - 5
1042 ));
1043 }
1044 }
1045 output.push('\n');
1046 }
1047
1048 output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
1050 output.push('\n');
1051
1052 output.push_str("SUGGESTION:\n");
1054 output.push_str("Use force=true for full output, or narrow your scope\n");
1055
1056 output
1057}
1058
1059#[instrument(skip_all)]
1061pub fn format_structure_paginated(
1062 paginated_files: &[FileInfo],
1063 total_files: usize,
1064 max_depth: Option<u32>,
1065 base_path: Option<&Path>,
1066 verbose: bool,
1067) -> String {
1068 let mut output = String::new();
1069
1070 let depth_label = match max_depth {
1071 Some(n) if n > 0 => format!(" (max_depth={})", n),
1072 _ => String::new(),
1073 };
1074 output.push_str(&format!(
1075 "PAGINATED: showing {} of {} files{}\n\n",
1076 paginated_files.len(),
1077 total_files,
1078 depth_label
1079 ));
1080
1081 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1082 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1083
1084 if !prod_files.is_empty() {
1085 if verbose {
1086 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1087 }
1088 for file in &prod_files {
1089 output.push_str(&format_file_entry(file, base_path));
1090 }
1091 }
1092
1093 if !test_files.is_empty() {
1094 if verbose {
1095 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1096 } else if !prod_files.is_empty() {
1097 output.push('\n');
1098 }
1099 for file in &test_files {
1100 output.push_str(&format_file_entry(file, base_path));
1101 }
1102 }
1103
1104 output
1105}
1106
1107#[instrument(skip_all)]
1112pub fn format_file_details_paginated(
1113 functions_page: &[FunctionInfo],
1114 total_functions: usize,
1115 semantic: &SemanticAnalysis,
1116 path: &str,
1117 line_count: usize,
1118 offset: usize,
1119 verbose: bool,
1120) -> String {
1121 let mut output = String::new();
1122
1123 let start = offset + 1; let end = offset + functions_page.len();
1125
1126 output.push_str(&format!(
1127 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1128 path,
1129 line_count,
1130 start,
1131 end,
1132 total_functions,
1133 semantic.classes.len(),
1134 semantic.imports.len(),
1135 ));
1136
1137 if offset == 0 && !semantic.classes.is_empty() {
1139 output.push_str(&format_classes_section(&semantic.classes));
1140 }
1141
1142 if offset == 0 && verbose {
1144 output.push_str(&format_imports_section(&semantic.imports));
1145 }
1146
1147 if !functions_page.is_empty() {
1149 output.push_str("F:\n");
1150 output.push_str(&format_function_list_wrapped(
1151 functions_page.iter(),
1152 &semantic.call_frequency,
1153 ));
1154 }
1155
1156 output
1157}
1158
1159pub struct FocusedPaginatedParams<'a> {
1161 pub paginated_chains: &'a [CallChain],
1162 pub total: usize,
1163 pub mode: PaginationMode,
1164 pub symbol: &'a str,
1165 pub prod_chains: &'a [CallChain],
1166 pub test_chains: &'a [CallChain],
1167 pub outgoing_chains: &'a [CallChain],
1168 pub def_count: usize,
1169 pub offset: usize,
1170 pub base_path: Option<&'a Path>,
1171}
1172
1173#[instrument(skip_all)]
1178#[allow(clippy::too_many_arguments)]
1179pub fn format_focused_paginated(
1180 paginated_chains: &[CallChain],
1181 total: usize,
1182 mode: PaginationMode,
1183 symbol: &str,
1184 prod_chains: &[CallChain],
1185 test_chains: &[CallChain],
1186 outgoing_chains: &[CallChain],
1187 def_count: usize,
1188 offset: usize,
1189 base_path: Option<&Path>,
1190 _verbose: bool,
1191) -> String {
1192 let start = offset + 1; let end = offset + paginated_chains.len();
1194
1195 let callers_count = prod_chains
1196 .iter()
1197 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1198 .collect::<std::collections::HashSet<_>>()
1199 .len();
1200
1201 let callees_count = outgoing_chains
1202 .iter()
1203 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1204 .collect::<std::collections::HashSet<_>>()
1205 .len();
1206
1207 let mut output = String::new();
1208
1209 output.push_str(&format!(
1210 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1211 symbol, def_count, callers_count, callees_count
1212 ));
1213
1214 match mode {
1215 PaginationMode::Callers => {
1216 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1218
1219 let page_refs: Vec<_> = paginated_chains
1220 .iter()
1221 .filter_map(|chain| {
1222 if chain.chain.len() >= 2 {
1223 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1224 } else if chain.chain.len() == 1 {
1225 Some((chain.chain[0].0.as_str(), ""))
1226 } else {
1227 None
1228 }
1229 })
1230 .collect();
1231
1232 if page_refs.is_empty() {
1233 output.push_str(" (none)\n");
1234 } else {
1235 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1236 }
1237
1238 if !test_chains.is_empty() {
1240 let mut test_files: Vec<_> = test_chains
1241 .iter()
1242 .filter_map(|chain| {
1243 chain
1244 .chain
1245 .first()
1246 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1247 })
1248 .collect();
1249 test_files.sort();
1250 test_files.dedup();
1251
1252 let display_files: Vec<_> = test_files
1253 .iter()
1254 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1255 .collect();
1256
1257 output.push_str(&format!(
1258 "CALLERS (test): {} test functions (in {})\n",
1259 test_chains.len(),
1260 display_files.join(", ")
1261 ));
1262 }
1263
1264 let callee_names: Vec<_> = outgoing_chains
1266 .iter()
1267 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1268 .collect::<std::collections::HashSet<_>>()
1269 .into_iter()
1270 .collect();
1271 if callee_names.is_empty() {
1272 output.push_str("CALLEES: (none)\n");
1273 } else {
1274 output.push_str(&format!(
1275 "CALLEES: {} (use cursor for callee pagination)\n",
1276 callees_count
1277 ));
1278 }
1279 }
1280 PaginationMode::Callees => {
1281 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1283
1284 if !test_chains.is_empty() {
1286 output.push_str(&format!(
1287 "CALLERS (test): {} test functions\n",
1288 test_chains.len()
1289 ));
1290 }
1291
1292 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1294
1295 let page_refs: Vec<_> = paginated_chains
1296 .iter()
1297 .filter_map(|chain| {
1298 if chain.chain.len() >= 2 {
1299 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1300 } else if chain.chain.len() == 1 {
1301 Some((chain.chain[0].0.as_str(), ""))
1302 } else {
1303 None
1304 }
1305 })
1306 .collect();
1307
1308 if page_refs.is_empty() {
1309 output.push_str(" (none)\n");
1310 } else {
1311 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1312 }
1313 }
1314 PaginationMode::Default => {
1315 unreachable!("format_focused_paginated called with PaginationMode::Default")
1316 }
1317 }
1318
1319 output
1320}
1321
1322fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1323 let mut parts = Vec::new();
1324 if file.line_count > 0 {
1325 parts.push(format!("{}L", file.line_count));
1326 }
1327 if file.function_count > 0 {
1328 parts.push(format!("{}F", file.function_count));
1329 }
1330 if file.class_count > 0 {
1331 parts.push(format!("{}C", file.class_count));
1332 }
1333 let display_path = strip_base_path(Path::new(&file.path), base_path);
1334 if parts.is_empty() {
1335 format!("{}\n", display_path)
1336 } else {
1337 format!("{} [{}]\n", display_path, parts.join(", "))
1338 }
1339}
1340
1341#[instrument(skip_all)]
1355pub fn format_module_info(info: &ModuleInfo) -> String {
1356 use std::fmt::Write as _;
1357 let fn_count = info.functions.len();
1358 let import_count = info.imports.len();
1359 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1360 let _ = writeln!(
1361 out,
1362 "FILE: {} ({}L, {}F, {}I)",
1363 info.name, info.line_count, fn_count, import_count
1364 );
1365 if !info.functions.is_empty() {
1366 out.push_str("F:\n ");
1367 let parts: Vec<String> = info
1368 .functions
1369 .iter()
1370 .map(|f| format!("{}:{}", f.name, f.line))
1371 .collect();
1372 out.push_str(&parts.join(", "));
1373 out.push('\n');
1374 }
1375 if !info.imports.is_empty() {
1376 out.push_str("I:\n ");
1377 let parts: Vec<String> = info
1378 .imports
1379 .iter()
1380 .map(|i| {
1381 if i.items.is_empty() {
1382 i.module.clone()
1383 } else {
1384 format!("{}:{}", i.module, i.items.join(", "))
1385 }
1386 })
1387 .collect();
1388 out.push_str(&parts.join("; "));
1389 out.push('\n');
1390 }
1391 out
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396 use super::*;
1397
1398 #[test]
1399 fn test_strip_base_path_relative() {
1400 let path = Path::new("/home/user/project/src/main.rs");
1401 let base = Path::new("/home/user/project");
1402 let result = strip_base_path(path, Some(base));
1403 assert_eq!(result, "src/main.rs");
1404 }
1405
1406 #[test]
1407 fn test_strip_base_path_fallback_absolute() {
1408 let path = Path::new("/other/project/src/main.rs");
1409 let base = Path::new("/home/user/project");
1410 let result = strip_base_path(path, Some(base));
1411 assert_eq!(result, "/other/project/src/main.rs");
1412 }
1413
1414 #[test]
1415 fn test_strip_base_path_none() {
1416 let path = Path::new("/home/user/project/src/main.rs");
1417 let result = strip_base_path(path, None);
1418 assert_eq!(result, "/home/user/project/src/main.rs");
1419 }
1420
1421 #[test]
1422 fn test_format_file_details_summary_empty() {
1423 use crate::types::SemanticAnalysis;
1424 use std::collections::HashMap;
1425
1426 let semantic = SemanticAnalysis {
1427 functions: vec![],
1428 classes: vec![],
1429 imports: vec![],
1430 references: vec![],
1431 call_frequency: HashMap::new(),
1432 calls: vec![],
1433 assignments: vec![],
1434 field_accesses: vec![],
1435 };
1436
1437 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1438
1439 assert!(result.contains("FILE:"));
1441 assert!(result.contains("100L, 0F, 0C"));
1442 assert!(result.contains("src/main.rs"));
1443 assert!(result.contains("Imports: 0"));
1444 assert!(result.contains("SUGGESTION:"));
1445 }
1446
1447 #[test]
1448 fn test_format_file_details_summary_with_functions() {
1449 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1450 use std::collections::HashMap;
1451
1452 let semantic = SemanticAnalysis {
1453 functions: vec![
1454 FunctionInfo {
1455 name: "short".to_string(),
1456 line: 10,
1457 end_line: 12,
1458 parameters: vec![],
1459 return_type: None,
1460 },
1461 FunctionInfo {
1462 name: "long_function".to_string(),
1463 line: 20,
1464 end_line: 50,
1465 parameters: vec!["x".to_string(), "y".to_string()],
1466 return_type: Some("i32".to_string()),
1467 },
1468 ],
1469 classes: vec![ClassInfo {
1470 name: "MyClass".to_string(),
1471 line: 60,
1472 end_line: 80,
1473 methods: vec![],
1474 fields: vec![],
1475 inherits: vec![],
1476 }],
1477 imports: vec![],
1478 references: vec![],
1479 call_frequency: HashMap::new(),
1480 calls: vec![],
1481 assignments: vec![],
1482 field_accesses: vec![],
1483 };
1484
1485 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1486
1487 assert!(result.contains("FILE:"));
1489 assert!(result.contains("src/lib.rs"));
1490 assert!(result.contains("250L, 2F, 1C"));
1491
1492 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1494 let long_idx = result.find("long_function").unwrap_or(0);
1495 let short_idx = result.find("short").unwrap_or(0);
1496 assert!(
1497 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1498 "long_function should appear before short"
1499 );
1500
1501 assert!(result.contains("CLASSES:"));
1503 assert!(result.contains("MyClass:"));
1504
1505 assert!(result.contains("Imports: 0"));
1507 }
1508 #[test]
1509 fn test_format_file_info_parts_all_zero() {
1510 assert_eq!(format_file_info_parts(0, 0, 0), None);
1511 }
1512
1513 #[test]
1514 fn test_format_file_info_parts_partial() {
1515 assert_eq!(
1516 format_file_info_parts(42, 0, 3),
1517 Some("[42L, 3C]".to_string())
1518 );
1519 }
1520
1521 #[test]
1522 fn test_format_file_info_parts_all_nonzero() {
1523 assert_eq!(
1524 format_file_info_parts(100, 5, 2),
1525 Some("[100L, 5F, 2C]".to_string())
1526 );
1527 }
1528
1529 #[test]
1530 fn test_format_function_list_wrapped_empty() {
1531 let freq = std::collections::HashMap::new();
1532 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1533 assert_eq!(result, "");
1534 }
1535
1536 #[test]
1537 fn test_format_function_list_wrapped_bullet_annotation() {
1538 use crate::types::FunctionInfo;
1539 use std::collections::HashMap;
1540
1541 let mut freq = HashMap::new();
1542 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1545 name: "frequent".to_string(),
1546 line: 1,
1547 end_line: 10,
1548 parameters: vec![],
1549 return_type: Some("void".to_string()),
1550 }];
1551
1552 let result = format_function_list_wrapped(funcs.iter(), &freq);
1553 assert!(result.contains("\u{2022}5"));
1555 }
1556
1557 #[test]
1558 fn test_compact_format_omits_sections() {
1559 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1560 use std::collections::HashMap;
1561
1562 let funcs: Vec<FunctionInfo> = (0..10)
1563 .map(|i| FunctionInfo {
1564 name: format!("fn_{}", i),
1565 line: i * 5 + 1,
1566 end_line: i * 5 + 4,
1567 parameters: vec![format!("x: u32")],
1568 return_type: Some("bool".to_string()),
1569 })
1570 .collect();
1571 let imports: Vec<ImportInfo> = vec![ImportInfo {
1572 module: "std::collections".to_string(),
1573 items: vec!["HashMap".to_string()],
1574 line: 1,
1575 }];
1576 let classes: Vec<ClassInfo> = vec![ClassInfo {
1577 name: "MyStruct".to_string(),
1578 line: 5,
1579 end_line: 50,
1580 methods: vec![],
1581 fields: vec![],
1582 inherits: vec![],
1583 }];
1584 let semantic = SemanticAnalysis {
1585 functions: funcs,
1586 classes,
1587 imports,
1588 references: vec![],
1589 call_frequency: HashMap::new(),
1590 calls: vec![],
1591 assignments: vec![],
1592 field_accesses: vec![],
1593 };
1594
1595 let verbose_out = format_file_details_paginated(
1596 &semantic.functions,
1597 semantic.functions.len(),
1598 &semantic,
1599 "src/lib.rs",
1600 100,
1601 0,
1602 true,
1603 );
1604 let compact_out = format_file_details_paginated(
1605 &semantic.functions,
1606 semantic.functions.len(),
1607 &semantic,
1608 "src/lib.rs",
1609 100,
1610 0,
1611 false,
1612 );
1613
1614 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1616 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1617 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1618
1619 assert!(
1621 compact_out.contains("C:\n"),
1622 "compact must have C: section (restored)"
1623 );
1624 assert!(
1625 !compact_out.contains("I:\n"),
1626 "compact must not have I: section (imports omitted)"
1627 );
1628 assert!(
1629 compact_out.contains("F:\n"),
1630 "compact must have F: section with wrapped formatting"
1631 );
1632
1633 assert!(compact_out.contains("fn_0"), "compact must list functions");
1635 let has_two_on_same_line = compact_out
1636 .lines()
1637 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1638 assert!(
1639 has_two_on_same_line,
1640 "compact must render multiple functions per line (wrapped), not one-per-line"
1641 );
1642 }
1643
1644 #[test]
1646 fn test_compact_mode_consistent_token_reduction() {
1647 use crate::types::{FunctionInfo, SemanticAnalysis};
1648 use std::collections::HashMap;
1649
1650 let funcs: Vec<FunctionInfo> = (0..50)
1651 .map(|i| FunctionInfo {
1652 name: format!("function_name_{}", i),
1653 line: i * 10 + 1,
1654 end_line: i * 10 + 8,
1655 parameters: vec![
1656 "arg1: u32".to_string(),
1657 "arg2: String".to_string(),
1658 "arg3: Option<bool>".to_string(),
1659 ],
1660 return_type: Some("Result<Vec<String>, Error>".to_string()),
1661 })
1662 .collect();
1663
1664 let semantic = SemanticAnalysis {
1665 functions: funcs,
1666 classes: vec![],
1667 imports: vec![],
1668 references: vec![],
1669 call_frequency: HashMap::new(),
1670 calls: vec![],
1671 assignments: vec![],
1672 field_accesses: vec![],
1673 };
1674
1675 let verbose_out = format_file_details_paginated(
1676 &semantic.functions,
1677 semantic.functions.len(),
1678 &semantic,
1679 "src/large_file.rs",
1680 1000,
1681 0,
1682 true,
1683 );
1684 let compact_out = format_file_details_paginated(
1685 &semantic.functions,
1686 semantic.functions.len(),
1687 &semantic,
1688 "src/large_file.rs",
1689 1000,
1690 0,
1691 false,
1692 );
1693
1694 assert!(
1695 compact_out.len() <= verbose_out.len(),
1696 "compact ({} chars) must be <= verbose ({} chars)",
1697 compact_out.len(),
1698 verbose_out.len(),
1699 );
1700 }
1701
1702 #[test]
1704 fn test_format_module_info_happy_path() {
1705 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1706 let info = ModuleInfo {
1707 name: "parser.rs".to_string(),
1708 line_count: 312,
1709 language: "rust".to_string(),
1710 functions: vec![
1711 ModuleFunctionInfo {
1712 name: "parse_file".to_string(),
1713 line: 24,
1714 },
1715 ModuleFunctionInfo {
1716 name: "parse_block".to_string(),
1717 line: 58,
1718 },
1719 ],
1720 imports: vec![
1721 ModuleImportInfo {
1722 module: "crate::types".to_string(),
1723 items: vec!["Token".to_string(), "Expr".to_string()],
1724 },
1725 ModuleImportInfo {
1726 module: "std::io".to_string(),
1727 items: vec!["BufReader".to_string()],
1728 },
1729 ],
1730 };
1731 let result = format_module_info(&info);
1732 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1733 assert!(result.contains("F:"));
1734 assert!(result.contains("parse_file:24"));
1735 assert!(result.contains("parse_block:58"));
1736 assert!(result.contains("I:"));
1737 assert!(result.contains("crate::types:Token, Expr"));
1738 assert!(result.contains("std::io:BufReader"));
1739 assert!(result.contains("; "));
1740 assert!(!result.contains('{'));
1741 }
1742
1743 #[test]
1744 fn test_format_module_info_empty() {
1745 use crate::types::ModuleInfo;
1746 let info = ModuleInfo {
1747 name: "empty.rs".to_string(),
1748 line_count: 0,
1749 language: "rust".to_string(),
1750 functions: vec![],
1751 imports: vec![],
1752 };
1753 let result = format_module_info(&info);
1754 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1755 assert!(!result.contains("F:"));
1756 assert!(!result.contains("I:"));
1757 }
1758
1759 #[test]
1760 fn test_compact_mode_empty_classes_no_header() {
1761 use crate::types::{FunctionInfo, SemanticAnalysis};
1762 use std::collections::HashMap;
1763
1764 let funcs: Vec<FunctionInfo> = (0..5)
1765 .map(|i| FunctionInfo {
1766 name: format!("fn_{}", i),
1767 line: i * 5 + 1,
1768 end_line: i * 5 + 4,
1769 parameters: vec![],
1770 return_type: None,
1771 })
1772 .collect();
1773
1774 let semantic = SemanticAnalysis {
1775 functions: funcs,
1776 classes: vec![], imports: vec![],
1778 references: vec![],
1779 call_frequency: HashMap::new(),
1780 calls: vec![],
1781 assignments: vec![],
1782 field_accesses: vec![],
1783 };
1784
1785 let compact_out = format_file_details_paginated(
1786 &semantic.functions,
1787 semantic.functions.len(),
1788 &semantic,
1789 "src/simple.rs",
1790 100,
1791 0,
1792 false,
1793 );
1794
1795 assert!(
1797 !compact_out.contains("C:\n"),
1798 "compact mode must not emit C: header when classes are empty"
1799 );
1800 }
1801}
1802
1803fn format_classes_section(classes: &[ClassInfo]) -> String {
1804 let mut output = String::new();
1805 if classes.is_empty() {
1806 return output;
1807 }
1808 output.push_str("C:\n");
1809 if classes.len() <= MULTILINE_THRESHOLD {
1810 let class_strs: Vec<String> = classes
1811 .iter()
1812 .map(|class| {
1813 if class.inherits.is_empty() {
1814 format!("{}:{}", class.name, class.line)
1815 } else {
1816 format!(
1817 "{}:{} ({})",
1818 class.name,
1819 class.line,
1820 class.inherits.join(", ")
1821 )
1822 }
1823 })
1824 .collect();
1825 output.push_str(" ");
1826 output.push_str(&class_strs.join("; "));
1827 output.push('\n');
1828 } else {
1829 for class in classes {
1830 if class.inherits.is_empty() {
1831 output.push_str(&format!(" {}:{}\n", class.name, class.line));
1832 } else {
1833 output.push_str(&format!(
1834 " {}:{} ({})\n",
1835 class.name,
1836 class.line,
1837 class.inherits.join(", ")
1838 ));
1839 }
1840 }
1841 }
1842 output
1843}
1844
1845fn format_imports_section(imports: &[ImportInfo]) -> String {
1846 let mut output = String::new();
1847 if imports.is_empty() {
1848 return output;
1849 }
1850 output.push_str("I:\n");
1851 let mut module_map: HashMap<String, usize> = HashMap::new();
1852 for import in imports {
1853 module_map
1854 .entry(import.module.clone())
1855 .and_modify(|count| *count += 1)
1856 .or_insert(1);
1857 }
1858 let mut modules: Vec<_> = module_map.keys().cloned().collect();
1859 modules.sort();
1860 let formatted_modules: Vec<String> = modules
1861 .iter()
1862 .map(|module| format!("{}({})", module, module_map[module]))
1863 .collect();
1864 if formatted_modules.len() <= MULTILINE_THRESHOLD {
1865 output.push_str(" ");
1866 output.push_str(&formatted_modules.join("; "));
1867 output.push('\n');
1868 } else {
1869 for module_str in formatted_modules {
1870 output.push_str(" ");
1871 output.push_str(&module_str);
1872 output.push('\n');
1873 }
1874 }
1875 output
1876}