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