1use crate::graph::CallChain;
7use crate::graph::CallGraph;
8use crate::graph::ImportGraph;
9use crate::pagination::PaginationMode;
10use crate::test_detection::is_test_file;
11use crate::traversal::WalkEntry;
12use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, ModuleInfo, SemanticAnalysis};
13use std::collections::{HashMap, HashSet};
14use std::fmt::Write;
15use std::path::{Path, PathBuf};
16use thiserror::Error;
17use tracing::instrument;
18
19const MULTILINE_THRESHOLD: usize = 10;
20
21fn is_method_of_class(func: &FunctionInfo, class: &ClassInfo) -> bool {
23 func.line >= class.line && func.end_line <= class.end_line
24}
25
26fn collect_class_methods<'a>(
29 classes: &'a [ClassInfo],
30 functions: &'a [FunctionInfo],
31) -> HashMap<String, Vec<&'a FunctionInfo>> {
32 let mut methods_by_class: HashMap<String, Vec<&'a FunctionInfo>> = HashMap::new();
33 for class in classes {
34 if !class.methods.is_empty() {
35 methods_by_class.insert(class.name.clone(), class.methods.iter().collect());
37 } else {
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 }
45 }
46 methods_by_class
47}
48
49fn format_function_list_wrapped<'a>(
51 functions: impl Iterator<Item = &'a crate::types::FunctionInfo>,
52 call_frequency: &std::collections::HashMap<String, usize>,
53) -> String {
54 let mut output = String::new();
55 let mut line = String::from(" ");
56 for (i, func) in functions.enumerate() {
57 let mut call_marker = func.compact_signature();
58
59 if let Some(&count) = call_frequency.get(&func.name)
60 && count > 3
61 {
62 call_marker.push_str(&format!("\u{2022}{}", count));
63 }
64
65 if i == 0 {
66 line.push_str(&call_marker);
67 } else if line.len() + call_marker.len() + 2 > 100 {
68 output.push_str(&line);
69 output.push('\n');
70 let mut new_line = String::with_capacity(2 + call_marker.len());
71 new_line.push_str(" ");
72 new_line.push_str(&call_marker);
73 line = new_line;
74 } else {
75 line.push_str(", ");
76 line.push_str(&call_marker);
77 }
78 }
79 if !line.trim().is_empty() {
80 output.push_str(&line);
81 output.push('\n');
82 }
83 output
84}
85
86fn format_file_info_parts(line_count: usize, fn_count: usize, cls_count: usize) -> Option<String> {
89 let mut parts = Vec::new();
90 if line_count > 0 {
91 parts.push(format!("{}L", line_count));
92 }
93 if fn_count > 0 {
94 parts.push(format!("{}F", fn_count));
95 }
96 if cls_count > 0 {
97 parts.push(format!("{}C", cls_count));
98 }
99 if parts.is_empty() {
100 None
101 } else {
102 Some(format!("[{}]", parts.join(", ")))
103 }
104}
105
106fn strip_base_path(path: &Path, base_path: Option<&Path>) -> String {
108 match base_path {
109 Some(base) => {
110 if let Ok(rel_path) = path.strip_prefix(base) {
111 rel_path.display().to_string()
112 } else {
113 path.display().to_string()
114 }
115 }
116 None => path.display().to_string(),
117 }
118}
119
120#[derive(Debug, Error)]
121pub enum FormatterError {
122 #[error("Graph error: {0}")]
123 GraphError(#[from] crate::graph::GraphError),
124}
125
126#[instrument(skip_all)]
128pub fn format_structure(
129 entries: &[WalkEntry],
130 analysis_results: &[FileInfo],
131 max_depth: Option<u32>,
132 _base_path: Option<&Path>,
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 for entry in entries {
219 if entry.depth == 0 {
221 continue;
222 }
223
224 let indent = " ".repeat(entry.depth - 1);
226
227 let name = entry
229 .path
230 .file_name()
231 .and_then(|n| n.to_str())
232 .unwrap_or("?");
233
234 if !entry.is_dir {
236 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
237 if analysis.is_test {
239 continue;
240 }
241
242 if let Some(info_str) = format_file_info_parts(
243 analysis.line_count,
244 analysis.function_count,
245 analysis.class_count,
246 ) {
247 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
248 } else {
249 output.push_str(&format!("{}{}\n", indent, name));
250 }
251 }
252 } else {
254 output.push_str(&format!("{}{}/\n", indent, name));
255 }
256 }
257
258 if !test_files.is_empty() {
260 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
261
262 for entry in entries {
263 if entry.depth == 0 {
265 continue;
266 }
267
268 let indent = " ".repeat(entry.depth - 1);
270
271 let name = entry
273 .path
274 .file_name()
275 .and_then(|n| n.to_str())
276 .unwrap_or("?");
277
278 if !entry.is_dir
280 && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
281 {
282 if !analysis.is_test {
284 continue;
285 }
286
287 if let Some(info_str) = format_file_info_parts(
288 analysis.line_count,
289 analysis.function_count,
290 analysis.class_count,
291 ) {
292 output.push_str(&format!("{}{} {}\n", indent, name, info_str));
293 } else {
294 output.push_str(&format!("{}{}\n", indent, name));
295 }
296 }
297 }
298 }
299
300 output
301}
302
303#[instrument(skip_all)]
305pub fn format_file_details(
306 path: &str,
307 analysis: &SemanticAnalysis,
308 line_count: usize,
309 is_test: bool,
310 base_path: Option<&Path>,
311) -> String {
312 let mut output = String::new();
313
314 let display_path = strip_base_path(Path::new(path), base_path);
316 if is_test {
317 output.push_str(&format!(
318 "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
319 display_path,
320 line_count,
321 analysis.functions.len(),
322 analysis.classes.len(),
323 analysis.imports.len()
324 ));
325 } else {
326 output.push_str(&format!(
327 "FILE: {}({}L, {}F, {}C, {}I)\n",
328 display_path,
329 line_count,
330 analysis.functions.len(),
331 analysis.classes.len(),
332 analysis.imports.len()
333 ));
334 }
335
336 output.push_str(&format_classes_section(
338 &analysis.classes,
339 &analysis.functions,
340 ));
341
342 let top_level_functions: Vec<&FunctionInfo> = analysis
344 .functions
345 .iter()
346 .filter(|func| {
347 !analysis
348 .classes
349 .iter()
350 .any(|class| is_method_of_class(func, class))
351 })
352 .collect();
353
354 if !top_level_functions.is_empty() {
355 output.push_str("F:\n");
356 output.push_str(&format_function_list_wrapped(
357 top_level_functions.iter().copied(),
358 &analysis.call_frequency,
359 ));
360 }
361
362 output.push_str(&format_imports_section(&analysis.imports));
364
365 output
366}
367
368fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
378 use std::collections::BTreeMap;
379
380 if chains.is_empty() {
381 return " (none)\n".to_string();
382 }
383
384 let mut output = String::new();
385
386 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
388 for (parent, child) in chains {
389 if !child.is_empty() {
391 *groups
392 .entry(parent.to_string())
393 .or_default()
394 .entry(child.to_string())
395 .or_insert(0) += 1;
396 } else {
397 groups.entry(parent.to_string()).or_default();
399 }
400 }
401
402 for (parent, children) in groups {
404 let _ = writeln!(output, " {} {} {}", focus_symbol, arrow, parent);
405 let mut sorted: Vec<_> = children.into_iter().collect();
407 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
408 for (child, count) in sorted {
409 if count > 1 {
410 let _ = writeln!(output, " {} {} (x{})", arrow, child, count);
411 } else {
412 let _ = writeln!(output, " {} {}", arrow, child);
413 }
414 }
415 }
416
417 output
418}
419
420#[instrument(skip_all)]
422pub fn format_focused(
423 graph: &CallGraph,
424 symbol: &str,
425 follow_depth: u32,
426 base_path: Option<&Path>,
427) -> Result<String, FormatterError> {
428 let mut output = String::new();
429
430 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
432 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
433 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
434
435 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
437 incoming_chains.clone().into_iter().partition(|chain| {
438 chain
439 .chain
440 .first()
441 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
442 });
443
444 let callers_count = prod_chains
446 .iter()
447 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
448 .collect::<std::collections::HashSet<_>>()
449 .len();
450
451 let callees_count = outgoing_chains
453 .iter()
454 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
455 .collect::<std::collections::HashSet<_>>()
456 .len();
457
458 output.push_str(&format!(
460 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
461 symbol, def_count, callers_count, callees_count
462 ));
463
464 output.push_str(&format!("DEPTH: {}\n", follow_depth));
466
467 if let Some(definitions) = graph.definitions.get(symbol) {
469 output.push_str("DEFINED:\n");
470 for (path, line) in definitions {
471 output.push_str(&format!(
472 " {}:{}\n",
473 strip_base_path(path, base_path),
474 line
475 ));
476 }
477 } else {
478 output.push_str("DEFINED: (not found)\n");
479 }
480
481 output.push_str("CALLERS:\n");
483
484 let prod_refs: Vec<_> = prod_chains
486 .iter()
487 .filter_map(|chain| {
488 if chain.chain.len() >= 2 {
489 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
490 } else if chain.chain.len() == 1 {
491 Some((chain.chain[0].0.as_str(), ""))
492 } else {
493 None
494 }
495 })
496 .collect();
497
498 if prod_refs.is_empty() {
499 output.push_str(" (none)\n");
500 } else {
501 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
502 }
503
504 if !test_chains.is_empty() {
506 let mut test_files: Vec<_> = test_chains
507 .iter()
508 .filter_map(|chain| {
509 chain
510 .chain
511 .first()
512 .map(|(_, path, _)| path.to_string_lossy().into_owned())
513 })
514 .collect();
515 test_files.sort();
516 test_files.dedup();
517
518 let display_files: Vec<_> = test_files
520 .iter()
521 .map(|f| strip_base_path(Path::new(f), base_path))
522 .collect();
523
524 let file_list = display_files.join(", ");
525 output.push_str(&format!(
526 "CALLERS (test): {} test functions (in {})\n",
527 test_chains.len(),
528 file_list
529 ));
530 }
531
532 output.push_str("CALLEES:\n");
534 let outgoing_refs: Vec<_> = outgoing_chains
535 .iter()
536 .filter_map(|chain| {
537 if chain.chain.len() >= 2 {
538 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
539 } else if chain.chain.len() == 1 {
540 Some((chain.chain[0].0.as_str(), ""))
541 } else {
542 None
543 }
544 })
545 .collect();
546
547 if outgoing_refs.is_empty() {
548 output.push_str(" (none)\n");
549 } else {
550 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
551 }
552
553 output.push_str("STATISTICS:\n");
555 let incoming_count = prod_refs
556 .iter()
557 .map(|(p, _)| p)
558 .collect::<std::collections::HashSet<_>>()
559 .len();
560 let outgoing_count = outgoing_refs
561 .iter()
562 .map(|(p, _)| p)
563 .collect::<std::collections::HashSet<_>>()
564 .len();
565 output.push_str(&format!(" Incoming calls: {}\n", incoming_count));
566 output.push_str(&format!(" Outgoing calls: {}\n", outgoing_count));
567
568 let mut files = HashSet::new();
570 for chain in &prod_chains {
571 for (_, path, _) in &chain.chain {
572 files.insert(path.clone());
573 }
574 }
575 for chain in &outgoing_chains {
576 for (_, path, _) in &chain.chain {
577 files.insert(path.clone());
578 }
579 }
580 if let Some(definitions) = graph.definitions.get(symbol) {
581 for (path, _) in definitions {
582 files.insert(path.clone());
583 }
584 }
585
586 let (prod_files, test_files): (Vec<_>, Vec<_>) =
588 files.into_iter().partition(|path| !is_test_file(path));
589
590 output.push_str("FILES:\n");
591 if prod_files.is_empty() && test_files.is_empty() {
592 output.push_str(" (none)\n");
593 } else {
594 if !prod_files.is_empty() {
596 let mut sorted_files = prod_files;
597 sorted_files.sort();
598 for file in sorted_files {
599 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
600 }
601 }
602
603 if !test_files.is_empty() {
605 output.push_str(" TEST FILES:\n");
606 let mut sorted_files = test_files;
607 sorted_files.sort();
608 for file in sorted_files {
609 output.push_str(&format!(" {}\n", strip_base_path(&file, base_path)));
610 }
611 }
612 }
613
614 Ok(output)
615}
616
617#[instrument(skip_all)]
620pub fn format_focused_summary(
621 graph: &CallGraph,
622 symbol: &str,
623 follow_depth: u32,
624 base_path: Option<&Path>,
625) -> Result<String, FormatterError> {
626 let mut output = String::new();
627
628 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
630 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
631 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
632
633 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
635 incoming_chains.into_iter().partition(|chain| {
636 chain
637 .chain
638 .first()
639 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
640 });
641
642 let callers_count = prod_chains
644 .iter()
645 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
646 .collect::<std::collections::HashSet<_>>()
647 .len();
648
649 let callees_count = outgoing_chains
651 .iter()
652 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
653 .collect::<std::collections::HashSet<_>>()
654 .len();
655
656 output.push_str(&format!(
658 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
659 symbol, def_count, callers_count, callees_count
660 ));
661
662 output.push_str(&format!("DEPTH: {}\n", follow_depth));
664
665 if let Some(definitions) = graph.definitions.get(symbol) {
667 output.push_str("DEFINED:\n");
668 for (path, line) in definitions {
669 output.push_str(&format!(
670 " {}:{}\n",
671 strip_base_path(path, base_path),
672 line
673 ));
674 }
675 } else {
676 output.push_str("DEFINED: (not found)\n");
677 }
678
679 output.push_str("CALLERS (top 10):\n");
681 if prod_chains.is_empty() {
682 output.push_str(" (none)\n");
683 } else {
684 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
686 std::collections::HashMap::new();
687 for chain in &prod_chains {
688 if let Some((name, path, _)) = chain.chain.first() {
689 let file_path = strip_base_path(path, base_path);
690 caller_freq
691 .entry(name.clone())
692 .and_modify(|(count, _)| *count += 1)
693 .or_insert((1, file_path));
694 }
695 }
696
697 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
699 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
700
701 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
702 output.push_str(&format!(" {} {}\n", name, file_path));
703 }
704 }
705
706 if !test_chains.is_empty() {
708 let mut test_files: Vec<_> = test_chains
709 .iter()
710 .filter_map(|chain| {
711 chain
712 .chain
713 .first()
714 .map(|(_, path, _)| path.to_string_lossy().into_owned())
715 })
716 .collect();
717 test_files.sort();
718 test_files.dedup();
719
720 output.push_str(&format!(
721 "CALLERS (test): {} test functions (in {} files)\n",
722 test_chains.len(),
723 test_files.len()
724 ));
725 }
726
727 output.push_str("CALLEES (top 10):\n");
729 if outgoing_chains.is_empty() {
730 output.push_str(" (none)\n");
731 } else {
732 let mut callee_freq: std::collections::HashMap<String, usize> =
734 std::collections::HashMap::new();
735 for chain in &outgoing_chains {
736 if let Some((name, _, _)) = chain.chain.first() {
737 *callee_freq.entry(name.clone()).or_insert(0) += 1;
738 }
739 }
740
741 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
743 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
744
745 for (name, _) in sorted_callees.into_iter().take(10) {
746 output.push_str(&format!(" {}\n", name));
747 }
748 }
749
750 output.push_str("SUGGESTION:\n");
752 output.push_str("Use summary=false with force=true for full output\n");
753
754 Ok(output)
755}
756
757#[instrument(skip_all)]
760pub fn format_summary(
761 entries: &[WalkEntry],
762 analysis_results: &[FileInfo],
763 max_depth: Option<u32>,
764 _base_path: Option<&Path>,
765 subtree_counts: Option<&[(PathBuf, usize)]>,
766) -> String {
767 let mut output = String::new();
768
769 let (prod_files, test_files): (Vec<_>, Vec<_>) =
771 analysis_results.iter().partition(|a| !a.is_test);
772
773 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
775 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
776 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
777
778 let mut lang_counts: HashMap<String, usize> = HashMap::new();
780 for analysis in analysis_results {
781 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
782 }
783 let total_files = analysis_results.len();
784
785 output.push_str("SUMMARY:\n");
787 let depth_label = match max_depth {
788 Some(n) if n > 0 => format!(" (max_depth={})", n),
789 _ => String::new(),
790 };
791 output.push_str(&format!(
792 "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
793 total_files,
794 prod_files.len(),
795 test_files.len(),
796 total_loc,
797 total_functions,
798 total_classes,
799 depth_label
800 ));
801
802 if !lang_counts.is_empty() {
803 output.push_str("Languages: ");
804 let mut langs: Vec<_> = lang_counts.iter().collect();
805 langs.sort_by_key(|&(name, _)| name);
806 let lang_strs: Vec<String> = langs
807 .iter()
808 .map(|(name, count)| {
809 let percentage = if total_files > 0 {
810 (**count * 100) / total_files
811 } else {
812 0
813 };
814 format!("{} ({}%)", name, percentage)
815 })
816 .collect();
817 output.push_str(&lang_strs.join(", "));
818 output.push('\n');
819 }
820
821 output.push('\n');
822
823 output.push_str("STRUCTURE (depth 1):\n");
825
826 let analysis_map: HashMap<String, &FileInfo> = analysis_results
828 .iter()
829 .map(|a| (a.path.clone(), a))
830 .collect();
831
832 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
834 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
835
836 let mut largest_dir_name: Option<String> = None;
838 let mut largest_dir_path: Option<String> = None;
839 let mut largest_dir_count: usize = 0;
840
841 for entry in depth1_entries {
842 let name = entry
843 .path
844 .file_name()
845 .and_then(|n| n.to_str())
846 .unwrap_or("?");
847
848 if entry.is_dir {
849 let dir_path_str = entry.path.display().to_string();
851 let files_in_dir: Vec<&FileInfo> = analysis_results
852 .iter()
853 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
854 .collect();
855
856 if !files_in_dir.is_empty() {
857 let dir_file_count = files_in_dir.len();
858 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
859 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
860 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
861
862 let entry_name_str = name.to_string();
864 let effective_count = if let Some(counts) = subtree_counts {
865 counts
866 .binary_search_by_key(&&entry.path, |(p, _)| p)
867 .ok()
868 .map(|i| counts[i].1)
869 .unwrap_or(dir_file_count)
870 } else {
871 dir_file_count
872 };
873 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
874 && effective_count > largest_dir_count
875 {
876 largest_dir_count = effective_count;
877 largest_dir_name = Some(entry_name_str);
878 largest_dir_path = Some(
879 entry
880 .path
881 .canonicalize()
882 .unwrap_or_else(|_| entry.path.clone())
883 .display()
884 .to_string(),
885 );
886 }
887
888 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
890 let mut top_files = files_in_dir.clone();
891 top_files.sort_unstable_by(|a, b| {
892 b.class_count
893 .cmp(&a.class_count)
894 .then(b.function_count.cmp(&a.function_count))
895 .then(a.path.cmp(&b.path))
896 });
897
898 let has_classes = top_files.iter().any(|f| f.class_count > 0);
899
900 if !has_classes {
902 top_files.sort_unstable_by(|a, b| {
903 b.function_count
904 .cmp(&a.function_count)
905 .then(a.path.cmp(&b.path))
906 });
907 }
908
909 let dir_path = Path::new(&dir_path_str);
910 let top_n: Vec<String> = top_files
911 .iter()
912 .take(3)
913 .filter(|f| {
914 if has_classes {
915 f.class_count > 0
916 } else {
917 f.function_count > 0
918 }
919 })
920 .map(|f| {
921 let rel = Path::new(&f.path)
922 .strip_prefix(dir_path)
923 .map(|p| p.to_string_lossy().into_owned())
924 .unwrap_or_else(|_| {
925 Path::new(&f.path)
926 .file_name()
927 .and_then(|n| n.to_str())
928 .map(|s| s.to_owned())
929 .unwrap_or_else(|| "?".to_owned())
930 });
931 let count = if has_classes {
932 f.class_count
933 } else {
934 f.function_count
935 };
936 let suffix = if has_classes { 'C' } else { 'F' };
937 format!("{}({}{})", rel, count, suffix)
938 })
939 .collect();
940 if top_n.is_empty() {
941 String::new()
942 } else {
943 format!(" top: {}", top_n.join(", "))
944 }
945 } else {
946 String::new()
947 };
948
949 let mut subdirs: Vec<String> = entries
951 .iter()
952 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
953 .filter_map(|e| {
954 e.path
955 .file_name()
956 .and_then(|n| n.to_str())
957 .map(|s| s.to_owned())
958 })
959 .collect();
960 subdirs.sort();
961 subdirs.dedup();
962 let subdir_suffix = if subdirs.is_empty() {
963 String::new()
964 } else {
965 let subdirs_capped: Vec<String> =
966 subdirs.iter().take(5).map(|s| format!("{}/", s)).collect();
967 format!(" sub: {}", subdirs_capped.join(", "))
968 };
969
970 let files_label = if let Some(counts) = subtree_counts {
971 let true_count = counts
972 .binary_search_by_key(&&entry.path, |(p, _)| p)
973 .ok()
974 .map(|i| counts[i].1)
975 .unwrap_or(dir_file_count);
976 if true_count != dir_file_count {
977 let depth_val = max_depth.unwrap_or(0);
978 format!(
979 "{} files total; showing {} at depth={}, {}L, {}F, {}C",
980 true_count,
981 dir_file_count,
982 depth_val,
983 dir_loc,
984 dir_functions,
985 dir_classes
986 )
987 } else {
988 format!(
989 "{} files, {}L, {}F, {}C",
990 dir_file_count, dir_loc, dir_functions, dir_classes
991 )
992 }
993 } else {
994 format!(
995 "{} files, {}L, {}F, {}C",
996 dir_file_count, dir_loc, dir_functions, dir_classes
997 )
998 };
999 output.push_str(&format!(
1000 " {}/ [{}]{}{}\n",
1001 name, files_label, hint, subdir_suffix
1002 ));
1003 } else {
1004 let entry_name_str = name.to_string();
1006 if let Some(counts) = subtree_counts {
1007 let true_count = counts
1008 .binary_search_by_key(&&entry.path, |(p, _)| p)
1009 .ok()
1010 .map(|i| counts[i].1)
1011 .unwrap_or(0);
1012 if true_count > 0 {
1013 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1015 && true_count > largest_dir_count
1016 {
1017 largest_dir_count = true_count;
1018 largest_dir_name = Some(entry_name_str);
1019 largest_dir_path = Some(
1020 entry
1021 .path
1022 .canonicalize()
1023 .unwrap_or_else(|_| entry.path.clone())
1024 .display()
1025 .to_string(),
1026 );
1027 }
1028 let depth_val = max_depth.unwrap_or(0);
1029 output.push_str(&format!(
1030 " {}/ [{} files total; showing 0 at depth={}, 0L, 0F, 0C]\n",
1031 name, true_count, depth_val
1032 ));
1033 } else {
1034 output.push_str(&format!(" {}/\n", name));
1035 }
1036 } else {
1037 output.push_str(&format!(" {}/\n", name));
1038 }
1039 }
1040 } else {
1041 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1043 if let Some(info_str) = format_file_info_parts(
1044 analysis.line_count,
1045 analysis.function_count,
1046 analysis.class_count,
1047 ) {
1048 output.push_str(&format!(" {} {}\n", name, info_str));
1049 } else {
1050 output.push_str(&format!(" {}\n", name));
1051 }
1052 }
1053 }
1054 }
1055
1056 output.push('\n');
1057
1058 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1060 output.push_str(&format!(
1061 "SUGGESTION: Largest source directory: {}/ ({} files total). For module details, re-run with path={} and max_depth=2.\n",
1062 name, largest_dir_count, path
1063 ));
1064 } else {
1065 output.push_str("SUGGESTION:\n");
1066 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1067 }
1068
1069 output
1070}
1071
1072#[instrument(skip_all)]
1077pub fn format_file_details_summary(
1078 semantic: &SemanticAnalysis,
1079 path: &str,
1080 line_count: usize,
1081) -> String {
1082 let mut output = String::new();
1083
1084 output.push_str("FILE:\n");
1086 output.push_str(&format!(" path: {}\n", path));
1087 output.push_str(&format!(
1088 " {}L, {}F, {}C\n",
1089 line_count,
1090 semantic.functions.len(),
1091 semantic.classes.len()
1092 ));
1093 output.push('\n');
1094
1095 if !semantic.functions.is_empty() {
1097 output.push_str("TOP FUNCTIONS BY SIZE:\n");
1098 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1099 let k = funcs.len().min(10);
1100 if k > 0 {
1101 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1102 let a_span = a.end_line.saturating_sub(a.line);
1103 let b_span = b.end_line.saturating_sub(b.line);
1104 b_span.cmp(&a_span)
1105 });
1106 funcs[..k].sort_by(|a, b| {
1107 let a_span = a.end_line.saturating_sub(a.line);
1108 let b_span = b.end_line.saturating_sub(b.line);
1109 b_span.cmp(&a_span)
1110 });
1111 }
1112
1113 for func in &funcs[..k] {
1114 let span = func.end_line.saturating_sub(func.line);
1115 let params = if func.parameters.is_empty() {
1116 String::new()
1117 } else {
1118 format!("({})", func.parameters.join(", "))
1119 };
1120 output.push_str(&format!(
1121 " {}:{}: {} {} [{}L]\n",
1122 func.line, func.end_line, func.name, params, span
1123 ));
1124 }
1125 output.push('\n');
1126 }
1127
1128 if !semantic.classes.is_empty() {
1130 output.push_str("CLASSES:\n");
1131 if semantic.classes.len() <= 10 {
1132 for class in &semantic.classes {
1134 let methods_count = class.methods.len();
1135 output.push_str(&format!(" {}: {}M\n", class.name, methods_count));
1136 }
1137 } else {
1138 output.push_str(&format!(" {} classes total\n", semantic.classes.len()));
1140 for class in semantic.classes.iter().take(5) {
1141 output.push_str(&format!(" {}\n", class.name));
1142 }
1143 if semantic.classes.len() > 5 {
1144 output.push_str(&format!(
1145 " ... and {} more\n",
1146 semantic.classes.len() - 5
1147 ));
1148 }
1149 }
1150 output.push('\n');
1151 }
1152
1153 output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
1155 output.push('\n');
1156
1157 output.push_str("SUGGESTION:\n");
1159 output.push_str("Use force=true for full output, or narrow your scope\n");
1160
1161 output
1162}
1163
1164#[instrument(skip_all)]
1166pub fn format_structure_paginated(
1167 paginated_files: &[FileInfo],
1168 total_files: usize,
1169 max_depth: Option<u32>,
1170 base_path: Option<&Path>,
1171 verbose: bool,
1172) -> String {
1173 let mut output = String::new();
1174
1175 let depth_label = match max_depth {
1176 Some(n) if n > 0 => format!(" (max_depth={})", n),
1177 _ => String::new(),
1178 };
1179 output.push_str(&format!(
1180 "PAGINATED: showing {} of {} files{}\n\n",
1181 paginated_files.len(),
1182 total_files,
1183 depth_label
1184 ));
1185
1186 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1187 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1188
1189 if !prod_files.is_empty() {
1190 if verbose {
1191 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1192 }
1193 for file in &prod_files {
1194 output.push_str(&format_file_entry(file, base_path));
1195 }
1196 }
1197
1198 if !test_files.is_empty() {
1199 if verbose {
1200 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1201 } else if !prod_files.is_empty() {
1202 output.push('\n');
1203 }
1204 for file in &test_files {
1205 output.push_str(&format_file_entry(file, base_path));
1206 }
1207 }
1208
1209 output
1210}
1211
1212#[instrument(skip_all)]
1217#[allow(clippy::too_many_arguments)]
1218pub fn format_file_details_paginated(
1219 functions_page: &[FunctionInfo],
1220 total_functions: usize,
1221 semantic: &SemanticAnalysis,
1222 path: &str,
1223 line_count: usize,
1224 offset: usize,
1225 verbose: bool,
1226) -> String {
1227 let mut output = String::new();
1228
1229 let start = offset + 1; let end = offset + functions_page.len();
1231
1232 output.push_str(&format!(
1233 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1234 path,
1235 line_count,
1236 start,
1237 end,
1238 total_functions,
1239 semantic.classes.len(),
1240 semantic.imports.len(),
1241 ));
1242
1243 if offset == 0 && !semantic.classes.is_empty() {
1245 output.push_str(&format_classes_section(
1246 &semantic.classes,
1247 &semantic.functions,
1248 ));
1249 }
1250
1251 if offset == 0 && verbose {
1253 output.push_str(&format_imports_section(&semantic.imports));
1254 }
1255
1256 let top_level_functions: Vec<&FunctionInfo> = functions_page
1258 .iter()
1259 .filter(|func| {
1260 !semantic
1261 .classes
1262 .iter()
1263 .any(|class| is_method_of_class(func, class))
1264 })
1265 .collect();
1266
1267 if !top_level_functions.is_empty() {
1268 output.push_str("F:\n");
1269 output.push_str(&format_function_list_wrapped(
1270 top_level_functions.iter().copied(),
1271 &semantic.call_frequency,
1272 ));
1273 }
1274
1275 output
1278}
1279
1280pub struct FocusedPaginatedParams<'a> {
1282 pub paginated_chains: &'a [CallChain],
1283 pub total: usize,
1284 pub mode: PaginationMode,
1285 pub symbol: &'a str,
1286 pub prod_chains: &'a [CallChain],
1287 pub test_chains: &'a [CallChain],
1288 pub outgoing_chains: &'a [CallChain],
1289 pub def_count: usize,
1290 pub offset: usize,
1291 pub base_path: Option<&'a Path>,
1292}
1293
1294#[instrument(skip_all)]
1299#[allow(clippy::too_many_arguments)]
1300pub fn format_focused_paginated(
1301 paginated_chains: &[CallChain],
1302 total: usize,
1303 mode: PaginationMode,
1304 symbol: &str,
1305 prod_chains: &[CallChain],
1306 test_chains: &[CallChain],
1307 outgoing_chains: &[CallChain],
1308 def_count: usize,
1309 offset: usize,
1310 base_path: Option<&Path>,
1311 _verbose: bool,
1312) -> String {
1313 let start = offset + 1; let end = offset + paginated_chains.len();
1315
1316 let callers_count = prod_chains
1317 .iter()
1318 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1319 .collect::<std::collections::HashSet<_>>()
1320 .len();
1321
1322 let callees_count = outgoing_chains
1323 .iter()
1324 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1325 .collect::<std::collections::HashSet<_>>()
1326 .len();
1327
1328 let mut output = String::new();
1329
1330 output.push_str(&format!(
1331 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1332 symbol, def_count, callers_count, callees_count
1333 ));
1334
1335 match mode {
1336 PaginationMode::Callers => {
1337 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1339
1340 let page_refs: Vec<_> = paginated_chains
1341 .iter()
1342 .filter_map(|chain| {
1343 if chain.chain.len() >= 2 {
1344 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1345 } else if chain.chain.len() == 1 {
1346 Some((chain.chain[0].0.as_str(), ""))
1347 } else {
1348 None
1349 }
1350 })
1351 .collect();
1352
1353 if page_refs.is_empty() {
1354 output.push_str(" (none)\n");
1355 } else {
1356 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1357 }
1358
1359 if !test_chains.is_empty() {
1361 let mut test_files: Vec<_> = test_chains
1362 .iter()
1363 .filter_map(|chain| {
1364 chain
1365 .chain
1366 .first()
1367 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1368 })
1369 .collect();
1370 test_files.sort();
1371 test_files.dedup();
1372
1373 let display_files: Vec<_> = test_files
1374 .iter()
1375 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1376 .collect();
1377
1378 output.push_str(&format!(
1379 "CALLERS (test): {} test functions (in {})\n",
1380 test_chains.len(),
1381 display_files.join(", ")
1382 ));
1383 }
1384
1385 let callee_names: Vec<_> = outgoing_chains
1387 .iter()
1388 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1389 .collect::<std::collections::HashSet<_>>()
1390 .into_iter()
1391 .collect();
1392 if callee_names.is_empty() {
1393 output.push_str("CALLEES: (none)\n");
1394 } else {
1395 output.push_str(&format!(
1396 "CALLEES: {} (use cursor for callee pagination)\n",
1397 callees_count
1398 ));
1399 }
1400 }
1401 PaginationMode::Callees => {
1402 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1404
1405 if !test_chains.is_empty() {
1407 output.push_str(&format!(
1408 "CALLERS (test): {} test functions\n",
1409 test_chains.len()
1410 ));
1411 }
1412
1413 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1415
1416 let page_refs: Vec<_> = paginated_chains
1417 .iter()
1418 .filter_map(|chain| {
1419 if chain.chain.len() >= 2 {
1420 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1421 } else if chain.chain.len() == 1 {
1422 Some((chain.chain[0].0.as_str(), ""))
1423 } else {
1424 None
1425 }
1426 })
1427 .collect();
1428
1429 if page_refs.is_empty() {
1430 output.push_str(" (none)\n");
1431 } else {
1432 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1433 }
1434 }
1435 PaginationMode::Default => {
1436 unreachable!("format_focused_paginated called with PaginationMode::Default")
1437 }
1438 }
1439
1440 output
1441}
1442
1443fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1444 let mut parts = Vec::new();
1445 if file.line_count > 0 {
1446 parts.push(format!("{}L", file.line_count));
1447 }
1448 if file.function_count > 0 {
1449 parts.push(format!("{}F", file.function_count));
1450 }
1451 if file.class_count > 0 {
1452 parts.push(format!("{}C", file.class_count));
1453 }
1454 let display_path = strip_base_path(Path::new(&file.path), base_path);
1455 if parts.is_empty() {
1456 format!("{}\n", display_path)
1457 } else {
1458 format!("{} [{}]\n", display_path, parts.join(", "))
1459 }
1460}
1461
1462#[instrument(skip_all)]
1476pub fn format_module_info(info: &ModuleInfo) -> String {
1477 use std::fmt::Write as _;
1478 let fn_count = info.functions.len();
1479 let import_count = info.imports.len();
1480 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1481 let _ = writeln!(
1482 out,
1483 "FILE: {} ({}L, {}F, {}I)",
1484 info.name, info.line_count, fn_count, import_count
1485 );
1486 if !info.functions.is_empty() {
1487 out.push_str("F:\n ");
1488 let parts: Vec<String> = info
1489 .functions
1490 .iter()
1491 .map(|f| format!("{}:{}", f.name, f.line))
1492 .collect();
1493 out.push_str(&parts.join(", "));
1494 out.push('\n');
1495 }
1496 if !info.imports.is_empty() {
1497 out.push_str("I:\n ");
1498 let parts: Vec<String> = info
1499 .imports
1500 .iter()
1501 .map(|i| {
1502 if i.items.is_empty() {
1503 i.module.clone()
1504 } else {
1505 format!("{}:{}", i.module, i.items.join(", "))
1506 }
1507 })
1508 .collect();
1509 out.push_str(&parts.join("; "));
1510 out.push('\n');
1511 }
1512 out
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517 use super::*;
1518
1519 #[test]
1520 fn test_strip_base_path_relative() {
1521 let path = Path::new("/home/user/project/src/main.rs");
1522 let base = Path::new("/home/user/project");
1523 let result = strip_base_path(path, Some(base));
1524 assert_eq!(result, "src/main.rs");
1525 }
1526
1527 #[test]
1528 fn test_strip_base_path_fallback_absolute() {
1529 let path = Path::new("/other/project/src/main.rs");
1530 let base = Path::new("/home/user/project");
1531 let result = strip_base_path(path, Some(base));
1532 assert_eq!(result, "/other/project/src/main.rs");
1533 }
1534
1535 #[test]
1536 fn test_strip_base_path_none() {
1537 let path = Path::new("/home/user/project/src/main.rs");
1538 let result = strip_base_path(path, None);
1539 assert_eq!(result, "/home/user/project/src/main.rs");
1540 }
1541
1542 #[test]
1543 fn test_format_file_details_summary_empty() {
1544 use crate::types::SemanticAnalysis;
1545 use std::collections::HashMap;
1546
1547 let semantic = SemanticAnalysis {
1548 functions: vec![],
1549 classes: vec![],
1550 imports: vec![],
1551 references: vec![],
1552 call_frequency: HashMap::new(),
1553 calls: vec![],
1554 assignments: vec![],
1555 field_accesses: vec![],
1556 };
1557
1558 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1559
1560 assert!(result.contains("FILE:"));
1562 assert!(result.contains("100L, 0F, 0C"));
1563 assert!(result.contains("src/main.rs"));
1564 assert!(result.contains("Imports: 0"));
1565 assert!(result.contains("SUGGESTION:"));
1566 }
1567
1568 #[test]
1569 fn test_format_file_details_summary_with_functions() {
1570 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1571 use std::collections::HashMap;
1572
1573 let semantic = SemanticAnalysis {
1574 functions: vec![
1575 FunctionInfo {
1576 name: "short".to_string(),
1577 line: 10,
1578 end_line: 12,
1579 parameters: vec![],
1580 return_type: None,
1581 },
1582 FunctionInfo {
1583 name: "long_function".to_string(),
1584 line: 20,
1585 end_line: 50,
1586 parameters: vec!["x".to_string(), "y".to_string()],
1587 return_type: Some("i32".to_string()),
1588 },
1589 ],
1590 classes: vec![ClassInfo {
1591 name: "MyClass".to_string(),
1592 line: 60,
1593 end_line: 80,
1594 methods: vec![],
1595 fields: vec![],
1596 inherits: vec![],
1597 }],
1598 imports: vec![],
1599 references: vec![],
1600 call_frequency: HashMap::new(),
1601 calls: vec![],
1602 assignments: vec![],
1603 field_accesses: vec![],
1604 };
1605
1606 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1607
1608 assert!(result.contains("FILE:"));
1610 assert!(result.contains("src/lib.rs"));
1611 assert!(result.contains("250L, 2F, 1C"));
1612
1613 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1615 let long_idx = result.find("long_function").unwrap_or(0);
1616 let short_idx = result.find("short").unwrap_or(0);
1617 assert!(
1618 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1619 "long_function should appear before short"
1620 );
1621
1622 assert!(result.contains("CLASSES:"));
1624 assert!(result.contains("MyClass:"));
1625
1626 assert!(result.contains("Imports: 0"));
1628 }
1629 #[test]
1630 fn test_format_file_info_parts_all_zero() {
1631 assert_eq!(format_file_info_parts(0, 0, 0), None);
1632 }
1633
1634 #[test]
1635 fn test_format_file_info_parts_partial() {
1636 assert_eq!(
1637 format_file_info_parts(42, 0, 3),
1638 Some("[42L, 3C]".to_string())
1639 );
1640 }
1641
1642 #[test]
1643 fn test_format_file_info_parts_all_nonzero() {
1644 assert_eq!(
1645 format_file_info_parts(100, 5, 2),
1646 Some("[100L, 5F, 2C]".to_string())
1647 );
1648 }
1649
1650 #[test]
1651 fn test_format_function_list_wrapped_empty() {
1652 let freq = std::collections::HashMap::new();
1653 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1654 assert_eq!(result, "");
1655 }
1656
1657 #[test]
1658 fn test_format_function_list_wrapped_bullet_annotation() {
1659 use crate::types::FunctionInfo;
1660 use std::collections::HashMap;
1661
1662 let mut freq = HashMap::new();
1663 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1666 name: "frequent".to_string(),
1667 line: 1,
1668 end_line: 10,
1669 parameters: vec![],
1670 return_type: Some("void".to_string()),
1671 }];
1672
1673 let result = format_function_list_wrapped(funcs.iter(), &freq);
1674 assert!(result.contains("\u{2022}5"));
1676 }
1677
1678 #[test]
1679 fn test_compact_format_omits_sections() {
1680 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1681 use std::collections::HashMap;
1682
1683 let funcs: Vec<FunctionInfo> = (0..10)
1684 .map(|i| FunctionInfo {
1685 name: format!("fn_{}", i),
1686 line: i * 5 + 1,
1687 end_line: i * 5 + 4,
1688 parameters: vec![format!("x: u32")],
1689 return_type: Some("bool".to_string()),
1690 })
1691 .collect();
1692 let imports: Vec<ImportInfo> = vec![ImportInfo {
1693 module: "std::collections".to_string(),
1694 items: vec!["HashMap".to_string()],
1695 line: 1,
1696 }];
1697 let classes: Vec<ClassInfo> = vec![ClassInfo {
1698 name: "MyStruct".to_string(),
1699 line: 100,
1700 end_line: 150,
1701 methods: vec![],
1702 fields: vec![],
1703 inherits: vec![],
1704 }];
1705 let semantic = SemanticAnalysis {
1706 functions: funcs,
1707 classes,
1708 imports,
1709 references: vec![],
1710 call_frequency: HashMap::new(),
1711 calls: vec![],
1712 assignments: vec![],
1713 field_accesses: vec![],
1714 };
1715
1716 let verbose_out = format_file_details_paginated(
1717 &semantic.functions,
1718 semantic.functions.len(),
1719 &semantic,
1720 "src/lib.rs",
1721 100,
1722 0,
1723 true,
1724 );
1725 let compact_out = format_file_details_paginated(
1726 &semantic.functions,
1727 semantic.functions.len(),
1728 &semantic,
1729 "src/lib.rs",
1730 100,
1731 0,
1732 false,
1733 );
1734
1735 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1737 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1738 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1739
1740 assert!(
1742 compact_out.contains("C:\n"),
1743 "compact must have C: section (restored)"
1744 );
1745 assert!(
1746 !compact_out.contains("I:\n"),
1747 "compact must not have I: section (imports omitted)"
1748 );
1749 assert!(
1750 compact_out.contains("F:\n"),
1751 "compact must have F: section with wrapped formatting"
1752 );
1753
1754 assert!(compact_out.contains("fn_0"), "compact must list functions");
1756 let has_two_on_same_line = compact_out
1757 .lines()
1758 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1759 assert!(
1760 has_two_on_same_line,
1761 "compact must render multiple functions per line (wrapped), not one-per-line"
1762 );
1763 }
1764
1765 #[test]
1767 fn test_compact_mode_consistent_token_reduction() {
1768 use crate::types::{FunctionInfo, SemanticAnalysis};
1769 use std::collections::HashMap;
1770
1771 let funcs: Vec<FunctionInfo> = (0..50)
1772 .map(|i| FunctionInfo {
1773 name: format!("function_name_{}", i),
1774 line: i * 10 + 1,
1775 end_line: i * 10 + 8,
1776 parameters: vec![
1777 "arg1: u32".to_string(),
1778 "arg2: String".to_string(),
1779 "arg3: Option<bool>".to_string(),
1780 ],
1781 return_type: Some("Result<Vec<String>, Error>".to_string()),
1782 })
1783 .collect();
1784
1785 let semantic = SemanticAnalysis {
1786 functions: funcs,
1787 classes: vec![],
1788 imports: vec![],
1789 references: vec![],
1790 call_frequency: HashMap::new(),
1791 calls: vec![],
1792 assignments: vec![],
1793 field_accesses: vec![],
1794 };
1795
1796 let verbose_out = format_file_details_paginated(
1797 &semantic.functions,
1798 semantic.functions.len(),
1799 &semantic,
1800 "src/large_file.rs",
1801 1000,
1802 0,
1803 true,
1804 );
1805 let compact_out = format_file_details_paginated(
1806 &semantic.functions,
1807 semantic.functions.len(),
1808 &semantic,
1809 "src/large_file.rs",
1810 1000,
1811 0,
1812 false,
1813 );
1814
1815 assert!(
1816 compact_out.len() <= verbose_out.len(),
1817 "compact ({} chars) must be <= verbose ({} chars)",
1818 compact_out.len(),
1819 verbose_out.len(),
1820 );
1821 }
1822
1823 #[test]
1825 fn test_format_module_info_happy_path() {
1826 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1827 let info = ModuleInfo {
1828 name: "parser.rs".to_string(),
1829 line_count: 312,
1830 language: "rust".to_string(),
1831 functions: vec![
1832 ModuleFunctionInfo {
1833 name: "parse_file".to_string(),
1834 line: 24,
1835 },
1836 ModuleFunctionInfo {
1837 name: "parse_block".to_string(),
1838 line: 58,
1839 },
1840 ],
1841 imports: vec![
1842 ModuleImportInfo {
1843 module: "crate::types".to_string(),
1844 items: vec!["Token".to_string(), "Expr".to_string()],
1845 },
1846 ModuleImportInfo {
1847 module: "std::io".to_string(),
1848 items: vec!["BufReader".to_string()],
1849 },
1850 ],
1851 };
1852 let result = format_module_info(&info);
1853 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1854 assert!(result.contains("F:"));
1855 assert!(result.contains("parse_file:24"));
1856 assert!(result.contains("parse_block:58"));
1857 assert!(result.contains("I:"));
1858 assert!(result.contains("crate::types:Token, Expr"));
1859 assert!(result.contains("std::io:BufReader"));
1860 assert!(result.contains("; "));
1861 assert!(!result.contains('{'));
1862 }
1863
1864 #[test]
1865 fn test_format_module_info_empty() {
1866 use crate::types::ModuleInfo;
1867 let info = ModuleInfo {
1868 name: "empty.rs".to_string(),
1869 line_count: 0,
1870 language: "rust".to_string(),
1871 functions: vec![],
1872 imports: vec![],
1873 };
1874 let result = format_module_info(&info);
1875 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1876 assert!(!result.contains("F:"));
1877 assert!(!result.contains("I:"));
1878 }
1879
1880 #[test]
1881 fn test_compact_mode_empty_classes_no_header() {
1882 use crate::types::{FunctionInfo, SemanticAnalysis};
1883 use std::collections::HashMap;
1884
1885 let funcs: Vec<FunctionInfo> = (0..5)
1886 .map(|i| FunctionInfo {
1887 name: format!("fn_{}", i),
1888 line: i * 5 + 1,
1889 end_line: i * 5 + 4,
1890 parameters: vec![],
1891 return_type: None,
1892 })
1893 .collect();
1894
1895 let semantic = SemanticAnalysis {
1896 functions: funcs,
1897 classes: vec![], imports: vec![],
1899 references: vec![],
1900 call_frequency: HashMap::new(),
1901 calls: vec![],
1902 assignments: vec![],
1903 field_accesses: vec![],
1904 };
1905
1906 let compact_out = format_file_details_paginated(
1907 &semantic.functions,
1908 semantic.functions.len(),
1909 &semantic,
1910 "src/simple.rs",
1911 100,
1912 0,
1913 false,
1914 );
1915
1916 assert!(
1918 !compact_out.contains("C:\n"),
1919 "compact mode must not emit C: header when classes are empty"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_format_classes_with_methods() {
1925 use crate::types::{ClassInfo, FunctionInfo};
1926
1927 let functions = vec![
1928 FunctionInfo {
1929 name: "method_a".to_string(),
1930 line: 5,
1931 end_line: 8,
1932 parameters: vec![],
1933 return_type: None,
1934 },
1935 FunctionInfo {
1936 name: "method_b".to_string(),
1937 line: 10,
1938 end_line: 12,
1939 parameters: vec![],
1940 return_type: None,
1941 },
1942 FunctionInfo {
1943 name: "top_level_func".to_string(),
1944 line: 50,
1945 end_line: 55,
1946 parameters: vec![],
1947 return_type: None,
1948 },
1949 ];
1950
1951 let classes = vec![ClassInfo {
1952 name: "MyClass".to_string(),
1953 line: 1,
1954 end_line: 30,
1955 methods: vec![],
1956 fields: vec![],
1957 inherits: vec![],
1958 }];
1959
1960 let output = format_classes_section(&classes, &functions);
1961
1962 assert!(
1963 output.contains("MyClass:1-30"),
1964 "class header should show start-end range"
1965 );
1966 assert!(output.contains("method_a:5"), "method_a should be listed");
1967 assert!(output.contains("method_b:10"), "method_b should be listed");
1968 assert!(
1969 !output.contains("top_level_func"),
1970 "top_level_func outside class range should not be listed"
1971 );
1972 }
1973
1974 #[test]
1975 fn test_format_classes_method_cap() {
1976 use crate::types::{ClassInfo, FunctionInfo};
1977
1978 let mut functions = Vec::new();
1979 for i in 0..15 {
1980 functions.push(FunctionInfo {
1981 name: format!("method_{}", i),
1982 line: 2 + i,
1983 end_line: 3 + i,
1984 parameters: vec![],
1985 return_type: None,
1986 });
1987 }
1988
1989 let classes = vec![ClassInfo {
1990 name: "LargeClass".to_string(),
1991 line: 1,
1992 end_line: 50,
1993 methods: vec![],
1994 fields: vec![],
1995 inherits: vec![],
1996 }];
1997
1998 let output = format_classes_section(&classes, &functions);
1999
2000 assert!(output.contains("method_0"), "first method should be listed");
2001 assert!(output.contains("method_9"), "10th method should be listed");
2002 assert!(
2003 !output.contains("method_10"),
2004 "11th method should not be listed (cap at 10)"
2005 );
2006 assert!(
2007 output.contains("... (5 more)"),
2008 "truncation message should show remaining count"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_format_classes_no_methods() {
2014 use crate::types::{ClassInfo, FunctionInfo};
2015
2016 let functions = vec![FunctionInfo {
2017 name: "top_level".to_string(),
2018 line: 100,
2019 end_line: 105,
2020 parameters: vec![],
2021 return_type: None,
2022 }];
2023
2024 let classes = vec![ClassInfo {
2025 name: "EmptyClass".to_string(),
2026 line: 1,
2027 end_line: 50,
2028 methods: vec![],
2029 fields: vec![],
2030 inherits: vec![],
2031 }];
2032
2033 let output = format_classes_section(&classes, &functions);
2034
2035 assert!(
2036 output.contains("EmptyClass:1-50"),
2037 "empty class header should appear"
2038 );
2039 assert!(
2040 !output.contains("top_level"),
2041 "top-level functions outside class should not appear"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_f_section_excludes_methods() {
2047 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2048 use std::collections::HashMap;
2049
2050 let functions = vec![
2051 FunctionInfo {
2052 name: "method_a".to_string(),
2053 line: 5,
2054 end_line: 10,
2055 parameters: vec![],
2056 return_type: None,
2057 },
2058 FunctionInfo {
2059 name: "top_level".to_string(),
2060 line: 50,
2061 end_line: 55,
2062 parameters: vec![],
2063 return_type: None,
2064 },
2065 ];
2066
2067 let semantic = SemanticAnalysis {
2068 functions,
2069 classes: vec![ClassInfo {
2070 name: "TestClass".to_string(),
2071 line: 1,
2072 end_line: 30,
2073 methods: vec![],
2074 fields: vec![],
2075 inherits: vec![],
2076 }],
2077 imports: vec![],
2078 references: vec![],
2079 call_frequency: HashMap::new(),
2080 calls: vec![],
2081 assignments: vec![],
2082 field_accesses: vec![],
2083 };
2084
2085 let output = format_file_details("test.rs", &semantic, 100, false, None);
2086
2087 assert!(output.contains("C:"), "classes section should exist");
2088 assert!(
2089 output.contains("method_a:5"),
2090 "method should be in C: section"
2091 );
2092 assert!(output.contains("F:"), "F: section should exist");
2093 assert!(
2094 output.contains("top_level"),
2095 "top-level function should be in F: section"
2096 );
2097
2098 let f_pos = output.find("F:").unwrap();
2100 let method_pos = output.find("method_a").unwrap();
2101 assert!(
2102 method_pos < f_pos,
2103 "method_a should appear before F: section"
2104 );
2105 }
2106
2107 #[test]
2108 fn test_related_section_with_data() {
2109 use crate::graph::ImportGraph;
2110 use std::collections::HashMap;
2111 use std::path::PathBuf;
2112
2113 let none_output = format_related_section(Path::new("test.rs"), None);
2115 assert!(
2116 !none_output.contains("RELATED:"),
2117 "RELATED: section should not appear when import_graph is None"
2118 );
2119
2120 let path = PathBuf::from("src/main.rs");
2121 let mut incoming = HashMap::new();
2122 incoming.insert(
2123 path.clone(),
2124 vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/utils.rs")],
2125 );
2126
2127 let mut outgoing = HashMap::new();
2128 outgoing.insert(path.clone(), vec![PathBuf::from("src/config.rs")]);
2129
2130 let graph = ImportGraph { incoming, outgoing };
2131
2132 let output = format_related_section(Path::new("src/main.rs"), Some(&graph));
2133
2134 assert!(
2135 output.contains("RELATED:"),
2136 "RELATED: section should appear"
2137 );
2138 assert!(output.contains("<-"), "incoming arrow should appear");
2139 assert!(output.contains("->"), "outgoing arrow should appear");
2140 assert!(output.contains("lib.rs"), "incoming file should be listed");
2141 assert!(
2142 output.contains("config.rs"),
2143 "outgoing file should be listed"
2144 );
2145 }
2146
2147 #[test]
2148 fn test_related_section_cap() {
2149 use crate::graph::ImportGraph;
2150 use std::collections::HashMap;
2151 use std::path::PathBuf;
2152
2153 let path = PathBuf::from("src/core.rs");
2154 let mut incoming = HashMap::new();
2155 incoming.insert(
2156 path.clone(),
2157 vec![
2158 PathBuf::from("src/a.rs"),
2159 PathBuf::from("src/b.rs"),
2160 PathBuf::from("src/c.rs"),
2161 PathBuf::from("src/d.rs"),
2162 PathBuf::from("src/e.rs"),
2163 PathBuf::from("src/f.rs"), ],
2165 );
2166
2167 let graph = ImportGraph {
2168 incoming,
2169 outgoing: HashMap::new(),
2170 };
2171
2172 let output = format_related_section(Path::new("src/core.rs"), Some(&graph));
2173
2174 assert!(output.contains("a.rs"), "first file should be listed");
2175 assert!(output.contains("e.rs"), "5th file should be listed");
2176 assert!(
2177 !output.contains("f.rs"),
2178 "6th file should not be listed (capped at 5)"
2179 );
2180 }
2181}
2182
2183fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2184 let mut output = String::new();
2185 if classes.is_empty() {
2186 return output;
2187 }
2188 output.push_str("C:\n");
2189
2190 let methods_by_class = collect_class_methods(classes, functions);
2191 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2192
2193 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2194 let class_strs: Vec<String> = classes
2195 .iter()
2196 .map(|class| {
2197 if class.inherits.is_empty() {
2198 format!("{}:{}-{}", class.name, class.line, class.end_line)
2199 } else {
2200 format!(
2201 "{}:{}-{} ({})",
2202 class.name,
2203 class.line,
2204 class.end_line,
2205 class.inherits.join(", ")
2206 )
2207 }
2208 })
2209 .collect();
2210 output.push_str(" ");
2211 output.push_str(&class_strs.join("; "));
2212 output.push('\n');
2213 } else {
2214 for class in classes {
2215 if class.inherits.is_empty() {
2216 output.push_str(&format!(
2217 " {}:{}-{}\n",
2218 class.name, class.line, class.end_line
2219 ));
2220 } else {
2221 output.push_str(&format!(
2222 " {}:{}-{} ({})\n",
2223 class.name,
2224 class.line,
2225 class.end_line,
2226 class.inherits.join(", ")
2227 ));
2228 }
2229
2230 if let Some(methods) = methods_by_class.get(&class.name)
2232 && !methods.is_empty()
2233 {
2234 for (i, method) in methods.iter().take(10).enumerate() {
2235 output.push_str(&format!(" {}:{}\n", method.name, method.line));
2236 if i + 1 == 10 && methods.len() > 10 {
2237 output.push_str(&format!(" ... ({} more)\n", methods.len() - 10));
2238 break;
2239 }
2240 }
2241 }
2242 }
2243 }
2244 output
2245}
2246
2247pub fn format_related_section(path: &Path, import_graph: Option<&ImportGraph>) -> String {
2250 let Some(graph) = import_graph else {
2251 return String::new();
2252 };
2253
2254 let mut output = String::new();
2255
2256 if let Some(inbound) = graph.incoming.get(path)
2258 && !inbound.is_empty()
2259 {
2260 output.push_str("RELATED:\n");
2261 output.push_str(" <- ");
2262 let file_names: Vec<String> = inbound
2263 .iter()
2264 .take(5)
2265 .filter_map(|p| p.file_name())
2266 .filter_map(|n| n.to_str())
2267 .map(|s| s.to_string())
2268 .collect();
2269 output.push_str(&file_names.join(", "));
2270 output.push('\n');
2271 }
2272
2273 if let Some(outbound) = graph.outgoing.get(path)
2275 && !outbound.is_empty()
2276 {
2277 if output.is_empty() {
2278 output.push_str("RELATED:\n");
2279 }
2280 output.push_str(" -> ");
2281 let file_names: Vec<String> = outbound
2282 .iter()
2283 .take(5)
2284 .filter_map(|p| p.file_name())
2285 .filter_map(|n| n.to_str())
2286 .map(|s| s.to_string())
2287 .collect();
2288 output.push_str(&file_names.join(", "));
2289 output.push('\n');
2290 }
2291
2292 output
2293}
2294
2295fn format_imports_section(imports: &[ImportInfo]) -> String {
2296 let mut output = String::new();
2297 if imports.is_empty() {
2298 return output;
2299 }
2300 output.push_str("I:\n");
2301 let mut module_map: HashMap<String, usize> = HashMap::new();
2302 for import in imports {
2303 module_map
2304 .entry(import.module.clone())
2305 .and_modify(|count| *count += 1)
2306 .or_insert(1);
2307 }
2308 let mut modules: Vec<_> = module_map.keys().cloned().collect();
2309 modules.sort();
2310 let formatted_modules: Vec<String> = modules
2311 .iter()
2312 .map(|module| format!("{}({})", module, module_map[module]))
2313 .collect();
2314 if formatted_modules.len() <= MULTILINE_THRESHOLD {
2315 output.push_str(" ");
2316 output.push_str(&formatted_modules.join("; "));
2317 output.push('\n');
2318 } else {
2319 for module_str in formatted_modules {
2320 output.push_str(" ");
2321 output.push_str(&module_str);
2322 output.push('\n');
2323 }
2324 }
2325 output
2326}