1use crate::formatter::emit;
6use crate::graph::{CallGraph, InternalCallChain};
7use crate::test_detection::is_test_file;
8use crate::traversal::WalkEntry;
9use crate::types::{DefUseKind, DefUseSite, FileInfo};
10use std::collections::{HashMap, HashSet};
11use std::fmt::Write;
12use std::path::{Path, PathBuf};
13use tracing::instrument;
14
15use super::render::FormatterError;
16
17#[instrument(skip_all)]
19#[allow(clippy::too_many_lines)]
20pub fn format_structure(
21 entries: &[WalkEntry],
22 analysis_results: &[FileInfo],
23 max_depth: Option<u32>,
24) -> String {
25 let mut output = String::new();
26
27 let analysis_map: HashMap<String, &FileInfo> = analysis_results
28 .iter()
29 .map(|a| (a.path.clone(), a))
30 .collect();
31
32 let (prod_files, test_files): (Vec<_>, Vec<_>) =
33 analysis_results.iter().partition(|a| !a.is_test);
34
35 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
36 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
37 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
38
39 let mut lang_counts: HashMap<String, usize> = HashMap::new();
40 for analysis in analysis_results {
41 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
42 }
43 let total_files = analysis_results.len();
44
45 let primary_lang = lang_counts
47 .iter()
48 .max_by_key(|&(_, count)| count)
49 .map_or_else(
50 || "unknown 0%".to_string(),
51 |(name, count)| {
52 let percentage = (*count * 100).checked_div(total_files).unwrap_or_default();
53 format!("{name} {percentage}%")
54 },
55 );
56
57 let _ = writeln!(
58 output,
59 "{total_files} files, {total_loc}L, {total_functions}F, {total_classes}C ({primary_lang})"
60 );
61
62 output.push_str("SUMMARY:\n");
64 let depth_label = match max_depth {
65 Some(n) if n > 0 => format!(" (max_depth={n})"),
66 _ => String::new(),
67 };
68 let _ = writeln!(
69 output,
70 "Shown: {} files ({} prod, {} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}",
71 total_files,
72 prod_files.len(),
73 test_files.len()
74 );
75
76 if !lang_counts.is_empty() {
77 output.push_str("Languages: ");
78 let mut langs: Vec<_> = lang_counts.iter().collect();
79 langs.sort_by_key(|&(name, _)| name);
80 let lang_strs: Vec<String> = langs
81 .iter()
82 .map(|(name, count)| {
83 let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
84 format!("{name} ({percentage}%)")
85 })
86 .collect();
87 output.push_str(&lang_strs.join(", "));
88 output.push('\n');
89 }
90
91 output.push('\n');
92
93 output.push_str("PATH [LOC, FUNCTIONS, CLASSES]\n");
95
96 let mut test_buf = String::new();
97
98 for entry in entries {
99 if entry.depth == 0 {
100 continue;
101 }
102
103 let indent = " ".repeat(entry.depth - 1);
104
105 let name = entry
106 .path
107 .file_name()
108 .and_then(|n| n.to_str())
109 .unwrap_or("?");
110
111 if entry.is_dir {
112 let line = format!("{indent}{name}/\n");
113 output.push_str(&line);
114 } else if let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
115 && let Some(info_str) = emit::format_file_info_parts(
116 analysis.line_count,
117 analysis.function_count,
118 analysis.class_count,
119 )
120 {
121 let line = format!("{indent}{name} {info_str}\n");
122 if analysis.is_test {
123 test_buf.push_str(&line);
124 } else {
125 output.push_str(&line);
126 }
127 }
128 }
129
130 if !test_buf.is_empty() {
131 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
132 output.push_str(&test_buf);
133 }
134
135 output
136}
137
138pub fn format_summary(
139 entries: &[WalkEntry],
140 analysis_results: &[FileInfo],
141 max_depth: Option<u32>,
142 subtree_counts: Option<&[(PathBuf, usize)]>,
143) -> String {
144 let mut output = String::new();
145
146 let (prod_files, test_files): (Vec<_>, Vec<_>) =
148 analysis_results.iter().partition(|a| !a.is_test);
149
150 let total_loc: usize = analysis_results.iter().map(|a| a.line_count).sum();
152 let total_functions: usize = analysis_results.iter().map(|a| a.function_count).sum();
153 let total_classes: usize = analysis_results.iter().map(|a| a.class_count).sum();
154
155 let mut lang_counts: HashMap<String, usize> = HashMap::new();
157 for analysis in analysis_results {
158 *lang_counts.entry(analysis.language.clone()).or_insert(0) += 1;
159 }
160 let total_files = analysis_results.len();
161
162 output.push_str("SUMMARY:\n");
164 let depth_label = match max_depth {
165 Some(n) if n > 0 => format!(" (max_depth={n})"),
166 _ => String::new(),
167 };
168 let prod_count = prod_files.len();
169 let test_count = test_files.len();
170 let _ = writeln!(
171 output,
172 "{total_files} files ({prod_count} prod, {test_count} test), {total_loc}L, {total_functions}F, {total_classes}C{depth_label}"
173 );
174
175 if !lang_counts.is_empty() {
176 output.push_str("Languages: ");
177 let mut langs: Vec<_> = lang_counts.iter().collect();
178 langs.sort_unstable_by_key(|&(name, _)| name);
179 let lang_strs: Vec<String> = langs
180 .iter()
181 .map(|(name, count)| {
182 let percentage = (**count * 100).checked_div(total_files).unwrap_or_default();
183 format!("{name} ({percentage}%)")
184 })
185 .collect();
186 output.push_str(&lang_strs.join(", "));
187 output.push('\n');
188 }
189
190 output.push('\n');
191
192 output.push_str("STRUCTURE (depth 1):\n");
194
195 let analysis_map: HashMap<String, &FileInfo> = analysis_results
197 .iter()
198 .map(|a| (a.path.clone(), a))
199 .collect();
200
201 let mut depth1_entries: Vec<&WalkEntry> = entries.iter().filter(|e| e.depth == 1).collect();
203 depth1_entries.sort_by(|a, b| a.path.cmp(&b.path));
204
205 let mut largest_dir_name: Option<String> = None;
207 let mut largest_dir_path: Option<String> = None;
208 let mut largest_dir_count: usize = 0;
209
210 for entry in depth1_entries {
211 let name = entry
212 .path
213 .file_name()
214 .and_then(|n| n.to_str())
215 .unwrap_or("?");
216
217 if entry.is_dir {
218 let dir_path_str = entry.path.display().to_string();
220 let files_in_dir: Vec<&FileInfo> = analysis_results
221 .iter()
222 .filter(|f| Path::new(&f.path).starts_with(&entry.path))
223 .collect();
224
225 if files_in_dir.is_empty() {
226 let entry_name_str = name.to_string();
228 if let Some(counts) = subtree_counts {
229 let true_count = counts
230 .binary_search_by_key(&&entry.path, |(p, _)| p)
231 .ok()
232 .map_or(0, |i| counts[i].1);
233 if true_count > 0 {
234 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
236 && true_count > largest_dir_count
237 {
238 largest_dir_count = true_count;
239 largest_dir_name = Some(entry_name_str);
240 largest_dir_path = Some(
241 entry
242 .path
243 .canonicalize()
244 .unwrap_or_else(|_| entry.path.clone())
245 .display()
246 .to_string(),
247 );
248 }
249 let depth_val = max_depth.unwrap_or(0);
250 let _ = writeln!(
251 output,
252 " {name}/ [{true_count} files total; showing 0 at depth={depth_val}, 0L, 0F, 0C]"
253 );
254 } else {
255 let _ = writeln!(output, " {name}/");
256 }
257 } else {
258 let _ = writeln!(output, " {name}/");
259 }
260 } else {
261 let dir_file_count = files_in_dir.len();
262 let (dir_loc, dir_functions, dir_classes) =
263 emit::aggregate_dir_stats(&files_in_dir);
264
265 let entry_name_str = name.to_string();
267 let effective_count = if let Some(counts) = subtree_counts {
268 counts
269 .binary_search_by_key(&&entry.path, |(p, _)| p)
270 .ok()
271 .map_or(dir_file_count, |i| counts[i].1)
272 } else {
273 dir_file_count
274 };
275 if !crate::EXCLUDED_DIRS.contains(&entry_name_str.as_str())
276 && effective_count > largest_dir_count
277 {
278 largest_dir_count = effective_count;
279 largest_dir_name = Some(entry_name_str);
280 largest_dir_path = Some(
281 entry
282 .path
283 .canonicalize()
284 .unwrap_or_else(|_| entry.path.clone())
285 .display()
286 .to_string(),
287 );
288 }
289
290 let hint = if files_in_dir.len() > 1 && (dir_classes > 0 || dir_functions > 0) {
292 let has_classes = files_in_dir.iter().any(|f| f.class_count > 0);
293 let dir_path = Path::new(&dir_path_str);
294 emit::render_top_files_section(&files_in_dir, dir_path, has_classes)
295 } else {
296 String::new()
297 };
298
299 let mut subdirs: Vec<String> = entries
301 .iter()
302 .filter(|e| e.depth == 2 && e.is_dir && e.path.starts_with(&entry.path))
303 .filter_map(|e| {
304 e.path
305 .file_name()
306 .and_then(|n| n.to_str())
307 .map(std::borrow::ToOwned::to_owned)
308 })
309 .collect();
310 subdirs.sort();
311 subdirs.dedup();
312 let subdir_suffix = if subdirs.is_empty() {
313 String::new()
314 } else {
315 let subdirs_capped: Vec<String> =
316 subdirs.iter().take(5).map(|s| format!("{s}/")).collect();
317 let joined = subdirs_capped.join(", ");
318 format!(" sub: {joined}")
319 };
320
321 let files_label = if let Some(counts) = subtree_counts {
322 let true_count = counts
323 .binary_search_by_key(&&entry.path, |(p, _)| p)
324 .ok()
325 .map_or(dir_file_count, |i| counts[i].1);
326 if true_count == dir_file_count {
327 format!(
328 "{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C"
329 )
330 } else {
331 let depth_val = max_depth.unwrap_or(0);
332 format!(
333 "{true_count} files total; showing {dir_file_count} at depth={depth_val}, {dir_loc}L, {dir_functions}F, {dir_classes}C"
334 )
335 }
336 } else {
337 format!("{dir_file_count} files, {dir_loc}L, {dir_functions}F, {dir_classes}C")
338 };
339 let _ = writeln!(output, " {name}/ [{files_label}]{hint}{subdir_suffix}");
340 }
341 } else {
342 if let Some(analysis) = analysis_map.get(&entry.path.display().to_string())
344 && let Some(info_str) = emit::format_file_info_parts(
345 analysis.line_count,
346 analysis.function_count,
347 analysis.class_count,
348 )
349 {
350 let _ = writeln!(output, " {name} {info_str}");
351 } else if analysis_map.contains_key(&entry.path.display().to_string()) {
352 let _ = writeln!(output, " {name}");
353 }
354 }
355 }
356
357 output.push('\n');
358
359 if let (Some(name), Some(path)) = (largest_dir_name, largest_dir_path) {
361 let _ = writeln!(
362 output,
363 "SUGGESTION: Largest source directory: {name}/ ({largest_dir_count} files total). For module details, re-run with path={path} and max_depth=2."
364 );
365 } else {
366 output.push_str("SUGGESTION:\n");
367 output.push_str("Use a narrower path for details (e.g., analyze src/core/)\n");
368 }
369
370 output
371}
372
373#[instrument(skip_all)]
378
379pub(crate) fn format_focused_internal(
381 graph: &CallGraph,
382 symbol: &str,
383 follow_depth: u32,
384 base_path: Option<&Path>,
385 incoming_chains: Option<&[InternalCallChain]>,
386 outgoing_chains: Option<&[InternalCallChain]>,
387 def_use_sites: &[DefUseSite],
388) -> Result<String, FormatterError> {
389 let mut output = String::new();
390
391 let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
393
394 let (incoming_chains_vec, outgoing_chains_vec);
396 let (incoming_chains_ref, outgoing_chains_ref) =
397 if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
398 (inc, out)
399 } else {
400 incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
401 outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
402 (
403 incoming_chains_vec.as_slice(),
404 outgoing_chains_vec.as_slice(),
405 )
406 };
407
408 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
410 incoming_chains_ref.iter().cloned().partition(|chain| {
411 chain
412 .chain
413 .first()
414 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
415 });
416
417 let callers_count = prod_chains
419 .iter()
420 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
421 .collect::<std::collections::HashSet<_>>()
422 .len();
423
424 let callees_count = outgoing_chains_ref
426 .iter()
427 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
428 .collect::<std::collections::HashSet<_>>()
429 .len();
430
431 let _ = writeln!(
433 output,
434 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
435 );
436
437 let _ = writeln!(output, "DEPTH: {follow_depth}");
439
440 if let Some(definitions) = graph.definitions.get(symbol) {
442 output.push_str("DEFINED:\n");
443 for (path, line) in definitions {
444 let display = emit::strip_base_path(path, base_path);
445 let _ = writeln!(output, " {display}:{line}");
446 }
447 } else {
448 output.push_str("DEFINED: (not found)\n");
449 }
450
451 output.push_str("CALLERS:\n");
453
454 let prod_refs: Vec<_> = prod_chains
456 .iter()
457 .filter_map(|chain| {
458 if chain.chain.len() >= 2 {
459 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
460 } else if chain.chain.len() == 1 {
461 Some((chain.chain[0].0.as_str(), ""))
462 } else {
463 None
464 }
465 })
466 .collect();
467
468 if prod_refs.is_empty() {
469 output.push_str(" (none)\n");
470 } else {
471 output.push_str(&crate::formatter::pagination::format_chains_as_tree(
472 &prod_refs, "<-", symbol,
473 ));
474 }
475
476 if !test_chains.is_empty() {
478 let mut test_files: Vec<_> = test_chains
479 .iter()
480 .filter_map(|chain| {
481 chain
482 .chain
483 .first()
484 .map(|(_, path, _)| path.to_string_lossy().into_owned())
485 })
486 .collect();
487 test_files.sort();
488 test_files.dedup();
489
490 let display_files: Vec<_> = test_files
492 .iter()
493 .map(|f| emit::strip_base_path(Path::new(f), base_path))
494 .collect();
495
496 let file_list = display_files.join(", ");
497 let test_count = test_chains.len();
498 let _ = writeln!(
499 output,
500 "CALLERS (test): {test_count} test functions (in {file_list})"
501 );
502 }
503
504 output.push_str("CALLEES:\n");
506 let outgoing_refs: Vec<_> = outgoing_chains_ref
507 .iter()
508 .filter_map(|chain| {
509 if chain.chain.len() >= 2 {
510 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
511 } else if chain.chain.len() == 1 {
512 Some((chain.chain[0].0.as_str(), ""))
513 } else {
514 None
515 }
516 })
517 .collect();
518
519 if outgoing_refs.is_empty() {
520 output.push_str(" (none)\n");
521 } else {
522 output.push_str(&crate::formatter::pagination::format_chains_as_tree(
523 &outgoing_refs,
524 "->",
525 symbol,
526 ));
527 }
528
529 let mut files: HashSet<PathBuf> = HashSet::new();
531 for chain in &prod_chains {
532 for (_, path, _) in &chain.chain {
533 files.insert(path.clone());
534 }
535 }
536 for chain in outgoing_chains_ref {
537 for (_, path, _) in &chain.chain {
538 files.insert(path.clone());
539 }
540 }
541 if let Some(definitions) = graph.definitions.get(symbol) {
542 for (path, _) in definitions {
543 files.insert(path.clone());
544 }
545 }
546
547 let (prod_files, test_files): (Vec<_>, Vec<_>) =
549 files.into_iter().partition(|path| !is_test_file(path));
550
551 output.push_str("FILES:\n");
552 if prod_files.is_empty() && test_files.is_empty() {
553 output.push_str(" (none)\n");
554 } else {
555 if !prod_files.is_empty() {
557 let mut sorted_files = prod_files;
558 sorted_files.sort();
559 for file in sorted_files {
560 let display = emit::strip_base_path(&file, base_path);
561 let _ = writeln!(output, " {display}");
562 }
563 }
564
565 if !test_files.is_empty() {
567 output.push_str(" TEST FILES:\n");
568 let mut sorted_files = test_files;
569 sorted_files.sort();
570 for file in sorted_files {
571 let display = emit::strip_base_path(&file, base_path);
572 let _ = writeln!(output, " {display}");
573 }
574 }
575 }
576
577 if !def_use_sites.is_empty() {
579 let (write_count, read_count) = emit::def_use_write_read_counts(def_use_sites);
580 let total = def_use_sites.len();
581 let _ = writeln!(
582 output,
583 "DEF-USE SITES {symbol} ({total} total: {write_count} writes, {read_count} reads)"
584 );
585
586 emit::render_def_use_group(
587 &mut output,
588 def_use_sites,
589 "WRITES",
590 |s| matches!(s.kind, DefUseKind::Write | DefUseKind::WriteRead),
591 base_path,
592 );
593 emit::render_def_use_group(
594 &mut output,
595 def_use_sites,
596 "READS",
597 |s| s.kind == DefUseKind::Read,
598 base_path,
599 );
600 }
601
602 Ok(output)
603}
604
605#[instrument(skip_all)]
609#[allow(clippy::too_many_lines)] #[allow(clippy::similar_names)] pub(crate) fn format_focused_summary_internal(
612 graph: &CallGraph,
613 symbol: &str,
614 follow_depth: u32,
615 base_path: Option<&Path>,
616 incoming_chains: Option<&[InternalCallChain]>,
617 outgoing_chains: Option<&[InternalCallChain]>,
618 def_use_sites: &[DefUseSite],
619) -> Result<String, FormatterError> {
620 let mut output = String::new();
621
622 let def_count = graph.definitions.get(symbol).map_or(0, Vec::len);
624
625 let (incoming_chains_vec, outgoing_chains_vec);
627 let (incoming_chains_ref, outgoing_chains_ref) =
628 if let (Some(inc), Some(out)) = (incoming_chains, outgoing_chains) {
629 (inc, out)
630 } else {
631 incoming_chains_vec = graph.find_incoming_chains(symbol, follow_depth)?;
632 outgoing_chains_vec = graph.find_outgoing_chains(symbol, follow_depth)?;
633 (
634 incoming_chains_vec.as_slice(),
635 outgoing_chains_vec.as_slice(),
636 )
637 };
638
639 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
641 incoming_chains_ref.iter().cloned().partition(|chain| {
642 chain
643 .chain
644 .first()
645 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
646 });
647
648 let callers_count = prod_chains
650 .iter()
651 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
652 .collect::<std::collections::HashSet<_>>()
653 .len();
654
655 let callees_count = outgoing_chains_ref
657 .iter()
658 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p))
659 .collect::<std::collections::HashSet<_>>()
660 .len();
661
662 let _ = writeln!(
664 output,
665 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
666 );
667
668 let _ = writeln!(output, "DEPTH: {follow_depth}");
670
671 if let Some(definitions) = graph.definitions.get(symbol) {
673 output.push_str("DEFINED:\n");
674 for (path, line) in definitions {
675 let display = emit::strip_base_path(path, base_path);
676 let _ = writeln!(output, " {display}:{line}");
677 }
678 } else {
679 output.push_str("DEFINED: (not found)\n");
680 }
681
682 output.push_str("CALLERS (top 10):\n");
684 if prod_chains.is_empty() {
685 output.push_str(" (none)\n");
686 } else {
687 let mut caller_freq: std::collections::HashMap<String, (usize, String)> =
689 std::collections::HashMap::new();
690 for chain in &prod_chains {
691 if let Some((name, path, _)) = chain.chain.first() {
692 let file_path = emit::strip_base_path(path, base_path);
693 caller_freq
694 .entry(name.clone())
695 .and_modify(|(count, _)| *count += 1)
696 .or_insert((1, file_path));
697 }
698 }
699
700 let mut sorted_callers: Vec<_> = caller_freq.into_iter().collect();
702 sorted_callers.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.0));
703
704 for (name, (_, file_path)) in sorted_callers.into_iter().take(10) {
705 let _ = writeln!(output, " {name} {file_path}");
706 }
707 }
708
709 if !test_chains.is_empty() {
711 let mut test_files: Vec<_> = test_chains
712 .iter()
713 .filter_map(|chain| {
714 chain
715 .chain
716 .first()
717 .map(|(_, path, _)| path.to_string_lossy().into_owned())
718 })
719 .collect();
720 test_files.sort();
721 test_files.dedup();
722
723 let test_count = test_chains.len();
724 let test_file_count = test_files.len();
725 let _ = writeln!(
726 output,
727 "CALLERS (test): {test_count} test functions (in {test_file_count} files)"
728 );
729 }
730
731 output.push_str("CALLEES (top 10):\n");
733 if outgoing_chains_ref.is_empty() {
734 output.push_str(" (none)\n");
735 } else {
736 let mut callee_freq: std::collections::HashMap<String, usize> =
738 std::collections::HashMap::new();
739 for chain in outgoing_chains_ref {
740 if let Some((name, _, _)) = chain.chain.first() {
741 *callee_freq.entry(name.clone()).or_insert(0) += 1;
742 }
743 }
744
745 let mut sorted_callees: Vec<_> = callee_freq.into_iter().collect();
747 sorted_callees.sort_unstable_by_key(|b| std::cmp::Reverse(b.1));
748
749 for (name, _) in sorted_callees.into_iter().take(10) {
750 let _ = writeln!(output, " {name}");
751 }
752 }
753
754 output.push_str("SUGGESTION:\n");
756 output.push_str("Use summary=false with force=true for full output\n");
757
758 if !def_use_sites.is_empty() {
760 let (write_count, read_count) = emit::def_use_write_read_counts(def_use_sites);
761 let total = def_use_sites.len();
762 let _ = writeln!(
763 output,
764 "DEF-USE SITES: {total} total ({write_count} writes, {read_count} reads)",
765 );
766 }
767
768 Ok(output)
769}
770
771pub fn format_focused_summary(
774 graph: &CallGraph,
775 symbol: &str,
776 follow_depth: u32,
777 base_path: Option<&Path>,
778) -> Result<String, FormatterError> {
779 format_focused_summary_internal(graph, symbol, follow_depth, base_path, None, None, &[])
780}