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
842fn render_top_files_section(
844 files_in_dir: &[&FileInfo],
845 dir_path: &Path,
846 has_classes: bool,
847) -> String {
848 let top_files_sorted: Vec<&FileInfo> = if has_classes {
849 let mut sorted = files_in_dir.to_vec();
850 sorted.sort_unstable_by(|a, b| {
851 b.class_count
852 .cmp(&a.class_count)
853 .then(b.function_count.cmp(&a.function_count))
854 .then(a.path.cmp(&b.path))
855 });
856 sorted
857 } else {
858 let mut sorted = files_in_dir.to_vec();
859 sorted.sort_unstable_by(|a, b| {
860 b.function_count
861 .cmp(&a.function_count)
862 .then(a.path.cmp(&b.path))
863 });
864 sorted
865 };
866
867 let top_n: Vec<String> = top_files_sorted
868 .iter()
869 .take(3)
870 .filter(|f| {
871 if has_classes {
872 f.class_count > 0
873 } else {
874 f.function_count > 0
875 }
876 })
877 .map(|f| {
878 let rel = Path::new(&f.path).strip_prefix(dir_path).map_or_else(
879 |_| {
880 Path::new(&f.path)
881 .file_name()
882 .and_then(|n| n.to_str())
883 .map_or_else(|| "?".to_owned(), std::borrow::ToOwned::to_owned)
884 },
885 |p| p.to_string_lossy().into_owned(),
886 );
887 let count = if has_classes {
888 f.class_count
889 } else {
890 f.function_count
891 };
892 let suffix = if has_classes { 'C' } else { 'F' };
893 format!("{rel}({count}{suffix})")
894 })
895 .collect();
896
897 if top_n.is_empty() {
898 String::new()
899 } else {
900 let joined = top_n.join(", ");
901 format!(" top: {joined}")
902 }
903}
904
905fn aggregate_dir_stats(files_in_dir: &[&FileInfo]) -> (usize, usize, usize) {
907 let dir_loc: usize = files_in_dir.iter().map(|f| f.line_count).sum();
908 let dir_functions: usize = files_in_dir.iter().map(|f| f.function_count).sum();
909 let dir_classes: usize = files_in_dir.iter().map(|f| f.class_count).sum();
910 (dir_loc, dir_functions, dir_classes)
911}
912
913#[instrument(skip_all)]
916#[allow(clippy::too_many_lines)] pub fn format_summary(
918 entries: &[WalkEntry],
919 analysis_results: &[FileInfo],
920 max_depth: Option<u32>,
921 subtree_counts: Option<&[(PathBuf, usize)]>,
922) -> String {
923 let mut output = String::new();
924
925 let (prod_files, test_files): (Vec<_>, Vec<_>) =
927 analysis_results.iter().partition(|a| !a.is_test);
928
929 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
931 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
932 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
933
934 let mut lang_counts: HashMap<String, usize> = HashMap::new();
936 for analysis in analysis_results {
937 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
938 }
939 let total_files = analysis_results.len();
940
941 output.push_str("SUMMARY:\n");
943 let depth_label = match max_depth {
944 Some(n) if n > 0 => format!(" (max_depth={n})"),
945 _ => String::new(),
946 };
947 let prod_count = prod_files.len();
948 let test_count = test_files.len();
949 let _ = writeln!(
950 output,
951 "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
952 );
953
954 if !lang_counts.is_empty() {
955 output.push_str("Languages: ");
956 let mut langs: Vec<_> = lang_counts.iter().collect();
957 langs.sort_unstable_by_key(|&(name, _)| name);
958 let lang_strs: Vec<String> = langs
959 .iter()
960 .map(|(name, count)| {
961 let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
962 format!("{name} ({percentage}%)")
963 })
964 .collect();
965 output.push_str(&lang_strs.join(", "));
966 output.push('\n');
967 }
968
969 output.push('\n');
970
971 output.push_str("STRUCTURE (depth 1):\n");
973
974 let analysis_map: HashMap<String, &FileInfo> = analysis_results
976 .iter()
977 .map(|a| (a.path.clone(), a))
978 .collect();
979
980 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
982 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
983
984 let mut largest_dir_name: Option<String> = None;
986 let mut largest_dir_path: Option<String> = None;
987 let mut largest_dir_count: usize = 0;
988
989 for entry in depth1_entries {
990 let name = entry
991 .path
992 .file_name()
993 .and_then(|n| n.to_str())
994 .unwrap_or("?");
995
996 if entry.is_dir {
997 let dir_path_str = entry.path.display().to_string();
999 let files_in_dir: Vec<&FileInfo> = analysis_results
1000 .iter()
1001 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
1002 .collect();
1003
1004 if files_in_dir.is_empty() {
1005 let entry_name_str = name.to_string();
1007 if let Some(counts) = subtree_counts {
1008 let true_count = counts
1009 .binary_search_by_key(&&entry.path, |(p, _)| p)
1010 .ok()
1011 .map_or(0, |i| counts[i].1);
1012 if true_count > 0 {
1013 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1015 && true_count > largest_dir_count
1016 {
1017 largest_dir_count = true_count;
1018 largest_dir_name = Some(entry_name_str);
1019 largest_dir_path = Some(
1020 entry
1021 .path
1022 .canonicalize()
1023 .unwrap_or_else(|_| entry.path.clone())
1024 .display()
1025 .to_string(),
1026 );
1027 }
1028 let depth_val = max_depth.unwrap_or(0);
1029 let _ = writeln!(
1030 output,
1031 " {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
1032 );
1033 } else {
1034 let _ = writeln!(output, " {name}/");
1035 }
1036 } else {
1037 let _ = writeln!(output, " {name}/");
1038 }
1039 } else {
1040 let dir_file_count = files_in_dir.len();
1041 let (dir_loc, dir_functions, dir_classes) = aggregate_dir_stats(&files_in_dir);
1042
1043 let entry_name_str = name.to_string();
1045 let effective_count = if let Some(counts) = subtree_counts {
1046 counts
1047 .binary_search_by_key(&&entry.path, |(p, _)| p)
1048 .ok()
1049 .map_or(dir_file_count, |i| counts[i].1)
1050 } else {
1051 dir_file_count
1052 };
1053 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
1054 && effective_count > largest_dir_count
1055 {
1056 largest_dir_count = effective_count;
1057 largest_dir_name = Some(entry_name_str);
1058 largest_dir_path = Some(
1059 entry
1060 .path
1061 .canonicalize()
1062 .unwrap_or_else(|_| entry.path.clone())
1063 .display()
1064 .to_string(),
1065 );
1066 }
1067
1068 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
1070 let has_classes = files_in_dir.iter().any(|f| f.class_count > 0);
1071 let dir_path = Path::new(&dir_path_str);
1072 render_top_files_section(&files_in_dir, dir_path, has_classes)
1073 } else {
1074 String::new()
1075 };
1076
1077 let mut subdirs: Vec<String> = entries
1079 .iter()
1080 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
1081 .filter_map(|e| {
1082 e.path
1083 .file_name()
1084 .and_then(|n| n.to_str())
1085 .map(std::borrow::ToOwned::to_owned)
1086 })
1087 .collect();
1088 subdirs.sort();
1089 subdirs.dedup();
1090 let subdir_suffix = if subdirs.is_empty() {
1091 String::new()
1092 } else {
1093 let subdirs_capped: Vec<String> =
1094 subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
1095 let joined = subdirs_capped.join(", ");
1096 format!(" sub: {joined}")
1097 };
1098
1099 let files_label = if let Some(counts) = subtree_counts {
1100 let true_count = counts
1101 .binary_search_by_key(&&entry.path, |(p, _)| p)
1102 .ok()
1103 .map_or(dir_file_count, |i| counts[i].1);
1104 if true_count == dir_file_count {
1105 format!(
1106 "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1107 )
1108 } else {
1109 let depth_val = max_depth.unwrap_or(0);
1110 format!(
1111 "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
1112 )
1113 }
1114 } else {
1115 format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
1116 };
1117 let _ = writeln!(output, " {name}/ [{files_label}]{hint}{subdir_suffix}");
1118 }
1119 } else {
1120 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string()) {
1122 if let Some(info_str) = format_file_info_parts(
1123 analysis.line_count,
1124 analysis.function_count,
1125 analysis.class_count,
1126 ) {
1127 let _ = writeln!(output, " {name} {info_str}");
1128 } else {
1129 let _ = writeln!(output, " {name}");
1130 }
1131 }
1132 }
1133 }
1134
1135 output.push('\n');
1136
1137 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
1139 let _ = writeln!(
1140 output,
1141 "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
1142 );
1143 } else {
1144 output.push_str("SUGGESTION:\n");
1145 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
1146 }
1147
1148 output
1149}
1150
1151#[instrument(skip_all)]
1156pub fn format_file_details_summary(
1157 semantic: &SemanticAnalysis,
1158 path: &str,
1159 line_count: usize,
1160) -> String {
1161 let mut output = String::new();
1162
1163 output.push_str("FILE:\n");
1165 let _ = writeln!(output, " path: {path}");
1166 let fn_count = semantic.functions.len();
1167 let class_count = semantic.classes.len();
1168 let _ = writeln!(output, " {line_count}L, {fn_count}F, {class_count}C");
1169 output.push('\n');
1170
1171 if !semantic.functions.is_empty() {
1173 output.push_str("TOP FUNCTIONS BY SIZE:\n");
1174 let mut funcs: Vec<&crate::types::FunctionInfo> = semantic.functions.iter().collect();
1175 let k = funcs.len().min(10);
1176 if k > 0 {
1177 funcs.select_nth_unstable_by(k.saturating_sub(1), |a, b| {
1178 let a_span = a.end_line.saturating_sub(a.line);
1179 let b_span = b.end_line.saturating_sub(b.line);
1180 b_span.cmp(&a_span)
1181 });
1182 funcs[..k].sort_by(|a, b| {
1183 let a_span = a.end_line.saturating_sub(a.line);
1184 let b_span = b.end_line.saturating_sub(b.line);
1185 b_span.cmp(&a_span)
1186 });
1187 }
1188
1189 for func in &funcs[..k] {
1190 let span = func.end_line.saturating_sub(func.line);
1191 let params = if func.parameters.is_empty() {
1192 String::new()
1193 } else {
1194 format!("({})", func.parameters.join(", "))
1195 };
1196 let _ = writeln!(
1197 output,
1198 " {}:{}: {} {} [{}L]",
1199 func.line, func.end_line, func.name, params, span
1200 );
1201 }
1202 output.push('\n');
1203 }
1204
1205 if !semantic.classes.is_empty() {
1207 output.push_str("CLASSES:\n");
1208 if semantic.classes.len() <= 10 {
1209 for class in &semantic.classes {
1211 let methods_count = class.methods.len();
1212 let _ = writeln!(output, " {}: {}M", class.name, methods_count);
1213 }
1214 } else {
1215 let _ = writeln!(output, " {} classes total", semantic.classes.len());
1217 for class in semantic.classes.iter().take(5) {
1218 let _ = writeln!(output, " {}", class.name);
1219 }
1220 if semantic.classes.len() > 5 {
1221 let _ = writeln!(output, " ... and {} more", semantic.classes.len() - 5);
1222 }
1223 }
1224 output.push('\n');
1225 }
1226
1227 let _ = writeln!(output, "Imports: {}", semantic.imports.len());
1229 output.push('\n');
1230
1231 output.push_str("SUGGESTION:\n");
1233 output.push_str("Use force=true for full output, or narrow your scope\n");
1234
1235 output
1236}
1237
1238#[instrument(skip_all)]
1240pub fn format_structure_paginated(
1241 paginated_files: &[FileInfo],
1242 total_files: usize,
1243 max_depth: Option<u32>,
1244 base_path: Option<&Path>,
1245 verbose: bool,
1246) -> String {
1247 let mut output = String::new();
1248
1249 let depth_label = match max_depth {
1250 Some(n) if n > 0 => format!(" (max_depth={n})"),
1251 _ => String::new(),
1252 };
1253 let _ = writeln!(
1254 output,
1255 "PAGINATED: showing {} of {} files{}\n",
1256 paginated_files.len(),
1257 total_files,
1258 depth_label
1259 );
1260
1261 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
1262 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
1263
1264 if !prod_files.is_empty() {
1265 if verbose {
1266 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
1267 }
1268 for file in &prod_files {
1269 output.push_str(&format_file_entry(file, base_path));
1270 }
1271 }
1272
1273 if !test_files.is_empty() {
1274 if verbose {
1275 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
1276 } else if !prod_files.is_empty() {
1277 output.push('\n');
1278 }
1279 for file in &test_files {
1280 output.push_str(&format_file_entry(file, base_path));
1281 }
1282 }
1283
1284 output
1285}
1286
1287#[instrument(skip_all)]
1292#[allow(clippy::too_many_arguments)]
1293pub fn format_file_details_paginated(
1294 functions_page: &[FunctionInfo],
1295 total_functions: usize,
1296 semantic: &SemanticAnalysis,
1297 path: &str,
1298 line_count: usize,
1299 offset: usize,
1300 verbose: bool,
1301 fields: Option<&[AnalyzeFileField]>,
1302) -> String {
1303 let mut output = String::new();
1304
1305 let start = offset + 1; let end = offset + functions_page.len();
1307
1308 let _ = writeln!(
1309 output,
1310 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
1311 path,
1312 line_count,
1313 start,
1314 end,
1315 total_functions,
1316 semantic.classes.len(),
1317 semantic.imports.len()
1318 );
1319
1320 let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
1322 let show_classes = show_all
1323 || fields.is_some_and(|f| {
1324 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Classes)
1325 });
1326 let show_imports = show_all
1327 || fields.is_some_and(|f| {
1328 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Imports)
1329 });
1330 let show_functions = show_all
1331 || fields.is_some_and(|f| {
1332 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Functions)
1333 });
1334
1335 if show_classes && offset == 0 && !semantic.classes.is_empty() {
1337 output.push_str(&format_classes_section(
1338 &semantic.classes,
1339 &semantic.functions,
1340 ));
1341 }
1342
1343 if show_imports && offset == 0 && (verbose || !show_all) {
1345 output.push_str(&format_imports_section(&semantic.imports));
1346 }
1347
1348 let top_level_functions: Vec<&FunctionInfo> = functions_page
1350 .iter()
1351 .filter(|func| {
1352 !semantic
1353 .classes
1354 .iter()
1355 .any(|class| is_method_of_class(func, class))
1356 })
1357 .collect();
1358
1359 if show_functions && !top_level_functions.is_empty() {
1360 output.push_str("F:\n");
1361 output.push_str(&format_function_list_wrapped(
1362 top_level_functions.iter().copied(),
1363 &semantic.call_frequency,
1364 ));
1365 }
1366
1367 output
1368}
1369
1370#[instrument(skip_all)]
1375#[allow(clippy::too_many_arguments)]
1376#[allow(clippy::similar_names)] pub fn format_focused_paginated(
1378 paginated_chains: &[InternalCallChain],
1379 total: usize,
1380 mode: PaginationMode,
1381 symbol: &str,
1382 prod_chains: &[InternalCallChain],
1383 test_chains: &[InternalCallChain],
1384 outgoing_chains: &[InternalCallChain],
1385 def_count: usize,
1386 offset: usize,
1387 base_path: Option<&Path>,
1388 _verbose: bool,
1389) -> String {
1390 let start = offset + 1; let end = offset + paginated_chains.len();
1392
1393 let callers_count = prod_chains.len();
1394
1395 let callees_count = outgoing_chains.len();
1396
1397 let mut output = String::new();
1398
1399 let _ = writeln!(
1400 output,
1401 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
1402 );
1403
1404 match mode {
1405 PaginationMode::Callers => {
1406 let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
1408
1409 let page_refs: Vec<_> = paginated_chains
1410 .iter()
1411 .filter_map(|chain| {
1412 if chain.chain.len() >= 2 {
1413 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1414 } else if chain.chain.len() == 1 {
1415 Some((chain.chain[0].0.as_str(), ""))
1416 } else {
1417 None
1418 }
1419 })
1420 .collect();
1421
1422 if page_refs.is_empty() {
1423 output.push_str(" (none)\n");
1424 } else {
1425 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
1426 }
1427
1428 if !test_chains.is_empty() {
1430 let mut test_files: Vec<_> = test_chains
1431 .iter()
1432 .filter_map(|chain| {
1433 chain
1434 .chain
1435 .first()
1436 .map(|(_, path, _)| path.to_string_lossy().into_owned())
1437 })
1438 .collect();
1439 test_files.sort();
1440 test_files.dedup();
1441
1442 let display_files: Vec<_> = test_files
1443 .iter()
1444 .map(|f| strip_base_path(std::path::Path::new(f), base_path))
1445 .collect();
1446
1447 let _ = writeln!(
1448 output,
1449 "CALLERS (test): {} test functions (in {})",
1450 test_chains.len(),
1451 display_files.join(", ")
1452 );
1453 }
1454
1455 let callee_names: Vec<_> = outgoing_chains
1457 .iter()
1458 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
1459 .collect::<std::collections::HashSet<_>>()
1460 .into_iter()
1461 .collect();
1462 if callee_names.is_empty() {
1463 output.push_str("CALLEES: (none)\n");
1464 } else {
1465 let _ = writeln!(
1466 output,
1467 "CALLEES: {callees_count} (use cursor for callee pagination)"
1468 );
1469 }
1470 }
1471 PaginationMode::Callees => {
1472 let _ = writeln!(output, "CALLERS: {callers_count} production callers");
1474
1475 if !test_chains.is_empty() {
1477 let _ = writeln!(
1478 output,
1479 "CALLERS (test): {} test functions",
1480 test_chains.len()
1481 );
1482 }
1483
1484 let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
1486
1487 let page_refs: Vec<_> = paginated_chains
1488 .iter()
1489 .filter_map(|chain| {
1490 if chain.chain.len() >= 2 {
1491 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
1492 } else if chain.chain.len() == 1 {
1493 Some((chain.chain[0].0.as_str(), ""))
1494 } else {
1495 None
1496 }
1497 })
1498 .collect();
1499
1500 if page_refs.is_empty() {
1501 output.push_str(" (none)\n");
1502 } else {
1503 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
1504 }
1505 }
1506 PaginationMode::Default => {
1507 unreachable!("format_focused_paginated called with PaginationMode::Default")
1508 }
1509 PaginationMode::DefUse => {
1510 unreachable!("format_focused_paginated called with PaginationMode::DefUse")
1511 }
1512 }
1513
1514 output
1515}
1516
1517fn format_file_entry(file: &FileInfo, base_path: Option<&Path>) -> String {
1518 let mut parts = Vec::new();
1519 if file.line_count > 0 {
1520 parts.push(format!("{}L", file.line_count));
1521 }
1522 if file.function_count > 0 {
1523 parts.push(format!("{}F", file.function_count));
1524 }
1525 if file.class_count > 0 {
1526 parts.push(format!("{}C", file.class_count));
1527 }
1528 let display_path = strip_base_path(Path::new(&file.path), base_path);
1529 if parts.is_empty() {
1530 format!("{display_path}\n")
1531 } else {
1532 format!("{display_path} [{}]\n", parts.join(", "))
1533 }
1534}
1535
1536#[instrument(skip_all)]
1550pub fn format_module_info(info: &ModuleInfo) -> String {
1551 use std::fmt::Write as _;
1552 let fn_count = info.functions.len();
1553 let import_count = info.imports.len();
1554 let mut out = String::with_capacity(64 + fn_count * 24 + import_count * 32);
1555 let _ = writeln!(
1556 out,
1557 "FILE: {} ({}L, {}F, {}I)",
1558 info.name, info.line_count, fn_count, import_count
1559 );
1560 if !info.functions.is_empty() {
1561 out.push_str("F:\n ");
1562 let parts: Vec<String> = info
1563 .functions
1564 .iter()
1565 .map(|f| format!("{}:{}", f.name, f.line))
1566 .collect();
1567 out.push_str(&parts.join(", "));
1568 out.push('\n');
1569 }
1570 if !info.imports.is_empty() {
1571 out.push_str("I:\n ");
1572 let parts: Vec<String> = info
1573 .imports
1574 .iter()
1575 .map(|i| {
1576 if i.items.is_empty() {
1577 i.module.clone()
1578 } else {
1579 format!("{}:{}", i.module, i.items.join(", "))
1580 }
1581 })
1582 .collect();
1583 out.push_str(&parts.join("; "));
1584 out.push('\n');
1585 }
1586 out
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592
1593 #[test]
1594 fn test_strip_base_path_relative() {
1595 let path = Path::new("/home/user/project/src/main.rs");
1596 let base = Path::new("/home/user/project");
1597 let result = strip_base_path(path, Some(base));
1598 assert_eq!(result, "src/main.rs");
1599 }
1600
1601 #[test]
1602 fn test_strip_base_path_fallback_absolute() {
1603 let path = Path::new("/other/project/src/main.rs");
1604 let base = Path::new("/home/user/project");
1605 let result = strip_base_path(path, Some(base));
1606 assert_eq!(result, "/other/project/src/main.rs");
1607 }
1608
1609 #[test]
1610 fn test_strip_base_path_none() {
1611 let path = Path::new("/home/user/project/src/main.rs");
1612 let result = strip_base_path(path, None);
1613 assert_eq!(result, "/home/user/project/src/main.rs");
1614 }
1615
1616 #[test]
1617 fn test_format_file_details_summary_empty() {
1618 use crate::types::SemanticAnalysis;
1619 use std::collections::HashMap;
1620
1621 let semantic = SemanticAnalysis {
1622 functions: vec![],
1623 classes: vec![],
1624 imports: vec![],
1625 references: vec![],
1626 call_frequency: HashMap::new(),
1627 calls: vec![],
1628 impl_traits: vec![],
1629 def_use_sites: vec![],
1630 };
1631
1632 let result = format_file_details_summary(&semantic, "src/main.rs", 100);
1633
1634 assert!(result.contains("FILE:"));
1636 assert!(result.contains("100L, 0F, 0C"));
1637 assert!(result.contains("src/main.rs"));
1638 assert!(result.contains("Imports: 0"));
1639 assert!(result.contains("SUGGESTION:"));
1640 }
1641
1642 #[test]
1643 fn test_format_file_details_summary_with_functions() {
1644 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
1645 use std::collections::HashMap;
1646
1647 let semantic = SemanticAnalysis {
1648 functions: vec![
1649 FunctionInfo {
1650 name: "short".to_string(),
1651 line: 10,
1652 end_line: 12,
1653 parameters: vec![],
1654 return_type: None,
1655 },
1656 FunctionInfo {
1657 name: "long_function".to_string(),
1658 line: 20,
1659 end_line: 50,
1660 parameters: vec!["x".to_string(), "y".to_string()],
1661 return_type: Some("i32".to_string()),
1662 },
1663 ],
1664 classes: vec![ClassInfo {
1665 name: "MyClass".to_string(),
1666 line: 60,
1667 end_line: 80,
1668 methods: vec![],
1669 fields: vec![],
1670 inherits: vec![],
1671 }],
1672 imports: vec![],
1673 references: vec![],
1674 call_frequency: HashMap::new(),
1675 calls: vec![],
1676 impl_traits: vec![],
1677 def_use_sites: vec![],
1678 };
1679
1680 let result = format_file_details_summary(&semantic, "src/lib.rs", 250);
1681
1682 assert!(result.contains("FILE:"));
1684 assert!(result.contains("src/lib.rs"));
1685 assert!(result.contains("250L, 2F, 1C"));
1686
1687 assert!(result.contains("TOP FUNCTIONS BY SIZE:"));
1689 let long_idx = result.find("long_function").unwrap_or(0);
1690 let short_idx = result.find("short").unwrap_or(0);
1691 assert!(
1692 long_idx > 0 && short_idx > 0 && long_idx < short_idx,
1693 "long_function should appear before short"
1694 );
1695
1696 assert!(result.contains("CLASSES:"));
1698 assert!(result.contains("MyClass:"));
1699
1700 assert!(result.contains("Imports: 0"));
1702 }
1703 #[test]
1704 fn test_format_file_info_parts_all_zero() {
1705 assert_eq!(format_file_info_parts(0, 0, 0), None);
1706 }
1707
1708 #[test]
1709 fn test_format_file_info_parts_partial() {
1710 assert_eq!(
1711 format_file_info_parts(42, 0, 3),
1712 Some("[42L, 3C]".to_string())
1713 );
1714 }
1715
1716 #[test]
1717 fn test_format_file_info_parts_all_nonzero() {
1718 assert_eq!(
1719 format_file_info_parts(100, 5, 2),
1720 Some("[100L, 5F, 2C]".to_string())
1721 );
1722 }
1723
1724 #[test]
1725 fn test_format_function_list_wrapped_empty() {
1726 let freq = std::collections::HashMap::new();
1727 let result = format_function_list_wrapped(std::iter::empty(), &freq);
1728 assert_eq!(result, "");
1729 }
1730
1731 #[test]
1732 fn test_format_function_list_wrapped_bullet_annotation() {
1733 use crate::types::FunctionInfo;
1734 use std::collections::HashMap;
1735
1736 let mut freq = HashMap::new();
1737 freq.insert("frequent".to_string(), 5); let funcs = vec![FunctionInfo {
1740 name: "frequent".to_string(),
1741 line: 1,
1742 end_line: 10,
1743 parameters: vec![],
1744 return_type: Some("void".to_string()),
1745 }];
1746
1747 let result = format_function_list_wrapped(funcs.iter(), &freq);
1748 assert!(result.contains("\u{2022}5"));
1750 }
1751
1752 #[test]
1753 fn test_compact_format_omits_sections() {
1754 use crate::types::{ClassInfo, FunctionInfo, ImportInfo, SemanticAnalysis};
1755 use std::collections::HashMap;
1756
1757 let funcs: Vec<FunctionInfo> = (0..10)
1758 .map(|i| FunctionInfo {
1759 name: format!("fn_{}", i),
1760 line: i * 5 + 1,
1761 end_line: i * 5 + 4,
1762 parameters: vec![format!("x: u32")],
1763 return_type: Some("bool".to_string()),
1764 })
1765 .collect();
1766 let imports: Vec<ImportInfo> = vec![ImportInfo {
1767 module: "std::collections".to_string(),
1768 items: vec!["HashMap".to_string()],
1769 line: 1,
1770 }];
1771 let classes: Vec<ClassInfo> = vec![ClassInfo {
1772 name: "MyStruct".to_string(),
1773 line: 100,
1774 end_line: 150,
1775 methods: vec![],
1776 fields: vec![],
1777 inherits: vec![],
1778 }];
1779 let semantic = SemanticAnalysis {
1780 functions: funcs,
1781 classes,
1782 imports,
1783 references: vec![],
1784 call_frequency: HashMap::new(),
1785 calls: vec![],
1786 impl_traits: vec![],
1787 def_use_sites: vec![],
1788 };
1789
1790 let verbose_out = format_file_details_paginated(
1791 &semantic.functions,
1792 semantic.functions.len(),
1793 &semantic,
1794 "src/lib.rs",
1795 100,
1796 0,
1797 true,
1798 None,
1799 );
1800 let compact_out = format_file_details_paginated(
1801 &semantic.functions,
1802 semantic.functions.len(),
1803 &semantic,
1804 "src/lib.rs",
1805 100,
1806 0,
1807 false,
1808 None,
1809 );
1810
1811 assert!(verbose_out.contains("C:\n"), "verbose must have C: section");
1813 assert!(verbose_out.contains("I:\n"), "verbose must have I: section");
1814 assert!(verbose_out.contains("F:\n"), "verbose must have F: section");
1815
1816 assert!(
1818 compact_out.contains("C:\n"),
1819 "compact must have C: section (restored)"
1820 );
1821 assert!(
1822 !compact_out.contains("I:\n"),
1823 "compact must not have I: section (imports omitted)"
1824 );
1825 assert!(
1826 compact_out.contains("F:\n"),
1827 "compact must have F: section with wrapped formatting"
1828 );
1829
1830 assert!(compact_out.contains("fn_0"), "compact must list functions");
1832 let has_two_on_same_line = compact_out
1833 .lines()
1834 .any(|l| l.contains("fn_0") && l.contains("fn_1"));
1835 assert!(
1836 has_two_on_same_line,
1837 "compact must render multiple functions per line (wrapped), not one-per-line"
1838 );
1839 }
1840
1841 #[test]
1843 fn test_compact_mode_consistent_token_reduction() {
1844 use crate::types::{FunctionInfo, SemanticAnalysis};
1845 use std::collections::HashMap;
1846
1847 let funcs: Vec<FunctionInfo> = (0..50)
1848 .map(|i| FunctionInfo {
1849 name: format!("function_name_{}", i),
1850 line: i * 10 + 1,
1851 end_line: i * 10 + 8,
1852 parameters: vec![
1853 "arg1: u32".to_string(),
1854 "arg2: String".to_string(),
1855 "arg3: Option<bool>".to_string(),
1856 ],
1857 return_type: Some("Result<Vec<String>, Error>".to_string()),
1858 })
1859 .collect();
1860
1861 let semantic = SemanticAnalysis {
1862 functions: funcs,
1863 classes: vec![],
1864 imports: vec![],
1865 references: vec![],
1866 call_frequency: HashMap::new(),
1867 calls: vec![],
1868 impl_traits: vec![],
1869 def_use_sites: vec![],
1870 };
1871
1872 let verbose_out = format_file_details_paginated(
1873 &semantic.functions,
1874 semantic.functions.len(),
1875 &semantic,
1876 "src/large_file.rs",
1877 1000,
1878 0,
1879 true,
1880 None,
1881 );
1882 let compact_out = format_file_details_paginated(
1883 &semantic.functions,
1884 semantic.functions.len(),
1885 &semantic,
1886 "src/large_file.rs",
1887 1000,
1888 0,
1889 false,
1890 None,
1891 );
1892
1893 assert!(
1894 compact_out.len() <= verbose_out.len(),
1895 "compact ({} chars) must be <= verbose ({} chars)",
1896 compact_out.len(),
1897 verbose_out.len(),
1898 );
1899 }
1900
1901 #[test]
1903 fn test_format_module_info_happy_path() {
1904 use crate::types::{ModuleFunctionInfo, ModuleImportInfo, ModuleInfo};
1905 let info = ModuleInfo::new(
1906 "parser.rs".to_string(),
1907 312,
1908 "rust".to_string(),
1909 vec![
1910 ModuleFunctionInfo {
1911 name: "parse_file".to_string(),
1912 line: 24,
1913 },
1914 ModuleFunctionInfo {
1915 name: "parse_block".to_string(),
1916 line: 58,
1917 },
1918 ],
1919 vec![
1920 ModuleImportInfo {
1921 module: "crate::types".to_string(),
1922 items: vec!["Token".to_string(), "Expr".to_string()],
1923 },
1924 ModuleImportInfo {
1925 module: "std::io".to_string(),
1926 items: vec!["BufReader".to_string()],
1927 },
1928 ],
1929 );
1930 let result = format_module_info(&info);
1931 assert!(result.starts_with("FILE: parser.rs (312L, 2F, 2I)"));
1932 assert!(result.contains("F:"));
1933 assert!(result.contains("parse_file:24"));
1934 assert!(result.contains("parse_block:58"));
1935 assert!(result.contains("I:"));
1936 assert!(result.contains("crate::types:Token, Expr"));
1937 assert!(result.contains("std::io:BufReader"));
1938 assert!(result.contains("; "));
1939 assert!(!result.contains('{'));
1940 }
1941
1942 #[test]
1943 fn test_format_module_info_empty() {
1944 use crate::types::ModuleInfo;
1945 let info = ModuleInfo::new(
1946 "empty.rs".to_string(),
1947 0,
1948 "rust".to_string(),
1949 vec![],
1950 vec![],
1951 );
1952 let result = format_module_info(&info);
1953 assert!(result.starts_with("FILE: empty.rs (0L, 0F, 0I)"));
1954 assert!(!result.contains("F:"));
1955 assert!(!result.contains("I:"));
1956 }
1957
1958 #[test]
1959 fn test_compact_mode_empty_classes_no_header() {
1960 use crate::types::{FunctionInfo, SemanticAnalysis};
1961 use std::collections::HashMap;
1962
1963 let funcs: Vec<FunctionInfo> = (0..5)
1964 .map(|i| FunctionInfo {
1965 name: format!("fn_{}", i),
1966 line: i * 5 + 1,
1967 end_line: i * 5 + 4,
1968 parameters: vec![],
1969 return_type: None,
1970 })
1971 .collect();
1972
1973 let semantic = SemanticAnalysis {
1974 functions: funcs,
1975 classes: vec![], imports: vec![],
1977 references: vec![],
1978 call_frequency: HashMap::new(),
1979 calls: vec![],
1980 impl_traits: vec![],
1981 def_use_sites: vec![],
1982 };
1983
1984 let compact_out = format_file_details_paginated(
1985 &semantic.functions,
1986 semantic.functions.len(),
1987 &semantic,
1988 "src/simple.rs",
1989 100,
1990 0,
1991 false,
1992 None,
1993 );
1994
1995 assert!(
1997 !compact_out.contains("C:\n"),
1998 "compact mode must not emit C: header when classes are empty"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_format_classes_with_methods() {
2004 use crate::types::{ClassInfo, FunctionInfo};
2005
2006 let functions = vec![
2007 FunctionInfo {
2008 name: "method_a".to_string(),
2009 line: 5,
2010 end_line: 8,
2011 parameters: vec![],
2012 return_type: None,
2013 },
2014 FunctionInfo {
2015 name: "method_b".to_string(),
2016 line: 10,
2017 end_line: 12,
2018 parameters: vec![],
2019 return_type: None,
2020 },
2021 FunctionInfo {
2022 name: "top_level_func".to_string(),
2023 line: 50,
2024 end_line: 55,
2025 parameters: vec![],
2026 return_type: None,
2027 },
2028 ];
2029
2030 let classes = vec![ClassInfo {
2031 name: "MyClass".to_string(),
2032 line: 1,
2033 end_line: 30,
2034 methods: vec![],
2035 fields: vec![],
2036 inherits: vec![],
2037 }];
2038
2039 let output = format_classes_section(&classes, &functions);
2040
2041 assert!(
2042 output.contains("MyClass:1-30"),
2043 "class header should show start-end range"
2044 );
2045 assert!(output.contains("method_a:5"), "method_a should be listed");
2046 assert!(output.contains("method_b:10"), "method_b should be listed");
2047 assert!(
2048 !output.contains("top_level_func"),
2049 "top_level_func outside class range should not be listed"
2050 );
2051 }
2052
2053 #[test]
2054 fn test_format_classes_method_cap() {
2055 use crate::types::{ClassInfo, FunctionInfo};
2056
2057 let mut functions = Vec::new();
2058 for i in 0..15 {
2059 functions.push(FunctionInfo {
2060 name: format!("method_{}", i),
2061 line: 2 + i,
2062 end_line: 3 + i,
2063 parameters: vec![],
2064 return_type: None,
2065 });
2066 }
2067
2068 let classes = vec![ClassInfo {
2069 name: "LargeClass".to_string(),
2070 line: 1,
2071 end_line: 50,
2072 methods: vec![],
2073 fields: vec![],
2074 inherits: vec![],
2075 }];
2076
2077 let output = format_classes_section(&classes, &functions);
2078
2079 assert!(output.contains("method_0"), "first method should be listed");
2080 assert!(output.contains("method_9"), "10th method should be listed");
2081 assert!(
2082 !output.contains("method_10"),
2083 "11th method should not be listed (cap at 10)"
2084 );
2085 assert!(
2086 output.contains("... (5 more)"),
2087 "truncation message should show remaining count"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_format_classes_no_methods() {
2093 use crate::types::{ClassInfo, FunctionInfo};
2094
2095 let functions = vec![FunctionInfo {
2096 name: "top_level".to_string(),
2097 line: 100,
2098 end_line: 105,
2099 parameters: vec![],
2100 return_type: None,
2101 }];
2102
2103 let classes = vec![ClassInfo {
2104 name: "EmptyClass".to_string(),
2105 line: 1,
2106 end_line: 50,
2107 methods: vec![],
2108 fields: vec![],
2109 inherits: vec![],
2110 }];
2111
2112 let output = format_classes_section(&classes, &functions);
2113
2114 assert!(
2115 output.contains("EmptyClass:1-50"),
2116 "empty class header should appear"
2117 );
2118 assert!(
2119 !output.contains("top_level"),
2120 "top-level functions outside class should not appear"
2121 );
2122 }
2123
2124 #[test]
2125 fn test_f_section_excludes_methods() {
2126 use crate::types::{ClassInfo, FunctionInfo, SemanticAnalysis};
2127 use std::collections::HashMap;
2128
2129 let functions = vec![
2130 FunctionInfo {
2131 name: "method_a".to_string(),
2132 line: 5,
2133 end_line: 10,
2134 parameters: vec![],
2135 return_type: None,
2136 },
2137 FunctionInfo {
2138 name: "top_level".to_string(),
2139 line: 50,
2140 end_line: 55,
2141 parameters: vec![],
2142 return_type: None,
2143 },
2144 ];
2145
2146 let semantic = SemanticAnalysis {
2147 functions,
2148 classes: vec![ClassInfo {
2149 name: "TestClass".to_string(),
2150 line: 1,
2151 end_line: 30,
2152 methods: vec![],
2153 fields: vec![],
2154 inherits: vec![],
2155 }],
2156 imports: vec![],
2157 references: vec![],
2158 call_frequency: HashMap::new(),
2159 calls: vec![],
2160 impl_traits: vec![],
2161 def_use_sites: vec![],
2162 };
2163
2164 let output = format_file_details("test.rs", &semantic, 100, false, None);
2165
2166 assert!(output.contains("C:"), "classes section should exist");
2167 assert!(
2168 output.contains("method_a:5"),
2169 "method should be in C: section"
2170 );
2171 assert!(output.contains("F:"), "F: section should exist");
2172 assert!(
2173 output.contains("top_level"),
2174 "top-level function should be in F: section"
2175 );
2176
2177 let f_pos = output.find("F:").unwrap();
2179 let method_pos = output.find("method_a").unwrap();
2180 assert!(
2181 method_pos < f_pos,
2182 "method_a should appear before F: section"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_format_focused_paginated_unit() {
2188 use crate::graph::InternalCallChain;
2189 use crate::pagination::PaginationMode;
2190 use std::path::PathBuf;
2191
2192 let make_chain = |name: &str| -> InternalCallChain {
2194 InternalCallChain {
2195 chain: vec![
2196 (name.to_string(), PathBuf::from("src/lib.rs"), 10),
2197 ("target".to_string(), PathBuf::from("src/lib.rs"), 5),
2198 ],
2199 }
2200 };
2201
2202 let prod_chains: Vec<InternalCallChain> = (0..8)
2203 .map(|i| make_chain(&format!("caller_{}", i)))
2204 .collect();
2205 let page = &prod_chains[0..3];
2206
2207 let formatted = format_focused_paginated(
2209 page,
2210 8,
2211 PaginationMode::Callers,
2212 "target",
2213 &prod_chains,
2214 &[],
2215 &[],
2216 1,
2217 0,
2218 None,
2219 true,
2220 );
2221
2222 assert!(
2224 formatted.contains("CALLERS (1-3 of 8):"),
2225 "header should show 1-3 of 8, got: {}",
2226 formatted
2227 );
2228 assert!(
2229 formatted.contains("FOCUS: target"),
2230 "should have FOCUS header"
2231 );
2232 }
2233
2234 #[test]
2235 fn test_fields_none_regression() {
2236 use crate::types::SemanticAnalysis;
2237 use std::collections::HashMap;
2238
2239 let functions = vec![FunctionInfo {
2240 name: "hello".to_string(),
2241 line: 10,
2242 end_line: 15,
2243 parameters: vec![],
2244 return_type: None,
2245 }];
2246
2247 let classes = vec![ClassInfo {
2248 name: "MyClass".to_string(),
2249 line: 20,
2250 end_line: 50,
2251 methods: vec![],
2252 fields: vec![],
2253 inherits: vec![],
2254 }];
2255
2256 let imports = vec![ImportInfo {
2257 module: "std".to_string(),
2258 items: vec!["io".to_string()],
2259 line: 1,
2260 }];
2261
2262 let semantic = SemanticAnalysis {
2263 functions: functions.clone(),
2264 classes: classes.clone(),
2265 imports: imports.clone(),
2266 references: vec![],
2267 call_frequency: HashMap::new(),
2268 calls: vec![],
2269 impl_traits: vec![],
2270 def_use_sites: vec![],
2271 };
2272
2273 let output = format_file_details_paginated(
2274 &functions,
2275 functions.len(),
2276 &semantic,
2277 "test.rs",
2278 100,
2279 0,
2280 true,
2281 None,
2282 );
2283
2284 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2285 assert!(output.contains("C:"), "Classes section missing");
2286 assert!(output.contains("I:"), "Imports section missing");
2287 assert!(output.contains("F:"), "Functions section missing");
2288 }
2289
2290 #[test]
2291 fn test_fields_functions_only() {
2292 use crate::types::SemanticAnalysis;
2293 use std::collections::HashMap;
2294
2295 let functions = vec![FunctionInfo {
2296 name: "hello".to_string(),
2297 line: 10,
2298 end_line: 15,
2299 parameters: vec![],
2300 return_type: None,
2301 }];
2302
2303 let classes = vec![ClassInfo {
2304 name: "MyClass".to_string(),
2305 line: 20,
2306 end_line: 50,
2307 methods: vec![],
2308 fields: vec![],
2309 inherits: vec![],
2310 }];
2311
2312 let imports = vec![ImportInfo {
2313 module: "std".to_string(),
2314 items: vec!["io".to_string()],
2315 line: 1,
2316 }];
2317
2318 let semantic = SemanticAnalysis {
2319 functions: functions.clone(),
2320 classes: classes.clone(),
2321 imports: imports.clone(),
2322 references: vec![],
2323 call_frequency: HashMap::new(),
2324 calls: vec![],
2325 impl_traits: vec![],
2326 def_use_sites: vec![],
2327 };
2328
2329 let fields = Some(vec![AnalyzeFileField::Functions]);
2330 let output = format_file_details_paginated(
2331 &functions,
2332 functions.len(),
2333 &semantic,
2334 "test.rs",
2335 100,
2336 0,
2337 true,
2338 fields.as_deref(),
2339 );
2340
2341 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2342 assert!(!output.contains("C:"), "Classes section should not appear");
2343 assert!(!output.contains("I:"), "Imports section should not appear");
2344 assert!(output.contains("F:"), "Functions section missing");
2345 }
2346
2347 #[test]
2348 fn test_fields_classes_only() {
2349 use crate::types::SemanticAnalysis;
2350 use std::collections::HashMap;
2351
2352 let functions = vec![FunctionInfo {
2353 name: "hello".to_string(),
2354 line: 10,
2355 end_line: 15,
2356 parameters: vec![],
2357 return_type: None,
2358 }];
2359
2360 let classes = vec![ClassInfo {
2361 name: "MyClass".to_string(),
2362 line: 20,
2363 end_line: 50,
2364 methods: vec![],
2365 fields: vec![],
2366 inherits: vec![],
2367 }];
2368
2369 let imports = vec![ImportInfo {
2370 module: "std".to_string(),
2371 items: vec!["io".to_string()],
2372 line: 1,
2373 }];
2374
2375 let semantic = SemanticAnalysis {
2376 functions: functions.clone(),
2377 classes: classes.clone(),
2378 imports: imports.clone(),
2379 references: vec![],
2380 call_frequency: HashMap::new(),
2381 calls: vec![],
2382 impl_traits: vec![],
2383 def_use_sites: vec![],
2384 };
2385
2386 let fields = Some(vec![AnalyzeFileField::Classes]);
2387 let output = format_file_details_paginated(
2388 &functions,
2389 functions.len(),
2390 &semantic,
2391 "test.rs",
2392 100,
2393 0,
2394 true,
2395 fields.as_deref(),
2396 );
2397
2398 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2399 assert!(output.contains("C:"), "Classes section missing");
2400 assert!(!output.contains("I:"), "Imports section should not appear");
2401 assert!(
2402 !output.contains("F:"),
2403 "Functions section should not appear"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_fields_imports_verbose() {
2409 use crate::types::SemanticAnalysis;
2410 use std::collections::HashMap;
2411
2412 let functions = vec![FunctionInfo {
2413 name: "hello".to_string(),
2414 line: 10,
2415 end_line: 15,
2416 parameters: vec![],
2417 return_type: None,
2418 }];
2419
2420 let classes = vec![ClassInfo {
2421 name: "MyClass".to_string(),
2422 line: 20,
2423 end_line: 50,
2424 methods: vec![],
2425 fields: vec![],
2426 inherits: vec![],
2427 }];
2428
2429 let imports = vec![ImportInfo {
2430 module: "std".to_string(),
2431 items: vec!["io".to_string()],
2432 line: 1,
2433 }];
2434
2435 let semantic = SemanticAnalysis {
2436 functions: functions.clone(),
2437 classes: classes.clone(),
2438 imports: imports.clone(),
2439 references: vec![],
2440 call_frequency: HashMap::new(),
2441 calls: vec![],
2442 impl_traits: vec![],
2443 def_use_sites: vec![],
2444 };
2445
2446 let fields = Some(vec![AnalyzeFileField::Imports]);
2447 let output = format_file_details_paginated(
2448 &functions,
2449 functions.len(),
2450 &semantic,
2451 "test.rs",
2452 100,
2453 0,
2454 true,
2455 fields.as_deref(),
2456 );
2457
2458 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2459 assert!(!output.contains("C:"), "Classes section should not appear");
2460 assert!(output.contains("I:"), "Imports section missing");
2461 assert!(
2462 !output.contains("F:"),
2463 "Functions section should not appear"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_fields_imports_no_verbose() {
2469 use crate::types::SemanticAnalysis;
2470 use std::collections::HashMap;
2471
2472 let functions = vec![FunctionInfo {
2473 name: "hello".to_string(),
2474 line: 10,
2475 end_line: 15,
2476 parameters: vec![],
2477 return_type: None,
2478 }];
2479
2480 let classes = vec![ClassInfo {
2481 name: "MyClass".to_string(),
2482 line: 20,
2483 end_line: 50,
2484 methods: vec![],
2485 fields: vec![],
2486 inherits: vec![],
2487 }];
2488
2489 let imports = vec![ImportInfo {
2490 module: "std".to_string(),
2491 items: vec!["io".to_string()],
2492 line: 1,
2493 }];
2494
2495 let semantic = SemanticAnalysis {
2496 functions: functions.clone(),
2497 classes: classes.clone(),
2498 imports: imports.clone(),
2499 references: vec![],
2500 call_frequency: HashMap::new(),
2501 calls: vec![],
2502 impl_traits: vec![],
2503 def_use_sites: vec![],
2504 };
2505
2506 let fields = Some(vec![AnalyzeFileField::Imports]);
2507 let output = format_file_details_paginated(
2508 &functions,
2509 functions.len(),
2510 &semantic,
2511 "test.rs",
2512 100,
2513 0,
2514 false,
2515 fields.as_deref(),
2516 );
2517
2518 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2519 assert!(!output.contains("C:"), "Classes section should not appear");
2520 assert!(
2521 output.contains("I:"),
2522 "Imports section should appear (explicitly listed in fields)"
2523 );
2524 assert!(
2525 !output.contains("F:"),
2526 "Functions section should not appear"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_fields_empty_array() {
2532 use crate::types::SemanticAnalysis;
2533 use std::collections::HashMap;
2534
2535 let functions = vec![FunctionInfo {
2536 name: "hello".to_string(),
2537 line: 10,
2538 end_line: 15,
2539 parameters: vec![],
2540 return_type: None,
2541 }];
2542
2543 let classes = vec![ClassInfo {
2544 name: "MyClass".to_string(),
2545 line: 20,
2546 end_line: 50,
2547 methods: vec![],
2548 fields: vec![],
2549 inherits: vec![],
2550 }];
2551
2552 let imports = vec![ImportInfo {
2553 module: "std".to_string(),
2554 items: vec!["io".to_string()],
2555 line: 1,
2556 }];
2557
2558 let semantic = SemanticAnalysis {
2559 functions: functions.clone(),
2560 classes: classes.clone(),
2561 imports: imports.clone(),
2562 references: vec![],
2563 call_frequency: HashMap::new(),
2564 calls: vec![],
2565 impl_traits: vec![],
2566 def_use_sites: vec![],
2567 };
2568
2569 let fields = Some(vec![]);
2570 let output = format_file_details_paginated(
2571 &functions,
2572 functions.len(),
2573 &semantic,
2574 "test.rs",
2575 100,
2576 0,
2577 true,
2578 fields.as_deref(),
2579 );
2580
2581 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2582 assert!(
2583 output.contains("C:"),
2584 "Classes section missing (empty fields = show all)"
2585 );
2586 assert!(
2587 output.contains("I:"),
2588 "Imports section missing (empty fields = show all)"
2589 );
2590 assert!(
2591 output.contains("F:"),
2592 "Functions section missing (empty fields = show all)"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_fields_pagination_no_functions() {
2598 use crate::types::SemanticAnalysis;
2599 use std::collections::HashMap;
2600
2601 let functions = vec![FunctionInfo {
2602 name: "hello".to_string(),
2603 line: 10,
2604 end_line: 15,
2605 parameters: vec![],
2606 return_type: None,
2607 }];
2608
2609 let classes = vec![ClassInfo {
2610 name: "MyClass".to_string(),
2611 line: 20,
2612 end_line: 50,
2613 methods: vec![],
2614 fields: vec![],
2615 inherits: vec![],
2616 }];
2617
2618 let imports = vec![ImportInfo {
2619 module: "std".to_string(),
2620 items: vec!["io".to_string()],
2621 line: 1,
2622 }];
2623
2624 let semantic = SemanticAnalysis {
2625 functions: functions.clone(),
2626 classes: classes.clone(),
2627 imports: imports.clone(),
2628 references: vec![],
2629 call_frequency: HashMap::new(),
2630 calls: vec![],
2631 impl_traits: vec![],
2632 def_use_sites: vec![],
2633 };
2634
2635 let fields = Some(vec![AnalyzeFileField::Classes, AnalyzeFileField::Imports]);
2636 let output = format_file_details_paginated(
2637 &functions,
2638 functions.len(),
2639 &semantic,
2640 "test.rs",
2641 100,
2642 0,
2643 true,
2644 fields.as_deref(),
2645 );
2646
2647 assert!(output.contains("FILE: test.rs"), "FILE header missing");
2648 assert!(
2649 output.contains("1-1/1F"),
2650 "FILE header should contain valid range (1-1/1F)"
2651 );
2652 assert!(output.contains("C:"), "Classes section missing");
2653 assert!(output.contains("I:"), "Imports section missing");
2654 assert!(
2655 !output.contains("F:"),
2656 "Functions section should not appear (filtered by fields)"
2657 );
2658 }
2659
2660 #[test]
2661 fn test_snippet_one_line_short() {
2662 let snippet = "prev line\nlet x = 1;\nnext line";
2663 let result = snippet_one_line(snippet);
2664 assert_eq!(result, "let x = 1;");
2665 }
2666
2667 #[test]
2668 fn test_snippet_one_line_single_line() {
2669 let result = snippet_one_line("only line");
2670 assert_eq!(result, "only line");
2671 }
2672
2673 #[test]
2674 fn test_format_focused_internal_with_def_use_sites() {
2675 let mut graph = CallGraph::new();
2676 graph
2677 .definitions
2678 .insert("my_var".into(), vec![(PathBuf::from("src/lib.rs"), 5)]);
2679
2680 let site = |kind, file: &str, line, snippet: &str, scope: Option<&str>| DefUseSite {
2681 kind,
2682 symbol: "my_var".into(),
2683 file: file.into(),
2684 line,
2685 column: 0,
2686 snippet: snippet.into(),
2687 enclosing_scope: scope.map(Into::into),
2688 };
2689 let sites = vec![
2690 site(
2691 DefUseKind::Write,
2692 "src/lib.rs",
2693 5,
2694 "\nlet my_var = 42;\n",
2695 Some("init"),
2696 ),
2697 site(
2698 DefUseKind::WriteRead,
2699 "src/lib.rs",
2700 10,
2701 "\nmy_var += 1;\n",
2702 None,
2703 ),
2704 site(
2705 DefUseKind::Read,
2706 "src/main.rs",
2707 20,
2708 "\nprintln!(\"{}\", my_var);\n",
2709 Some("run"),
2710 ),
2711 ];
2712
2713 let output =
2714 format_focused_internal(&graph, "my_var", 1, None, Some(&[]), Some(&[]), &sites)
2715 .expect("format should succeed");
2716
2717 assert!(output.contains("DEF-USE SITES my_var (3 total: 2 writes, 1 reads)"));
2718 assert!(output.contains("WRITES"));
2719 assert!(output.contains("[write_read]"));
2720 assert!(output.contains("READS"));
2721 assert!(output.contains("run()"));
2722 }
2723
2724 #[test]
2725 fn test_format_focused_summary_internal_with_def_use_sites() {
2726 let mut graph = CallGraph::new();
2727 graph
2728 .definitions
2729 .insert("counter".into(), vec![(PathBuf::from("src/a.rs"), 1)]);
2730
2731 let sites = vec![DefUseSite {
2732 kind: DefUseKind::Read,
2733 symbol: "counter".into(),
2734 file: "src/b.rs".into(),
2735 line: 15,
2736 column: 0,
2737 snippet: "\nuse_counter(counter);\n".into(),
2738 enclosing_scope: Some("main".into()),
2739 }];
2740
2741 let output = format_focused_summary_internal(
2742 &graph,
2743 "counter",
2744 1,
2745 None,
2746 Some(&[]),
2747 Some(&[]),
2748 &sites,
2749 )
2750 .expect("format should succeed");
2751
2752 assert!(output.contains("DEF-USE SITES: 1 total (0 writes, 1 reads)"));
2753 }
2754
2755 #[test]
2756 fn test_analyze_file_field_all_equivalent_to_none() {
2757 use crate::types::AnalyzeFileField;
2758 let all_fields = [AnalyzeFileField::All];
2760 assert!(
2762 all_fields.contains(&AnalyzeFileField::All)
2763 || all_fields.contains(&AnalyzeFileField::Functions),
2764 "All variant should include Functions"
2765 );
2766 assert!(
2768 all_fields.contains(&AnalyzeFileField::All)
2769 || all_fields.contains(&AnalyzeFileField::Classes),
2770 "All variant should include Classes"
2771 );
2772 assert!(
2774 all_fields.contains(&AnalyzeFileField::All)
2775 || all_fields.contains(&AnalyzeFileField::Imports),
2776 "All variant should include Imports"
2777 );
2778 }
2779
2780 #[test]
2781 fn test_analyze_file_field_all_dominates() {
2782 use crate::types::AnalyzeFileField;
2783 let mixed = [AnalyzeFileField::All, AnalyzeFileField::Functions];
2785 assert!(mixed.contains(&AnalyzeFileField::All));
2786 assert!(
2788 mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Classes),
2789 "All dominates: Classes included even when not explicitly listed"
2790 );
2791 assert!(
2792 mixed.contains(&AnalyzeFileField::All) || mixed.contains(&AnalyzeFileField::Imports),
2793 "All dominates: Imports included even when not explicitly listed"
2794 );
2795 }
2796}
2797
2798fn format_classes_section(classes: &[ClassInfo], functions: &[FunctionInfo]) -> String {
2799 let mut output = String::new();
2800 if classes.is_empty() {
2801 return output;
2802 }
2803 output.push_str("C:\n");
2804
2805 let methods_by_class = collect_class_methods(classes, functions);
2806 let has_methods = methods_by_class.values().any(|m| !m.is_empty());
2807
2808 if classes.len() <= MULTILINE_THRESHOLD && !has_methods {
2809 let class_strs: Vec<String> = classes
2810 .iter()
2811 .map(|class| {
2812 if class.inherits.is_empty() {
2813 format!("{}:{}-{}", class.name, class.line, class.end_line)
2814 } else {
2815 format!(
2816 "{}:{}-{} ({})",
2817 class.name,
2818 class.line,
2819 class.end_line,
2820 class.inherits.join(", ")
2821 )
2822 }
2823 })
2824 .collect();
2825 output.push_str(" ");
2826 output.push_str(&class_strs.join("; "));
2827 output.push('\n');
2828 } else {
2829 for class in classes {
2830 if class.inherits.is_empty() {
2831 let _ = writeln!(output, " {}:{}-{}", class.name, class.line, class.end_line);
2832 } else {
2833 let _ = writeln!(
2834 output,
2835 " {}:{}-{} ({})",
2836 class.name,
2837 class.line,
2838 class.end_line,
2839 class.inherits.join(", ")
2840 );
2841 }
2842
2843 if let Some(methods) = methods_by_class.get(&class.name)
2845 && !methods.is_empty()
2846 {
2847 for (i, method) in methods.iter().take(10).enumerate() {
2848 let _ = writeln!(output, " {}:{}", method.name, method.line);
2849 if i + 1 == 10 && methods.len() > 10 {
2850 let _ = writeln!(output, " ... ({} more)", methods.len() - 10);
2851 break;
2852 }
2853 }
2854 }
2855 }
2856 }
2857 output
2858}
2859
2860fn format_imports_section(imports: &[ImportInfo]) -> String {
2863 let mut output = String::new();
2864 if imports.is_empty() {
2865 return output;
2866 }
2867 output.push_str("I:\n");
2868 let mut module_map: HashMap<String, usize> = HashMap::new();
2869 for import in imports {
2870 module_map
2871 .entry(import.module.clone())
2872 .and_modify(|count| *count += 1)
2873 .or_insert(1);
2874 }
2875 let mut modules: Vec<_> = module_map.keys().cloned().collect();
2876 modules.sort();
2877 let formatted_modules: Vec<String> = modules
2878 .iter()
2879 .map(|module| format!("{}({})", module, module_map[module]))
2880 .collect();
2881 if formatted_modules.len() <= MULTILINE_THRESHOLD {
2882 output.push_str(" ");
2883 output.push_str(&formatted_modules.join("; "));
2884 output.push('\n');
2885 } else {
2886 for module_str in formatted_modules {
2887 output.push_str(" ");
2888 output.push_str(&module_str);
2889 output.push('\n');
2890 }
2891 }
2892 output
2893}