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