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