aptu_coder_core/formatter/
pagination.rs1use crate::formatter::emit;
6use crate::graph::InternalCallChain;
7use crate::pagination::PaginationMode;
8use crate::types::{AnalyzeFileField, FileInfo, FunctionInfo, SemanticAnalysis};
9use std::collections::{BTreeMap, HashSet};
10use std::fmt::Write;
11use std::path::Path;
12use tracing::instrument;
13
14pub(crate) fn format_chains_as_tree(
16 chains: &[(&str, &str)],
17 arrow: &str,
18 focus_symbol: &str,
19) -> String {
20 if chains.is_empty() {
21 return " (none)\n".to_string();
22 }
23
24 let mut output = String::new();
25
26 let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
28 for (parent, child) in chains {
29 if child.is_empty() {
31 groups.entry(parent.to_string()).or_default();
33 } else {
34 *groups
35 .entry(parent.to_string())
36 .or_default()
37 .entry(child.to_string())
38 .or_insert(0) += 1;
39 }
40 }
41
42 for (parent, children) in groups {
44 let _ = writeln!(output, " {focus_symbol} {arrow} {parent}");
45 let mut sorted: Vec<_> = children.into_iter().collect();
47 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
48 for (child, count) in sorted {
49 if count > 1 {
50 let _ = writeln!(output, " {arrow} {child} (x{count})");
51 } else {
52 let _ = writeln!(output, " {arrow} {child}");
53 }
54 }
55 }
56
57 output
58}
59
60#[instrument(skip_all)]
62pub fn format_structure_paginated(
63 paginated_files: &[FileInfo],
64 total_files: usize,
65 max_depth: Option<u32>,
66 base_path: Option<&Path>,
67 verbose: bool,
68) -> String {
69 let mut output = String::new();
70
71 let depth_label = match max_depth {
72 Some(n) if n > 0 => format!(" (max_depth={n})"),
73 _ => String::new(),
74 };
75 let _ = writeln!(
76 output,
77 "PAGINATED: showing {} of {} files{}\n",
78 paginated_files.len(),
79 total_files,
80 depth_label
81 );
82
83 let prod_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| !f.is_test).collect();
84 let test_files: Vec<&FileInfo> = paginated_files.iter().filter(|f| f.is_test).collect();
85
86 if !prod_files.is_empty() {
87 if verbose {
88 output.push_str("FILES [LOC, FUNCTIONS, CLASSES]\n");
89 }
90 for file in &prod_files {
91 output.push_str(&emit::format_file_entry(file, base_path));
92 }
93 }
94
95 if !test_files.is_empty() {
96 if verbose {
97 output.push_str("\nTEST FILES [LOC, FUNCTIONS, CLASSES]\n");
98 } else if !prod_files.is_empty() {
99 output.push('\n');
100 }
101 for file in &test_files {
102 output.push_str(&emit::format_file_entry(file, base_path));
103 }
104 }
105
106 output
107}
108
109#[instrument(skip_all)]
111#[allow(clippy::too_many_arguments)]
112pub fn format_file_details_paginated(
113 functions_page: &[FunctionInfo],
114 total_functions: usize,
115 semantic: &SemanticAnalysis,
116 path: &str,
117 line_count: usize,
118 offset: usize,
119 verbose: bool,
120 fields: Option<&[AnalyzeFileField]>,
121) -> String {
122 let mut output = String::new();
123
124 let start = offset + 1;
125 let end = offset + functions_page.len();
126
127 let _ = writeln!(
128 output,
129 "FILE: {} ({}L, {}-{}/{}F, {}C, {}I)",
130 path,
131 line_count,
132 start,
133 end,
134 total_functions,
135 semantic.classes.len(),
136 semantic.imports.len()
137 );
138
139 let show_all = fields.is_none_or(<[AnalyzeFileField]>::is_empty);
140 let show_classes = show_all
141 || fields.is_some_and(|f| {
142 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Classes)
143 });
144 let show_imports = show_all
145 || fields.is_some_and(|f| {
146 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Imports)
147 });
148 let show_functions = show_all
149 || fields.is_some_and(|f| {
150 f.contains(&AnalyzeFileField::All) || f.contains(&AnalyzeFileField::Functions)
151 });
152
153 if show_classes && offset == 0 && !semantic.classes.is_empty() {
154 output.push_str(&emit::format_classes_section(
155 &semantic.classes,
156 &semantic.functions,
157 ));
158 }
159
160 if show_imports && offset == 0 && (verbose || !show_all) {
161 output.push_str(&emit::format_imports_section(&semantic.imports));
162 }
163
164 let top_level_functions: Vec<&FunctionInfo> = functions_page
165 .iter()
166 .filter(|func| {
167 !semantic
168 .classes
169 .iter()
170 .any(|class| emit::is_method_of_class(func, class))
171 })
172 .collect();
173
174 if show_functions && !top_level_functions.is_empty() {
175 output.push_str("F:\n");
176 output.push_str(&emit::format_function_list_wrapped(
177 top_level_functions.iter().copied(),
178 &semantic.call_frequency,
179 ));
180 }
181
182 output
183}
184
185#[instrument(skip_all)]
187#[allow(clippy::too_many_arguments)]
188#[allow(clippy::similar_names)]
189pub fn format_focused_paginated(
190 paginated_chains: &[InternalCallChain],
191 total: usize,
192 mode: PaginationMode,
193 symbol: &str,
194 prod_chains: &[InternalCallChain],
195 test_chains: &[InternalCallChain],
196 outgoing_chains: &[InternalCallChain],
197 def_count: usize,
198 offset: usize,
199 base_path: Option<&Path>,
200 _verbose: bool,
201) -> String {
202 let start = offset + 1;
203 let end = offset + paginated_chains.len();
204
205 let callers_count = prod_chains.len();
206 let callees_count = outgoing_chains.len();
207
208 let mut output = String::new();
209
210 let _ = writeln!(
211 output,
212 "FOCUS: {symbol} ({def_count} defs, {callers_count} callers, {callees_count} callees)"
213 );
214
215 match mode {
216 PaginationMode::Callers => {
217 let _ = writeln!(output, "CALLERS ({start}-{end} of {total}):");
218
219 let page_refs: Vec<_> = paginated_chains
220 .iter()
221 .filter_map(|chain| {
222 if chain.chain.len() >= 2 {
223 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
224 } else if chain.chain.len() == 1 {
225 Some((chain.chain[0].0.as_str(), ""))
226 } else {
227 None
228 }
229 })
230 .collect();
231
232 if page_refs.is_empty() {
233 output.push_str(" (none)\n");
234 } else {
235 output.push_str(&format_chains_as_tree(&page_refs, "<-", symbol));
236 }
237
238 if !test_chains.is_empty() {
239 let mut test_files: Vec<_> = test_chains
240 .iter()
241 .filter_map(|chain| {
242 chain
243 .chain
244 .first()
245 .map(|(_, path, _)| path.to_string_lossy().into_owned())
246 })
247 .collect();
248 test_files.sort();
249 test_files.dedup();
250
251 let display_files: Vec<_> = test_files
252 .iter()
253 .map(|f| emit::strip_base_path(Path::new(f), base_path))
254 .collect();
255
256 let test_count = test_chains.len();
257 let _ = writeln!(
258 output,
259 "CALLERS (test): {test_count} test functions (in {})",
260 display_files.join(", ")
261 );
262 }
263
264 let callee_names: Vec<_> = outgoing_chains
265 .iter()
266 .filter_map(|chain| chain.chain.first().map(|(p, _, _)| p.clone()))
267 .collect::<HashSet<_>>()
268 .into_iter()
269 .collect();
270 if callee_names.is_empty() {
271 output.push_str("CALLEES: (none)\n");
272 } else {
273 let _ = writeln!(
274 output,
275 "CALLEES: {callees_count} (use cursor for callee pagination)"
276 );
277 }
278 }
279 PaginationMode::Callees => {
280 let _ = writeln!(output, "CALLERS: {callers_count} production callers");
281
282 if !test_chains.is_empty() {
283 let _ = writeln!(
284 output,
285 "CALLERS (test): {} test functions",
286 test_chains.len()
287 );
288 }
289
290 let _ = writeln!(output, "CALLEES ({start}-{end} of {total}):");
291
292 let page_refs: Vec<_> = paginated_chains
293 .iter()
294 .filter_map(|chain| {
295 if chain.chain.len() >= 2 {
296 Some((chain.chain[0].0.as_str(), chain.chain[1].0.as_str()))
297 } else if chain.chain.len() == 1 {
298 Some((chain.chain[0].0.as_str(), ""))
299 } else {
300 None
301 }
302 })
303 .collect();
304
305 if page_refs.is_empty() {
306 output.push_str(" (none)\n");
307 } else {
308 output.push_str(&format_chains_as_tree(&page_refs, "->", symbol));
309 }
310 }
311 PaginationMode::Default => {
312 unreachable!("format_focused_paginated called with PaginationMode::Default")
313 }
314 PaginationMode::DefUse => {
315 unreachable!("format_focused_paginated called with PaginationMode::DefUse")
316 }
317 }
318
319 output
320}