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::{
12 AnalyzeFileField, ClassInfo, FileInfo, FunctionInfo, ImportInfo, ModuleInfo, SemanticAnalysis,
13};
14use std::collections::{HashMap, HashSet};
15use std::fmt::Write;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18use tracing::instrument;
19
20const MULTILINE_THRESHOLD: usize = 10;
21
22fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
24 func.line >= class.line && func.end_line <= class.end_line
25}
26
27fn collect_class_methods<'a>(
30 classes: &'a [ClassInfo],
31 functions: &'a [FunctionInfo],
32) -> HashMap<String, Vec<&'a FunctionInfo>> {
33 let mut methods_by_class: HashMap<String, Vec<&'a FunctionInfo>> = HashMap::new();
34 for class in classes {
35 if class.methods.is_empty() {
36 let methods: Vec<&FunctionInfo> = functions
38 .iter()
39 .filter(|f| is_method_of_class(f, class))
40 .collect();
41 methods_by_class.insert(class.name.clone(), methods);
42 } else {
43 methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
45 }
46 }
47 methods_by_class
48}
49
50fn format_function_list_wrapped<'a>(
52 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
53 call_frequency: &std::collections::HashMap<String, usize>,
54) -> String {
55 let mut output = String::new();
56 let mut line = String::from(" ");
57 for (i, func) in functions.enumerate() {
58 let mut call_marker = func.compact_signature();
59
60 if let Some(&count) = call_frequency.get(&func.name)
61 && count > 3
62 {
63 let _ = write!(call_marker, "\u{2022}{count}");
64 }
65
66 if i == 0 {
67 line.push_str(&call_marker);
68 } else if line.len() + call_marker.len() + 2 > 100 {
69 output.push_str(&line);
70 output.push('\n');
71 let mut new_line = String::with_capacity(2 + call_marker.len());
72 new_line.push_str(" ");
73 new_line.push_str(&call_marker);
74 line = new_line;
75 } else {
76 line.push_str(", ");
77 line.push_str(&call_marker);
78 }
79 }
80 if !line.trim().is_empty() {
81 output.push_str(&line);
82 output.push('\n');
83 }
84 output
85}
86
87fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
90 let mut parts = Vec::new();
91 if line_count > 0 {
92 parts.push(format!("{line_count}L"));
93 }
94 if fn_count > 0 {
95 parts.push(format!("{fn_count}F"));
96 }
97 if cls_count > 0 {
98 parts.push(format!("{cls_count}C"));
99 }
100 if parts.is_empty() {
101 None
102 } else {
103 Some(format!("[{}]", parts.join(", ")))
104 }
105}
106
107fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
109 match base_path {
110 Some(base) => {
111 if let Ok(rel_path) = path.strip_prefix(base) {
112 rel_path.display().to_string()
113 } else {
114 path.display().to_string()
115 }
116 }
117 None => path.display().to_string(),
118 }
119}
120
121#[derive(Debug, Error)]
122pub enum FormatterError {
123 #[error("Graph error: {0}")]
124 GraphError(#[from] crate::graph::GraphError),
125}
126
127#[instrument(skip_all)]
129#[allow(clippy::too_many_lines)] pub fn format_structure(
131 entries: &[WalkEntry],
132 analysis_results: &[FileInfo],
133 max_depth: Option<u32>,
134) -> String {
135 let mut output = String::new();
136
137 let analysis_map: HashMap<String, &FileInfo> = analysis_results
139 .iter()
140 .map(|a| (a.path.clone(), a))
141 .collect();
142
143 let (prod_files, test_files): (Vec<_>, Vec<_>) =
145 analysis_results.iter().partition(|a| !a.is_test);
146
147 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
149 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
150 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
151
152 let mut lang_counts: HashMap<String, usize> = HashMap::new();
154 for analysis in analysis_results {
155 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
156 }
157 let total_files = analysis_results.len();
158
159 let primary_lang = lang_counts
161 .iter()
162 .max_by_key(|&(_, count)| count)
163 .map_or_else(
164 || "unknown 0%".to_string(),
165 |(name, count)| {
166 let percentage = if total_files > 0 {
167 (*count * 100) / total_files
168 } else {
169 0
170 };
171 format!("{name} {percentage}%")
172 },
173 );
174
175 let _ = writeln!(
176 output,
177 "{total_files} files, {total_loc}L, {total_functions}F, {total_classes}C ({primary_lang})"
178 );
179
180 output.push_str("SUMMARY:\n");
182 let depth_label = match max_depth {
183 Some(n) if n > 0 => format!(" (max_depth={n})"),
184 _ => String::new(),
185 };
186 let _ = writeln!(
187 output,
188 "Shown: {} files ({} prod, {} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}",
189 total_files,
190 prod_files.len(),
191 test_files.len()
192 );
193
194 if !lang_counts.is_empty() {
195 output.push_str("Languages: ");
196 let mut langs: Vec<_> = lang_counts.iter().collect();
197 langs.sort_by_key(|&(name, _)| name);
198 let lang_strs: Vec<String> = langs
199 .iter()
200 .map(|(name, count)| {
201 let percentage = if total_files > 0 {
202 (**count * 100) / total_files
203 } else {
204 0
205 };
206 format!("{name} ({percentage}%)")
207 })
208 .collect();
209 output.push_str(&lang_strs.join(", "));
210 output.push('\n');
211 }
212
213 output.push('\n');
214
215 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
217
218 let mut test_buf = String::new();
219
220 for entry in entries {
221 if entry.depth == 0 {
223 continue;
224 }
225
226 let indent = " ".repeat(entry.depth - 1);
228
229 let name = entry
231 .path
232 .file_name()
233 .and_then(|n| n.to_str())
234 .unwrap_or("?");
235
236 if entry.is_dir {
238 let line = format!("{indent}{name}/\n");
239 output.push_str(&line);
240 } else if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
241 if let Some(info_str) = format_file_info_parts(
242 analysis.line_count,
243 analysis.function_count,
244 analysis.class_count,
245 ) {
246 let line = format!("{indent}{name} {info_str}\n");
247 if analysis.is_test {
248 test_buf.push_str(&line);
249 } else {
250 output.push_str(&line);
251 }
252 } else {
253 let line = format!("{indent}{name}\n");
254 if analysis.is_test {
255 test_buf.push_str(&line);
256 } else {
257 output.push_str(&line);
258 }
259 }
260 }
262 }
263
264 if !test_buf.is_empty() {
266 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
267 output.push_str(&test_buf);
268 }
269
270 output
271}
272
273#[instrument(skip_all)]
275pub fn format_file_details(
276 path: &str,
277 analysis: &SemanticAnalysis,
278 line_count: usize,
279 is_test: bool,
280 base_path: Option<&Path>,
281) -> String {
282 let mut output = String::new();
283
284 let display_path = strip_base_path(Path::new(path), base_path);
286 let fn_count = analysis.functions.len();
287 let class_count = analysis.classes.len();
288 let import_count = analysis.imports.len();
289 if is_test {
290 let _ = writeln!(
291 output,
292 "FILE [TEST] {display_path}({line_count}L, {fn_count}F, {class_count}C, {import_count}I)"
293 );
294 } else {
295 let _ = writeln!(
296 output,
297 "FILE: {display_path}({line_count}L, {fn_count}F, {class_count}C, {import_count}I)"
298 );
299 }
300
301 output.push_str(&format_classes_section(
303 &analysis.classes,
304 &analysis.functions,
305 ));
306
307 let top_level_functions: Vec<&FunctionInfo> = analysis
309 .functions
310 .iter()
311 .filter(|func| {
312 !analysis
313 .classes
314 .iter()
315 .any(|class| is_method_of_class(func, class))
316 })
317 .collect();
318
319 if !top_level_functions.is_empty() {
320 output.push_str("F:\n");
321 output.push_str(&format_function_list_wrapped(
322 top_level_functions.iter().copied(),
323 &analysis.call_frequency,
324 ));
325 }
326
327 output.push_str(&format_imports_section(&analysis.imports));
329
330 output
331}
332
333fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
343 use std::collections::BTreeMap;
344
345 if chains.is_empty() {
346 return " (none)\n".to_string();
347 }
348
349 let mut output = String::new();
350
351 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
353 for (parent, child) in chains {
354 if child.is_empty() {
356 groups.entry(parent.to_string()).or_default();
358 } else {
359 *groups
360 .entry(parent.to_string())
361 .or_default()
362 .entry(child.to_string())
363 .or_insert(0) += 1;
364 }
365 }
366
367 #[allow(clippy::similar_names)]
369 for (parent, children) in groups {
371 let _ = writeln!(output, " {focus_symbol} {arrow} {parent}");
372 let mut sorted: Vec<_> = children.into_iter().collect();
374 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
375 for (child, count) in sorted {
376 if count > 1 {
377 let _ = writeln!(output, " {arrow} {child} (x{count})");
378 } else {
379 let _ = writeln!(output, " {arrow} {child}");
380 }
381 }
382 }
383
384 output
385}
386
387#[instrument(skip_all)]
389#[allow(clippy::too_many_lines)] #[allow(clippy::similar_names)] pub(crate) fn format_focused_internal(
393 graph: &CallGraph,
394 symbol: &str,
395 follow_depth: u32,
396 base_path: Option<&Path>,
397 incoming_chains: Option<&[InternalCallChain]>,
398 outgoing_chains: Option<&[InternalCallChain]>,
399) -> Result<String, FormatterError> {
400 let mut output = String::new();
401
402 let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
404
405 let (incoming_chains_vec, outgoing_chains_vec);
407 let (incoming_chains_ref, outgoing_chains_ref) =
408 if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
409 (inc, out)
410 } else {
411 incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
412 outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
413 (
414 incoming_chains_vec.as_slice(),
415 outgoing_chains_vec.as_slice(),
416 )
417 };
418
419 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
421 incoming_chains_ref.iter().cloned().partition(|chain| {
422 chain
423 .chain
424 .first()
425 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
426 });
427
428 let callers_count = prod_chains
430 .iter()
431 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
432 .collect::<std::collections::HashSet<_>>()
433 .len();
434
435 let callees_count = outgoing_chains_ref
437 .iter()
438 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
439 .collect::<std::collections::HashSet<_>>()
440 .len();
441
442 let _ = writeln!(
444 output,
445 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
446 );
447
448 let _ = writeln!(output, "DEPTH: {follow_depth}");
450
451 if let Some(definitions) = graph.definitions.get(symbol) {
453 output.push_str("DEFINED:\n");
454 for (path, line) in definitions {
455 let display = strip_base_path(path, base_path);
456 let _ = writeln!(output, " {display}:{line}");
457 }
458 } else {
459 output.push_str("DEFINED: (not found)\n");
460 }
461
462 output.push_str("CALLERS:\n");
464
465 let prod_refs: Vec<_> = prod_chains
467 .iter()
468 .filter_map(|chain| {
469 if chain.chain.len() >= 2 {
470 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
471 } else if chain.chain.len() == 1 {
472 Some((chain.chain[0].0.as_str(), ""))
473 } else {
474 None
475 }
476 })
477 .collect();
478
479 if prod_refs.is_empty() {
480 output.push_str(" (none)\n");
481 } else {
482 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
483 }
484
485 if !test_chains.is_empty() {
487 let mut test_files: Vec<_> = test_chains
488 .iter()
489 .filter_map(|chain| {
490 chain
491 .chain
492 .first()
493 .map(|(_, path, _)| path.to_string_lossy().into_owned())
494 })
495 .collect();
496 test_files.sort();
497 test_files.dedup();
498
499 let display_files: Vec<_> = test_files
501 .iter()
502 .map(|f| strip_base_path(Path::new(f), base_path))
503 .collect();
504
505 let file_list = display_files.join(", ");
506 let test_count = test_chains.len();
507 let _ = writeln!(
508 output,
509 "CALLERS (test): {test_count} test functions (in {file_list})"
510 );
511 }
512
513 output.push_str("CALLEES:\n");
515 let outgoing_refs: Vec<_> = outgoing_chains_ref
516 .iter()
517 .filter_map(|chain| {
518 if chain.chain.len() >= 2 {
519 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
520 } else if chain.chain.len() == 1 {
521 Some((chain.chain[0].0.as_str(), ""))
522 } else {
523 None
524 }
525 })
526 .collect();
527
528 if outgoing_refs.is_empty() {
529 output.push_str(" (none)\n");
530 } else {
531 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
532 }
533
534 output.push_str("STATISTICS:\n");
536 let _ = writeln!(output, " Incoming calls: {callers_count}");
537 let _ = writeln!(output, " Outgoing calls: {callees_count}");
538
539 let mut files = HashSet::new();
541 for chain in &prod_chains {
542 for (_, path, _) in &chain.chain {
543 files.insert(path.clone());
544 }
545 }
546 for chain in outgoing_chains_ref {
547 for (_, path, _) in &chain.chain {
548 files.insert(path.clone());
549 }
550 }
551 if let Some(definitions) = graph.definitions.get(symbol) {
552 for (path, _) in definitions {
553 files.insert(path.clone());
554 }
555 }
556
557 let (prod_files, test_files): (Vec<_>, Vec<_>) =
559 files.into_iter().partition(|path| !is_test_file(path));
560
561 output.push_str("FILES:\n");
562 if prod_files.is_empty() && test_files.is_empty() {
563 output.push_str(" (none)\n");
564 } else {
565 if !prod_files.is_empty() {
567 let mut sorted_files = prod_files;
568 sorted_files.sort();
569 for file in sorted_files {
570 let display = strip_base_path(&file, base_path);
571 let _ = writeln!(output, " {display}");
572 }
573 }
574
575 if !test_files.is_empty() {
577 output.push_str(" TEST FILES:\n");
578 let mut sorted_files = test_files;
579 sorted_files.sort();
580 for file in sorted_files {
581 let display = strip_base_path(&file, base_path);
582 let _ = writeln!(output, " {display}");
583 }
584 }
585 }
586
587 Ok(output)
588}
589
590#[instrument(skip_all)]
593#[allow(clippy::too_many_lines)] #[allow(clippy::similar_names)] pub(crate) fn format_focused_summary_internal(
597 graph: &CallGraph,
598 symbol: &str,
599 follow_depth: u32,
600 base_path: Option<&Path>,
601 incoming_chains: Option<&[InternalCallChain]>,
602 outgoing_chains: Option<&[InternalCallChain]>,
603) -> Result<String, FormatterError> {
604 let mut output = String::new();
605
606 let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
608
609 let (incoming_chains_vec, outgoing_chains_vec);
611 let (incoming_chains_ref, outgoing_chains_ref) =
612 if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
613 (inc, out)
614 } else {
615 incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
616 outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
617 (
618 incoming_chains_vec.as_slice(),
619 outgoing_chains_vec.as_slice(),
620 )
621 };
622
623 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
625 incoming_chains_ref.iter().cloned().partition(|chain| {
626 chain
627 .chain
628 .first()
629 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
630 });
631
632 let callers_count = prod_chains
634 .iter()
635 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
636 .collect::<std::collections::HashSet<_>>()
637 .len();
638
639 let callees_count = outgoing_chains_ref
641 .iter()
642 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
643 .collect::<std::collections::HashSet<_>>()
644 .len();
645
646 let _ = writeln!(
648 output,
649 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
650 );
651
652 let _ = writeln!(output, "DEPTH: {follow_depth}");
654
655 if let Some(definitions) = graph.definitions.get(symbol) {
657 output.push_str("DEFINED:\n");
658 for (path, line) in definitions {
659 let display = strip_base_path(path, base_path);
660 let _ = writeln!(output, " {display}:{line}");
661 }
662 } else {
663 output.push_str("DEFINED: (not found)\n");
664 }
665
666 output.push_str("CALLERS (top 10):\n");
668 if prod_chains.is_empty() {
669 output.push_str(" (none)\n");
670 } else {
671 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
673 std::collections::HashMap::new();
674 for chain in &prod_chains {
675 if let Some((name, path, _)) = chain.chain.first() {
676 let file_path = strip_base_path(path, base_path);
677 caller_freq
678 .entry(name.clone())
679 .and_modify(|(count, _)| *count += 1)
680 .or_insert((1, file_path));
681 }
682 }
683
684 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
686 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
687
688 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
689 let _ = writeln!(output, " {name} {file_path}");
690 }
691 }
692
693 if !test_chains.is_empty() {
695 let mut test_files: Vec<_> = test_chains
696 .iter()
697 .filter_map(|chain| {
698 chain
699 .chain
700 .first()
701 .map(|(_, path, _)| path.to_string_lossy().into_owned())
702 })
703 .collect();
704 test_files.sort();
705 test_files.dedup();
706
707 let test_count = test_chains.len();
708 let test_file_count = test_files.len();
709 let _ = writeln!(
710 output,
711 "CALLERS (test): {test_count} test functions (in {test_file_count} files)"
712 );
713 }
714
715 output.push_str("CALLEES (top 10):\n");
717 if outgoing_chains_ref.is_empty() {
718 output.push_str(" (none)\n");
719 } else {
720 let mut callee_freq: std::collections::HashMap<String, usize> =
722 std::collections::HashMap::new();
723 for chain in outgoing_chains_ref {
724 if let Some((name, _, _)) = chain.chain.first() {
725 *callee_freq.entry(name.clone()).or_insert(0) += 1;
726 }
727 }
728
729 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
731 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
732
733 for (name, _) in sorted_callees.into_iter().take(10) {
734 let _ = writeln!(output, " {name}");
735 }
736 }
737
738 output.push_str("SUGGESTION:\n");
740 output.push_str("Use summary=false with force=true for full output\n");
741
742 Ok(output)
743}
744
745pub fn format_focused(
748 graph: &CallGraph,
749 symbol: &str,
750 follow_depth: u32,
751 base_path: Option<&Path>,
752) -> Result<String, FormatterError> {
753 format_focused_internal(graph, symbol, follow_depth, base_path, None, None)
754}
755
756pub fn format_focused_summary(
759 graph: &CallGraph,
760 symbol: &str,
761 follow_depth: u32,
762 base_path: Option<&Path>,
763) -> Result<String, FormatterError> {
764 format_focused_summary_internal(graph, symbol, follow_depth, base_path, None, None)
765}
766
767#[instrument(skip_all)]
770#[allow(clippy::too_many_lines)] pub fn format_summary(
772 entries: &[WalkEntry],
773 analysis_results: &[FileInfo],
774 max_depth: Option<u32>,
775 subtree_counts: Option<&[(PathBuf, usize)]>,
776) -> String {
777 let mut output = String::new();
778
779 let (prod_files, test_files): (Vec<_>, Vec<_>) =
781 analysis_results.iter().partition(|a| !a.is_test);
782
783 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
785 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
786 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
787
788 let mut lang_counts: HashMap<String, usize> = HashMap::new();
790 for analysis in analysis_results {
791 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
792 }
793 let total_files = analysis_results.len();
794
795 output.push_str("SUMMARY:\n");
797 let depth_label = match max_depth {
798 Some(n) if n > 0 => format!(" (max_depth={n})"),
799 _ => String::new(),
800 };
801 let prod_count = prod_files.len();
802 let test_count = test_files.len();
803 let _ = writeln!(
804 output,
805 "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
806 );
807
808 if !lang_counts.is_empty() {
809 output.push_str("Languages: ");
810 let mut langs: Vec<_> = lang_counts.iter().collect();
811 langs.sort_by_key(|&(name, _)| name);
812 let lang_strs: Vec<String> = langs
813 .iter()
814 .map(|(name, count)| {
815 let percentage = if total_files > 0 {
816 (**count * 100) / total_files
817 } else {
818 0
819 };
820 format!("{name} ({percentage}%)")
821 })
822 .collect();
823 output.push_str(&lang_strs.join(", "));
824 output.push('\n');
825 }
826
827 output.push('\n');
828
829 output.push_str("STRUCTURE (depth 1):\n");
831
832 let analysis_map: HashMap<String, &FileInfo> = analysis_results
834 .iter()
835 .map(|a| (a.path.clone(), a))
836 .collect();
837
838 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
840 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
841
842 let mut largest_dir_name: Option<String> = None;
844 let mut largest_dir_path: Option<String> = None;
845 let mut largest_dir_count: usize = 0;
846
847 for entry in depth1_entries {
848 let name = entry
849 .path
850 .file_name()
851 .and_then(|n| n.to_str())
852 .unwrap_or("?");
853
854 if entry.is_dir {
855 let dir_path_str = entry.path.display().to_string();
857 let files_in_dir: Vec<&FileInfo> = analysis_results
858 .iter()
859 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
860 .collect();
861
862 if files_in_dir.is_empty() {
863 let entry_name_str = name.to_string();
865 if let Some(counts) = subtree_counts {
866 let true_count = counts
867 .binary_search_by_key(&&entry.path, |(p, _)| p)
868 .ok()
869 .map_or(0, |i| counts[i].1);
870 if true_count > 0 {
871 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
873 && true_count > largest_dir_count
874 {
875 largest_dir_count = true_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 let depth_val = max_depth.unwrap_or(0);
887 let _ = writeln!(
888 output,
889 " {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
890 );
891 } else {
892 let _ = writeln!(output, " {name}/");
893 }
894 } else {
895 let _ = writeln!(output, " {name}/");
896 }
897 } else {
898 let dir_file_count = files_in_dir.len();
899 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
900 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
901 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
902
903 let entry_name_str = name.to_string();
905 let effective_count = if let Some(counts) = subtree_counts {
906 counts
907 .binary_search_by_key(&&entry.path, |(p, _)| p)
908 .ok()
909 .map_or(dir_file_count, |i| counts[i].1)
910 } else {
911 dir_file_count
912 };
913 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
914 && effective_count > largest_dir_count
915 {
916 largest_dir_count = effective_count;
917 largest_dir_name = Some(entry_name_str);
918 largest_dir_path = Some(
919 entry
920 .path
921 .canonicalize()
922 .unwrap_or_else(|_| entry.path.clone())
923 .display()
924 .to_string(),
925 );
926 }
927
928 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
930 let mut top_files = files_in_dir.clone();
931 top_files.sort_unstable_by(|a, b| {
932 b.class_count
933 .cmp(&a.class_count)
934 .then(b.function_count.cmp(&a.function_count))
935 .then(a.path.cmp(&b.path))
936 });
937
938 let has_classes = top_files.iter().any(|f| f.class_count > 0);
939
940 if !has_classes {
942 top_files.sort_unstable_by(|a, b| {
943 b.function_count
944 .cmp(&a.function_count)
945 .then(a.path.cmp(&b.path))
946 });
947 }
948
949 let dir_path = Path::new(&dir_path_str);
950 let top_n: Vec<String> = top_files
951 .iter()
952 .take(3)
953 .filter(|f| {
954 if has_classes {
955 f.class_count > 0
956 } else {
957 f.function_count > 0
958 }
959 })
960 .map(|f| {
961 let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
962 |_| {
963 Path::new(&f.path)
964 .file_name()
965 .and_then(|n| n.to_str())
966 .map_or_else(
967 || "?".to_owned(),
968 std::borrow::ToOwned::to_owned,
969 )
970 },
971 |p| p.to_string_lossy().into_owned(),
972 );
973 let count = if has_classes {
974 f.class_count
975 } else {
976 f.function_count
977 };
978 let suffix = if has_classes { 'C' } else { 'F' };
979 format!("{rel}({count}{suffix})")
980 })
981 .collect();
982 if top_n.is_empty() {
983 String::new()
984 } else {
985 let joined = top_n.join(", ");
986 format!(" top: {joined}")
987 }
988 } else {
989 String::new()
990 };
991
992 let mut subdirs: Vec<String> = entries
994 .iter()
995 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
996 .filter_map(|e| {
997 e.path
998 .file_name()
999 .and_then(|n| n.to_str())
1000 .map(std::borrow::ToOwned::to_owned)
1001 })
1002 .collect();
1003 subdirs.sort();
1004 subdirs.dedup();
1005 let subdir_suffix = if subdirs.is_empty() {
1006 String::new()
1007 } else {
1008 let subdirs_capped: Vec<String> =
1009 subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
1010 let joined = subdirs_capped.join(", ");
1011 format!(" sub: {joined}")
1012 };
1013
1014 let files_label = if let Some(counts) = subtree_counts {
1015 let true_count = counts
1016 .binary_search_by_key(&&entry.path, |(p, _)| p)
1017 .ok()
1018 .map_or(dir_file_count, |i| counts[i].1);
1019 if true_count == dir_file_count {
1020 format!(
1021 "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1022 )
1023 } else {
1024 let depth_val = max_depth.unwrap_or(0);
1025 format!(
1026 "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1027 )
1028 }
1029 } else {
1030 format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
1031 };
1032 let _ = writeln!(output, " {name}/ [{files_label}]{hint}{subdir_suffix}");
1033 }
1034 } else {
1035 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1037 if let Some(info_str) = format_file_info_parts(
1038 analysis.line_count,
1039 analysis.function_count,
1040 analysis.class_count,
1041 ) {
1042 let _ = writeln!(output, " {name} {info_str}");
1043 } else {
1044 let _ = writeln!(output, " {name}");
1045 }
1046 }
1047 }
1048 }
1049
1050 output.push('\n');
1051
1052 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1054 let _ = writeln!(
1055 output,
1056 "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
1057 );
1058 } else {
1059 output.push_str("SUGGESTION:\n");
1060 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1061 }
1062
1063 output
1064}
1065
1066#[instrument(skip_all)]
1071pub fn format_file_details_summary(
1072 semantic: &SemanticAnalysis,
1073 path: &str,
1074 line_count: usize,
1075) -> String {
1076 let mut output = String::new();
1077
1078 output.push_str("FILE:\n");
1080 let _ = writeln!(output, " path: {path}");
1081 let fn_count = semantic.functions.len();
1082 let class_count = semantic.classes.len();
1083 let _ = writeln!(output, " {line_count}L, {fn_count}F, {class_count}C");
1084 output.push('\n');
1085
1086 if !semantic.functions.is_empty() {
1088 output.push_str("TOP FUNCTIONS BY SIZE:\n");
1089 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1090 let k = funcs.len().min(10);
1091 if k > 0 {
1092 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1093 let a_span = a.end_line.saturating_sub(a.line);
1094 let b_span = b.end_line.saturating_sub(b.line);
1095 b_span.cmp(&a_span)
1096 });
1097 funcs[..k].sort_by(|a, b| {
1098 let a_span = a.end_line.saturating_sub(a.line);
1099 let b_span = b.end_line.saturating_sub(b.line);
1100 b_span.cmp(&a_span)
1101 });
1102 }
1103
1104 for func in &funcs[..k] {
1105 let span = func.end_line.saturating_sub(func.line);
1106 let params = if func.parameters.is_empty() {
1107 String::new()
1108 } else {
1109 format!("({})", func.parameters.join(", "))
1110 };
1111 let _ = writeln!(
1112 output,
1113 " {}:{}: {} {} [{}L]",
1114 func.line, func.end_line, func.name, params, span
1115 );
1116 }
1117 output.push('\n');
1118 }
1119
1120 if !semantic.classes.is_empty() {
1122 output.push_str("CLASSES:\n");
1123 if semantic.classes.len() <= 10 {
1124 for class in &semantic.classes {
1126 let methods_count = class.methods.len();
1127 let _ = writeln!(output, " {}: {}M", class.name, methods_count);
1128 }
1129 } else {
1130 let _ = writeln!(output, " {} classes total", semantic.classes.len());
1132 for class in semantic.classes.iter().take(5) {
1133 let _ = writeln!(output, " {}", class.name);
1134 }
1135 if semantic.classes.len() > 5 {
1136 let _ = writeln!(output, " ... and {} more", semantic.classes.len() - 5);
1137 }
1138 }
1139 output.push('\n');
1140 }
1141
1142 let _ = writeln!(output, "Imports: {}", semantic.imports.len());
1144 output.push('\n');
1145
1146 output.push_str("SUGGESTION:\n");
1148 output.push_str("Use force=true for full output, or narrow your scope\n");
1149
1150 output
1151}
1152
1153#[instrument(skip_all)]
1155pub fn format_structure_paginated(
1156 paginated_files: &[FileInfo],
1157 total_files: usize,
1158 max_depth: Option<u32>,
1159 base_path: Option<&Path>,
1160 verbose: bool,
1161) -> String {
1162 let mut output = String::new();
1163
1164 let depth_label = match max_depth {
1165 Some(n) if n > 0 => format!(" (max_depth={n})"),
1166 _ => String::new(),
1167 };
1168 let _ = writeln!(
1169 output,
1170 "PAGINATED: showing {} of {} files{}\n",
1171 paginated_files.len(),
1172 total_files,
1173 depth_label
1174 );
1175
1176 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1177 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1178
1179 if !prod_files.is_empty() {
1180 if verbose {
1181 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1182 }
1183 for file in &prod_files {
1184 output.push_str(&format_file_entry(file, base_path));
1185 }
1186 }
1187
1188 if !test_files.is_empty() {
1189 if verbose {
1190 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1191 } else if !prod_files.is_empty() {
1192 output.push('\n');
1193 }
1194 for file in &test_files {
1195 output.push_str(&format_file_entry(file, base_path));
1196 }
1197 }
1198
1199 output
1200}
1201
1202#[instrument(skip_all)]
1207#[allow(clippy::too_many_arguments)]
1208pub fn format_file_details_paginated(
1209 functions_page: &[FunctionInfo],
1210 total_functions: usize,
1211 semantic: &SemanticAnalysis,
1212 path: &str,
1213 line_count: usize,
1214 offset: usize,
1215 verbose: bool,
1216 fields: Option<&[AnalyzeFileField]>,
1217) -> String {
1218 let mut output = String::new();
1219
1220 let start = offset + 1; let end = offset + functions_page.len();
1222
1223 let _ = writeln!(
1224 output,
1225 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
1226 path,
1227 line_count,
1228 start,
1229 end,
1230 total_functions,
1231 semantic.classes.len(),
1232 semantic.imports.len()
1233 );
1234
1235 let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
1237 let show_classes = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Classes));
1238 let show_imports = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Imports));
1239 let show_functions =
1240 show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Functions));
1241
1242 if show_classes && offset == 0 && !semantic.classes.is_empty() {
1244 output.push_str(&format_classes_section(
1245 &semantic.classes,
1246 &semantic.functions,
1247 ));
1248 }
1249
1250 if show_imports && offset == 0 && (verbose || !show_all) {
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 show_functions && !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)]
1283#[allow(clippy::similar_names)] pub(crate) fn format_focused_paginated(
1285 paginated_chains: &[InternalCallChain],
1286 total: usize,
1287 mode: PaginationMode,
1288 symbol: &str,
1289 prod_chains: &[InternalCallChain],
1290 test_chains: &[InternalCallChain],
1291 outgoing_chains: &[InternalCallChain],
1292 def_count: usize,
1293 offset: usize,
1294 base_path: Option<&Path>,
1295 _verbose: bool,
1296) -> String {
1297 let start = offset + 1; let end = offset + paginated_chains.len();
1299
1300 let callers_count = prod_chains.len();
1301
1302 let callees_count = outgoing_chains.len();
1303
1304 let mut output = String::new();
1305
1306 let _ = writeln!(
1307 output,
1308 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1309 );
1310
1311 match mode {
1312 PaginationMode::Callers => {
1313 let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1315
1316 let page_refs: Vec<_> = paginated_chains
1317 .iter()
1318 .filter_map(|chain| {
1319 if chain.chain.len() >= 2 {
1320 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1321 } else if chain.chain.len() == 1 {
1322 Some((chain.chain[0].0.as_str(), ""))
1323 } else {
1324 None
1325 }
1326 })
1327 .collect();
1328
1329 if page_refs.is_empty() {
1330 output.push_str(" (none)\n");
1331 } else {
1332 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1333 }
1334
1335 if !test_chains.is_empty() {
1337 let mut test_files: Vec<_> = test_chains
1338 .iter()
1339 .filter_map(|chain| {
1340 chain
1341 .chain
1342 .first()
1343 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1344 })
1345 .collect();
1346 test_files.sort();
1347 test_files.dedup();
1348
1349 let display_files: Vec<_> = test_files
1350 .iter()
1351 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1352 .collect();
1353
1354 let _ = writeln!(
1355 output,
1356 "CALLERS (test): {} test functions (in {})",
1357 test_chains.len(),
1358 display_files.join(", ")
1359 );
1360 }
1361
1362 let callee_names: Vec<_> = outgoing_chains
1364 .iter()
1365 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1366 .collect::<std::collections::HashSet<_>>()
1367 .into_iter()
1368 .collect();
1369 if callee_names.is_empty() {
1370 output.push_str("CALLEES: (none)\n");
1371 } else {
1372 let _ = writeln!(
1373 output,
1374 "CALLEES: {callees_count} (use cursor for callee pagination)"
1375 );
1376 }
1377 }
1378 PaginationMode::Callees => {
1379 let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1381
1382 if !test_chains.is_empty() {
1384 let _ = writeln!(
1385 output,
1386 "CALLERS (test): {} test functions",
1387 test_chains.len()
1388 );
1389 }
1390
1391 let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1393
1394 let page_refs: Vec<_> = paginated_chains
1395 .iter()
1396 .filter_map(|chain| {
1397 if chain.chain.len() >= 2 {
1398 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1399 } else if chain.chain.len() == 1 {
1400 Some((chain.chain[0].0.as_str(), ""))
1401 } else {
1402 None
1403 }
1404 })
1405 .collect();
1406
1407 if page_refs.is_empty() {
1408 output.push_str(" (none)\n");
1409 } else {
1410 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1411 }
1412 }
1413 PaginationMode::Default => {
1414 unreachable!("format_focused_paginated called with PaginationMode::Default")
1415 }
1416 }
1417
1418 output
1419}
1420
1421fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1422 let mut parts = Vec::new();
1423 if file.line_count > 0 {
1424 parts.push(format!("{}L", file.line_count));
1425 }
1426 if file.function_count > 0 {
1427 parts.push(format!("{}F", file.function_count));
1428 }
1429 if file.class_count > 0 {
1430 parts.push(format!("{}C", file.class_count));
1431 }
1432 let display_path = strip_base_path(Path::new(&file.path), base_path);
1433 if parts.is_empty() {
1434 format!("{display_path}\n")
1435 } else {
1436 format!("{display_path} [{}]\n", parts.join(", "))
1437 }
1438}
1439
1440#[instrument(skip_all)]
1454pub fn format_module_info(info: &ModuleInfo) -> String {
1455 use std::fmt::Write as _;
1456 let fn_count = info.functions.len();
1457 let import_count = info.imports.len();
1458 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1459 let _ = writeln!(
1460 out,
1461 "FILE: {} ({}L, {}F, {}I)",
1462 info.name, info.line_count, fn_count, import_count
1463 );
1464 if !info.functions.is_empty() {
1465 out.push_str("F:\n ");
1466 let parts: Vec<String> = info
1467 .functions
1468 .iter()
1469 .map(|f| format!("{}:{}", f.name, f.line))
1470 .collect();
1471 out.push_str(&parts.join(", "));
1472 out.push('\n');
1473 }
1474 if !info.imports.is_empty() {
1475 out.push_str("I:\n ");
1476 let parts: Vec<String> = info
1477 .imports
1478 .iter()
1479 .map(|i| {
1480 if i.items.is_empty() {
1481 i.module.clone()
1482 } else {
1483 format!("{}:{}", i.module, i.items.join(", "))
1484 }
1485 })
1486 .collect();
1487 out.push_str(&parts.join("; "));
1488 out.push('\n');
1489 }
1490 out
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 #[test]
1498 fn test_strip_base_path_relative() {
1499 let path = Path::new("/home/user/project/src/main.rs");
1500 let base = Path::new("/home/user/project");
1501 let result = strip_base_path(path, Some(base));
1502 assert_eq!(result, "src/main.rs");
1503 }
1504
1505 #[test]
1506 fn test_strip_base_path_fallback_absolute() {
1507 let path = Path::new("/other/project/src/main.rs");
1508 let base = Path::new("/home/user/project");
1509 let result = strip_base_path(path, Some(base));
1510 assert_eq!(result, "/other/project/src/main.rs");
1511 }
1512
1513 #[test]
1514 fn test_strip_base_path_none() {
1515 let path = Path::new("/home/user/project/src/main.rs");
1516 let result = strip_base_path(path, None);
1517 assert_eq!(result, "/home/user/project/src/main.rs");
1518 }
1519
1520 #[test]
1521 fn test_format_file_details_summary_empty() {
1522 use crate::types::SemanticAnalysis;
1523 use std::collections::HashMap;
1524
1525 let semantic = SemanticAnalysis {
1526 functions: vec![],
1527 classes: vec![],
1528 imports: vec![],
1529 references: vec![],
1530 call_frequency: HashMap::new(),
1531 calls: vec![],
1532 impl_traits: vec![],
1533 };
1534
1535 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1536
1537 assert!(result.contains("FILE:"));
1539 assert!(result.contains("100L, 0F, 0C"));
1540 assert!(result.contains("src/main.rs"));
1541 assert!(result.contains("Imports: 0"));
1542 assert!(result.contains("SUGGESTION:"));
1543 }
1544
1545 #[test]
1546 fn test_format_file_details_summary_with_functions() {
1547 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1548 use std::collections::HashMap;
1549
1550 let semantic = SemanticAnalysis {
1551 functions: vec![
1552 FunctionInfo {
1553 name: "short".to_string(),
1554 line: 10,
1555 end_line: 12,
1556 parameters: vec![],
1557 return_type: None,
1558 },
1559 FunctionInfo {
1560 name: "long_function".to_string(),
1561 line: 20,
1562 end_line: 50,
1563 parameters: vec!["x".to_string(), "y".to_string()],
1564 return_type: Some("i32".to_string()),
1565 },
1566 ],
1567 classes: vec![ClassInfo {
1568 name: "MyClass".to_string(),
1569 line: 60,
1570 end_line: 80,
1571 methods: vec![],
1572 fields: vec![],
1573 inherits: vec![],
1574 }],
1575 imports: vec![],
1576 references: vec![],
1577 call_frequency: HashMap::new(),
1578 calls: vec![],
1579 impl_traits: vec![],
1580 };
1581
1582 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1583
1584 assert!(result.contains("FILE:"));
1586 assert!(result.contains("src/lib.rs"));
1587 assert!(result.contains("250L, 2F, 1C"));
1588
1589 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1591 let long_idx = result.find("long_function").unwrap_or(0);
1592 let short_idx = result.find("short").unwrap_or(0);
1593 assert!(
1594 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1595 "long_function should appear before short"
1596 );
1597
1598 assert!(result.contains("CLASSES:"));
1600 assert!(result.contains("MyClass:"));
1601
1602 assert!(result.contains("Imports: 0"));
1604 }
1605 #[test]
1606 fn test_format_file_info_parts_all_zero() {
1607 assert_eq!(format_file_info_parts(0, 0, 0), None);
1608 }
1609
1610 #[test]
1611 fn test_format_file_info_parts_partial() {
1612 assert_eq!(
1613 format_file_info_parts(42, 0, 3),
1614 Some("[42L, 3C]".to_string())
1615 );
1616 }
1617
1618 #[test]
1619 fn test_format_file_info_parts_all_nonzero() {
1620 assert_eq!(
1621 format_file_info_parts(100, 5, 2),
1622 Some("[100L, 5F, 2C]".to_string())
1623 );
1624 }
1625
1626 #[test]
1627 fn test_format_function_list_wrapped_empty() {
1628 let freq = std::collections::HashMap::new();
1629 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1630 assert_eq!(result, "");
1631 }
1632
1633 #[test]
1634 fn test_format_function_list_wrapped_bullet_annotation() {
1635 use crate::types::FunctionInfo;
1636 use std::collections::HashMap;
1637
1638 let mut freq = HashMap::new();
1639 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1642 name: "frequent".to_string(),
1643 line: 1,
1644 end_line: 10,
1645 parameters: vec![],
1646 return_type: Some("void".to_string()),
1647 }];
1648
1649 let result = format_function_list_wrapped(funcs.iter(), &freq);
1650 assert!(result.contains("\u{2022}5"));
1652 }
1653
1654 #[test]
1655 fn test_compact_format_omits_sections() {
1656 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1657 use std::collections::HashMap;
1658
1659 let funcs: Vec<FunctionInfo> = (0..10)
1660 .map(|i| FunctionInfo {
1661 name: format!("fn_{}", i),
1662 line: i * 5 + 1,
1663 end_line: i * 5 + 4,
1664 parameters: vec![format!("x: u32")],
1665 return_type: Some("bool".to_string()),
1666 })
1667 .collect();
1668 let imports: Vec<ImportInfo> = vec![ImportInfo {
1669 module: "std::collections".to_string(),
1670 items: vec!["HashMap".to_string()],
1671 line: 1,
1672 }];
1673 let classes: Vec<ClassInfo> = vec![ClassInfo {
1674 name: "MyStruct".to_string(),
1675 line: 100,
1676 end_line: 150,
1677 methods: vec![],
1678 fields: vec![],
1679 inherits: vec![],
1680 }];
1681 let semantic = SemanticAnalysis {
1682 functions: funcs,
1683 classes,
1684 imports,
1685 references: vec![],
1686 call_frequency: HashMap::new(),
1687 calls: vec![],
1688 impl_traits: 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 None,
1700 );
1701 let compact_out = format_file_details_paginated(
1702 &semantic.functions,
1703 semantic.functions.len(),
1704 &semantic,
1705 "src/lib.rs",
1706 100,
1707 0,
1708 false,
1709 None,
1710 );
1711
1712 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1714 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1715 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1716
1717 assert!(
1719 compact_out.contains("C:\n"),
1720 "compact must have C: section (restored)"
1721 );
1722 assert!(
1723 !compact_out.contains("I:\n"),
1724 "compact must not have I: section (imports omitted)"
1725 );
1726 assert!(
1727 compact_out.contains("F:\n"),
1728 "compact must have F: section with wrapped formatting"
1729 );
1730
1731 assert!(compact_out.contains("fn_0"), "compact must list functions");
1733 let has_two_on_same_line = compact_out
1734 .lines()
1735 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1736 assert!(
1737 has_two_on_same_line,
1738 "compact must render multiple functions per line (wrapped), not one-per-line"
1739 );
1740 }
1741
1742 #[test]
1744 fn test_compact_mode_consistent_token_reduction() {
1745 use crate::types::{FunctionInfo, SemanticAnalysis};
1746 use std::collections::HashMap;
1747
1748 let funcs: Vec<FunctionInfo> = (0..50)
1749 .map(|i| FunctionInfo {
1750 name: format!("function_name_{}", i),
1751 line: i * 10 + 1,
1752 end_line: i * 10 + 8,
1753 parameters: vec![
1754 "arg1: u32".to_string(),
1755 "arg2: String".to_string(),
1756 "arg3: Option<bool>".to_string(),
1757 ],
1758 return_type: Some("Result<Vec<String>, Error>".to_string()),
1759 })
1760 .collect();
1761
1762 let semantic = SemanticAnalysis {
1763 functions: funcs,
1764 classes: vec![],
1765 imports: vec![],
1766 references: vec![],
1767 call_frequency: HashMap::new(),
1768 calls: vec![],
1769 impl_traits: vec![],
1770 };
1771
1772 let verbose_out = format_file_details_paginated(
1773 &semantic.functions,
1774 semantic.functions.len(),
1775 &semantic,
1776 "src/large_file.rs",
1777 1000,
1778 0,
1779 true,
1780 None,
1781 );
1782 let compact_out = format_file_details_paginated(
1783 &semantic.functions,
1784 semantic.functions.len(),
1785 &semantic,
1786 "src/large_file.rs",
1787 1000,
1788 0,
1789 false,
1790 None,
1791 );
1792
1793 assert!(
1794 compact_out.len() <= verbose_out.len(),
1795 "compact ({} chars) must be <= verbose ({} chars)",
1796 compact_out.len(),
1797 verbose_out.len(),
1798 );
1799 }
1800
1801 #[test]
1803 fn test_format_module_info_happy_path() {
1804 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1805 let info = ModuleInfo {
1806 name: "parser.rs".to_string(),
1807 line_count: 312,
1808 language: "rust".to_string(),
1809 functions: vec![
1810 ModuleFunctionInfo {
1811 name: "parse_file".to_string(),
1812 line: 24,
1813 },
1814 ModuleFunctionInfo {
1815 name: "parse_block".to_string(),
1816 line: 58,
1817 },
1818 ],
1819 imports: vec![
1820 ModuleImportInfo {
1821 module: "crate::types".to_string(),
1822 items: vec!["Token".to_string(), "Expr".to_string()],
1823 },
1824 ModuleImportInfo {
1825 module: "std::io".to_string(),
1826 items: vec!["BufReader".to_string()],
1827 },
1828 ],
1829 };
1830 let result = format_module_info(&info);
1831 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1832 assert!(result.contains("F:"));
1833 assert!(result.contains("parse_file:24"));
1834 assert!(result.contains("parse_block:58"));
1835 assert!(result.contains("I:"));
1836 assert!(result.contains("crate::types:Token, Expr"));
1837 assert!(result.contains("std::io:BufReader"));
1838 assert!(result.contains("; "));
1839 assert!(!result.contains('{'));
1840 }
1841
1842 #[test]
1843 fn test_format_module_info_empty() {
1844 use crate::types::ModuleInfo;
1845 let info = ModuleInfo {
1846 name: "empty.rs".to_string(),
1847 line_count: 0,
1848 language: "rust".to_string(),
1849 functions: vec![],
1850 imports: vec![],
1851 };
1852 let result = format_module_info(&info);
1853 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1854 assert!(!result.contains("F:"));
1855 assert!(!result.contains("I:"));
1856 }
1857
1858 #[test]
1859 fn test_compact_mode_empty_classes_no_header() {
1860 use crate::types::{FunctionInfo, SemanticAnalysis};
1861 use std::collections::HashMap;
1862
1863 let funcs: Vec<FunctionInfo> = (0..5)
1864 .map(|i| FunctionInfo {
1865 name: format!("fn_{}", i),
1866 line: i * 5 + 1,
1867 end_line: i * 5 + 4,
1868 parameters: vec![],
1869 return_type: None,
1870 })
1871 .collect();
1872
1873 let semantic = SemanticAnalysis {
1874 functions: funcs,
1875 classes: vec![], imports: vec![],
1877 references: vec![],
1878 call_frequency: HashMap::new(),
1879 calls: vec![],
1880 impl_traits: vec![],
1881 };
1882
1883 let compact_out = format_file_details_paginated(
1884 &semantic.functions,
1885 semantic.functions.len(),
1886 &semantic,
1887 "src/simple.rs",
1888 100,
1889 0,
1890 false,
1891 None,
1892 );
1893
1894 assert!(
1896 !compact_out.contains("C:\n"),
1897 "compact mode must not emit C: header when classes are empty"
1898 );
1899 }
1900
1901 #[test]
1902 fn test_format_classes_with_methods() {
1903 use crate::types::{ClassInfo, FunctionInfo};
1904
1905 let functions = vec![
1906 FunctionInfo {
1907 name: "method_a".to_string(),
1908 line: 5,
1909 end_line: 8,
1910 parameters: vec![],
1911 return_type: None,
1912 },
1913 FunctionInfo {
1914 name: "method_b".to_string(),
1915 line: 10,
1916 end_line: 12,
1917 parameters: vec![],
1918 return_type: None,
1919 },
1920 FunctionInfo {
1921 name: "top_level_func".to_string(),
1922 line: 50,
1923 end_line: 55,
1924 parameters: vec![],
1925 return_type: None,
1926 },
1927 ];
1928
1929 let classes = vec![ClassInfo {
1930 name: "MyClass".to_string(),
1931 line: 1,
1932 end_line: 30,
1933 methods: vec![],
1934 fields: vec![],
1935 inherits: vec![],
1936 }];
1937
1938 let output = format_classes_section(&classes, &functions);
1939
1940 assert!(
1941 output.contains("MyClass:1-30"),
1942 "class header should show start-end range"
1943 );
1944 assert!(output.contains("method_a:5"), "method_a should be listed");
1945 assert!(output.contains("method_b:10"), "method_b should be listed");
1946 assert!(
1947 !output.contains("top_level_func"),
1948 "top_level_func outside class range should not be listed"
1949 );
1950 }
1951
1952 #[test]
1953 fn test_format_classes_method_cap() {
1954 use crate::types::{ClassInfo, FunctionInfo};
1955
1956 let mut functions = Vec::new();
1957 for i in 0..15 {
1958 functions.push(FunctionInfo {
1959 name: format!("method_{}", i),
1960 line: 2 + i,
1961 end_line: 3 + i,
1962 parameters: vec![],
1963 return_type: None,
1964 });
1965 }
1966
1967 let classes = vec![ClassInfo {
1968 name: "LargeClass".to_string(),
1969 line: 1,
1970 end_line: 50,
1971 methods: vec![],
1972 fields: vec![],
1973 inherits: vec![],
1974 }];
1975
1976 let output = format_classes_section(&classes, &functions);
1977
1978 assert!(output.contains("method_0"), "first method should be listed");
1979 assert!(output.contains("method_9"), "10th method should be listed");
1980 assert!(
1981 !output.contains("method_10"),
1982 "11th method should not be listed (cap at 10)"
1983 );
1984 assert!(
1985 output.contains("... (5 more)"),
1986 "truncation message should show remaining count"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_format_classes_no_methods() {
1992 use crate::types::{ClassInfo, FunctionInfo};
1993
1994 let functions = vec![FunctionInfo {
1995 name: "top_level".to_string(),
1996 line: 100,
1997 end_line: 105,
1998 parameters: vec![],
1999 return_type: None,
2000 }];
2001
2002 let classes = vec![ClassInfo {
2003 name: "EmptyClass".to_string(),
2004 line: 1,
2005 end_line: 50,
2006 methods: vec![],
2007 fields: vec![],
2008 inherits: vec![],
2009 }];
2010
2011 let output = format_classes_section(&classes, &functions);
2012
2013 assert!(
2014 output.contains("EmptyClass:1-50"),
2015 "empty class header should appear"
2016 );
2017 assert!(
2018 !output.contains("top_level"),
2019 "top-level functions outside class should not appear"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_f_section_excludes_methods() {
2025 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2026 use std::collections::HashMap;
2027
2028 let functions = vec![
2029 FunctionInfo {
2030 name: "method_a".to_string(),
2031 line: 5,
2032 end_line: 10,
2033 parameters: vec![],
2034 return_type: None,
2035 },
2036 FunctionInfo {
2037 name: "top_level".to_string(),
2038 line: 50,
2039 end_line: 55,
2040 parameters: vec![],
2041 return_type: None,
2042 },
2043 ];
2044
2045 let semantic = SemanticAnalysis {
2046 functions,
2047 classes: vec![ClassInfo {
2048 name: "TestClass".to_string(),
2049 line: 1,
2050 end_line: 30,
2051 methods: vec![],
2052 fields: vec![],
2053 inherits: vec![],
2054 }],
2055 imports: vec![],
2056 references: vec![],
2057 call_frequency: HashMap::new(),
2058 calls: vec![],
2059 impl_traits: vec![],
2060 };
2061
2062 let output = format_file_details("test.rs", &semantic, 100, false, None);
2063
2064 assert!(output.contains("C:"), "classes section should exist");
2065 assert!(
2066 output.contains("method_a:5"),
2067 "method should be in C: section"
2068 );
2069 assert!(output.contains("F:"), "F: section should exist");
2070 assert!(
2071 output.contains("top_level"),
2072 "top-level function should be in F: section"
2073 );
2074
2075 let f_pos = output.find("F:").unwrap();
2077 let method_pos = output.find("method_a").unwrap();
2078 assert!(
2079 method_pos < f_pos,
2080 "method_a should appear before F: section"
2081 );
2082 }
2083
2084 #[test]
2085 fn test_format_focused_paginated_unit() {
2086 use crate::graph::InternalCallChain;
2087 use crate::pagination::PaginationMode;
2088 use std::path::PathBuf;
2089
2090 let make_chain = |name: &str| -> InternalCallChain {
2092 InternalCallChain {
2093 chain: vec![
2094 (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2095 ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2096 ],
2097 }
2098 };
2099
2100 let prod_chains: Vec<InternalCallChain> = (0..8)
2101 .map(|i| make_chain(&format!("caller_{}", i)))
2102 .collect();
2103 let page = &prod_chains[0..3];
2104
2105 let formatted = format_focused_paginated(
2107 page,
2108 8,
2109 PaginationMode::Callers,
2110 "target",
2111 &prod_chains,
2112 &[],
2113 &[],
2114 1,
2115 0,
2116 None,
2117 true,
2118 );
2119
2120 assert!(
2122 formatted.contains("CALLERS (1-3 of 8):"),
2123 "header should show 1-3 of 8, got: {}",
2124 formatted
2125 );
2126 assert!(
2127 formatted.contains("FOCUS: target"),
2128 "should have FOCUS header"
2129 );
2130 }
2131
2132 #[test]
2133 fn test_fields_none_regression() {
2134 use crate::types::SemanticAnalysis;
2135 use std::collections::HashMap;
2136
2137 let functions = vec![FunctionInfo {
2138 name: "hello".to_string(),
2139 line: 10,
2140 end_line: 15,
2141 parameters: vec![],
2142 return_type: None,
2143 }];
2144
2145 let classes = vec![ClassInfo {
2146 name: "MyClass".to_string(),
2147 line: 20,
2148 end_line: 50,
2149 methods: vec![],
2150 fields: vec![],
2151 inherits: vec![],
2152 }];
2153
2154 let imports = vec![ImportInfo {
2155 module: "std".to_string(),
2156 items: vec!["io".to_string()],
2157 line: 1,
2158 }];
2159
2160 let semantic = SemanticAnalysis {
2161 functions: functions.clone(),
2162 classes: classes.clone(),
2163 imports: imports.clone(),
2164 references: vec![],
2165 call_frequency: HashMap::new(),
2166 calls: vec![],
2167 impl_traits: vec![],
2168 };
2169
2170 let output = format_file_details_paginated(
2171 &functions,
2172 functions.len(),
2173 &semantic,
2174 "test.rs",
2175 100,
2176 0,
2177 true,
2178 None,
2179 );
2180
2181 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2182 assert!(output.contains("C:"), "Classes section missing");
2183 assert!(output.contains("I:"), "Imports section missing");
2184 assert!(output.contains("F:"), "Functions section missing");
2185 }
2186
2187 #[test]
2188 fn test_fields_functions_only() {
2189 use crate::types::SemanticAnalysis;
2190 use std::collections::HashMap;
2191
2192 let functions = vec![FunctionInfo {
2193 name: "hello".to_string(),
2194 line: 10,
2195 end_line: 15,
2196 parameters: vec![],
2197 return_type: None,
2198 }];
2199
2200 let classes = vec![ClassInfo {
2201 name: "MyClass".to_string(),
2202 line: 20,
2203 end_line: 50,
2204 methods: vec![],
2205 fields: vec![],
2206 inherits: vec![],
2207 }];
2208
2209 let imports = vec![ImportInfo {
2210 module: "std".to_string(),
2211 items: vec!["io".to_string()],
2212 line: 1,
2213 }];
2214
2215 let semantic = SemanticAnalysis {
2216 functions: functions.clone(),
2217 classes: classes.clone(),
2218 imports: imports.clone(),
2219 references: vec![],
2220 call_frequency: HashMap::new(),
2221 calls: vec![],
2222 impl_traits: vec![],
2223 };
2224
2225 let fields = Some(vec![AnalyzeFileField::Functions]);
2226 let output = format_file_details_paginated(
2227 &functions,
2228 functions.len(),
2229 &semantic,
2230 "test.rs",
2231 100,
2232 0,
2233 true,
2234 fields.as_deref(),
2235 );
2236
2237 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2238 assert!(!output.contains("C:"), "Classes section should not appear");
2239 assert!(!output.contains("I:"), "Imports section should not appear");
2240 assert!(output.contains("F:"), "Functions section missing");
2241 }
2242
2243 #[test]
2244 fn test_fields_classes_only() {
2245 use crate::types::SemanticAnalysis;
2246 use std::collections::HashMap;
2247
2248 let functions = vec![FunctionInfo {
2249 name: "hello".to_string(),
2250 line: 10,
2251 end_line: 15,
2252 parameters: vec![],
2253 return_type: None,
2254 }];
2255
2256 let classes = vec![ClassInfo {
2257 name: "MyClass".to_string(),
2258 line: 20,
2259 end_line: 50,
2260 methods: vec![],
2261 fields: vec![],
2262 inherits: vec![],
2263 }];
2264
2265 let imports = vec![ImportInfo {
2266 module: "std".to_string(),
2267 items: vec!["io".to_string()],
2268 line: 1,
2269 }];
2270
2271 let semantic = SemanticAnalysis {
2272 functions: functions.clone(),
2273 classes: classes.clone(),
2274 imports: imports.clone(),
2275 references: vec![],
2276 call_frequency: HashMap::new(),
2277 calls: vec![],
2278 impl_traits: vec![],
2279 };
2280
2281 let fields = Some(vec![AnalyzeFileField::Classes]);
2282 let output = format_file_details_paginated(
2283 &functions,
2284 functions.len(),
2285 &semantic,
2286 "test.rs",
2287 100,
2288 0,
2289 true,
2290 fields.as_deref(),
2291 );
2292
2293 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2294 assert!(output.contains("C:"), "Classes section missing");
2295 assert!(!output.contains("I:"), "Imports section should not appear");
2296 assert!(
2297 !output.contains("F:"),
2298 "Functions section should not appear"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_fields_imports_verbose() {
2304 use crate::types::SemanticAnalysis;
2305 use std::collections::HashMap;
2306
2307 let functions = vec![FunctionInfo {
2308 name: "hello".to_string(),
2309 line: 10,
2310 end_line: 15,
2311 parameters: vec![],
2312 return_type: None,
2313 }];
2314
2315 let classes = vec![ClassInfo {
2316 name: "MyClass".to_string(),
2317 line: 20,
2318 end_line: 50,
2319 methods: vec![],
2320 fields: vec![],
2321 inherits: vec![],
2322 }];
2323
2324 let imports = vec![ImportInfo {
2325 module: "std".to_string(),
2326 items: vec!["io".to_string()],
2327 line: 1,
2328 }];
2329
2330 let semantic = SemanticAnalysis {
2331 functions: functions.clone(),
2332 classes: classes.clone(),
2333 imports: imports.clone(),
2334 references: vec![],
2335 call_frequency: HashMap::new(),
2336 calls: vec![],
2337 impl_traits: vec![],
2338 };
2339
2340 let fields = Some(vec![AnalyzeFileField::Imports]);
2341 let output = format_file_details_paginated(
2342 &functions,
2343 functions.len(),
2344 &semantic,
2345 "test.rs",
2346 100,
2347 0,
2348 true,
2349 fields.as_deref(),
2350 );
2351
2352 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2353 assert!(!output.contains("C:"), "Classes section should not appear");
2354 assert!(output.contains("I:"), "Imports section missing");
2355 assert!(
2356 !output.contains("F:"),
2357 "Functions section should not appear"
2358 );
2359 }
2360
2361 #[test]
2362 fn test_fields_imports_no_verbose() {
2363 use crate::types::SemanticAnalysis;
2364 use std::collections::HashMap;
2365
2366 let functions = vec![FunctionInfo {
2367 name: "hello".to_string(),
2368 line: 10,
2369 end_line: 15,
2370 parameters: vec![],
2371 return_type: None,
2372 }];
2373
2374 let classes = vec![ClassInfo {
2375 name: "MyClass".to_string(),
2376 line: 20,
2377 end_line: 50,
2378 methods: vec![],
2379 fields: vec![],
2380 inherits: vec![],
2381 }];
2382
2383 let imports = vec![ImportInfo {
2384 module: "std".to_string(),
2385 items: vec!["io".to_string()],
2386 line: 1,
2387 }];
2388
2389 let semantic = SemanticAnalysis {
2390 functions: functions.clone(),
2391 classes: classes.clone(),
2392 imports: imports.clone(),
2393 references: vec![],
2394 call_frequency: HashMap::new(),
2395 calls: vec![],
2396 impl_traits: vec![],
2397 };
2398
2399 let fields = Some(vec![AnalyzeFileField::Imports]);
2400 let output = format_file_details_paginated(
2401 &functions,
2402 functions.len(),
2403 &semantic,
2404 "test.rs",
2405 100,
2406 0,
2407 false,
2408 fields.as_deref(),
2409 );
2410
2411 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2412 assert!(!output.contains("C:"), "Classes section should not appear");
2413 assert!(
2414 output.contains("I:"),
2415 "Imports section should appear (explicitly listed in fields)"
2416 );
2417 assert!(
2418 !output.contains("F:"),
2419 "Functions section should not appear"
2420 );
2421 }
2422
2423 #[test]
2424 fn test_fields_empty_array() {
2425 use crate::types::SemanticAnalysis;
2426 use std::collections::HashMap;
2427
2428 let functions = vec![FunctionInfo {
2429 name: "hello".to_string(),
2430 line: 10,
2431 end_line: 15,
2432 parameters: vec![],
2433 return_type: None,
2434 }];
2435
2436 let classes = vec![ClassInfo {
2437 name: "MyClass".to_string(),
2438 line: 20,
2439 end_line: 50,
2440 methods: vec![],
2441 fields: vec![],
2442 inherits: vec![],
2443 }];
2444
2445 let imports = vec![ImportInfo {
2446 module: "std".to_string(),
2447 items: vec!["io".to_string()],
2448 line: 1,
2449 }];
2450
2451 let semantic = SemanticAnalysis {
2452 functions: functions.clone(),
2453 classes: classes.clone(),
2454 imports: imports.clone(),
2455 references: vec![],
2456 call_frequency: HashMap::new(),
2457 calls: vec![],
2458 impl_traits: vec![],
2459 };
2460
2461 let fields = Some(vec![]);
2462 let output = format_file_details_paginated(
2463 &functions,
2464 functions.len(),
2465 &semantic,
2466 "test.rs",
2467 100,
2468 0,
2469 true,
2470 fields.as_deref(),
2471 );
2472
2473 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2474 assert!(
2475 output.contains("C:"),
2476 "Classes section missing (empty fields = show all)"
2477 );
2478 assert!(
2479 output.contains("I:"),
2480 "Imports section missing (empty fields = show all)"
2481 );
2482 assert!(
2483 output.contains("F:"),
2484 "Functions section missing (empty fields = show all)"
2485 );
2486 }
2487
2488 #[test]
2489 fn test_fields_pagination_no_functions() {
2490 use crate::types::SemanticAnalysis;
2491 use std::collections::HashMap;
2492
2493 let functions = vec![FunctionInfo {
2494 name: "hello".to_string(),
2495 line: 10,
2496 end_line: 15,
2497 parameters: vec![],
2498 return_type: None,
2499 }];
2500
2501 let classes = vec![ClassInfo {
2502 name: "MyClass".to_string(),
2503 line: 20,
2504 end_line: 50,
2505 methods: vec![],
2506 fields: vec![],
2507 inherits: vec![],
2508 }];
2509
2510 let imports = vec![ImportInfo {
2511 module: "std".to_string(),
2512 items: vec!["io".to_string()],
2513 line: 1,
2514 }];
2515
2516 let semantic = SemanticAnalysis {
2517 functions: functions.clone(),
2518 classes: classes.clone(),
2519 imports: imports.clone(),
2520 references: vec![],
2521 call_frequency: HashMap::new(),
2522 calls: vec![],
2523 impl_traits: vec![],
2524 };
2525
2526 let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2527 let output = format_file_details_paginated(
2528 &functions,
2529 functions.len(),
2530 &semantic,
2531 "test.rs",
2532 100,
2533 0,
2534 true,
2535 fields.as_deref(),
2536 );
2537
2538 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2539 assert!(
2540 output.contains("1-1/1F"),
2541 "FILE header should contain valid range (1-1/1F)"
2542 );
2543 assert!(output.contains("C:"), "Classes section missing");
2544 assert!(output.contains("I:"), "Imports section missing");
2545 assert!(
2546 !output.contains("F:"),
2547 "Functions section should not appear (filtered by fields)"
2548 );
2549 }
2550}
2551
2552fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2553 let mut output = String::new();
2554 if classes.is_empty() {
2555 return output;
2556 }
2557 output.push_str("C:\n");
2558
2559 let methods_by_class = collect_class_methods(classes, functions);
2560 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2561
2562 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2563 let class_strs: Vec<String> = classes
2564 .iter()
2565 .map(|class| {
2566 if class.inherits.is_empty() {
2567 format!("{}:{}-{}", class.name, class.line, class.end_line)
2568 } else {
2569 format!(
2570 "{}:{}-{} ({})",
2571 class.name,
2572 class.line,
2573 class.end_line,
2574 class.inherits.join(", ")
2575 )
2576 }
2577 })
2578 .collect();
2579 output.push_str(" ");
2580 output.push_str(&class_strs.join("; "));
2581 output.push('\n');
2582 } else {
2583 for class in classes {
2584 if class.inherits.is_empty() {
2585 let _ = writeln!(output, " {}:{}-{}", class.name, class.line, class.end_line);
2586 } else {
2587 let _ = writeln!(
2588 output,
2589 " {}:{}-{} ({})",
2590 class.name,
2591 class.line,
2592 class.end_line,
2593 class.inherits.join(", ")
2594 );
2595 }
2596
2597 if let Some(methods) = methods_by_class.get(&class.name)
2599 && !methods.is_empty()
2600 {
2601 for (i, method) in methods.iter().take(10).enumerate() {
2602 let _ = writeln!(output, " {}:{}", method.name, method.line);
2603 if i + 1 == 10 && methods.len() > 10 {
2604 let _ = writeln!(output, " ... ({} more)", methods.len() - 10);
2605 break;
2606 }
2607 }
2608 }
2609 }
2610 }
2611 output
2612}
2613
2614fn format_imports_section(imports: &[ImportInfo]) -> String {
2617 let mut output = String::new();
2618 if imports.is_empty() {
2619 return output;
2620 }
2621 output.push_str("I:\n");
2622 let mut module_map: HashMap<String, usize> = HashMap::new();
2623 for import in imports {
2624 module_map
2625 .entry(import.module.clone())
2626 .and_modify(|count| *count += 1)
2627 .or_insert(1);
2628 }
2629 let mut modules: Vec<_> = module_map.keys().cloned().collect();
2630 modules.sort();
2631 let formatted_modules: Vec<String> = modules
2632 .iter()
2633 .map(|module| format!("{}({})", module, module_map[module]))
2634 .collect();
2635 if formatted_modules.len() <= MULTILINE_THRESHOLD {
2636 output.push_str(" ");
2637 output.push_str(&formatted_modules.join("; "));
2638 output.push('\n');
2639 } else {
2640 for module_str in formatted_modules {
2641 output.push_str(" ");
2642 output.push_str(&module_str);
2643 output.push('\n');
2644 }
2645 }
2646 output
2647}