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 let mut files = HashSet::new();
538 for chain in &prod_chains {
539 for (_, path, _) in &chain.chain {
540 files.insert(path.clone());
541 }
542 }
543 for chain in outgoing_chains_ref {
544 for (_, path, _) in &chain.chain {
545 files.insert(path.clone());
546 }
547 }
548 if let Some(definitions) = graph.definitions.get(symbol) {
549 for (path, _) in definitions {
550 files.insert(path.clone());
551 }
552 }
553
554 let (prod_files, test_files): (Vec<_>, Vec<_>) =
556 files.into_iter().partition(|path| !is_test_file(path));
557
558 output.push_str("FILES:\n");
559 if prod_files.is_empty() && test_files.is_empty() {
560 output.push_str(" (none)\n");
561 } else {
562 if !prod_files.is_empty() {
564 let mut sorted_files = prod_files;
565 sorted_files.sort();
566 for file in sorted_files {
567 let display = strip_base_path(&file, base_path);
568 let _ = writeln!(output, " {display}");
569 }
570 }
571
572 if !test_files.is_empty() {
574 output.push_str(" TEST FILES:\n");
575 let mut sorted_files = test_files;
576 sorted_files.sort();
577 for file in sorted_files {
578 let display = strip_base_path(&file, base_path);
579 let _ = writeln!(output, " {display}");
580 }
581 }
582 }
583
584 Ok(output)
585}
586
587#[instrument(skip_all)]
590#[allow(clippy::too_many_lines)] #[allow(clippy::similar_names)] pub(crate) fn format_focused_summary_internal(
594 graph: &CallGraph,
595 symbol: &str,
596 follow_depth: u32,
597 base_path: Option<&Path>,
598 incoming_chains: Option<&[InternalCallChain]>,
599 outgoing_chains: Option<&[InternalCallChain]>,
600) -> Result<String, FormatterError> {
601 let mut output = String::new();
602
603 let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
605
606 let (incoming_chains_vec, outgoing_chains_vec);
608 let (incoming_chains_ref, outgoing_chains_ref) =
609 if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
610 (inc, out)
611 } else {
612 incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
613 outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
614 (
615 incoming_chains_vec.as_slice(),
616 outgoing_chains_vec.as_slice(),
617 )
618 };
619
620 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
622 incoming_chains_ref.iter().cloned().partition(|chain| {
623 chain
624 .chain
625 .first()
626 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
627 });
628
629 let callers_count = prod_chains
631 .iter()
632 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
633 .collect::<std::collections::HashSet<_>>()
634 .len();
635
636 let callees_count = outgoing_chains_ref
638 .iter()
639 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
640 .collect::<std::collections::HashSet<_>>()
641 .len();
642
643 let _ = writeln!(
645 output,
646 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
647 );
648
649 let _ = writeln!(output, "DEPTH: {follow_depth}");
651
652 if let Some(definitions) = graph.definitions.get(symbol) {
654 output.push_str("DEFINED:\n");
655 for (path, line) in definitions {
656 let display = strip_base_path(path, base_path);
657 let _ = writeln!(output, " {display}:{line}");
658 }
659 } else {
660 output.push_str("DEFINED: (not found)\n");
661 }
662
663 output.push_str("CALLERS (top 10):\n");
665 if prod_chains.is_empty() {
666 output.push_str(" (none)\n");
667 } else {
668 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
670 std::collections::HashMap::new();
671 for chain in &prod_chains {
672 if let Some((name, path, _)) = chain.chain.first() {
673 let file_path = strip_base_path(path, base_path);
674 caller_freq
675 .entry(name.clone())
676 .and_modify(|(count, _)| *count += 1)
677 .or_insert((1, file_path));
678 }
679 }
680
681 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
683 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
684
685 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
686 let _ = writeln!(output, " {name} {file_path}");
687 }
688 }
689
690 if !test_chains.is_empty() {
692 let mut test_files: Vec<_> = test_chains
693 .iter()
694 .filter_map(|chain| {
695 chain
696 .chain
697 .first()
698 .map(|(_, path, _)| path.to_string_lossy().into_owned())
699 })
700 .collect();
701 test_files.sort();
702 test_files.dedup();
703
704 let test_count = test_chains.len();
705 let test_file_count = test_files.len();
706 let _ = writeln!(
707 output,
708 "CALLERS (test): {test_count} test functions (in {test_file_count} files)"
709 );
710 }
711
712 output.push_str("CALLEES (top 10):\n");
714 if outgoing_chains_ref.is_empty() {
715 output.push_str(" (none)\n");
716 } else {
717 let mut callee_freq: std::collections::HashMap<String, usize> =
719 std::collections::HashMap::new();
720 for chain in outgoing_chains_ref {
721 if let Some((name, _, _)) = chain.chain.first() {
722 *callee_freq.entry(name.clone()).or_insert(0) += 1;
723 }
724 }
725
726 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
728 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
729
730 for (name, _) in sorted_callees.into_iter().take(10) {
731 let _ = writeln!(output, " {name}");
732 }
733 }
734
735 output.push_str("SUGGESTION:\n");
737 output.push_str("Use summary=false with force=true for full output\n");
738
739 Ok(output)
740}
741
742pub fn format_focused_summary(
745 graph: &CallGraph,
746 symbol: &str,
747 follow_depth: u32,
748 base_path: Option<&Path>,
749) -> Result<String, FormatterError> {
750 format_focused_summary_internal(graph, symbol, follow_depth, base_path, None, None)
751}
752
753#[instrument(skip_all)]
756#[allow(clippy::too_many_lines)] pub fn format_summary(
758 entries: &[WalkEntry],
759 analysis_results: &[FileInfo],
760 max_depth: Option<u32>,
761 subtree_counts: Option<&[(PathBuf, usize)]>,
762) -> String {
763 let mut output = String::new();
764
765 let (prod_files, test_files): (Vec<_>, Vec<_>) =
767 analysis_results.iter().partition(|a| !a.is_test);
768
769 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
771 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
772 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
773
774 let mut lang_counts: HashMap<String, usize> = HashMap::new();
776 for analysis in analysis_results {
777 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
778 }
779 let total_files = analysis_results.len();
780
781 output.push_str("SUMMARY:\n");
783 let depth_label = match max_depth {
784 Some(n) if n > 0 => format!(" (max_depth={n})"),
785 _ => String::new(),
786 };
787 let prod_count = prod_files.len();
788 let test_count = test_files.len();
789 let _ = writeln!(
790 output,
791 "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
792 );
793
794 if !lang_counts.is_empty() {
795 output.push_str("Languages: ");
796 let mut langs: Vec<_> = lang_counts.iter().collect();
797 langs.sort_by_key(|&(name, _)| name);
798 let lang_strs: Vec<String> = langs
799 .iter()
800 .map(|(name, count)| {
801 let percentage = if total_files > 0 {
802 (**count * 100) / total_files
803 } else {
804 0
805 };
806 format!("{name} ({percentage}%)")
807 })
808 .collect();
809 output.push_str(&lang_strs.join(", "));
810 output.push('\n');
811 }
812
813 output.push('\n');
814
815 output.push_str("STRUCTURE (depth 1):\n");
817
818 let analysis_map: HashMap<String, &FileInfo> = analysis_results
820 .iter()
821 .map(|a| (a.path.clone(), a))
822 .collect();
823
824 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
826 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
827
828 let mut largest_dir_name: Option<String> = None;
830 let mut largest_dir_path: Option<String> = None;
831 let mut largest_dir_count: usize = 0;
832
833 for entry in depth1_entries {
834 let name = entry
835 .path
836 .file_name()
837 .and_then(|n| n.to_str())
838 .unwrap_or("?");
839
840 if entry.is_dir {
841 let dir_path_str = entry.path.display().to_string();
843 let files_in_dir: Vec<&FileInfo> = analysis_results
844 .iter()
845 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
846 .collect();
847
848 if files_in_dir.is_empty() {
849 let entry_name_str = name.to_string();
851 if let Some(counts) = subtree_counts {
852 let true_count = counts
853 .binary_search_by_key(&&entry.path, |(p, _)| p)
854 .ok()
855 .map_or(0, |i| counts[i].1);
856 if true_count > 0 {
857 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
859 && true_count > largest_dir_count
860 {
861 largest_dir_count = true_count;
862 largest_dir_name = Some(entry_name_str);
863 largest_dir_path = Some(
864 entry
865 .path
866 .canonicalize()
867 .unwrap_or_else(|_| entry.path.clone())
868 .display()
869 .to_string(),
870 );
871 }
872 let depth_val = max_depth.unwrap_or(0);
873 let _ = writeln!(
874 output,
875 " {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
876 );
877 } else {
878 let _ = writeln!(output, " {name}/");
879 }
880 } else {
881 let _ = writeln!(output, " {name}/");
882 }
883 } else {
884 let dir_file_count = files_in_dir.len();
885 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
886 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
887 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
888
889 let entry_name_str = name.to_string();
891 let effective_count = if let Some(counts) = subtree_counts {
892 counts
893 .binary_search_by_key(&&entry.path, |(p, _)| p)
894 .ok()
895 .map_or(dir_file_count, |i| counts[i].1)
896 } else {
897 dir_file_count
898 };
899 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
900 && effective_count > largest_dir_count
901 {
902 largest_dir_count = effective_count;
903 largest_dir_name = Some(entry_name_str);
904 largest_dir_path = Some(
905 entry
906 .path
907 .canonicalize()
908 .unwrap_or_else(|_| entry.path.clone())
909 .display()
910 .to_string(),
911 );
912 }
913
914 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
916 let mut top_files = files_in_dir.clone();
917 top_files.sort_unstable_by(|a, b| {
918 b.class_count
919 .cmp(&a.class_count)
920 .then(b.function_count.cmp(&a.function_count))
921 .then(a.path.cmp(&b.path))
922 });
923
924 let has_classes = top_files.iter().any(|f| f.class_count > 0);
925
926 if !has_classes {
928 top_files.sort_unstable_by(|a, b| {
929 b.function_count
930 .cmp(&a.function_count)
931 .then(a.path.cmp(&b.path))
932 });
933 }
934
935 let dir_path = Path::new(&dir_path_str);
936 let top_n: Vec<String> = top_files
937 .iter()
938 .take(3)
939 .filter(|f| {
940 if has_classes {
941 f.class_count > 0
942 } else {
943 f.function_count > 0
944 }
945 })
946 .map(|f| {
947 let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
948 |_| {
949 Path::new(&f.path)
950 .file_name()
951 .and_then(|n| n.to_str())
952 .map_or_else(
953 || "?".to_owned(),
954 std::borrow::ToOwned::to_owned,
955 )
956 },
957 |p| p.to_string_lossy().into_owned(),
958 );
959 let count = if has_classes {
960 f.class_count
961 } else {
962 f.function_count
963 };
964 let suffix = if has_classes { 'C' } else { 'F' };
965 format!("{rel}({count}{suffix})")
966 })
967 .collect();
968 if top_n.is_empty() {
969 String::new()
970 } else {
971 let joined = top_n.join(", ");
972 format!(" top: {joined}")
973 }
974 } else {
975 String::new()
976 };
977
978 let mut subdirs: Vec<String> = entries
980 .iter()
981 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
982 .filter_map(|e| {
983 e.path
984 .file_name()
985 .and_then(|n| n.to_str())
986 .map(std::borrow::ToOwned::to_owned)
987 })
988 .collect();
989 subdirs.sort();
990 subdirs.dedup();
991 let subdir_suffix = if subdirs.is_empty() {
992 String::new()
993 } else {
994 let subdirs_capped: Vec<String> =
995 subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
996 let joined = subdirs_capped.join(", ");
997 format!(" sub: {joined}")
998 };
999
1000 let files_label = if let Some(counts) = subtree_counts {
1001 let true_count = counts
1002 .binary_search_by_key(&&entry.path, |(p, _)| p)
1003 .ok()
1004 .map_or(dir_file_count, |i| counts[i].1);
1005 if true_count == dir_file_count {
1006 format!(
1007 "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1008 )
1009 } else {
1010 let depth_val = max_depth.unwrap_or(0);
1011 format!(
1012 "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1013 )
1014 }
1015 } else {
1016 format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
1017 };
1018 let _ = writeln!(output, " {name}/ [{files_label}]{hint}{subdir_suffix}");
1019 }
1020 } else {
1021 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1023 if let Some(info_str) = format_file_info_parts(
1024 analysis.line_count,
1025 analysis.function_count,
1026 analysis.class_count,
1027 ) {
1028 let _ = writeln!(output, " {name} {info_str}");
1029 } else {
1030 let _ = writeln!(output, " {name}");
1031 }
1032 }
1033 }
1034 }
1035
1036 output.push('\n');
1037
1038 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1040 let _ = writeln!(
1041 output,
1042 "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
1043 );
1044 } else {
1045 output.push_str("SUGGESTION:\n");
1046 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1047 }
1048
1049 output
1050}
1051
1052#[instrument(skip_all)]
1057pub fn format_file_details_summary(
1058 semantic: &SemanticAnalysis,
1059 path: &str,
1060 line_count: usize,
1061) -> String {
1062 let mut output = String::new();
1063
1064 output.push_str("FILE:\n");
1066 let _ = writeln!(output, " path: {path}");
1067 let fn_count = semantic.functions.len();
1068 let class_count = semantic.classes.len();
1069 let _ = writeln!(output, " {line_count}L, {fn_count}F, {class_count}C");
1070 output.push('\n');
1071
1072 if !semantic.functions.is_empty() {
1074 output.push_str("TOP FUNCTIONS BY SIZE:\n");
1075 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1076 let k = funcs.len().min(10);
1077 if k > 0 {
1078 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1079 let a_span = a.end_line.saturating_sub(a.line);
1080 let b_span = b.end_line.saturating_sub(b.line);
1081 b_span.cmp(&a_span)
1082 });
1083 funcs[..k].sort_by(|a, b| {
1084 let a_span = a.end_line.saturating_sub(a.line);
1085 let b_span = b.end_line.saturating_sub(b.line);
1086 b_span.cmp(&a_span)
1087 });
1088 }
1089
1090 for func in &funcs[..k] {
1091 let span = func.end_line.saturating_sub(func.line);
1092 let params = if func.parameters.is_empty() {
1093 String::new()
1094 } else {
1095 format!("({})", func.parameters.join(", "))
1096 };
1097 let _ = writeln!(
1098 output,
1099 " {}:{}: {} {} [{}L]",
1100 func.line, func.end_line, func.name, params, span
1101 );
1102 }
1103 output.push('\n');
1104 }
1105
1106 if !semantic.classes.is_empty() {
1108 output.push_str("CLASSES:\n");
1109 if semantic.classes.len() <= 10 {
1110 for class in &semantic.classes {
1112 let methods_count = class.methods.len();
1113 let _ = writeln!(output, " {}: {}M", class.name, methods_count);
1114 }
1115 } else {
1116 let _ = writeln!(output, " {} classes total", semantic.classes.len());
1118 for class in semantic.classes.iter().take(5) {
1119 let _ = writeln!(output, " {}", class.name);
1120 }
1121 if semantic.classes.len() > 5 {
1122 let _ = writeln!(output, " ... and {} more", semantic.classes.len() - 5);
1123 }
1124 }
1125 output.push('\n');
1126 }
1127
1128 let _ = writeln!(output, "Imports: {}", semantic.imports.len());
1130 output.push('\n');
1131
1132 output.push_str("SUGGESTION:\n");
1134 output.push_str("Use force=true for full output, or narrow your scope\n");
1135
1136 output
1137}
1138
1139#[instrument(skip_all)]
1141pub fn format_structure_paginated(
1142 paginated_files: &[FileInfo],
1143 total_files: usize,
1144 max_depth: Option<u32>,
1145 base_path: Option<&Path>,
1146 verbose: bool,
1147) -> String {
1148 let mut output = String::new();
1149
1150 let depth_label = match max_depth {
1151 Some(n) if n > 0 => format!(" (max_depth={n})"),
1152 _ => String::new(),
1153 };
1154 let _ = writeln!(
1155 output,
1156 "PAGINATED: showing {} of {} files{}\n",
1157 paginated_files.len(),
1158 total_files,
1159 depth_label
1160 );
1161
1162 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1163 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1164
1165 if !prod_files.is_empty() {
1166 if verbose {
1167 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1168 }
1169 for file in &prod_files {
1170 output.push_str(&format_file_entry(file, base_path));
1171 }
1172 }
1173
1174 if !test_files.is_empty() {
1175 if verbose {
1176 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1177 } else if !prod_files.is_empty() {
1178 output.push('\n');
1179 }
1180 for file in &test_files {
1181 output.push_str(&format_file_entry(file, base_path));
1182 }
1183 }
1184
1185 output
1186}
1187
1188#[instrument(skip_all)]
1193#[allow(clippy::too_many_arguments)]
1194pub fn format_file_details_paginated(
1195 functions_page: &[FunctionInfo],
1196 total_functions: usize,
1197 semantic: &SemanticAnalysis,
1198 path: &str,
1199 line_count: usize,
1200 offset: usize,
1201 verbose: bool,
1202 fields: Option<&[AnalyzeFileField]>,
1203) -> String {
1204 let mut output = String::new();
1205
1206 let start = offset + 1; let end = offset + functions_page.len();
1208
1209 let _ = writeln!(
1210 output,
1211 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
1212 path,
1213 line_count,
1214 start,
1215 end,
1216 total_functions,
1217 semantic.classes.len(),
1218 semantic.imports.len()
1219 );
1220
1221 let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
1223 let show_classes = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Classes));
1224 let show_imports = show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Imports));
1225 let show_functions =
1226 show_all || fields.is_some_and(|f| f.contains(&AnalyzeFileField::Functions));
1227
1228 if show_classes && offset == 0 && !semantic.classes.is_empty() {
1230 output.push_str(&format_classes_section(
1231 &semantic.classes,
1232 &semantic.functions,
1233 ));
1234 }
1235
1236 if show_imports && offset == 0 && (verbose || !show_all) {
1238 output.push_str(&format_imports_section(&semantic.imports));
1239 }
1240
1241 let top_level_functions: Vec<&FunctionInfo> = functions_page
1243 .iter()
1244 .filter(|func| {
1245 !semantic
1246 .classes
1247 .iter()
1248 .any(|class| is_method_of_class(func, class))
1249 })
1250 .collect();
1251
1252 if show_functions && !top_level_functions.is_empty() {
1253 output.push_str("F:\n");
1254 output.push_str(&format_function_list_wrapped(
1255 top_level_functions.iter().copied(),
1256 &semantic.call_frequency,
1257 ));
1258 }
1259
1260 output
1261}
1262
1263#[instrument(skip_all)]
1268#[allow(clippy::too_many_arguments)]
1269#[allow(clippy::similar_names)] pub fn format_focused_paginated(
1271 paginated_chains: &[InternalCallChain],
1272 total: usize,
1273 mode: PaginationMode,
1274 symbol: &str,
1275 prod_chains: &[InternalCallChain],
1276 test_chains: &[InternalCallChain],
1277 outgoing_chains: &[InternalCallChain],
1278 def_count: usize,
1279 offset: usize,
1280 base_path: Option<&Path>,
1281 _verbose: bool,
1282) -> String {
1283 let start = offset + 1; let end = offset + paginated_chains.len();
1285
1286 let callers_count = prod_chains.len();
1287
1288 let callees_count = outgoing_chains.len();
1289
1290 let mut output = String::new();
1291
1292 let _ = writeln!(
1293 output,
1294 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1295 );
1296
1297 match mode {
1298 PaginationMode::Callers => {
1299 let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1301
1302 let page_refs: Vec<_> = paginated_chains
1303 .iter()
1304 .filter_map(|chain| {
1305 if chain.chain.len() >= 2 {
1306 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1307 } else if chain.chain.len() == 1 {
1308 Some((chain.chain[0].0.as_str(), ""))
1309 } else {
1310 None
1311 }
1312 })
1313 .collect();
1314
1315 if page_refs.is_empty() {
1316 output.push_str(" (none)\n");
1317 } else {
1318 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1319 }
1320
1321 if !test_chains.is_empty() {
1323 let mut test_files: Vec<_> = test_chains
1324 .iter()
1325 .filter_map(|chain| {
1326 chain
1327 .chain
1328 .first()
1329 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1330 })
1331 .collect();
1332 test_files.sort();
1333 test_files.dedup();
1334
1335 let display_files: Vec<_> = test_files
1336 .iter()
1337 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1338 .collect();
1339
1340 let _ = writeln!(
1341 output,
1342 "CALLERS (test): {} test functions (in {})",
1343 test_chains.len(),
1344 display_files.join(", ")
1345 );
1346 }
1347
1348 let callee_names: Vec<_> = outgoing_chains
1350 .iter()
1351 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1352 .collect::<std::collections::HashSet<_>>()
1353 .into_iter()
1354 .collect();
1355 if callee_names.is_empty() {
1356 output.push_str("CALLEES: (none)\n");
1357 } else {
1358 let _ = writeln!(
1359 output,
1360 "CALLEES: {callees_count} (use cursor for callee pagination)"
1361 );
1362 }
1363 }
1364 PaginationMode::Callees => {
1365 let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1367
1368 if !test_chains.is_empty() {
1370 let _ = writeln!(
1371 output,
1372 "CALLERS (test): {} test functions",
1373 test_chains.len()
1374 );
1375 }
1376
1377 let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1379
1380 let page_refs: Vec<_> = paginated_chains
1381 .iter()
1382 .filter_map(|chain| {
1383 if chain.chain.len() >= 2 {
1384 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1385 } else if chain.chain.len() == 1 {
1386 Some((chain.chain[0].0.as_str(), ""))
1387 } else {
1388 None
1389 }
1390 })
1391 .collect();
1392
1393 if page_refs.is_empty() {
1394 output.push_str(" (none)\n");
1395 } else {
1396 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1397 }
1398 }
1399 PaginationMode::Default => {
1400 unreachable!("format_focused_paginated called with PaginationMode::Default")
1401 }
1402 }
1403
1404 output
1405}
1406
1407fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1408 let mut parts = Vec::new();
1409 if file.line_count > 0 {
1410 parts.push(format!("{}L", file.line_count));
1411 }
1412 if file.function_count > 0 {
1413 parts.push(format!("{}F", file.function_count));
1414 }
1415 if file.class_count > 0 {
1416 parts.push(format!("{}C", file.class_count));
1417 }
1418 let display_path = strip_base_path(Path::new(&file.path), base_path);
1419 if parts.is_empty() {
1420 format!("{display_path}\n")
1421 } else {
1422 format!("{display_path} [{}]\n", parts.join(", "))
1423 }
1424}
1425
1426#[instrument(skip_all)]
1440pub fn format_module_info(info: &ModuleInfo) -> String {
1441 use std::fmt::Write as _;
1442 let fn_count = info.functions.len();
1443 let import_count = info.imports.len();
1444 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1445 let _ = writeln!(
1446 out,
1447 "FILE: {} ({}L, {}F, {}I)",
1448 info.name, info.line_count, fn_count, import_count
1449 );
1450 if !info.functions.is_empty() {
1451 out.push_str("F:\n ");
1452 let parts: Vec<String> = info
1453 .functions
1454 .iter()
1455 .map(|f| format!("{}:{}", f.name, f.line))
1456 .collect();
1457 out.push_str(&parts.join(", "));
1458 out.push('\n');
1459 }
1460 if !info.imports.is_empty() {
1461 out.push_str("I:\n ");
1462 let parts: Vec<String> = info
1463 .imports
1464 .iter()
1465 .map(|i| {
1466 if i.items.is_empty() {
1467 i.module.clone()
1468 } else {
1469 format!("{}:{}", i.module, i.items.join(", "))
1470 }
1471 })
1472 .collect();
1473 out.push_str(&parts.join("; "));
1474 out.push('\n');
1475 }
1476 out
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481 use super::*;
1482
1483 #[test]
1484 fn test_strip_base_path_relative() {
1485 let path = Path::new("/home/user/project/src/main.rs");
1486 let base = Path::new("/home/user/project");
1487 let result = strip_base_path(path, Some(base));
1488 assert_eq!(result, "src/main.rs");
1489 }
1490
1491 #[test]
1492 fn test_strip_base_path_fallback_absolute() {
1493 let path = Path::new("/other/project/src/main.rs");
1494 let base = Path::new("/home/user/project");
1495 let result = strip_base_path(path, Some(base));
1496 assert_eq!(result, "/other/project/src/main.rs");
1497 }
1498
1499 #[test]
1500 fn test_strip_base_path_none() {
1501 let path = Path::new("/home/user/project/src/main.rs");
1502 let result = strip_base_path(path, None);
1503 assert_eq!(result, "/home/user/project/src/main.rs");
1504 }
1505
1506 #[test]
1507 fn test_format_file_details_summary_empty() {
1508 use crate::types::SemanticAnalysis;
1509 use std::collections::HashMap;
1510
1511 let semantic = SemanticAnalysis {
1512 functions: vec![],
1513 classes: vec![],
1514 imports: vec![],
1515 references: vec![],
1516 call_frequency: HashMap::new(),
1517 calls: vec![],
1518 impl_traits: vec![],
1519 };
1520
1521 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1522
1523 assert!(result.contains("FILE:"));
1525 assert!(result.contains("100L, 0F, 0C"));
1526 assert!(result.contains("src/main.rs"));
1527 assert!(result.contains("Imports: 0"));
1528 assert!(result.contains("SUGGESTION:"));
1529 }
1530
1531 #[test]
1532 fn test_format_file_details_summary_with_functions() {
1533 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1534 use std::collections::HashMap;
1535
1536 let semantic = SemanticAnalysis {
1537 functions: vec![
1538 FunctionInfo {
1539 name: "short".to_string(),
1540 line: 10,
1541 end_line: 12,
1542 parameters: vec![],
1543 return_type: None,
1544 },
1545 FunctionInfo {
1546 name: "long_function".to_string(),
1547 line: 20,
1548 end_line: 50,
1549 parameters: vec!["x".to_string(), "y".to_string()],
1550 return_type: Some("i32".to_string()),
1551 },
1552 ],
1553 classes: vec![ClassInfo {
1554 name: "MyClass".to_string(),
1555 line: 60,
1556 end_line: 80,
1557 methods: vec![],
1558 fields: vec![],
1559 inherits: vec![],
1560 }],
1561 imports: vec![],
1562 references: vec![],
1563 call_frequency: HashMap::new(),
1564 calls: vec![],
1565 impl_traits: vec![],
1566 };
1567
1568 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1569
1570 assert!(result.contains("FILE:"));
1572 assert!(result.contains("src/lib.rs"));
1573 assert!(result.contains("250L, 2F, 1C"));
1574
1575 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1577 let long_idx = result.find("long_function").unwrap_or(0);
1578 let short_idx = result.find("short").unwrap_or(0);
1579 assert!(
1580 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1581 "long_function should appear before short"
1582 );
1583
1584 assert!(result.contains("CLASSES:"));
1586 assert!(result.contains("MyClass:"));
1587
1588 assert!(result.contains("Imports: 0"));
1590 }
1591 #[test]
1592 fn test_format_file_info_parts_all_zero() {
1593 assert_eq!(format_file_info_parts(0, 0, 0), None);
1594 }
1595
1596 #[test]
1597 fn test_format_file_info_parts_partial() {
1598 assert_eq!(
1599 format_file_info_parts(42, 0, 3),
1600 Some("[42L, 3C]".to_string())
1601 );
1602 }
1603
1604 #[test]
1605 fn test_format_file_info_parts_all_nonzero() {
1606 assert_eq!(
1607 format_file_info_parts(100, 5, 2),
1608 Some("[100L, 5F, 2C]".to_string())
1609 );
1610 }
1611
1612 #[test]
1613 fn test_format_function_list_wrapped_empty() {
1614 let freq = std::collections::HashMap::new();
1615 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1616 assert_eq!(result, "");
1617 }
1618
1619 #[test]
1620 fn test_format_function_list_wrapped_bullet_annotation() {
1621 use crate::types::FunctionInfo;
1622 use std::collections::HashMap;
1623
1624 let mut freq = HashMap::new();
1625 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1628 name: "frequent".to_string(),
1629 line: 1,
1630 end_line: 10,
1631 parameters: vec![],
1632 return_type: Some("void".to_string()),
1633 }];
1634
1635 let result = format_function_list_wrapped(funcs.iter(), &freq);
1636 assert!(result.contains("\u{2022}5"));
1638 }
1639
1640 #[test]
1641 fn test_compact_format_omits_sections() {
1642 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1643 use std::collections::HashMap;
1644
1645 let funcs: Vec<FunctionInfo> = (0..10)
1646 .map(|i| FunctionInfo {
1647 name: format!("fn_{}", i),
1648 line: i * 5 + 1,
1649 end_line: i * 5 + 4,
1650 parameters: vec![format!("x: u32")],
1651 return_type: Some("bool".to_string()),
1652 })
1653 .collect();
1654 let imports: Vec<ImportInfo> = vec![ImportInfo {
1655 module: "std::collections".to_string(),
1656 items: vec!["HashMap".to_string()],
1657 line: 1,
1658 }];
1659 let classes: Vec<ClassInfo> = vec![ClassInfo {
1660 name: "MyStruct".to_string(),
1661 line: 100,
1662 end_line: 150,
1663 methods: vec![],
1664 fields: vec![],
1665 inherits: vec![],
1666 }];
1667 let semantic = SemanticAnalysis {
1668 functions: funcs,
1669 classes,
1670 imports,
1671 references: vec![],
1672 call_frequency: HashMap::new(),
1673 calls: vec![],
1674 impl_traits: vec![],
1675 };
1676
1677 let verbose_out = format_file_details_paginated(
1678 &semantic.functions,
1679 semantic.functions.len(),
1680 &semantic,
1681 "src/lib.rs",
1682 100,
1683 0,
1684 true,
1685 None,
1686 );
1687 let compact_out = format_file_details_paginated(
1688 &semantic.functions,
1689 semantic.functions.len(),
1690 &semantic,
1691 "src/lib.rs",
1692 100,
1693 0,
1694 false,
1695 None,
1696 );
1697
1698 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1700 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1701 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1702
1703 assert!(
1705 compact_out.contains("C:\n"),
1706 "compact must have C: section (restored)"
1707 );
1708 assert!(
1709 !compact_out.contains("I:\n"),
1710 "compact must not have I: section (imports omitted)"
1711 );
1712 assert!(
1713 compact_out.contains("F:\n"),
1714 "compact must have F: section with wrapped formatting"
1715 );
1716
1717 assert!(compact_out.contains("fn_0"), "compact must list functions");
1719 let has_two_on_same_line = compact_out
1720 .lines()
1721 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1722 assert!(
1723 has_two_on_same_line,
1724 "compact must render multiple functions per line (wrapped), not one-per-line"
1725 );
1726 }
1727
1728 #[test]
1730 fn test_compact_mode_consistent_token_reduction() {
1731 use crate::types::{FunctionInfo, SemanticAnalysis};
1732 use std::collections::HashMap;
1733
1734 let funcs: Vec<FunctionInfo> = (0..50)
1735 .map(|i| FunctionInfo {
1736 name: format!("function_name_{}", i),
1737 line: i * 10 + 1,
1738 end_line: i * 10 + 8,
1739 parameters: vec![
1740 "arg1: u32".to_string(),
1741 "arg2: String".to_string(),
1742 "arg3: Option<bool>".to_string(),
1743 ],
1744 return_type: Some("Result<Vec<String>, Error>".to_string()),
1745 })
1746 .collect();
1747
1748 let semantic = SemanticAnalysis {
1749 functions: funcs,
1750 classes: vec![],
1751 imports: vec![],
1752 references: vec![],
1753 call_frequency: HashMap::new(),
1754 calls: vec![],
1755 impl_traits: vec![],
1756 };
1757
1758 let verbose_out = format_file_details_paginated(
1759 &semantic.functions,
1760 semantic.functions.len(),
1761 &semantic,
1762 "src/large_file.rs",
1763 1000,
1764 0,
1765 true,
1766 None,
1767 );
1768 let compact_out = format_file_details_paginated(
1769 &semantic.functions,
1770 semantic.functions.len(),
1771 &semantic,
1772 "src/large_file.rs",
1773 1000,
1774 0,
1775 false,
1776 None,
1777 );
1778
1779 assert!(
1780 compact_out.len() <= verbose_out.len(),
1781 "compact ({} chars) must be <= verbose ({} chars)",
1782 compact_out.len(),
1783 verbose_out.len(),
1784 );
1785 }
1786
1787 #[test]
1789 fn test_format_module_info_happy_path() {
1790 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1791 let info = ModuleInfo {
1792 name: "parser.rs".to_string(),
1793 line_count: 312,
1794 language: "rust".to_string(),
1795 functions: vec![
1796 ModuleFunctionInfo {
1797 name: "parse_file".to_string(),
1798 line: 24,
1799 },
1800 ModuleFunctionInfo {
1801 name: "parse_block".to_string(),
1802 line: 58,
1803 },
1804 ],
1805 imports: vec![
1806 ModuleImportInfo {
1807 module: "crate::types".to_string(),
1808 items: vec!["Token".to_string(), "Expr".to_string()],
1809 },
1810 ModuleImportInfo {
1811 module: "std::io".to_string(),
1812 items: vec!["BufReader".to_string()],
1813 },
1814 ],
1815 };
1816 let result = format_module_info(&info);
1817 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1818 assert!(result.contains("F:"));
1819 assert!(result.contains("parse_file:24"));
1820 assert!(result.contains("parse_block:58"));
1821 assert!(result.contains("I:"));
1822 assert!(result.contains("crate::types:Token, Expr"));
1823 assert!(result.contains("std::io:BufReader"));
1824 assert!(result.contains("; "));
1825 assert!(!result.contains('{'));
1826 }
1827
1828 #[test]
1829 fn test_format_module_info_empty() {
1830 use crate::types::ModuleInfo;
1831 let info = ModuleInfo {
1832 name: "empty.rs".to_string(),
1833 line_count: 0,
1834 language: "rust".to_string(),
1835 functions: vec![],
1836 imports: vec![],
1837 };
1838 let result = format_module_info(&info);
1839 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1840 assert!(!result.contains("F:"));
1841 assert!(!result.contains("I:"));
1842 }
1843
1844 #[test]
1845 fn test_compact_mode_empty_classes_no_header() {
1846 use crate::types::{FunctionInfo, SemanticAnalysis};
1847 use std::collections::HashMap;
1848
1849 let funcs: Vec<FunctionInfo> = (0..5)
1850 .map(|i| FunctionInfo {
1851 name: format!("fn_{}", i),
1852 line: i * 5 + 1,
1853 end_line: i * 5 + 4,
1854 parameters: vec![],
1855 return_type: None,
1856 })
1857 .collect();
1858
1859 let semantic = SemanticAnalysis {
1860 functions: funcs,
1861 classes: vec![], imports: vec![],
1863 references: vec![],
1864 call_frequency: HashMap::new(),
1865 calls: vec![],
1866 impl_traits: vec![],
1867 };
1868
1869 let compact_out = format_file_details_paginated(
1870 &semantic.functions,
1871 semantic.functions.len(),
1872 &semantic,
1873 "src/simple.rs",
1874 100,
1875 0,
1876 false,
1877 None,
1878 );
1879
1880 assert!(
1882 !compact_out.contains("C:\n"),
1883 "compact mode must not emit C: header when classes are empty"
1884 );
1885 }
1886
1887 #[test]
1888 fn test_format_classes_with_methods() {
1889 use crate::types::{ClassInfo, FunctionInfo};
1890
1891 let functions = vec![
1892 FunctionInfo {
1893 name: "method_a".to_string(),
1894 line: 5,
1895 end_line: 8,
1896 parameters: vec![],
1897 return_type: None,
1898 },
1899 FunctionInfo {
1900 name: "method_b".to_string(),
1901 line: 10,
1902 end_line: 12,
1903 parameters: vec![],
1904 return_type: None,
1905 },
1906 FunctionInfo {
1907 name: "top_level_func".to_string(),
1908 line: 50,
1909 end_line: 55,
1910 parameters: vec![],
1911 return_type: None,
1912 },
1913 ];
1914
1915 let classes = vec![ClassInfo {
1916 name: "MyClass".to_string(),
1917 line: 1,
1918 end_line: 30,
1919 methods: vec![],
1920 fields: vec![],
1921 inherits: vec![],
1922 }];
1923
1924 let output = format_classes_section(&classes, &functions);
1925
1926 assert!(
1927 output.contains("MyClass:1-30"),
1928 "class header should show start-end range"
1929 );
1930 assert!(output.contains("method_a:5"), "method_a should be listed");
1931 assert!(output.contains("method_b:10"), "method_b should be listed");
1932 assert!(
1933 !output.contains("top_level_func"),
1934 "top_level_func outside class range should not be listed"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_format_classes_method_cap() {
1940 use crate::types::{ClassInfo, FunctionInfo};
1941
1942 let mut functions = Vec::new();
1943 for i in 0..15 {
1944 functions.push(FunctionInfo {
1945 name: format!("method_{}", i),
1946 line: 2 + i,
1947 end_line: 3 + i,
1948 parameters: vec![],
1949 return_type: None,
1950 });
1951 }
1952
1953 let classes = vec![ClassInfo {
1954 name: "LargeClass".to_string(),
1955 line: 1,
1956 end_line: 50,
1957 methods: vec![],
1958 fields: vec![],
1959 inherits: vec![],
1960 }];
1961
1962 let output = format_classes_section(&classes, &functions);
1963
1964 assert!(output.contains("method_0"), "first method should be listed");
1965 assert!(output.contains("method_9"), "10th method should be listed");
1966 assert!(
1967 !output.contains("method_10"),
1968 "11th method should not be listed (cap at 10)"
1969 );
1970 assert!(
1971 output.contains("... (5 more)"),
1972 "truncation message should show remaining count"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_format_classes_no_methods() {
1978 use crate::types::{ClassInfo, FunctionInfo};
1979
1980 let functions = vec![FunctionInfo {
1981 name: "top_level".to_string(),
1982 line: 100,
1983 end_line: 105,
1984 parameters: vec![],
1985 return_type: None,
1986 }];
1987
1988 let classes = vec![ClassInfo {
1989 name: "EmptyClass".to_string(),
1990 line: 1,
1991 end_line: 50,
1992 methods: vec![],
1993 fields: vec![],
1994 inherits: vec![],
1995 }];
1996
1997 let output = format_classes_section(&classes, &functions);
1998
1999 assert!(
2000 output.contains("EmptyClass:1-50"),
2001 "empty class header should appear"
2002 );
2003 assert!(
2004 !output.contains("top_level"),
2005 "top-level functions outside class should not appear"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_f_section_excludes_methods() {
2011 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2012 use std::collections::HashMap;
2013
2014 let functions = vec![
2015 FunctionInfo {
2016 name: "method_a".to_string(),
2017 line: 5,
2018 end_line: 10,
2019 parameters: vec![],
2020 return_type: None,
2021 },
2022 FunctionInfo {
2023 name: "top_level".to_string(),
2024 line: 50,
2025 end_line: 55,
2026 parameters: vec![],
2027 return_type: None,
2028 },
2029 ];
2030
2031 let semantic = SemanticAnalysis {
2032 functions,
2033 classes: vec![ClassInfo {
2034 name: "TestClass".to_string(),
2035 line: 1,
2036 end_line: 30,
2037 methods: vec![],
2038 fields: vec![],
2039 inherits: vec![],
2040 }],
2041 imports: vec![],
2042 references: vec![],
2043 call_frequency: HashMap::new(),
2044 calls: vec![],
2045 impl_traits: vec![],
2046 };
2047
2048 let output = format_file_details("test.rs", &semantic, 100, false, None);
2049
2050 assert!(output.contains("C:"), "classes section should exist");
2051 assert!(
2052 output.contains("method_a:5"),
2053 "method should be in C: section"
2054 );
2055 assert!(output.contains("F:"), "F: section should exist");
2056 assert!(
2057 output.contains("top_level"),
2058 "top-level function should be in F: section"
2059 );
2060
2061 let f_pos = output.find("F:").unwrap();
2063 let method_pos = output.find("method_a").unwrap();
2064 assert!(
2065 method_pos < f_pos,
2066 "method_a should appear before F: section"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_format_focused_paginated_unit() {
2072 use crate::graph::InternalCallChain;
2073 use crate::pagination::PaginationMode;
2074 use std::path::PathBuf;
2075
2076 let make_chain = |name: &str| -> InternalCallChain {
2078 InternalCallChain {
2079 chain: vec![
2080 (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2081 ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2082 ],
2083 }
2084 };
2085
2086 let prod_chains: Vec<InternalCallChain> = (0..8)
2087 .map(|i| make_chain(&format!("caller_{}", i)))
2088 .collect();
2089 let page = &prod_chains[0..3];
2090
2091 let formatted = format_focused_paginated(
2093 page,
2094 8,
2095 PaginationMode::Callers,
2096 "target",
2097 &prod_chains,
2098 &[],
2099 &[],
2100 1,
2101 0,
2102 None,
2103 true,
2104 );
2105
2106 assert!(
2108 formatted.contains("CALLERS (1-3 of 8):"),
2109 "header should show 1-3 of 8, got: {}",
2110 formatted
2111 );
2112 assert!(
2113 formatted.contains("FOCUS: target"),
2114 "should have FOCUS header"
2115 );
2116 }
2117
2118 #[test]
2119 fn test_fields_none_regression() {
2120 use crate::types::SemanticAnalysis;
2121 use std::collections::HashMap;
2122
2123 let functions = vec![FunctionInfo {
2124 name: "hello".to_string(),
2125 line: 10,
2126 end_line: 15,
2127 parameters: vec![],
2128 return_type: None,
2129 }];
2130
2131 let classes = vec![ClassInfo {
2132 name: "MyClass".to_string(),
2133 line: 20,
2134 end_line: 50,
2135 methods: vec![],
2136 fields: vec![],
2137 inherits: vec![],
2138 }];
2139
2140 let imports = vec![ImportInfo {
2141 module: "std".to_string(),
2142 items: vec!["io".to_string()],
2143 line: 1,
2144 }];
2145
2146 let semantic = SemanticAnalysis {
2147 functions: functions.clone(),
2148 classes: classes.clone(),
2149 imports: imports.clone(),
2150 references: vec![],
2151 call_frequency: HashMap::new(),
2152 calls: vec![],
2153 impl_traits: vec![],
2154 };
2155
2156 let output = format_file_details_paginated(
2157 &functions,
2158 functions.len(),
2159 &semantic,
2160 "test.rs",
2161 100,
2162 0,
2163 true,
2164 None,
2165 );
2166
2167 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2168 assert!(output.contains("C:"), "Classes section missing");
2169 assert!(output.contains("I:"), "Imports section missing");
2170 assert!(output.contains("F:"), "Functions section missing");
2171 }
2172
2173 #[test]
2174 fn test_fields_functions_only() {
2175 use crate::types::SemanticAnalysis;
2176 use std::collections::HashMap;
2177
2178 let functions = vec![FunctionInfo {
2179 name: "hello".to_string(),
2180 line: 10,
2181 end_line: 15,
2182 parameters: vec![],
2183 return_type: None,
2184 }];
2185
2186 let classes = vec![ClassInfo {
2187 name: "MyClass".to_string(),
2188 line: 20,
2189 end_line: 50,
2190 methods: vec![],
2191 fields: vec![],
2192 inherits: vec![],
2193 }];
2194
2195 let imports = vec![ImportInfo {
2196 module: "std".to_string(),
2197 items: vec!["io".to_string()],
2198 line: 1,
2199 }];
2200
2201 let semantic = SemanticAnalysis {
2202 functions: functions.clone(),
2203 classes: classes.clone(),
2204 imports: imports.clone(),
2205 references: vec![],
2206 call_frequency: HashMap::new(),
2207 calls: vec![],
2208 impl_traits: vec![],
2209 };
2210
2211 let fields = Some(vec![AnalyzeFileField::Functions]);
2212 let output = format_file_details_paginated(
2213 &functions,
2214 functions.len(),
2215 &semantic,
2216 "test.rs",
2217 100,
2218 0,
2219 true,
2220 fields.as_deref(),
2221 );
2222
2223 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2224 assert!(!output.contains("C:"), "Classes section should not appear");
2225 assert!(!output.contains("I:"), "Imports section should not appear");
2226 assert!(output.contains("F:"), "Functions section missing");
2227 }
2228
2229 #[test]
2230 fn test_fields_classes_only() {
2231 use crate::types::SemanticAnalysis;
2232 use std::collections::HashMap;
2233
2234 let functions = vec![FunctionInfo {
2235 name: "hello".to_string(),
2236 line: 10,
2237 end_line: 15,
2238 parameters: vec![],
2239 return_type: None,
2240 }];
2241
2242 let classes = vec![ClassInfo {
2243 name: "MyClass".to_string(),
2244 line: 20,
2245 end_line: 50,
2246 methods: vec![],
2247 fields: vec![],
2248 inherits: vec![],
2249 }];
2250
2251 let imports = vec![ImportInfo {
2252 module: "std".to_string(),
2253 items: vec!["io".to_string()],
2254 line: 1,
2255 }];
2256
2257 let semantic = SemanticAnalysis {
2258 functions: functions.clone(),
2259 classes: classes.clone(),
2260 imports: imports.clone(),
2261 references: vec![],
2262 call_frequency: HashMap::new(),
2263 calls: vec![],
2264 impl_traits: vec![],
2265 };
2266
2267 let fields = Some(vec![AnalyzeFileField::Classes]);
2268 let output = format_file_details_paginated(
2269 &functions,
2270 functions.len(),
2271 &semantic,
2272 "test.rs",
2273 100,
2274 0,
2275 true,
2276 fields.as_deref(),
2277 );
2278
2279 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2280 assert!(output.contains("C:"), "Classes section missing");
2281 assert!(!output.contains("I:"), "Imports section should not appear");
2282 assert!(
2283 !output.contains("F:"),
2284 "Functions section should not appear"
2285 );
2286 }
2287
2288 #[test]
2289 fn test_fields_imports_verbose() {
2290 use crate::types::SemanticAnalysis;
2291 use std::collections::HashMap;
2292
2293 let functions = vec![FunctionInfo {
2294 name: "hello".to_string(),
2295 line: 10,
2296 end_line: 15,
2297 parameters: vec![],
2298 return_type: None,
2299 }];
2300
2301 let classes = vec![ClassInfo {
2302 name: "MyClass".to_string(),
2303 line: 20,
2304 end_line: 50,
2305 methods: vec![],
2306 fields: vec![],
2307 inherits: vec![],
2308 }];
2309
2310 let imports = vec![ImportInfo {
2311 module: "std".to_string(),
2312 items: vec!["io".to_string()],
2313 line: 1,
2314 }];
2315
2316 let semantic = SemanticAnalysis {
2317 functions: functions.clone(),
2318 classes: classes.clone(),
2319 imports: imports.clone(),
2320 references: vec![],
2321 call_frequency: HashMap::new(),
2322 calls: vec![],
2323 impl_traits: vec![],
2324 };
2325
2326 let fields = Some(vec![AnalyzeFileField::Imports]);
2327 let output = format_file_details_paginated(
2328 &functions,
2329 functions.len(),
2330 &semantic,
2331 "test.rs",
2332 100,
2333 0,
2334 true,
2335 fields.as_deref(),
2336 );
2337
2338 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2339 assert!(!output.contains("C:"), "Classes section should not appear");
2340 assert!(output.contains("I:"), "Imports section missing");
2341 assert!(
2342 !output.contains("F:"),
2343 "Functions section should not appear"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_fields_imports_no_verbose() {
2349 use crate::types::SemanticAnalysis;
2350 use std::collections::HashMap;
2351
2352 let functions = vec![FunctionInfo {
2353 name: "hello".to_string(),
2354 line: 10,
2355 end_line: 15,
2356 parameters: vec![],
2357 return_type: None,
2358 }];
2359
2360 let classes = vec![ClassInfo {
2361 name: "MyClass".to_string(),
2362 line: 20,
2363 end_line: 50,
2364 methods: vec![],
2365 fields: vec![],
2366 inherits: vec![],
2367 }];
2368
2369 let imports = vec![ImportInfo {
2370 module: "std".to_string(),
2371 items: vec!["io".to_string()],
2372 line: 1,
2373 }];
2374
2375 let semantic = SemanticAnalysis {
2376 functions: functions.clone(),
2377 classes: classes.clone(),
2378 imports: imports.clone(),
2379 references: vec![],
2380 call_frequency: HashMap::new(),
2381 calls: vec![],
2382 impl_traits: vec![],
2383 };
2384
2385 let fields = Some(vec![AnalyzeFileField::Imports]);
2386 let output = format_file_details_paginated(
2387 &functions,
2388 functions.len(),
2389 &semantic,
2390 "test.rs",
2391 100,
2392 0,
2393 false,
2394 fields.as_deref(),
2395 );
2396
2397 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2398 assert!(!output.contains("C:"), "Classes section should not appear");
2399 assert!(
2400 output.contains("I:"),
2401 "Imports section should appear (explicitly listed in fields)"
2402 );
2403 assert!(
2404 !output.contains("F:"),
2405 "Functions section should not appear"
2406 );
2407 }
2408
2409 #[test]
2410 fn test_fields_empty_array() {
2411 use crate::types::SemanticAnalysis;
2412 use std::collections::HashMap;
2413
2414 let functions = vec![FunctionInfo {
2415 name: "hello".to_string(),
2416 line: 10,
2417 end_line: 15,
2418 parameters: vec![],
2419 return_type: None,
2420 }];
2421
2422 let classes = vec![ClassInfo {
2423 name: "MyClass".to_string(),
2424 line: 20,
2425 end_line: 50,
2426 methods: vec![],
2427 fields: vec![],
2428 inherits: vec![],
2429 }];
2430
2431 let imports = vec![ImportInfo {
2432 module: "std".to_string(),
2433 items: vec!["io".to_string()],
2434 line: 1,
2435 }];
2436
2437 let semantic = SemanticAnalysis {
2438 functions: functions.clone(),
2439 classes: classes.clone(),
2440 imports: imports.clone(),
2441 references: vec![],
2442 call_frequency: HashMap::new(),
2443 calls: vec![],
2444 impl_traits: vec![],
2445 };
2446
2447 let fields = Some(vec![]);
2448 let output = format_file_details_paginated(
2449 &functions,
2450 functions.len(),
2451 &semantic,
2452 "test.rs",
2453 100,
2454 0,
2455 true,
2456 fields.as_deref(),
2457 );
2458
2459 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2460 assert!(
2461 output.contains("C:"),
2462 "Classes section missing (empty fields = show all)"
2463 );
2464 assert!(
2465 output.contains("I:"),
2466 "Imports section missing (empty fields = show all)"
2467 );
2468 assert!(
2469 output.contains("F:"),
2470 "Functions section missing (empty fields = show all)"
2471 );
2472 }
2473
2474 #[test]
2475 fn test_fields_pagination_no_functions() {
2476 use crate::types::SemanticAnalysis;
2477 use std::collections::HashMap;
2478
2479 let functions = vec![FunctionInfo {
2480 name: "hello".to_string(),
2481 line: 10,
2482 end_line: 15,
2483 parameters: vec![],
2484 return_type: None,
2485 }];
2486
2487 let classes = vec![ClassInfo {
2488 name: "MyClass".to_string(),
2489 line: 20,
2490 end_line: 50,
2491 methods: vec![],
2492 fields: vec![],
2493 inherits: vec![],
2494 }];
2495
2496 let imports = vec![ImportInfo {
2497 module: "std".to_string(),
2498 items: vec!["io".to_string()],
2499 line: 1,
2500 }];
2501
2502 let semantic = SemanticAnalysis {
2503 functions: functions.clone(),
2504 classes: classes.clone(),
2505 imports: imports.clone(),
2506 references: vec![],
2507 call_frequency: HashMap::new(),
2508 calls: vec![],
2509 impl_traits: vec![],
2510 };
2511
2512 let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2513 let output = format_file_details_paginated(
2514 &functions,
2515 functions.len(),
2516 &semantic,
2517 "test.rs",
2518 100,
2519 0,
2520 true,
2521 fields.as_deref(),
2522 );
2523
2524 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2525 assert!(
2526 output.contains("1-1/1F"),
2527 "FILE header should contain valid range (1-1/1F)"
2528 );
2529 assert!(output.contains("C:"), "Classes section missing");
2530 assert!(output.contains("I:"), "Imports section missing");
2531 assert!(
2532 !output.contains("F:"),
2533 "Functions section should not appear (filtered by fields)"
2534 );
2535 }
2536}
2537
2538fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2539 let mut output = String::new();
2540 if classes.is_empty() {
2541 return output;
2542 }
2543 output.push_str("C:\n");
2544
2545 let methods_by_class = collect_class_methods(classes, functions);
2546 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2547
2548 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2549 let class_strs: Vec<String> = classes
2550 .iter()
2551 .map(|class| {
2552 if class.inherits.is_empty() {
2553 format!("{}:{}-{}", class.name, class.line, class.end_line)
2554 } else {
2555 format!(
2556 "{}:{}-{} ({})",
2557 class.name,
2558 class.line,
2559 class.end_line,
2560 class.inherits.join(", ")
2561 )
2562 }
2563 })
2564 .collect();
2565 output.push_str(" ");
2566 output.push_str(&class_strs.join("; "));
2567 output.push('\n');
2568 } else {
2569 for class in classes {
2570 if class.inherits.is_empty() {
2571 let _ = writeln!(output, " {}:{}-{}", class.name, class.line, class.end_line);
2572 } else {
2573 let _ = writeln!(
2574 output,
2575 " {}:{}-{} ({})",
2576 class.name,
2577 class.line,
2578 class.end_line,
2579 class.inherits.join(", ")
2580 );
2581 }
2582
2583 if let Some(methods) = methods_by_class.get(&class.name)
2585 && !methods.is_empty()
2586 {
2587 for (i, method) in methods.iter().take(10).enumerate() {
2588 let _ = writeln!(output, " {}:{}", method.name, method.line);
2589 if i + 1 == 10 && methods.len() > 10 {
2590 let _ = writeln!(output, " ... ({} more)", methods.len() - 10);
2591 break;
2592 }
2593 }
2594 }
2595 }
2596 }
2597 output
2598}
2599
2600fn format_imports_section(imports: &[ImportInfo]) -> String {
2603 let mut output = String::new();
2604 if imports.is_empty() {
2605 return output;
2606 }
2607 output.push_str("I:\n");
2608 let mut module_map: HashMap<String, usize> = HashMap::new();
2609 for import in imports {
2610 module_map
2611 .entry(import.module.clone())
2612 .and_modify(|count| *count += 1)
2613 .or_insert(1);
2614 }
2615 let mut modules: Vec<_> = module_map.keys().cloned().collect();
2616 modules.sort();
2617 let formatted_modules: Vec<String> = modules
2618 .iter()
2619 .map(|module| format!("{}({})", module, module_map[module]))
2620 .collect();
2621 if formatted_modules.len() <= MULTILINE_THRESHOLD {
2622 output.push_str(" ");
2623 output.push_str(&formatted_modules.join("; "));
2624 output.push('\n');
2625 } else {
2626 for module_str in formatted_modules {
2627 output.push_str(" ");
2628 output.push_str(&module_str);
2629 output.push('\n');
2630 }
2631 }
2632 output
2633}