Skip to main content

aptu_coder_core/formatter/
pagination.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Paginated formatters for directory, file details, and focused symbol output.
4
5use 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
14/// Format chains as a tree-indented output, grouped by depth-1 symbol.
15pub(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    // Group chains by depth-1 symbol, counting duplicate children
27    let mut groups: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
28    for (parent, child) in chains {
29        // Only count non-empty children
30        if child.is_empty() {
31            // Ensure parent is in groups even if no children
32            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    // Render grouped tree
43    for (parent, children) in groups {
44        let _ = writeln!(output, "  {focus_symbol} {arrow} {parent}");
45        // Sort children by count descending, then alphabetically
46        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/// Format a paginated list of files for directory analysis results.
61#[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/// Format a paginated subset of functions for `FileDetails` mode.
110#[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/// Format a paginated subset of callers or callees for `SymbolFocus` mode.
186#[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}