1use crate::dataflow::DataflowGraph;
2use crate::graph::CallChain;
3use crate::graph::CallGraph;
4use crate::pagination::PaginationMode;
5use crate::test_detection::is_test_file;
6use crate::traversal::WalkEntry;
7use crate::types::{ClassInfo, FileInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
8use std::collections::{HashMap, HashSet};
9use std::fmt::Write;
10use std::path::Path;
11use thiserror::Error;
12use tracing::instrument;
13
14const MULTILINE_THRESHOLD: usize = 10;
15
16fn strip_base_path(path_str: &str, base_path: Option<&Path>) -> String {
18 match base_path {
19 Some(base) => {
20 if let Ok(rel_path) = Path::new(path_str).strip_prefix(base) {
21 rel_path.display().to_string()
22 } else {
23 path_str.to_string()
24 }
25 }
26 None => path_str.to_string(),
27 }
28}
29
30fn strip_base_path_buf(path: &Path, base_path: Option<&Path>) -> String {
32 match base_path {
33 Some(base) => {
34 if let Ok(rel_path) = path.strip_prefix(base) {
35 rel_path.display().to_string()
36 } else {
37 path.display().to_string()
38 }
39 }
40 None => path.display().to_string(),
41 }
42}
43
44#[derive(Debug, Error)]
45pub enum FormatterError {
46 #[error("Graph error: {0}")]
47 GraphError(#[from] crate::graph::GraphError),
48}
49
50#[instrument(skip_all)]
52pub fn format_structure(
53 entries: &[WalkEntry],
54 analysis_results: &[FileInfo],
55 max_depth: Option<u32>,
56 _base_path: Option<&Path>,
57) -> String {
58 let mut output = String::new();
59
60 let analysis_map: HashMap<String, &FileInfo> = analysis_results
62 .iter()
63 .map(|a| (a.path.clone(), a))
64 .collect();
65
66 let (prod_files, test_files): (Vec<_>, Vec<_>) =
68 analysis_results.iter().partition(|a| !a.is_test);
69
70 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
72 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
73 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
74
75 let mut lang_counts: HashMap<String, usize> = HashMap::new();
77 for analysis in analysis_results {
78 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
79 }
80 let total_files = analysis_results.len();
81
82 let primary_lang = lang_counts
84 .iter()
85 .max_by_key(|&(_, count)| count)
86 .map(|(name, count)| {
87 let percentage = if total_files > 0 {
88 (*count * 100) / total_files
89 } else {
90 0
91 };
92 format!("{} {}%", name, percentage)
93 })
94 .unwrap_or_else(|| "unknown 0%".to_string());
95
96 output.push_str(&format!(
97 "{} files, {}L, {}F, {}C ({})\n",
98 total_files, total_loc, total_functions, total_classes, primary_lang
99 ));
100
101 output.push_str("SUMMARY:\n");
103 let depth_label = match max_depth {
104 Some(n) if n > 0 => format!(" (max_depth={})", n),
105 _ => String::new(),
106 };
107 output.push_str(&format!(
108 "Shown: {} files ({} prod, {} test), {}L, {}F, {}C{}\n",
109 total_files,
110 prod_files.len(),
111 test_files.len(),
112 total_loc,
113 total_functions,
114 total_classes,
115 depth_label
116 ));
117
118 if !lang_counts.is_empty() {
119 output.push_str("Languages: ");
120 let mut langs: Vec<_> = lang_counts.iter().collect();
121 langs.sort_by_key(|&(name, _)| name);
122 let lang_strs: Vec<String> = langs
123 .iter()
124 .map(|(name, count)| {
125 let percentage = if total_files > 0 {
126 (**count * 100) / total_files
127 } else {
128 0
129 };
130 format!("{} ({}%)", name, percentage)
131 })
132 .collect();
133 output.push_str(&lang_strs.join(", "));
134 output.push('\n');
135 }
136
137 output.push('\n');
138
139 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
141
142 for entry in entries {
143 if entry.depth == 0 {
145 continue;
146 }
147
148 let indent = " ".repeat(entry.depth - 1);
150
151 let name = entry
153 .path
154 .file_name()
155 .and_then(|n| n.to_str())
156 .unwrap_or("?");
157
158 if !entry.is_dir {
160 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
161 if analysis.is_test {
163 continue;
164 }
165
166 let mut info_parts = Vec::new();
167
168 if analysis.line_count > 0 {
169 info_parts.push(format!("{}L", analysis.line_count));
170 }
171 if analysis.function_count > 0 {
172 info_parts.push(format!("{}F", analysis.function_count));
173 }
174 if analysis.class_count > 0 {
175 info_parts.push(format!("{}C", analysis.class_count));
176 }
177
178 if info_parts.is_empty() {
179 output.push_str(&format!("{}{}\n", indent, name));
180 } else {
181 output.push_str(&format!("{}{} [{}]\n", indent, name, info_parts.join(", ")));
182 }
183 }
184 } else {
186 output.push_str(&format!("{}{}/\n", indent, name));
187 }
188 }
189
190 if !test_files.is_empty() {
192 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
193
194 for entry in entries {
195 if entry.depth == 0 {
197 continue;
198 }
199
200 let indent = " ".repeat(entry.depth - 1);
202
203 let name = entry
205 .path
206 .file_name()
207 .and_then(|n| n.to_str())
208 .unwrap_or("?");
209
210 if !entry.is_dir
212 && let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
213 {
214 if !analysis.is_test {
216 continue;
217 }
218
219 let mut info_parts = Vec::new();
220
221 if analysis.line_count > 0 {
222 info_parts.push(format!("{}L", analysis.line_count));
223 }
224 if analysis.function_count > 0 {
225 info_parts.push(format!("{}F", analysis.function_count));
226 }
227 if analysis.class_count > 0 {
228 info_parts.push(format!("{}C", analysis.class_count));
229 }
230
231 if info_parts.is_empty() {
232 output.push_str(&format!("{}{}\n", indent, name));
233 } else {
234 output.push_str(&format!("{}{} [{}]\n", indent, name, info_parts.join(", ")));
235 }
236 }
237 }
238 }
239
240 output
241}
242
243#[instrument(skip_all)]
245pub fn format_file_details(
246 path: &str,
247 analysis: &SemanticAnalysis,
248 line_count: usize,
249 is_test: bool,
250 base_path: Option<&Path>,
251) -> String {
252 let mut output = String::new();
253
254 let display_path = strip_base_path(path, base_path);
256 if is_test {
257 output.push_str(&format!(
258 "FILE [TEST] {}({}L, {}F, {}C, {}I)\n",
259 display_path,
260 line_count,
261 analysis.functions.len(),
262 analysis.classes.len(),
263 analysis.imports.len()
264 ));
265 } else {
266 output.push_str(&format!(
267 "FILE: {}({}L, {}F, {}C, {}I)\n",
268 display_path,
269 line_count,
270 analysis.functions.len(),
271 analysis.classes.len(),
272 analysis.imports.len()
273 ));
274 }
275
276 output.push_str(&format_classes_section(&analysis.classes));
278
279 if !analysis.functions.is_empty() {
281 output.push_str("F:\n");
282 let mut line = String::from(" ");
283 for (i, func) in analysis.functions.iter().enumerate() {
284 let mut call_marker = func.compact_signature();
285
286 if let Some(&count) = analysis.call_frequency.get(&func.name)
287 && count > 3
288 {
289 write!(call_marker, "\u{2022}{}", count).ok();
290 }
291
292 if i == 0 {
293 line.push_str(&call_marker);
294 } else if line.len() + call_marker.len() + 2 > 100 {
295 output.push_str(&line);
296 output.push('\n');
297 let mut new_line = String::with_capacity(2 + call_marker.len());
298 new_line.push_str(" ");
299 new_line.push_str(&call_marker);
300 line = new_line;
301 } else {
302 line.push_str(", ");
303 line.push_str(&call_marker);
304 }
305 }
306 if !line.trim().is_empty() {
307 output.push_str(&line);
308 output.push('\n');
309 }
310 }
311
312 output.push_str(&format_imports_section(&analysis.imports));
314
315 output
316}
317
318fn format_chains_as_tree(chains: &[(&str, &str)], arrow: &str, focus_symbol: &str) -> String {
328 use std::collections::BTreeMap;
329
330 if chains.is_empty() {
331 return " (none)\n".to_string();
332 }
333
334 let mut output = String::new();
335
336 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
338 for (parent, child) in chains {
339 if !child.is_empty() {
341 *groups
342 .entry(parent.to_string())
343 .or_default()
344 .entry(child.to_string())
345 .or_insert(0) += 1;
346 } else {
347 groups.entry(parent.to_string()).or_default();
349 }
350 }
351
352 for (parent, children) in groups {
354 let _ = writeln!(output, " {} {} {}", focus_symbol, arrow, parent);
355 let mut sorted: Vec<_> = children.into_iter().collect();
357 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
358 for (child, count) in sorted {
359 if count > 1 {
360 let _ = writeln!(output, " {} {} (x{})", arrow, child, count);
361 } else {
362 let _ = writeln!(output, " {} {}", arrow, child);
363 }
364 }
365 }
366
367 output
368}
369
370#[instrument(skip_all)]
372pub fn format_focused(
373 graph: &CallGraph,
374 dataflow: &DataflowGraph,
375 symbol: &str,
376 follow_depth: u32,
377 base_path: Option<&Path>,
378) -> Result<String, FormatterError> {
379 let mut output = String::new();
380
381 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
383 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
384 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
385
386 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
388 incoming_chains.clone().into_iter().partition(|chain| {
389 chain
390 .chain
391 .first()
392 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
393 });
394
395 let callers_count = prod_chains
397 .iter()
398 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
399 .collect::<std::collections::HashSet<_>>()
400 .len();
401
402 let callees_count = outgoing_chains
404 .iter()
405 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
406 .collect::<std::collections::HashSet<_>>()
407 .len();
408
409 output.push_str(&format!(
411 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
412 symbol, def_count, callers_count, callees_count
413 ));
414
415 output.push_str(&format!("DEPTH: {}\n", follow_depth));
417
418 if let Some(definitions) = graph.definitions.get(symbol) {
420 output.push_str("DEFINED:\n");
421 for (path, line) in definitions {
422 output.push_str(&format!(
423 " {}:{}\n",
424 strip_base_path_buf(path, base_path),
425 line
426 ));
427 }
428 } else {
429 output.push_str("DEFINED: (not found)\n");
430 }
431
432 output.push_str("CALLERS:\n");
434
435 let prod_refs: Vec<_> = prod_chains
437 .iter()
438 .filter_map(|chain| {
439 if chain.chain.len() >= 2 {
440 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
441 } else if chain.chain.len() == 1 {
442 Some((chain.chain[0].0.as_str(), ""))
443 } else {
444 None
445 }
446 })
447 .collect();
448
449 if prod_refs.is_empty() {
450 output.push_str(" (none)\n");
451 } else {
452 output.push_str(&format_chains_as_tree(&prod_refs, "<-", symbol));
453 }
454
455 if !test_chains.is_empty() {
457 let mut test_files: Vec<_> = test_chains
458 .iter()
459 .filter_map(|chain| {
460 chain
461 .chain
462 .first()
463 .map(|(_, path, _)| path.to_string_lossy().into_owned())
464 })
465 .collect();
466 test_files.sort();
467 test_files.dedup();
468
469 let display_files: Vec<_> = test_files
471 .iter()
472 .map(|f| strip_base_path_buf(Path::new(f), base_path))
473 .collect();
474
475 let file_list = display_files.join(", ");
476 output.push_str(&format!(
477 "CALLERS (test): {} test functions (in {})\n",
478 test_chains.len(),
479 file_list
480 ));
481 }
482
483 output.push_str("CALLEES:\n");
485 let outgoing_refs: Vec<_> = outgoing_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 outgoing_refs.is_empty() {
499 output.push_str(" (none)\n");
500 } else {
501 output.push_str(&format_chains_as_tree(&outgoing_refs, "->", symbol));
502 }
503
504 output.push_str("STATISTICS:\n");
506 let incoming_count = prod_refs
507 .iter()
508 .map(|(p, _)| p)
509 .collect::<std::collections::HashSet<_>>()
510 .len();
511 let outgoing_count = outgoing_refs
512 .iter()
513 .map(|(p, _)| p)
514 .collect::<std::collections::HashSet<_>>()
515 .len();
516 output.push_str(&format!(" Incoming calls: {}\n", incoming_count));
517 output.push_str(&format!(" Outgoing calls: {}\n", outgoing_count));
518
519 let mut files = HashSet::new();
521 for chain in &prod_chains {
522 for (_, path, _) in &chain.chain {
523 files.insert(path.clone());
524 }
525 }
526 for chain in &outgoing_chains {
527 for (_, path, _) in &chain.chain {
528 files.insert(path.clone());
529 }
530 }
531 if let Some(definitions) = graph.definitions.get(symbol) {
532 for (path, _) in definitions {
533 files.insert(path.clone());
534 }
535 }
536
537 let (prod_files, test_files): (Vec<_>, Vec<_>) =
539 files.into_iter().partition(|path| !is_test_file(path));
540
541 output.push_str("FILES:\n");
542 if prod_files.is_empty() && test_files.is_empty() {
543 output.push_str(" (none)\n");
544 } else {
545 if !prod_files.is_empty() {
547 let mut sorted_files = prod_files;
548 sorted_files.sort();
549 for file in sorted_files {
550 output.push_str(&format!(" {}\n", strip_base_path_buf(&file, base_path)));
551 }
552 }
553
554 if !test_files.is_empty() {
556 output.push_str(" TEST FILES:\n");
557 let mut sorted_files = test_files;
558 sorted_files.sort();
559 for file in sorted_files {
560 output.push_str(&format!(" {}\n", strip_base_path_buf(&file, base_path)));
561 }
562 }
563 }
564
565 output.push_str("DATAFLOW:\n");
567 let assignments = dataflow.find_assignments(symbol);
568 if assignments.is_empty() {
569 output.push_str(" ASSIGNMENTS: (none)\n");
570 } else {
571 output.push_str(" ASSIGNMENTS:\n");
572 for (file, line, scope) in &assignments {
573 output.push_str(&format!(
574 " {} = ... (scope: {}) {}:{}\n",
575 symbol,
576 scope,
577 strip_base_path_buf(file, base_path),
578 line
579 ));
580 }
581 }
582
583 let field_accesses = dataflow.find_field_accesses(symbol);
584 if field_accesses.is_empty() {
585 output.push_str(" FIELD_ACCESSES: (none)\n");
586 } else {
587 output.push_str(" FIELD_ACCESSES:\n");
588 for (file, line, scope) in &field_accesses {
589 output.push_str(&format!(
590 " {}.* (scope: {}) {}:{}\n",
591 symbol,
592 scope,
593 strip_base_path_buf(file, base_path),
594 line
595 ));
596 }
597 }
598
599 Ok(output)
600}
601
602#[instrument(skip_all)]
605pub fn format_focused_summary(
606 graph: &CallGraph,
607 dataflow: &DataflowGraph,
608 symbol: &str,
609 follow_depth: u32,
610 base_path: Option<&Path>,
611) -> Result<String, FormatterError> {
612 let mut output = String::new();
613
614 let def_count = graph.definitions.get(symbol).map_or(0, |d| d.len());
616 let incoming_chains = graph.find_incoming_chains(symbol, follow_depth)?;
617 let outgoing_chains = graph.find_outgoing_chains(symbol, follow_depth)?;
618
619 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
621 incoming_chains.into_iter().partition(|chain| {
622 chain
623 .chain
624 .first()
625 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
626 });
627
628 let callers_count = prod_chains
630 .iter()
631 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
632 .collect::<std::collections::HashSet<_>>()
633 .len();
634
635 let callees_count = outgoing_chains
637 .iter()
638 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
639 .collect::<std::collections::HashSet<_>>()
640 .len();
641
642 output.push_str(&format!(
644 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
645 symbol, def_count, callers_count, callees_count
646 ));
647
648 output.push_str(&format!("DEPTH: {}\n", follow_depth));
650
651 if let Some(definitions) = graph.definitions.get(symbol) {
653 output.push_str("DEFINED:\n");
654 for (path, line) in definitions {
655 output.push_str(&format!(
656 " {}:{}\n",
657 strip_base_path_buf(path, base_path),
658 line
659 ));
660 }
661 } else {
662 output.push_str("DEFINED: (not found)\n");
663 }
664
665 output.push_str("CALLERS (top 10):\n");
667 if prod_chains.is_empty() {
668 output.push_str(" (none)\n");
669 } else {
670 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
672 std::collections::HashMap::new();
673 for chain in &prod_chains {
674 if let Some((name, path, _)) = chain.chain.first() {
675 let file_path = strip_base_path_buf(path, base_path);
676 caller_freq
677 .entry(name.clone())
678 .and_modify(|(count, _)| *count += 1)
679 .or_insert((1, file_path));
680 }
681 }
682
683 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
685 sorted_callers.sort_by(|a, b| b.1.0.cmp(&a.1.0));
686
687 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
688 output.push_str(&format!(" {} {}\n", name, file_path));
689 }
690 }
691
692 if !test_chains.is_empty() {
694 let mut test_files: Vec<_> = test_chains
695 .iter()
696 .filter_map(|chain| {
697 chain
698 .chain
699 .first()
700 .map(|(_, path, _)| path.to_string_lossy().into_owned())
701 })
702 .collect();
703 test_files.sort();
704 test_files.dedup();
705
706 output.push_str(&format!(
707 "CALLERS (test): {} test functions (in {} files)\n",
708 test_chains.len(),
709 test_files.len()
710 ));
711 }
712
713 output.push_str("CALLEES (top 10):\n");
715 if outgoing_chains.is_empty() {
716 output.push_str(" (none)\n");
717 } else {
718 let mut callee_freq: std::collections::HashMap<String, usize> =
720 std::collections::HashMap::new();
721 for chain in &outgoing_chains {
722 if let Some((name, _, _)) = chain.chain.first() {
723 *callee_freq.entry(name.clone()).or_insert(0) += 1;
724 }
725 }
726
727 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
729 sorted_callees.sort_by(|a, b| b.1.cmp(&a.1));
730
731 for (name, _) in sorted_callees.into_iter().take(10) {
732 output.push_str(&format!(" {}\n", name));
733 }
734 }
735
736 output.push_str("DATAFLOW: ");
738 let assignments = dataflow.find_assignments(symbol);
739 let field_accesses = dataflow.find_field_accesses(symbol);
740 output.push_str(&format!(
741 "{} assignments, {} field accesses\n",
742 assignments.len(),
743 field_accesses.len()
744 ));
745
746 output.push_str("SUGGESTION:\n");
748 output.push_str("Use summary=false with force=true for full output\n");
749
750 Ok(output)
751}
752
753#[instrument(skip_all)]
756pub fn format_summary(
757 entries: &[WalkEntry],
758 analysis_results: &[FileInfo],
759 max_depth: Option<u32>,
760 _base_path: Option<&Path>,
761) -> String {
762 let mut output = String::new();
763
764 let (prod_files, test_files): (Vec<_>, Vec<_>) =
766 analysis_results.iter().partition(|a| !a.is_test);
767
768 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
770 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
771 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
772
773 let mut lang_counts: HashMap<String, usize> = HashMap::new();
775 for analysis in analysis_results {
776 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
777 }
778 let total_files = analysis_results.len();
779
780 let primary_lang = lang_counts
782 .iter()
783 .max_by_key(|&(_, count)| count)
784 .map(|(name, count)| {
785 let percentage = if total_files > 0 {
786 (*count * 100) / total_files
787 } else {
788 0
789 };
790 format!("{} {}%", name, percentage)
791 })
792 .unwrap_or_else(|| "unknown 0%".to_string());
793
794 output.push_str(&format!(
795 "{} files, {}L, {}F, {}C ({})\n",
796 total_files, total_loc, total_functions, total_classes, primary_lang
797 ));
798
799 output.push_str("SUMMARY:\n");
801 let depth_label = match max_depth {
802 Some(n) if n > 0 => format!(" (max_depth={})", n),
803 _ => String::new(),
804 };
805 output.push_str(&format!(
806 "{} files ({} prod, {} test), {}L, {}F, {}C{}\n",
807 total_files,
808 prod_files.len(),
809 test_files.len(),
810 total_loc,
811 total_functions,
812 total_classes,
813 depth_label
814 ));
815
816 if !lang_counts.is_empty() {
817 output.push_str("Languages: ");
818 let mut langs: Vec<_> = lang_counts.iter().collect();
819 langs.sort_by_key(|&(name, _)| name);
820 let lang_strs: Vec<String> = langs
821 .iter()
822 .map(|(name, count)| {
823 let percentage = if total_files > 0 {
824 (**count * 100) / total_files
825 } else {
826 0
827 };
828 format!("{} ({}%)", name, percentage)
829 })
830 .collect();
831 output.push_str(&lang_strs.join(", "));
832 output.push('\n');
833 }
834
835 output.push('\n');
836
837 output.push_str("STRUCTURE (depth 1):\n");
839
840 let analysis_map: HashMap<String, &FileInfo> = analysis_results
842 .iter()
843 .map(|a| (a.path.clone(), a))
844 .collect();
845
846 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
848 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
849
850 for entry in depth1_entries {
851 let name = entry
852 .path
853 .file_name()
854 .and_then(|n| n.to_str())
855 .unwrap_or("?");
856
857 if entry.is_dir {
858 let dir_path_str = entry.path.display().to_string();
860 let files_in_dir: Vec<&FileInfo> = analysis_results
861 .iter()
862 .filter(|f| f.path.starts_with(&dir_path_str))
863 .collect();
864
865 if !files_in_dir.is_empty() {
866 let dir_file_count = files_in_dir.len();
867 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
868 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
869 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
870
871 output.push_str(&format!(
872 " {}/ [{} files, {}L, {}F, {}C]\n",
873 name, dir_file_count, dir_loc, dir_functions, dir_classes
874 ));
875 } else {
876 output.push_str(&format!(" {}/\n", name));
877 }
878 } else {
879 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
881 let mut info_parts = Vec::new();
882
883 if analysis.line_count > 0 {
884 info_parts.push(format!("{}L", analysis.line_count));
885 }
886 if analysis.function_count > 0 {
887 info_parts.push(format!("{}F", analysis.function_count));
888 }
889 if analysis.class_count > 0 {
890 info_parts.push(format!("{}C", analysis.class_count));
891 }
892
893 if info_parts.is_empty() {
894 output.push_str(&format!(" {}\n", name));
895 } else {
896 output.push_str(&format!(" {} [{}]\n", name, info_parts.join(", ")));
897 }
898 }
899 }
900 }
901
902 output.push('\n');
903
904 output.push_str("SUGGESTION:\n");
906 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
907
908 output
909}
910
911#[instrument(skip_all)]
916pub fn format_file_details_summary(
917 semantic: &SemanticAnalysis,
918 path: &str,
919 line_count: usize,
920) -> String {
921 let mut output = String::new();
922
923 output.push_str("FILE:\n");
925 output.push_str(&format!(" path: {}\n", path));
926 output.push_str(&format!(
927 " {}L, {}F, {}C\n",
928 line_count,
929 semantic.functions.len(),
930 semantic.classes.len()
931 ));
932 output.push('\n');
933
934 if !semantic.functions.is_empty() {
936 output.push_str("TOP FUNCTIONS BY SIZE:\n");
937 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
938 let k = funcs.len().min(10);
939 if k > 0 {
940 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
941 let a_span = a.end_line.saturating_sub(a.line);
942 let b_span = b.end_line.saturating_sub(b.line);
943 b_span.cmp(&a_span)
944 });
945 funcs[..k].sort_by(|a, b| {
946 let a_span = a.end_line.saturating_sub(a.line);
947 let b_span = b.end_line.saturating_sub(b.line);
948 b_span.cmp(&a_span)
949 });
950 }
951
952 for func in &funcs[..k] {
953 let span = func.end_line.saturating_sub(func.line);
954 let params = if func.parameters.is_empty() {
955 String::new()
956 } else {
957 format!("({})", func.parameters.join(", "))
958 };
959 output.push_str(&format!(
960 " {}:{}: {} {} [{}L]\n",
961 func.line, func.end_line, func.name, params, span
962 ));
963 }
964 output.push('\n');
965 }
966
967 if !semantic.classes.is_empty() {
969 output.push_str("CLASSES:\n");
970 if semantic.classes.len() <= 10 {
971 for class in &semantic.classes {
973 let methods_count = class.methods.len();
974 output.push_str(&format!(" {}: {}M\n", class.name, methods_count));
975 }
976 } else {
977 output.push_str(&format!(" {} classes total\n", semantic.classes.len()));
979 for class in semantic.classes.iter().take(5) {
980 output.push_str(&format!(" {}\n", class.name));
981 }
982 if semantic.classes.len() > 5 {
983 output.push_str(&format!(
984 " ... and {} more\n",
985 semantic.classes.len() - 5
986 ));
987 }
988 }
989 output.push('\n');
990 }
991
992 output.push_str(&format!("Imports: {}\n", semantic.imports.len()));
994 output.push('\n');
995
996 output.push_str("SUGGESTION:\n");
998 output.push_str("Use force=true for full output, or narrow your scope\n");
999
1000 output
1001}
1002
1003#[instrument(skip_all)]
1005pub fn format_structure_paginated(
1006 paginated_files: &[FileInfo],
1007 total_files: usize,
1008 max_depth: Option<u32>,
1009 base_path: Option<&Path>,
1010) -> String {
1011 let mut output = String::new();
1012
1013 let depth_label = match max_depth {
1014 Some(n) if n > 0 => format!(" (max_depth={})", n),
1015 _ => String::new(),
1016 };
1017 output.push_str(&format!(
1018 "PAGINATED: showing {} of {} files{}\n\n",
1019 paginated_files.len(),
1020 total_files,
1021 depth_label
1022 ));
1023
1024 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1025 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1026
1027 if !prod_files.is_empty() {
1028 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1029 for file in &prod_files {
1030 output.push_str(&format_file_entry(file, base_path));
1031 }
1032 }
1033
1034 if !test_files.is_empty() {
1035 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1036 for file in &test_files {
1037 output.push_str(&format_file_entry(file, base_path));
1038 }
1039 }
1040
1041 output
1042}
1043
1044#[instrument(skip_all)]
1048pub fn format_file_details_paginated(
1049 functions_page: &[FunctionInfo],
1050 total_functions: usize,
1051 semantic: &SemanticAnalysis,
1052 path: &str,
1053 line_count: usize,
1054 offset: usize,
1055) -> String {
1056 let mut output = String::new();
1057
1058 let start = offset + 1; let end = offset + functions_page.len();
1060
1061 output.push_str(&format!(
1062 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)\n",
1063 path,
1064 line_count,
1065 start,
1066 end,
1067 total_functions,
1068 semantic.classes.len(),
1069 semantic.imports.len(),
1070 ));
1071
1072 if offset == 0 {
1074 output.push_str(&format_classes_section(&semantic.classes));
1075 output.push_str(&format_imports_section(&semantic.imports));
1076 }
1077
1078 if !functions_page.is_empty() {
1080 output.push_str("F:\n");
1081 let mut line = String::from(" ");
1082 for (i, func) in functions_page.iter().enumerate() {
1083 let mut call_marker = func.compact_signature();
1084
1085 if let Some(&count) = semantic.call_frequency.get(&func.name)
1086 && count > 3
1087 {
1088 write!(call_marker, "\u{2022}{}", count).ok();
1089 }
1090
1091 if i == 0 {
1092 line.push_str(&call_marker);
1093 } else if line.len() + call_marker.len() + 2 > 100 {
1094 output.push_str(&line);
1095 output.push('\n');
1096 let mut new_line = String::with_capacity(2 + call_marker.len());
1097 new_line.push_str(" ");
1098 new_line.push_str(&call_marker);
1099 line = new_line;
1100 } else {
1101 line.push_str(", ");
1102 line.push_str(&call_marker);
1103 }
1104 }
1105 if !line.trim().is_empty() {
1106 output.push_str(&line);
1107 output.push('\n');
1108 }
1109 }
1110
1111 output
1112}
1113
1114pub struct FocusedPaginatedParams<'a> {
1116 pub paginated_chains: &'a [CallChain],
1117 pub total: usize,
1118 pub mode: PaginationMode,
1119 pub symbol: &'a str,
1120 pub prod_chains: &'a [CallChain],
1121 pub test_chains: &'a [CallChain],
1122 pub outgoing_chains: &'a [CallChain],
1123 pub def_count: usize,
1124 pub offset: usize,
1125 pub base_path: Option<&'a Path>,
1126}
1127
1128#[instrument(skip_all)]
1133#[allow(clippy::too_many_arguments)]
1134pub fn format_focused_paginated(
1135 paginated_chains: &[CallChain],
1136 total: usize,
1137 mode: PaginationMode,
1138 symbol: &str,
1139 prod_chains: &[CallChain],
1140 test_chains: &[CallChain],
1141 outgoing_chains: &[CallChain],
1142 def_count: usize,
1143 offset: usize,
1144 base_path: Option<&Path>,
1145) -> String {
1146 let start = offset + 1; let end = offset + paginated_chains.len();
1148
1149 let callers_count = prod_chains
1150 .iter()
1151 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1152 .collect::<std::collections::HashSet<_>>()
1153 .len();
1154
1155 let callees_count = outgoing_chains
1156 .iter()
1157 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
1158 .collect::<std::collections::HashSet<_>>()
1159 .len();
1160
1161 let mut output = String::new();
1162
1163 output.push_str(&format!(
1164 "FOCUS: {} ({} defs, {} callers, {} callees)\n",
1165 symbol, def_count, callers_count, callees_count
1166 ));
1167
1168 match mode {
1169 PaginationMode::Callers => {
1170 output.push_str(&format!("CALLERS ({}-{} of {}):\n", start, end, total));
1172
1173 let page_refs: Vec<_> = paginated_chains
1174 .iter()
1175 .filter_map(|chain| {
1176 if chain.chain.len() >= 2 {
1177 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1178 } else if chain.chain.len() == 1 {
1179 Some((chain.chain[0].0.as_str(), ""))
1180 } else {
1181 None
1182 }
1183 })
1184 .collect();
1185
1186 if page_refs.is_empty() {
1187 output.push_str(" (none)\n");
1188 } else {
1189 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1190 }
1191
1192 if !test_chains.is_empty() {
1194 let mut test_files: Vec<_> = test_chains
1195 .iter()
1196 .filter_map(|chain| {
1197 chain
1198 .chain
1199 .first()
1200 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1201 })
1202 .collect();
1203 test_files.sort();
1204 test_files.dedup();
1205
1206 let display_files: Vec<_> = test_files
1207 .iter()
1208 .map(|f| strip_base_path_buf(std::path::Path::new(f), base_path))
1209 .collect();
1210
1211 output.push_str(&format!(
1212 "CALLERS (test): {} test functions (in {})\n",
1213 test_chains.len(),
1214 display_files.join(", ")
1215 ));
1216 }
1217
1218 let callee_names: Vec<_> = outgoing_chains
1220 .iter()
1221 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1222 .collect::<std::collections::HashSet<_>>()
1223 .into_iter()
1224 .collect();
1225 if callee_names.is_empty() {
1226 output.push_str("CALLEES: (none)\n");
1227 } else {
1228 output.push_str(&format!(
1229 "CALLEES: {} (use cursor for callee pagination)\n",
1230 callees_count
1231 ));
1232 }
1233 }
1234 PaginationMode::Callees => {
1235 output.push_str(&format!("CALLERS: {} production callers\n", callers_count));
1237
1238 if !test_chains.is_empty() {
1240 output.push_str(&format!(
1241 "CALLERS (test): {} test functions\n",
1242 test_chains.len()
1243 ));
1244 }
1245
1246 output.push_str(&format!("CALLEES ({}-{} of {}):\n", start, end, total));
1248
1249 let page_refs: Vec<_> = paginated_chains
1250 .iter()
1251 .filter_map(|chain| {
1252 if chain.chain.len() >= 2 {
1253 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1254 } else if chain.chain.len() == 1 {
1255 Some((chain.chain[0].0.as_str(), ""))
1256 } else {
1257 None
1258 }
1259 })
1260 .collect();
1261
1262 if page_refs.is_empty() {
1263 output.push_str(" (none)\n");
1264 } else {
1265 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1266 }
1267 }
1268 PaginationMode::Default => {
1269 unreachable!("format_focused_paginated called with PaginationMode::Default")
1270 }
1271 }
1272
1273 output
1274}
1275
1276fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1277 let mut parts = Vec::new();
1278 if file.line_count > 0 {
1279 parts.push(format!("{}L", file.line_count));
1280 }
1281 if file.function_count > 0 {
1282 parts.push(format!("{}F", file.function_count));
1283 }
1284 if file.class_count > 0 {
1285 parts.push(format!("{}C", file.class_count));
1286 }
1287 let display_path = strip_base_path(&file.path, base_path);
1288 if parts.is_empty() {
1289 format!("{}\n", display_path)
1290 } else {
1291 format!("{} [{}]\n", display_path, parts.join(", "))
1292 }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297 use super::*;
1298
1299 #[test]
1300 fn test_strip_base_path_relative() {
1301 let path_str = "/home/user/project/src/main.rs";
1302 let base = Path::new("/home/user/project");
1303 let result = strip_base_path(path_str, Some(base));
1304 assert_eq!(result, "src/main.rs");
1305 }
1306
1307 #[test]
1308 fn test_strip_base_path_fallback_absolute() {
1309 let path_str = "/other/project/src/main.rs";
1310 let base = Path::new("/home/user/project");
1311 let result = strip_base_path(path_str, Some(base));
1312 assert_eq!(result, "/other/project/src/main.rs");
1313 }
1314
1315 #[test]
1316 fn test_strip_base_path_none() {
1317 let path_str = "/home/user/project/src/main.rs";
1318 let result = strip_base_path(path_str, None);
1319 assert_eq!(result, "/home/user/project/src/main.rs");
1320 }
1321
1322 #[test]
1323 fn test_format_file_details_summary_empty() {
1324 use crate::types::SemanticAnalysis;
1325 use std::collections::HashMap;
1326
1327 let semantic = SemanticAnalysis {
1328 functions: vec![],
1329 classes: vec![],
1330 imports: vec![],
1331 references: vec![],
1332 call_frequency: HashMap::new(),
1333 calls: vec![],
1334 assignments: vec![],
1335 field_accesses: vec![],
1336 };
1337
1338 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1339
1340 assert!(result.contains("FILE:"));
1342 assert!(result.contains("100L, 0F, 0C"));
1343 assert!(result.contains("src/main.rs"));
1344 assert!(result.contains("Imports: 0"));
1345 assert!(result.contains("SUGGESTION:"));
1346 }
1347
1348 #[test]
1349 fn test_format_file_details_summary_with_functions() {
1350 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1351 use std::collections::HashMap;
1352
1353 let semantic = SemanticAnalysis {
1354 functions: vec![
1355 FunctionInfo {
1356 name: "short".to_string(),
1357 line: 10,
1358 end_line: 12,
1359 parameters: vec![],
1360 return_type: None,
1361 },
1362 FunctionInfo {
1363 name: "long_function".to_string(),
1364 line: 20,
1365 end_line: 50,
1366 parameters: vec!["x".to_string(), "y".to_string()],
1367 return_type: Some("i32".to_string()),
1368 },
1369 ],
1370 classes: vec![ClassInfo {
1371 name: "MyClass".to_string(),
1372 line: 60,
1373 end_line: 80,
1374 methods: vec![],
1375 fields: vec![],
1376 inherits: vec![],
1377 }],
1378 imports: vec![],
1379 references: vec![],
1380 call_frequency: HashMap::new(),
1381 calls: vec![],
1382 assignments: vec![],
1383 field_accesses: vec![],
1384 };
1385
1386 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1387
1388 assert!(result.contains("FILE:"));
1390 assert!(result.contains("src/lib.rs"));
1391 assert!(result.contains("250L, 2F, 1C"));
1392
1393 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1395 let long_idx = result.find("long_function").unwrap_or(0);
1396 let short_idx = result.find("short").unwrap_or(0);
1397 assert!(
1398 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1399 "long_function should appear before short"
1400 );
1401
1402 assert!(result.contains("CLASSES:"));
1404 assert!(result.contains("MyClass:"));
1405
1406 assert!(result.contains("Imports: 0"));
1408 }
1409}
1410
1411fn format_classes_section(classes: &[ClassInfo]) -> String {
1412 let mut output = String::new();
1413 if classes.is_empty() {
1414 return output;
1415 }
1416 output.push_str("C:\n");
1417 if classes.len() <= MULTILINE_THRESHOLD {
1418 let class_strs: Vec<String> = classes
1419 .iter()
1420 .map(|class| {
1421 if class.inherits.is_empty() {
1422 format!("{}:{}", class.name, class.line)
1423 } else {
1424 format!(
1425 "{}:{} ({})",
1426 class.name,
1427 class.line,
1428 class.inherits.join(", ")
1429 )
1430 }
1431 })
1432 .collect();
1433 output.push_str(" ");
1434 output.push_str(&class_strs.join("; "));
1435 output.push('\n');
1436 } else {
1437 for class in classes {
1438 if class.inherits.is_empty() {
1439 output.push_str(&format!(" {}:{}\n", class.name, class.line));
1440 } else {
1441 output.push_str(&format!(
1442 " {}:{} ({})\n",
1443 class.name,
1444 class.line,
1445 class.inherits.join(", ")
1446 ));
1447 }
1448 }
1449 }
1450 output
1451}
1452
1453fn format_imports_section(imports: &[ImportInfo]) -> String {
1454 let mut output = String::new();
1455 if imports.is_empty() {
1456 return output;
1457 }
1458 output.push_str("I:\n");
1459 let mut module_map: HashMap<String, usize> = HashMap::new();
1460 for import in imports {
1461 module_map
1462 .entry(import.module.clone())
1463 .and_modify(|count| *count += 1)
1464 .or_insert(1);
1465 }
1466 let mut modules: Vec<_> = module_map.keys().cloned().collect();
1467 modules.sort();
1468 let formatted_modules: Vec<String> = modules
1469 .iter()
1470 .map(|module| format!("{}({})", module, module_map[module]))
1471 .collect();
1472 if formatted_modules.len() <= MULTILINE_THRESHOLD {
1473 output.push_str(" ");
1474 output.push_str(&formatted_modules.join("; "));
1475 output.push('\n');
1476 } else {
1477 for module_str in formatted_modules {
1478 output.push_str(" ");
1479 output.push_str(&module_str);
1480 output.push('\n');
1481 }
1482 }
1483 output
1484}