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