1use crate::graph::CallGraph;
7use crate::graph::InternalCallChain;
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, PathBuf};
15use thiserror::Error;
16use tracing::instrument;
17
18const MULTILINE_THRESHOLD: usize = 10;
19
20fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
22 func.line >= class.line && func.end_line <= class.end_line
23}
24
25fn collect_class_methods<'a>(
28 classes: &'a [ClassInfo],
29 functions: &'a [FunctionInfo],
30) -> HashMap<String, Vec<&'a FunctionInfo>> {
31 let mut methods_by_class: HashMap<String, Vec<&'a FunctionInfo>> = HashMap::new();
32 for class in classes {
33 if !class.methods.is_empty() {
34 methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
36 } else {
37 let methods: Vec<&FunctionInfo> = functions
39 .iter()
40 .filter(|f| is_method_of_class(f, class))
41 .collect();
42 methods_by_class.insert(class.name.clone(), methods);
43 }
44 }
45 methods_by_class
46}
47
48fn format_function_list_wrapped<'a>(
50 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
51 call_frequency: &std::collections::HashMap<String, usize>,
52) -> String {
53 let mut output = String::new();
54 let mut line = String::from(" ");
55 for (i, func) in functions.enumerate() {
56 let mut call_marker = func.compact_signature();
57
58 if let Some(&count) = call_frequency.get(&func.name)
59 && count > 3
60 {
61 call_marker.push_str(&format!("\u{2022}{}", count));
62 }
63
64 if i == 0 {
65 line.push_str(&call_marker);
66 } else if line.len() + call_marker.len() + 2 > 100 {
67 output.push_str(&line);
68 output.push('\n');
69 let mut new_line = String::with_capacity(2 + call_marker.len());
70 new_line.push_str(" ");
71 new_line.push_str(&call_marker);
72 line = new_line;
73 } else {
74 line.push_str(", ");
75 line.push_str(&call_marker);
76 }
77 }
78 if !line.trim().is_empty() {
79 output.push_str(&line);
80 output.push('\n');
81 }
82 output
83}
84
85fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
88 let mut parts = Vec::new();
89 if line_count > 0 {
90 parts.push(format!("{}L", line_count));
91 }
92 if fn_count > 0 {
93 parts.push(format!("{}F", fn_count));
94 }
95 if cls_count > 0 {
96 parts.push(format!("{}C", cls_count));
97 }
98 if parts.is_empty() {
99 None
100 } else {
101 Some(format!("[{}]", parts.join(", ")))
102 }
103}
104
105fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
107 match base_path {
108 Some(base) => {
109 if let Ok(rel_path) = path.strip_prefix(base) {
110 rel_path.display().to_string()
111 } else {
112 path.display().to_string()
113 }
114 }
115 None => path.display().to_string(),
116 }
117}
118
119#[derive(Debug, Error)]
120pub enum FormatterError {
121 #[error("Graph error: {0}")]
122 GraphError(#[from] crate::graph::GraphError),
123}
124
125#[instrument(skip_all)]
127pub fn format_structure(
128 entries: &[WalkEntry],
129 analysis_results: &[FileInfo],
130 max_depth: Option<u32>,
131 _base_path: Option<&Path>,
132) -> String {
133 let mut output = String::new();
134
135 let analysis_map: HashMap<String, &FileInfo> = analysis_results
137 .iter()
138 .map(|a| (a.path.clone(), a))
139 .collect();
140
141 let (prod_files, test_files): (Vec<_>, Vec<_>) =
143 analysis_results.iter().partition(|a| !a.is_test);
144
145 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
147 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
148 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
149
150 let mut lang_counts: HashMap<String, usize> = HashMap::new();
152 for analysis in analysis_results {
153 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
154 }
155 let total_files = analysis_results.len();
156
157 let primary_lang = lang_counts
159 .iter()
160 .max_by_key(|&(_, count)| count)
161 .map(|(name, count)| {
162 let percentage = if total_files > 0 {
163 (*count * 100) / total_files
164 } else {
165 0
166 };
167 format!("{} {}%", name, percentage)
168 })
169 .unwrap_or_else(|| "unknown 0%".to_string());
170
171 output.push_str(&format!(
172 "{} files, {}L, {}F, {}C ({})\n",
173 total_files, total_loc, total_functions, total_classes, primary_lang
174 ));
175
176 output.push_str("SUMMARY:\n");
178 let depth_label = match max_depth {
179 Some(n) if n > 0 => format!(" (max_depth={})", n),
180 _ => String::new(),
181 };
182 output.push_str(&format!(
183 "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
184 total_files,
185 prod_files.len(),
186 test_files.len(),
187 total_loc,
188 total_functions,
189 total_classes,
190 depth_label
191 ));
192
193 if !lang_counts.is_empty() {
194 output.push_str("Languages: ");
195 let mut langs: Vec<_> = lang_counts.iter().collect();
196 langs.sort_by_key(|&(name, _)| name);
197 let lang_strs: Vec<String> = langs
198 .iter()
199 .map(|(name, count)| {
200 let percentage = if total_files > 0 {
201 (**count * 100) / total_files
202 } else {
203 0
204 };
205 format!("{} ({}%)", name, percentage)
206 })
207 .collect();
208 output.push_str(&lang_strs.join(", "));
209 output.push('\n');
210 }
211
212 output.push('\n');
213
214 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
216
217 for entry in entries {
218 if entry.depth == 0 {
220 continue;
221 }
222
223 let indent = " ".repeat(entry.depth - 1);
225
226 let name = entry
228 .path
229 .file_name()
230 .and_then(|n| n.to_str())
231 .unwrap_or("?");
232
233 if !entry.is_dir {
235 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
236 if analysis.is_test {
238 continue;
239 }
240
241 if let Some(info_str) = format_file_info_parts(
242 analysis.line_count,
243 analysis.function_count,
244 analysis.class_count,
245 ) {
246 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
247 } else {
248 output.push_str(&format!("{}{}\n", indent, name));
249 }
250 }
251 } else {
253 output.push_str(&format!("{}{}/\n", indent, name));
254 }
255 }
256
257 if !test_files.is_empty() {
259 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
260
261 for entry in entries {
262 if entry.depth == 0 {
264 continue;
265 }
266
267 let indent = " ".repeat(entry.depth - 1);
269
270 let name = entry
272 .path
273 .file_name()
274 .and_then(|n| n.to_str())
275 .unwrap_or("?");
276
277 if !entry.is_dir
279 && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
280 {
281 if !analysis.is_test {
283 continue;
284 }
285
286 if let Some(info_str) = format_file_info_parts(
287 analysis.line_count,
288 analysis.function_count,
289 analysis.class_count,
290 ) {
291 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
292 } else {
293 output.push_str(&format!("{}{}\n", indent, name));
294 }
295 }
296 }
297 }
298
299 output
300}
301
302#[instrument(skip_all)]
304pub fn format_file_details(
305 path: &str,
306 analysis: &SemanticAnalysis,
307 line_count: usize,
308 is_test: bool,
309 base_path: Option<&Path>,
310) -> String {
311 let mut output = String::new();
312
313 let display_path = strip_base_path(Path::new(path), base_path);
315 if is_test {
316 output.push_str(&format!(
317 "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
318 display_path,
319 line_count,
320 analysis.functions.len(),
321 analysis.classes.len(),
322 analysis.imports.len()
323 ));
324 } else {
325 output.push_str(&format!(
326 "FILE: {}({}L, {}F, {}C, {}I)\n",
327 display_path,
328 line_count,
329 analysis.functions.len(),
330 analysis.classes.len(),
331 analysis.imports.len()
332 ));
333 }
334
335 output.push_str(&format_classes_section(
337 &analysis.classes,
338 &analysis.functions,
339 ));
340
341 let top_level_functions: Vec<&FunctionInfo> = analysis
343 .functions
344 .iter()
345 .filter(|func| {
346 !analysis
347 .classes
348 .iter()
349 .any(|class| is_method_of_class(func, class))
350 })
351 .collect();
352
353 if !top_level_functions.is_empty() {
354 output.push_str("F:\n");
355 output.push_str(&format_function_list_wrapped(
356 top_level_functions.iter().copied(),
357 &analysis.call_frequency,
358 ));
359 }
360
361 output.push_str(&format_imports_section(&analysis.imports));
363
364 output
365}
366
367fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
377 use std::collections::BTreeMap;
378
379 if chains.is_empty() {
380 return " (none)\n".to_string();
381 }
382
383 let mut output = String::new();
384
385 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
387 for (parent, child) in chains {
388 if !child.is_empty() {
390 *groups
391 .entry(parent.to_string())
392 .or_default()
393 .entry(child.to_string())
394 .or_insert(0) += 1;
395 } else {
396 groups.entry(parent.to_string()).or_default();
398 }
399 }
400
401 for (parent, children) in groups {
403 let _ = writeln!(output, " {} {} {}", focus_symbol, arrow, parent);
404 let mut sorted: Vec<_> = children.into_iter().collect();
406 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
407 for (child, count) in sorted {
408 if count > 1 {
409 let _ = writeln!(output, " {} {} (x{})", arrow, child, count);
410 } else {
411 let _ = writeln!(output, " {} {}", arrow, child);
412 }
413 }
414 }
415
416 output
417}
418
419#[instrument(skip_all)]
421pub fn format_focused(
422 graph: &CallGraph,
423 symbol: &str,
424 follow_depth: u32,
425 base_path: Option<&Path>,
426) -> Result<String, FormatterError> {
427 let mut output = String::new();
428
429 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
431 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
432 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
433
434 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
436 incoming_chains.clone().into_iter().partition(|chain| {
437 chain
438 .chain
439 .first()
440 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
441 });
442
443 let callers_count = prod_chains
445 .iter()
446 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
447 .collect::<std::collections::HashSet<_>>()
448 .len();
449
450 let callees_count = outgoing_chains
452 .iter()
453 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
454 .collect::<std::collections::HashSet<_>>()
455 .len();
456
457 output.push_str(&format!(
459 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
460 symbol, def_count, callers_count, callees_count
461 ));
462
463 output.push_str(&format!("DEPTH: {}\n", follow_depth));
465
466 if let Some(definitions) = graph.definitions.get(symbol) {
468 output.push_str("DEFINED:\n");
469 for (path, line) in definitions {
470 output.push_str(&format!(
471 " {}:{}\n",
472 strip_base_path(path, base_path),
473 line
474 ));
475 }
476 } else {
477 output.push_str("DEFINED: (not found)\n");
478 }
479
480 output.push_str("CALLERS:\n");
482
483 let prod_refs: Vec<_> = prod_chains
485 .iter()
486 .filter_map(|chain| {
487 if chain.chain.len() >= 2 {
488 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
489 } else if chain.chain.len() == 1 {
490 Some((chain.chain[0].0.as_str(), ""))
491 } else {
492 None
493 }
494 })
495 .collect();
496
497 if prod_refs.is_empty() {
498 output.push_str(" (none)\n");
499 } else {
500 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
501 }
502
503 if !test_chains.is_empty() {
505 let mut test_files: Vec<_> = test_chains
506 .iter()
507 .filter_map(|chain| {
508 chain
509 .chain
510 .first()
511 .map(|(_, path, _)| path.to_string_lossy().into_owned())
512 })
513 .collect();
514 test_files.sort();
515 test_files.dedup();
516
517 let display_files: Vec<_> = test_files
519 .iter()
520 .map(|f| strip_base_path(Path::new(f), base_path))
521 .collect();
522
523 let file_list = display_files.join(", ");
524 output.push_str(&format!(
525 "CALLERS (test): {} test functions (in {})\n",
526 test_chains.len(),
527 file_list
528 ));
529 }
530
531 output.push_str("CALLEES:\n");
533 let outgoing_refs: Vec<_> = outgoing_chains
534 .iter()
535 .filter_map(|chain| {
536 if chain.chain.len() >= 2 {
537 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
538 } else if chain.chain.len() == 1 {
539 Some((chain.chain[0].0.as_str(), ""))
540 } else {
541 None
542 }
543 })
544 .collect();
545
546 if outgoing_refs.is_empty() {
547 output.push_str(" (none)\n");
548 } else {
549 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
550 }
551
552 output.push_str("STATISTICS:\n");
554 let incoming_count = prod_refs
555 .iter()
556 .map(|(p, _)| p)
557 .collect::<std::collections::HashSet<_>>()
558 .len();
559 let outgoing_count = outgoing_refs
560 .iter()
561 .map(|(p, _)| p)
562 .collect::<std::collections::HashSet<_>>()
563 .len();
564 output.push_str(&format!(" Incoming calls: {}\n", incoming_count));
565 output.push_str(&format!(" Outgoing calls: {}\n", outgoing_count));
566
567 let mut files = HashSet::new();
569 for chain in &prod_chains {
570 for (_, path, _) in &chain.chain {
571 files.insert(path.clone());
572 }
573 }
574 for chain in &outgoing_chains {
575 for (_, path, _) in &chain.chain {
576 files.insert(path.clone());
577 }
578 }
579 if let Some(definitions) = graph.definitions.get(symbol) {
580 for (path, _) in definitions {
581 files.insert(path.clone());
582 }
583 }
584
585 let (prod_files, test_files): (Vec<_>, Vec<_>) =
587 files.into_iter().partition(|path| !is_test_file(path));
588
589 output.push_str("FILES:\n");
590 if prod_files.is_empty() && test_files.is_empty() {
591 output.push_str(" (none)\n");
592 } else {
593 if !prod_files.is_empty() {
595 let mut sorted_files = prod_files;
596 sorted_files.sort();
597 for file in sorted_files {
598 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
599 }
600 }
601
602 if !test_files.is_empty() {
604 output.push_str(" TEST FILES:\n");
605 let mut sorted_files = test_files;
606 sorted_files.sort();
607 for file in sorted_files {
608 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
609 }
610 }
611 }
612
613 Ok(output)
614}
615
616#[instrument(skip_all)]
619pub fn format_focused_summary(
620 graph: &CallGraph,
621 symbol: &str,
622 follow_depth: u32,
623 base_path: Option<&Path>,
624) -> Result<String, FormatterError> {
625 let mut output = String::new();
626
627 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
629 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
630 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
631
632 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
634 incoming_chains.into_iter().partition(|chain| {
635 chain
636 .chain
637 .first()
638 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
639 });
640
641 let callers_count = prod_chains
643 .iter()
644 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
645 .collect::<std::collections::HashSet<_>>()
646 .len();
647
648 let callees_count = outgoing_chains
650 .iter()
651 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
652 .collect::<std::collections::HashSet<_>>()
653 .len();
654
655 output.push_str(&format!(
657 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
658 symbol, def_count, callers_count, callees_count
659 ));
660
661 output.push_str(&format!("DEPTH: {}\n", follow_depth));
663
664 if let Some(definitions) = graph.definitions.get(symbol) {
666 output.push_str("DEFINED:\n");
667 for (path, line) in definitions {
668 output.push_str(&format!(
669 " {}:{}\n",
670 strip_base_path(path, base_path),
671 line
672 ));
673 }
674 } else {
675 output.push_str("DEFINED: (not found)\n");
676 }
677
678 output.push_str("CALLERS (top 10):\n");
680 if prod_chains.is_empty() {
681 output.push_str(" (none)\n");
682 } else {
683 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
685 std::collections::HashMap::new();
686 for chain in &prod_chains {
687 if let Some((name, path, _)) = chain.chain.first() {
688 let file_path = strip_base_path(path, base_path);
689 caller_freq
690 .entry(name.clone())
691 .and_modify(|(count, _)| *count += 1)
692 .or_insert((1, file_path));
693 }
694 }
695
696 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
698 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
699
700 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
701 output.push_str(&format!(" {} {}\n", name, file_path));
702 }
703 }
704
705 if !test_chains.is_empty() {
707 let mut test_files: Vec<_> = test_chains
708 .iter()
709 .filter_map(|chain| {
710 chain
711 .chain
712 .first()
713 .map(|(_, path, _)| path.to_string_lossy().into_owned())
714 })
715 .collect();
716 test_files.sort();
717 test_files.dedup();
718
719 output.push_str(&format!(
720 "CALLERS (test): {} test functions (in {} files)\n",
721 test_chains.len(),
722 test_files.len()
723 ));
724 }
725
726 output.push_str("CALLEES (top 10):\n");
728 if outgoing_chains.is_empty() {
729 output.push_str(" (none)\n");
730 } else {
731 let mut callee_freq: std::collections::HashMap<String, usize> =
733 std::collections::HashMap::new();
734 for chain in &outgoing_chains {
735 if let Some((name, _, _)) = chain.chain.first() {
736 *callee_freq.entry(name.clone()).or_insert(0) += 1;
737 }
738 }
739
740 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
742 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
743
744 for (name, _) in sorted_callees.into_iter().take(10) {
745 output.push_str(&format!(" {}\n", name));
746 }
747 }
748
749 output.push_str("SUGGESTION:\n");
751 output.push_str("Use summary=false with force=true for full output\n");
752
753 Ok(output)
754}
755
756#[instrument(skip_all)]
759pub fn format_summary(
760 entries: &[WalkEntry],
761 analysis_results: &[FileInfo],
762 max_depth: Option<u32>,
763 _base_path: Option<&Path>,
764 subtree_counts: Option<&[(PathBuf, usize)]>,
765) -> String {
766 let mut output = String::new();
767
768 let (prod_files, test_files): (Vec<_>, Vec<_>) =
770 analysis_results.iter().partition(|a| !a.is_test);
771
772 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
774 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
775 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
776
777 let mut lang_counts: HashMap<String, usize> = HashMap::new();
779 for analysis in analysis_results {
780 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
781 }
782 let total_files = analysis_results.len();
783
784 output.push_str("SUMMARY:\n");
786 let depth_label = match max_depth {
787 Some(n) if n > 0 => format!(" (max_depth={})", n),
788 _ => String::new(),
789 };
790 output.push_str(&format!(
791 "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
792 total_files,
793 prod_files.len(),
794 test_files.len(),
795 total_loc,
796 total_functions,
797 total_classes,
798 depth_label
799 ));
800
801 if !lang_counts.is_empty() {
802 output.push_str("Languages: ");
803 let mut langs: Vec<_> = lang_counts.iter().collect();
804 langs.sort_by_key(|&(name, _)| name);
805 let lang_strs: Vec<String> = langs
806 .iter()
807 .map(|(name, count)| {
808 let percentage = if total_files > 0 {
809 (**count * 100) / total_files
810 } else {
811 0
812 };
813 format!("{} ({}%)", name, percentage)
814 })
815 .collect();
816 output.push_str(&lang_strs.join(", "));
817 output.push('\n');
818 }
819
820 output.push('\n');
821
822 output.push_str("STRUCTURE (depth 1):\n");
824
825 let analysis_map: HashMap<String, &FileInfo> = analysis_results
827 .iter()
828 .map(|a| (a.path.clone(), a))
829 .collect();
830
831 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
833 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
834
835 let mut largest_dir_name: Option<String> = None;
837 let mut largest_dir_path: Option<String> = None;
838 let mut largest_dir_count: usize = 0;
839
840 for entry in depth1_entries {
841 let name = entry
842 .path
843 .file_name()
844 .and_then(|n| n.to_str())
845 .unwrap_or("?");
846
847 if entry.is_dir {
848 let dir_path_str = entry.path.display().to_string();
850 let files_in_dir: Vec<&FileInfo> = analysis_results
851 .iter()
852 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
853 .collect();
854
855 if !files_in_dir.is_empty() {
856 let dir_file_count = files_in_dir.len();
857 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
858 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
859 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
860
861 let entry_name_str = name.to_string();
863 let effective_count = if let Some(counts) = subtree_counts {
864 counts
865 .binary_search_by_key(&&entry.path, |(p, _)| p)
866 .ok()
867 .map(|i| counts[i].1)
868 .unwrap_or(dir_file_count)
869 } else {
870 dir_file_count
871 };
872 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
873 && effective_count > largest_dir_count
874 {
875 largest_dir_count = effective_count;
876 largest_dir_name = Some(entry_name_str);
877 largest_dir_path = Some(
878 entry
879 .path
880 .canonicalize()
881 .unwrap_or_else(|_| entry.path.clone())
882 .display()
883 .to_string(),
884 );
885 }
886
887 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
889 let mut top_files = files_in_dir.clone();
890 top_files.sort_unstable_by(|a, b| {
891 b.class_count
892 .cmp(&a.class_count)
893 .then(b.function_count.cmp(&a.function_count))
894 .then(a.path.cmp(&b.path))
895 });
896
897 let has_classes = top_files.iter().any(|f| f.class_count > 0);
898
899 if !has_classes {
901 top_files.sort_unstable_by(|a, b| {
902 b.function_count
903 .cmp(&a.function_count)
904 .then(a.path.cmp(&b.path))
905 });
906 }
907
908 let dir_path = Path::new(&dir_path_str);
909 let top_n: Vec<String> = top_files
910 .iter()
911 .take(3)
912 .filter(|f| {
913 if has_classes {
914 f.class_count > 0
915 } else {
916 f.function_count > 0
917 }
918 })
919 .map(|f| {
920 let rel = Path::new(&f.path)
921 .strip_prefix(dir_path)
922 .map(|p| p.to_string_lossy().into_owned())
923 .unwrap_or_else(|_| {
924 Path::new(&f.path)
925 .file_name()
926 .and_then(|n| n.to_str())
927 .map(|s| s.to_owned())
928 .unwrap_or_else(|| "?".to_owned())
929 });
930 let count = if has_classes {
931 f.class_count
932 } else {
933 f.function_count
934 };
935 let suffix = if has_classes { 'C' } else { 'F' };
936 format!("{}({}{})", rel, count, suffix)
937 })
938 .collect();
939 if top_n.is_empty() {
940 String::new()
941 } else {
942 format!(" top: {}", top_n.join(", "))
943 }
944 } else {
945 String::new()
946 };
947
948 let mut subdirs: Vec<String> = entries
950 .iter()
951 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
952 .filter_map(|e| {
953 e.path
954 .file_name()
955 .and_then(|n| n.to_str())
956 .map(|s| s.to_owned())
957 })
958 .collect();
959 subdirs.sort();
960 subdirs.dedup();
961 let subdir_suffix = if subdirs.is_empty() {
962 String::new()
963 } else {
964 let subdirs_capped: Vec<String> =
965 subdirs.iter().take(5).map(|s| format!("{}/", s)).collect();
966 format!(" sub: {}", subdirs_capped.join(", "))
967 };
968
969 let files_label = if let Some(counts) = subtree_counts {
970 let true_count = counts
971 .binary_search_by_key(&&entry.path, |(p, _)| p)
972 .ok()
973 .map(|i| counts[i].1)
974 .unwrap_or(dir_file_count);
975 if true_count != dir_file_count {
976 let depth_val = max_depth.unwrap_or(0);
977 format!(
978 "{} files total; showing {} at depth={}, {}L, {}F, {}C",
979 true_count,
980 dir_file_count,
981 depth_val,
982 dir_loc,
983 dir_functions,
984 dir_classes
985 )
986 } else {
987 format!(
988 "{} files, {}L, {}F, {}C",
989 dir_file_count, dir_loc, dir_functions, dir_classes
990 )
991 }
992 } else {
993 format!(
994 "{} files, {}L, {}F, {}C",
995 dir_file_count, dir_loc, dir_functions, dir_classes
996 )
997 };
998 output.push_str(&format!(
999 " {}/ [{}]{}{}\n",
1000 name, files_label, hint, subdir_suffix
1001 ));
1002 } else {
1003 let entry_name_str = name.to_string();
1005 if let Some(counts) = subtree_counts {
1006 let true_count = counts
1007 .binary_search_by_key(&&entry.path, |(p, _)| p)
1008 .ok()
1009 .map(|i| counts[i].1)
1010 .unwrap_or(0);
1011 if true_count > 0 {
1012 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1014 && true_count > largest_dir_count
1015 {
1016 largest_dir_count = true_count;
1017 largest_dir_name = Some(entry_name_str);
1018 largest_dir_path = Some(
1019 entry
1020 .path
1021 .canonicalize()
1022 .unwrap_or_else(|_| entry.path.clone())
1023 .display()
1024 .to_string(),
1025 );
1026 }
1027 let depth_val = max_depth.unwrap_or(0);
1028 output.push_str(&format!(
1029 " {}/ [{} files total; showing 0 at depth={}, 0L, 0F, 0C]\n",
1030 name, true_count, depth_val
1031 ));
1032 } else {
1033 output.push_str(&format!(" {}/\n", name));
1034 }
1035 } else {
1036 output.push_str(&format!(" {}/\n", name));
1037 }
1038 }
1039 } else {
1040 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1042 if let Some(info_str) = format_file_info_parts(
1043 analysis.line_count,
1044 analysis.function_count,
1045 analysis.class_count,
1046 ) {
1047 output.push_str(&format!(" {} {}\n", name, info_str));
1048 } else {
1049 output.push_str(&format!(" {}\n", name));
1050 }
1051 }
1052 }
1053 }
1054
1055 output.push('\n');
1056
1057 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1059 output.push_str(&format!(
1060 "SUGGESTION: Largest source directory: {}/ ({} files total). For module details, re-run with path={} and max_depth=2.\n",
1061 name, largest_dir_count, path
1062 ));
1063 } else {
1064 output.push_str("SUGGESTION:\n");
1065 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1066 }
1067
1068 output
1069}
1070
1071#[instrument(skip_all)]
1076pub fn format_file_details_summary(
1077 semantic: &SemanticAnalysis,
1078 path: &str,
1079 line_count: usize,
1080) -> String {
1081 let mut output = String::new();
1082
1083 output.push_str("FILE:\n");
1085 output.push_str(&format!(" path: {}\n", path));
1086 output.push_str(&format!(
1087 " {}L, {}F, {}C\n",
1088 line_count,
1089 semantic.functions.len(),
1090 semantic.classes.len()
1091 ));
1092 output.push('\n');
1093
1094 if !semantic.functions.is_empty() {
1096 output.push_str("TOP FUNCTIONS BY SIZE:\n");
1097 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1098 let k = funcs.len().min(10);
1099 if k > 0 {
1100 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1101 let a_span = a.end_line.saturating_sub(a.line);
1102 let b_span = b.end_line.saturating_sub(b.line);
1103 b_span.cmp(&a_span)
1104 });
1105 funcs[..k].sort_by(|a, b| {
1106 let a_span = a.end_line.saturating_sub(a.line);
1107 let b_span = b.end_line.saturating_sub(b.line);
1108 b_span.cmp(&a_span)
1109 });
1110 }
1111
1112 for func in &funcs[..k] {
1113 let span = func.end_line.saturating_sub(func.line);
1114 let params = if func.parameters.is_empty() {
1115 String::new()
1116 } else {
1117 format!("({})", func.parameters.join(", "))
1118 };
1119 output.push_str(&format!(
1120 " {}:{}: {} {} [{}L]\n",
1121 func.line, func.end_line, func.name, params, span
1122 ));
1123 }
1124 output.push('\n');
1125 }
1126
1127 if !semantic.classes.is_empty() {
1129 output.push_str("CLASSES:\n");
1130 if semantic.classes.len() <= 10 {
1131 for class in &semantic.classes {
1133 let methods_count = class.methods.len();
1134 output.push_str(&format!(" {}: {}M\n", class.name, methods_count));
1135 }
1136 } else {
1137 output.push_str(&format!(" {} classes total\n", semantic.classes.len()));
1139 for class in semantic.classes.iter().take(5) {
1140 output.push_str(&format!(" {}\n", class.name));
1141 }
1142 if semantic.classes.len() > 5 {
1143 output.push_str(&format!(
1144 " ... and {} more\n",
1145 semantic.classes.len() - 5
1146 ));
1147 }
1148 }
1149 output.push('\n');
1150 }
1151
1152 output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
1154 output.push('\n');
1155
1156 output.push_str("SUGGESTION:\n");
1158 output.push_str("Use force=true for full output, or narrow your scope\n");
1159
1160 output
1161}
1162
1163#[instrument(skip_all)]
1165pub fn format_structure_paginated(
1166 paginated_files: &[FileInfo],
1167 total_files: usize,
1168 max_depth: Option<u32>,
1169 base_path: Option<&Path>,
1170 verbose: bool,
1171) -> String {
1172 let mut output = String::new();
1173
1174 let depth_label = match max_depth {
1175 Some(n) if n > 0 => format!(" (max_depth={})", n),
1176 _ => String::new(),
1177 };
1178 output.push_str(&format!(
1179 "PAGINATED: showing {} of {} files{}\n\n",
1180 paginated_files.len(),
1181 total_files,
1182 depth_label
1183 ));
1184
1185 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1186 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1187
1188 if !prod_files.is_empty() {
1189 if verbose {
1190 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1191 }
1192 for file in &prod_files {
1193 output.push_str(&format_file_entry(file, base_path));
1194 }
1195 }
1196
1197 if !test_files.is_empty() {
1198 if verbose {
1199 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1200 } else if !prod_files.is_empty() {
1201 output.push('\n');
1202 }
1203 for file in &test_files {
1204 output.push_str(&format_file_entry(file, base_path));
1205 }
1206 }
1207
1208 output
1209}
1210
1211#[instrument(skip_all)]
1216#[allow(clippy::too_many_arguments)]
1217pub fn format_file_details_paginated(
1218 functions_page: &[FunctionInfo],
1219 total_functions: usize,
1220 semantic: &SemanticAnalysis,
1221 path: &str,
1222 line_count: usize,
1223 offset: usize,
1224 verbose: bool,
1225) -> String {
1226 let mut output = String::new();
1227
1228 let start = offset + 1; let end = offset + functions_page.len();
1230
1231 output.push_str(&format!(
1232 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1233 path,
1234 line_count,
1235 start,
1236 end,
1237 total_functions,
1238 semantic.classes.len(),
1239 semantic.imports.len(),
1240 ));
1241
1242 if offset == 0 && !semantic.classes.is_empty() {
1244 output.push_str(&format_classes_section(
1245 &semantic.classes,
1246 &semantic.functions,
1247 ));
1248 }
1249
1250 if offset == 0 && verbose {
1252 output.push_str(&format_imports_section(&semantic.imports));
1253 }
1254
1255 let top_level_functions: Vec<&FunctionInfo> = functions_page
1257 .iter()
1258 .filter(|func| {
1259 !semantic
1260 .classes
1261 .iter()
1262 .any(|class| is_method_of_class(func, class))
1263 })
1264 .collect();
1265
1266 if !top_level_functions.is_empty() {
1267 output.push_str("F:\n");
1268 output.push_str(&format_function_list_wrapped(
1269 top_level_functions.iter().copied(),
1270 &semantic.call_frequency,
1271 ));
1272 }
1273
1274 output
1275}
1276
1277#[instrument(skip_all)]
1282#[allow(clippy::too_many_arguments)]
1283pub(crate) fn format_focused_paginated(
1284 paginated_chains: &[InternalCallChain],
1285 total: usize,
1286 mode: PaginationMode,
1287 symbol: &str,
1288 prod_chains: &[InternalCallChain],
1289 test_chains: &[InternalCallChain],
1290 outgoing_chains: &[InternalCallChain],
1291 def_count: usize,
1292 offset: usize,
1293 base_path: Option<&Path>,
1294 _verbose: bool,
1295) -> String {
1296 let start = offset + 1; let end = offset + paginated_chains.len();
1298
1299 let callers_count = prod_chains.len();
1300
1301 let callees_count = outgoing_chains.len();
1302
1303 let mut output = String::new();
1304
1305 output.push_str(&format!(
1306 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1307 symbol, def_count, callers_count, callees_count
1308 ));
1309
1310 match mode {
1311 PaginationMode::Callers => {
1312 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1314
1315 let page_refs: Vec<_> = paginated_chains
1316 .iter()
1317 .filter_map(|chain| {
1318 if chain.chain.len() >= 2 {
1319 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1320 } else if chain.chain.len() == 1 {
1321 Some((chain.chain[0].0.as_str(), ""))
1322 } else {
1323 None
1324 }
1325 })
1326 .collect();
1327
1328 if page_refs.is_empty() {
1329 output.push_str(" (none)\n");
1330 } else {
1331 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1332 }
1333
1334 if !test_chains.is_empty() {
1336 let mut test_files: Vec<_> = test_chains
1337 .iter()
1338 .filter_map(|chain| {
1339 chain
1340 .chain
1341 .first()
1342 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1343 })
1344 .collect();
1345 test_files.sort();
1346 test_files.dedup();
1347
1348 let display_files: Vec<_> = test_files
1349 .iter()
1350 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1351 .collect();
1352
1353 output.push_str(&format!(
1354 "CALLERS (test): {} test functions (in {})\n",
1355 test_chains.len(),
1356 display_files.join(", ")
1357 ));
1358 }
1359
1360 let callee_names: Vec<_> = outgoing_chains
1362 .iter()
1363 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1364 .collect::<std::collections::HashSet<_>>()
1365 .into_iter()
1366 .collect();
1367 if callee_names.is_empty() {
1368 output.push_str("CALLEES: (none)\n");
1369 } else {
1370 output.push_str(&format!(
1371 "CALLEES: {} (use cursor for callee pagination)\n",
1372 callees_count
1373 ));
1374 }
1375 }
1376 PaginationMode::Callees => {
1377 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1379
1380 if !test_chains.is_empty() {
1382 output.push_str(&format!(
1383 "CALLERS (test): {} test functions\n",
1384 test_chains.len()
1385 ));
1386 }
1387
1388 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1390
1391 let page_refs: Vec<_> = paginated_chains
1392 .iter()
1393 .filter_map(|chain| {
1394 if chain.chain.len() >= 2 {
1395 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1396 } else if chain.chain.len() == 1 {
1397 Some((chain.chain[0].0.as_str(), ""))
1398 } else {
1399 None
1400 }
1401 })
1402 .collect();
1403
1404 if page_refs.is_empty() {
1405 output.push_str(" (none)\n");
1406 } else {
1407 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1408 }
1409 }
1410 PaginationMode::Default => {
1411 unreachable!("format_focused_paginated called with PaginationMode::Default")
1412 }
1413 }
1414
1415 output
1416}
1417
1418fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1419 let mut parts = Vec::new();
1420 if file.line_count > 0 {
1421 parts.push(format!("{}L", file.line_count));
1422 }
1423 if file.function_count > 0 {
1424 parts.push(format!("{}F", file.function_count));
1425 }
1426 if file.class_count > 0 {
1427 parts.push(format!("{}C", file.class_count));
1428 }
1429 let display_path = strip_base_path(Path::new(&file.path), base_path);
1430 if parts.is_empty() {
1431 format!("{}\n", display_path)
1432 } else {
1433 format!("{} [{}]\n", display_path, parts.join(", "))
1434 }
1435}
1436
1437#[instrument(skip_all)]
1451pub fn format_module_info(info: &ModuleInfo) -> String {
1452 use std::fmt::Write as _;
1453 let fn_count = info.functions.len();
1454 let import_count = info.imports.len();
1455 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1456 let _ = writeln!(
1457 out,
1458 "FILE: {} ({}L, {}F, {}I)",
1459 info.name, info.line_count, fn_count, import_count
1460 );
1461 if !info.functions.is_empty() {
1462 out.push_str("F:\n ");
1463 let parts: Vec<String> = info
1464 .functions
1465 .iter()
1466 .map(|f| format!("{}:{}", f.name, f.line))
1467 .collect();
1468 out.push_str(&parts.join(", "));
1469 out.push('\n');
1470 }
1471 if !info.imports.is_empty() {
1472 out.push_str("I:\n ");
1473 let parts: Vec<String> = info
1474 .imports
1475 .iter()
1476 .map(|i| {
1477 if i.items.is_empty() {
1478 i.module.clone()
1479 } else {
1480 format!("{}:{}", i.module, i.items.join(", "))
1481 }
1482 })
1483 .collect();
1484 out.push_str(&parts.join("; "));
1485 out.push('\n');
1486 }
1487 out
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493
1494 #[test]
1495 fn test_strip_base_path_relative() {
1496 let path = Path::new("/home/user/project/src/main.rs");
1497 let base = Path::new("/home/user/project");
1498 let result = strip_base_path(path, Some(base));
1499 assert_eq!(result, "src/main.rs");
1500 }
1501
1502 #[test]
1503 fn test_strip_base_path_fallback_absolute() {
1504 let path = Path::new("/other/project/src/main.rs");
1505 let base = Path::new("/home/user/project");
1506 let result = strip_base_path(path, Some(base));
1507 assert_eq!(result, "/other/project/src/main.rs");
1508 }
1509
1510 #[test]
1511 fn test_strip_base_path_none() {
1512 let path = Path::new("/home/user/project/src/main.rs");
1513 let result = strip_base_path(path, None);
1514 assert_eq!(result, "/home/user/project/src/main.rs");
1515 }
1516
1517 #[test]
1518 fn test_format_file_details_summary_empty() {
1519 use crate::types::SemanticAnalysis;
1520 use std::collections::HashMap;
1521
1522 let semantic = SemanticAnalysis {
1523 functions: vec![],
1524 classes: vec![],
1525 imports: vec![],
1526 references: vec![],
1527 call_frequency: HashMap::new(),
1528 calls: vec![],
1529 assignments: vec![],
1530 field_accesses: vec![],
1531 };
1532
1533 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1534
1535 assert!(result.contains("FILE:"));
1537 assert!(result.contains("100L, 0F, 0C"));
1538 assert!(result.contains("src/main.rs"));
1539 assert!(result.contains("Imports: 0"));
1540 assert!(result.contains("SUGGESTION:"));
1541 }
1542
1543 #[test]
1544 fn test_format_file_details_summary_with_functions() {
1545 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1546 use std::collections::HashMap;
1547
1548 let semantic = SemanticAnalysis {
1549 functions: vec![
1550 FunctionInfo {
1551 name: "short".to_string(),
1552 line: 10,
1553 end_line: 12,
1554 parameters: vec![],
1555 return_type: None,
1556 },
1557 FunctionInfo {
1558 name: "long_function".to_string(),
1559 line: 20,
1560 end_line: 50,
1561 parameters: vec!["x".to_string(), "y".to_string()],
1562 return_type: Some("i32".to_string()),
1563 },
1564 ],
1565 classes: vec![ClassInfo {
1566 name: "MyClass".to_string(),
1567 line: 60,
1568 end_line: 80,
1569 methods: vec![],
1570 fields: vec![],
1571 inherits: vec![],
1572 }],
1573 imports: vec![],
1574 references: vec![],
1575 call_frequency: HashMap::new(),
1576 calls: vec![],
1577 assignments: vec![],
1578 field_accesses: vec![],
1579 };
1580
1581 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1582
1583 assert!(result.contains("FILE:"));
1585 assert!(result.contains("src/lib.rs"));
1586 assert!(result.contains("250L, 2F, 1C"));
1587
1588 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1590 let long_idx = result.find("long_function").unwrap_or(0);
1591 let short_idx = result.find("short").unwrap_or(0);
1592 assert!(
1593 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1594 "long_function should appear before short"
1595 );
1596
1597 assert!(result.contains("CLASSES:"));
1599 assert!(result.contains("MyClass:"));
1600
1601 assert!(result.contains("Imports: 0"));
1603 }
1604 #[test]
1605 fn test_format_file_info_parts_all_zero() {
1606 assert_eq!(format_file_info_parts(0, 0, 0), None);
1607 }
1608
1609 #[test]
1610 fn test_format_file_info_parts_partial() {
1611 assert_eq!(
1612 format_file_info_parts(42, 0, 3),
1613 Some("[42L, 3C]".to_string())
1614 );
1615 }
1616
1617 #[test]
1618 fn test_format_file_info_parts_all_nonzero() {
1619 assert_eq!(
1620 format_file_info_parts(100, 5, 2),
1621 Some("[100L, 5F, 2C]".to_string())
1622 );
1623 }
1624
1625 #[test]
1626 fn test_format_function_list_wrapped_empty() {
1627 let freq = std::collections::HashMap::new();
1628 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1629 assert_eq!(result, "");
1630 }
1631
1632 #[test]
1633 fn test_format_function_list_wrapped_bullet_annotation() {
1634 use crate::types::FunctionInfo;
1635 use std::collections::HashMap;
1636
1637 let mut freq = HashMap::new();
1638 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1641 name: "frequent".to_string(),
1642 line: 1,
1643 end_line: 10,
1644 parameters: vec![],
1645 return_type: Some("void".to_string()),
1646 }];
1647
1648 let result = format_function_list_wrapped(funcs.iter(), &freq);
1649 assert!(result.contains("\u{2022}5"));
1651 }
1652
1653 #[test]
1654 fn test_compact_format_omits_sections() {
1655 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1656 use std::collections::HashMap;
1657
1658 let funcs: Vec<FunctionInfo> = (0..10)
1659 .map(|i| FunctionInfo {
1660 name: format!("fn_{}", i),
1661 line: i * 5 + 1,
1662 end_line: i * 5 + 4,
1663 parameters: vec![format!("x: u32")],
1664 return_type: Some("bool".to_string()),
1665 })
1666 .collect();
1667 let imports: Vec<ImportInfo> = vec![ImportInfo {
1668 module: "std::collections".to_string(),
1669 items: vec!["HashMap".to_string()],
1670 line: 1,
1671 }];
1672 let classes: Vec<ClassInfo> = vec![ClassInfo {
1673 name: "MyStruct".to_string(),
1674 line: 100,
1675 end_line: 150,
1676 methods: vec![],
1677 fields: vec![],
1678 inherits: vec![],
1679 }];
1680 let semantic = SemanticAnalysis {
1681 functions: funcs,
1682 classes,
1683 imports,
1684 references: vec![],
1685 call_frequency: HashMap::new(),
1686 calls: vec![],
1687 assignments: vec![],
1688 field_accesses: vec![],
1689 };
1690
1691 let verbose_out = format_file_details_paginated(
1692 &semantic.functions,
1693 semantic.functions.len(),
1694 &semantic,
1695 "src/lib.rs",
1696 100,
1697 0,
1698 true,
1699 );
1700 let compact_out = format_file_details_paginated(
1701 &semantic.functions,
1702 semantic.functions.len(),
1703 &semantic,
1704 "src/lib.rs",
1705 100,
1706 0,
1707 false,
1708 );
1709
1710 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1712 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1713 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1714
1715 assert!(
1717 compact_out.contains("C:\n"),
1718 "compact must have C: section (restored)"
1719 );
1720 assert!(
1721 !compact_out.contains("I:\n"),
1722 "compact must not have I: section (imports omitted)"
1723 );
1724 assert!(
1725 compact_out.contains("F:\n"),
1726 "compact must have F: section with wrapped formatting"
1727 );
1728
1729 assert!(compact_out.contains("fn_0"), "compact must list functions");
1731 let has_two_on_same_line = compact_out
1732 .lines()
1733 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1734 assert!(
1735 has_two_on_same_line,
1736 "compact must render multiple functions per line (wrapped), not one-per-line"
1737 );
1738 }
1739
1740 #[test]
1742 fn test_compact_mode_consistent_token_reduction() {
1743 use crate::types::{FunctionInfo, SemanticAnalysis};
1744 use std::collections::HashMap;
1745
1746 let funcs: Vec<FunctionInfo> = (0..50)
1747 .map(|i| FunctionInfo {
1748 name: format!("function_name_{}", i),
1749 line: i * 10 + 1,
1750 end_line: i * 10 + 8,
1751 parameters: vec![
1752 "arg1: u32".to_string(),
1753 "arg2: String".to_string(),
1754 "arg3: Option<bool>".to_string(),
1755 ],
1756 return_type: Some("Result<Vec<String>, Error>".to_string()),
1757 })
1758 .collect();
1759
1760 let semantic = SemanticAnalysis {
1761 functions: funcs,
1762 classes: vec![],
1763 imports: vec![],
1764 references: vec![],
1765 call_frequency: HashMap::new(),
1766 calls: vec![],
1767 assignments: vec![],
1768 field_accesses: vec![],
1769 };
1770
1771 let verbose_out = format_file_details_paginated(
1772 &semantic.functions,
1773 semantic.functions.len(),
1774 &semantic,
1775 "src/large_file.rs",
1776 1000,
1777 0,
1778 true,
1779 );
1780 let compact_out = format_file_details_paginated(
1781 &semantic.functions,
1782 semantic.functions.len(),
1783 &semantic,
1784 "src/large_file.rs",
1785 1000,
1786 0,
1787 false,
1788 );
1789
1790 assert!(
1791 compact_out.len() <= verbose_out.len(),
1792 "compact ({} chars) must be <= verbose ({} chars)",
1793 compact_out.len(),
1794 verbose_out.len(),
1795 );
1796 }
1797
1798 #[test]
1800 fn test_format_module_info_happy_path() {
1801 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1802 let info = ModuleInfo {
1803 name: "parser.rs".to_string(),
1804 line_count: 312,
1805 language: "rust".to_string(),
1806 functions: vec![
1807 ModuleFunctionInfo {
1808 name: "parse_file".to_string(),
1809 line: 24,
1810 },
1811 ModuleFunctionInfo {
1812 name: "parse_block".to_string(),
1813 line: 58,
1814 },
1815 ],
1816 imports: vec![
1817 ModuleImportInfo {
1818 module: "crate::types".to_string(),
1819 items: vec!["Token".to_string(), "Expr".to_string()],
1820 },
1821 ModuleImportInfo {
1822 module: "std::io".to_string(),
1823 items: vec!["BufReader".to_string()],
1824 },
1825 ],
1826 };
1827 let result = format_module_info(&info);
1828 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1829 assert!(result.contains("F:"));
1830 assert!(result.contains("parse_file:24"));
1831 assert!(result.contains("parse_block:58"));
1832 assert!(result.contains("I:"));
1833 assert!(result.contains("crate::types:Token, Expr"));
1834 assert!(result.contains("std::io:BufReader"));
1835 assert!(result.contains("; "));
1836 assert!(!result.contains('{'));
1837 }
1838
1839 #[test]
1840 fn test_format_module_info_empty() {
1841 use crate::types::ModuleInfo;
1842 let info = ModuleInfo {
1843 name: "empty.rs".to_string(),
1844 line_count: 0,
1845 language: "rust".to_string(),
1846 functions: vec![],
1847 imports: vec![],
1848 };
1849 let result = format_module_info(&info);
1850 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1851 assert!(!result.contains("F:"));
1852 assert!(!result.contains("I:"));
1853 }
1854
1855 #[test]
1856 fn test_compact_mode_empty_classes_no_header() {
1857 use crate::types::{FunctionInfo, SemanticAnalysis};
1858 use std::collections::HashMap;
1859
1860 let funcs: Vec<FunctionInfo> = (0..5)
1861 .map(|i| FunctionInfo {
1862 name: format!("fn_{}", i),
1863 line: i * 5 + 1,
1864 end_line: i * 5 + 4,
1865 parameters: vec![],
1866 return_type: None,
1867 })
1868 .collect();
1869
1870 let semantic = SemanticAnalysis {
1871 functions: funcs,
1872 classes: vec![], imports: vec![],
1874 references: vec![],
1875 call_frequency: HashMap::new(),
1876 calls: vec![],
1877 assignments: vec![],
1878 field_accesses: vec![],
1879 };
1880
1881 let compact_out = format_file_details_paginated(
1882 &semantic.functions,
1883 semantic.functions.len(),
1884 &semantic,
1885 "src/simple.rs",
1886 100,
1887 0,
1888 false,
1889 );
1890
1891 assert!(
1893 !compact_out.contains("C:\n"),
1894 "compact mode must not emit C: header when classes are empty"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_format_classes_with_methods() {
1900 use crate::types::{ClassInfo, FunctionInfo};
1901
1902 let functions = vec![
1903 FunctionInfo {
1904 name: "method_a".to_string(),
1905 line: 5,
1906 end_line: 8,
1907 parameters: vec![],
1908 return_type: None,
1909 },
1910 FunctionInfo {
1911 name: "method_b".to_string(),
1912 line: 10,
1913 end_line: 12,
1914 parameters: vec![],
1915 return_type: None,
1916 },
1917 FunctionInfo {
1918 name: "top_level_func".to_string(),
1919 line: 50,
1920 end_line: 55,
1921 parameters: vec![],
1922 return_type: None,
1923 },
1924 ];
1925
1926 let classes = vec![ClassInfo {
1927 name: "MyClass".to_string(),
1928 line: 1,
1929 end_line: 30,
1930 methods: vec![],
1931 fields: vec![],
1932 inherits: vec![],
1933 }];
1934
1935 let output = format_classes_section(&classes, &functions);
1936
1937 assert!(
1938 output.contains("MyClass:1-30"),
1939 "class header should show start-end range"
1940 );
1941 assert!(output.contains("method_a:5"), "method_a should be listed");
1942 assert!(output.contains("method_b:10"), "method_b should be listed");
1943 assert!(
1944 !output.contains("top_level_func"),
1945 "top_level_func outside class range should not be listed"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_format_classes_method_cap() {
1951 use crate::types::{ClassInfo, FunctionInfo};
1952
1953 let mut functions = Vec::new();
1954 for i in 0..15 {
1955 functions.push(FunctionInfo {
1956 name: format!("method_{}", i),
1957 line: 2 + i,
1958 end_line: 3 + i,
1959 parameters: vec![],
1960 return_type: None,
1961 });
1962 }
1963
1964 let classes = vec![ClassInfo {
1965 name: "LargeClass".to_string(),
1966 line: 1,
1967 end_line: 50,
1968 methods: vec![],
1969 fields: vec![],
1970 inherits: vec![],
1971 }];
1972
1973 let output = format_classes_section(&classes, &functions);
1974
1975 assert!(output.contains("method_0"), "first method should be listed");
1976 assert!(output.contains("method_9"), "10th method should be listed");
1977 assert!(
1978 !output.contains("method_10"),
1979 "11th method should not be listed (cap at 10)"
1980 );
1981 assert!(
1982 output.contains("... (5 more)"),
1983 "truncation message should show remaining count"
1984 );
1985 }
1986
1987 #[test]
1988 fn test_format_classes_no_methods() {
1989 use crate::types::{ClassInfo, FunctionInfo};
1990
1991 let functions = vec![FunctionInfo {
1992 name: "top_level".to_string(),
1993 line: 100,
1994 end_line: 105,
1995 parameters: vec![],
1996 return_type: None,
1997 }];
1998
1999 let classes = vec![ClassInfo {
2000 name: "EmptyClass".to_string(),
2001 line: 1,
2002 end_line: 50,
2003 methods: vec![],
2004 fields: vec![],
2005 inherits: vec![],
2006 }];
2007
2008 let output = format_classes_section(&classes, &functions);
2009
2010 assert!(
2011 output.contains("EmptyClass:1-50"),
2012 "empty class header should appear"
2013 );
2014 assert!(
2015 !output.contains("top_level"),
2016 "top-level functions outside class should not appear"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_f_section_excludes_methods() {
2022 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2023 use std::collections::HashMap;
2024
2025 let functions = vec![
2026 FunctionInfo {
2027 name: "method_a".to_string(),
2028 line: 5,
2029 end_line: 10,
2030 parameters: vec![],
2031 return_type: None,
2032 },
2033 FunctionInfo {
2034 name: "top_level".to_string(),
2035 line: 50,
2036 end_line: 55,
2037 parameters: vec![],
2038 return_type: None,
2039 },
2040 ];
2041
2042 let semantic = SemanticAnalysis {
2043 functions,
2044 classes: vec![ClassInfo {
2045 name: "TestClass".to_string(),
2046 line: 1,
2047 end_line: 30,
2048 methods: vec![],
2049 fields: vec![],
2050 inherits: vec![],
2051 }],
2052 imports: vec![],
2053 references: vec![],
2054 call_frequency: HashMap::new(),
2055 calls: vec![],
2056 assignments: vec![],
2057 field_accesses: vec![],
2058 };
2059
2060 let output = format_file_details("test.rs", &semantic, 100, false, None);
2061
2062 assert!(output.contains("C:"), "classes section should exist");
2063 assert!(
2064 output.contains("method_a:5"),
2065 "method should be in C: section"
2066 );
2067 assert!(output.contains("F:"), "F: section should exist");
2068 assert!(
2069 output.contains("top_level"),
2070 "top-level function should be in F: section"
2071 );
2072
2073 let f_pos = output.find("F:").unwrap();
2075 let method_pos = output.find("method_a").unwrap();
2076 assert!(
2077 method_pos < f_pos,
2078 "method_a should appear before F: section"
2079 );
2080 }
2081
2082 #[test]
2083 fn test_format_focused_paginated_unit() {
2084 use crate::graph::InternalCallChain;
2085 use crate::pagination::PaginationMode;
2086 use std::path::PathBuf;
2087
2088 let make_chain = |name: &str| -> InternalCallChain {
2090 InternalCallChain {
2091 chain: vec![
2092 (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2093 ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2094 ],
2095 }
2096 };
2097
2098 let prod_chains: Vec<InternalCallChain> = (0..8)
2099 .map(|i| make_chain(&format!("caller_{}", i)))
2100 .collect();
2101 let page = &prod_chains[0..3];
2102
2103 let formatted = format_focused_paginated(
2105 page,
2106 8,
2107 PaginationMode::Callers,
2108 "target",
2109 &prod_chains,
2110 &[],
2111 &[],
2112 1,
2113 0,
2114 None,
2115 true,
2116 );
2117
2118 assert!(
2120 formatted.contains("CALLERS (1-3 of 8):"),
2121 "header should show 1-3 of 8, got: {}",
2122 formatted
2123 );
2124 assert!(
2125 formatted.contains("FOCUS: target"),
2126 "should have FOCUS header"
2127 );
2128 }
2129}
2130
2131fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2132 let mut output = String::new();
2133 if classes.is_empty() {
2134 return output;
2135 }
2136 output.push_str("C:\n");
2137
2138 let methods_by_class = collect_class_methods(classes, functions);
2139 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2140
2141 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2142 let class_strs: Vec<String> = classes
2143 .iter()
2144 .map(|class| {
2145 if class.inherits.is_empty() {
2146 format!("{}:{}-{}", class.name, class.line, class.end_line)
2147 } else {
2148 format!(
2149 "{}:{}-{} ({})",
2150 class.name,
2151 class.line,
2152 class.end_line,
2153 class.inherits.join(", ")
2154 )
2155 }
2156 })
2157 .collect();
2158 output.push_str(" ");
2159 output.push_str(&class_strs.join("; "));
2160 output.push('\n');
2161 } else {
2162 for class in classes {
2163 if class.inherits.is_empty() {
2164 output.push_str(&format!(
2165 " {}:{}-{}\n",
2166 class.name, class.line, class.end_line
2167 ));
2168 } else {
2169 output.push_str(&format!(
2170 " {}:{}-{} ({})\n",
2171 class.name,
2172 class.line,
2173 class.end_line,
2174 class.inherits.join(", ")
2175 ));
2176 }
2177
2178 if let Some(methods) = methods_by_class.get(&class.name)
2180 && !methods.is_empty()
2181 {
2182 for (i, method) in methods.iter().take(10).enumerate() {
2183 output.push_str(&format!(" {}:{}\n", method.name, method.line));
2184 if i + 1 == 10 && methods.len() > 10 {
2185 output.push_str(&format!(" ... ({} more)\n", methods.len() - 10));
2186 break;
2187 }
2188 }
2189 }
2190 }
2191 }
2192 output
2193}
2194
2195fn format_imports_section(imports: &[ImportInfo]) -> String {
2198 let mut output = String::new();
2199 if imports.is_empty() {
2200 return output;
2201 }
2202 output.push_str("I:\n");
2203 let mut module_map: HashMap<String, usize> = HashMap::new();
2204 for import in imports {
2205 module_map
2206 .entry(import.module.clone())
2207 .and_modify(|count| *count += 1)
2208 .or_insert(1);
2209 }
2210 let mut modules: Vec<_> = module_map.keys().cloned().collect();
2211 modules.sort();
2212 let formatted_modules: Vec<String> = modules
2213 .iter()
2214 .map(|module| format!("{}({})", module, module_map[module]))
2215 .collect();
2216 if formatted_modules.len() <= MULTILINE_THRESHOLD {
2217 output.push_str(" ");
2218 output.push_str(&formatted_modules.join("; "));
2219 output.push('\n');
2220 } else {
2221 for module_str in formatted_modules {
2222 output.push_str(" ");
2223 output.push_str(&module_str);
2224 output.push('\n');
2225 }
2226 }
2227 output
2228}