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