Skip to main content

aptu_coder_core/
pagination.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Cursor-based pagination for large result sets.
4//!
5//! Provides encoding and decoding of pagination cursors to track position within result sets,
6//! supporting different pagination modes (default, callers, callees). Uses base64-encoded JSON.
7
8use base64::engine::general_purpose::STANDARD;
9use base64::{DecodeError, engine::Engine};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const DEFAULT_PAGE_SIZE: usize = 100;
14pub const MAX_PAGE_SIZE: usize = 10_000;
15
16/// Selects which call-chain direction a pagination cursor tracks.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum PaginationMode {
20    /// Standard file/directory listing or call-graph default traversal.
21    Default,
22    /// Paginating through the callers chain of a symbol.
23    Callers,
24    /// Paginating through the callees chain of a symbol.
25    Callees,
26    /// Paginating through def-use sites for a symbol.
27    DefUse,
28}
29
30/// Serializable state embedded in a pagination cursor.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CursorData {
33    /// Which chain direction this cursor belongs to.
34    pub mode: PaginationMode,
35    /// Zero-based index of the next item to return.
36    pub offset: usize,
37}
38
39#[derive(Debug, Error)]
40pub enum PaginationError {
41    #[error("Invalid cursor: {0}")]
42    InvalidCursor(String),
43}
44
45impl From<DecodeError> for PaginationError {
46    fn from(err: DecodeError) -> Self {
47        PaginationError::InvalidCursor(format!("Base64 decode error: {err}"))
48    }
49}
50
51impl From<serde_json::Error> for PaginationError {
52    fn from(err: serde_json::Error) -> Self {
53        PaginationError::InvalidCursor(format!("JSON parse error: {err}"))
54    }
55}
56
57/// Encode a cursor into a base64-encoded JSON string.
58///
59/// # Errors
60///
61/// Returns `PaginationError::InvalidCursor` if JSON serialization fails.
62pub fn encode_cursor(data: &CursorData) -> Result<String, PaginationError> {
63    let json = serde_json::to_string(data)?;
64    Ok(STANDARD.encode(json))
65}
66
67/// Decode a base64-encoded JSON cursor string.
68///
69/// # Errors
70///
71/// Returns `PaginationError::InvalidCursor` if base64 decoding fails, UTF-8 conversion fails, or JSON parsing fails.
72pub fn decode_cursor(cursor: &str) -> Result<CursorData, PaginationError> {
73    let decoded = STANDARD.decode(cursor)?;
74    let json_str = String::from_utf8(decoded)
75        .map_err(|e| PaginationError::InvalidCursor(format!("UTF-8 decode error: {e}")))?;
76    Ok(serde_json::from_str(&json_str)?)
77}
78
79#[derive(Debug, Clone)]
80pub struct PaginationResult<T> {
81    pub items: Vec<T>,
82    pub next_cursor: Option<String>,
83    pub total: usize,
84}
85
86/// Paginate a slice, returning a page of items and an optional next-page cursor.
87///
88/// Returns [`PaginationResult`] with the page items, total count, and a base64-encoded
89/// cursor for the next page (or `None` if this is the last page).
90///
91/// # Errors
92///
93/// Returns [`PaginationError`] if cursor encoding fails.
94pub fn paginate_slice<T: Clone>(
95    items: &[T],
96    offset: usize,
97    page_size: usize,
98    mode: PaginationMode,
99) -> Result<PaginationResult<T>, PaginationError> {
100    if page_size == 0 {
101        return Err(PaginationError::InvalidCursor(
102            "page_size must be at least 1".to_string(),
103        ));
104    }
105
106    let total = items.len();
107
108    if offset >= total {
109        return Ok(PaginationResult {
110            items: vec![],
111            next_cursor: None,
112            total,
113        });
114    }
115
116    let clamped_page_size = page_size.min(MAX_PAGE_SIZE);
117    let end = std::cmp::min(offset.saturating_add(clamped_page_size), total);
118    let page_items = items[offset..end].to_vec();
119
120    let next_cursor = if end < total {
121        let cursor_data = CursorData { mode, offset: end };
122        Some(encode_cursor(&cursor_data)?)
123    } else {
124        None
125    };
126
127    Ok(PaginationResult {
128        items: page_items,
129        next_cursor,
130        total,
131    })
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_cursor_encode_decode_roundtrip() {
140        let original = CursorData {
141            mode: PaginationMode::Default,
142            offset: 42,
143        };
144
145        let encoded = encode_cursor(&original).expect("encode failed");
146        let decoded = decode_cursor(&encoded).expect("decode failed");
147
148        assert_eq!(decoded.mode, original.mode);
149        assert_eq!(decoded.offset, original.offset);
150    }
151
152    #[test]
153    fn test_pagination_mode_wire_format() {
154        let cursor_data = CursorData {
155            mode: PaginationMode::Callers,
156            offset: 0,
157        };
158
159        let encoded = encode_cursor(&cursor_data).expect("encode failed");
160        let decoded = decode_cursor(&encoded).expect("decode failed");
161
162        assert_eq!(decoded.mode, PaginationMode::Callers);
163
164        let json_str = serde_json::to_string(&cursor_data).expect("serialize failed");
165        assert!(
166            json_str.contains("\"mode\":\"callers\""),
167            "expected lowercase 'callers' in JSON, got: {}",
168            json_str
169        );
170    }
171
172    #[test]
173    fn test_pagination_mode_defuse_roundtrip() {
174        let original = CursorData {
175            mode: PaginationMode::DefUse,
176            offset: 123,
177        };
178
179        let encoded = encode_cursor(&original).expect("encode failed");
180        let decoded = decode_cursor(&encoded).expect("decode failed");
181
182        assert_eq!(decoded.mode, PaginationMode::DefUse);
183        assert_eq!(decoded.offset, 123);
184    }
185
186    #[test]
187    fn test_paginate_slice_middle_page() {
188        let items: Vec<i32> = (0..250).collect();
189        let result =
190            paginate_slice(&items, 100, 100, PaginationMode::Default).expect("paginate failed");
191
192        assert_eq!(result.items.len(), 100);
193        assert_eq!(result.items[0], 100);
194        assert_eq!(result.items[99], 199);
195        assert!(result.next_cursor.is_some());
196        assert_eq!(result.total, 250);
197    }
198
199    #[test]
200    fn test_paginate_slice_empty_and_beyond() {
201        let empty: Vec<i32> = vec![];
202        let result =
203            paginate_slice(&empty, 0, 100, PaginationMode::Default).expect("paginate failed");
204        assert_eq!(result.items.len(), 0);
205        assert!(result.next_cursor.is_none());
206        assert_eq!(result.total, 0);
207
208        let items: Vec<i32> = (0..50).collect();
209        let result =
210            paginate_slice(&items, 100, 100, PaginationMode::Default).expect("paginate failed");
211        assert_eq!(result.items.len(), 0);
212        assert!(result.next_cursor.is_none());
213        assert_eq!(result.total, 50);
214    }
215
216    #[test]
217    fn test_paginate_slice_exact_boundary() {
218        let items: Vec<i32> = (0..200).collect();
219        let result =
220            paginate_slice(&items, 100, 100, PaginationMode::Default).expect("paginate failed");
221
222        assert_eq!(result.items.len(), 100);
223        assert_eq!(result.items[0], 100);
224        assert!(result.next_cursor.is_none());
225        assert_eq!(result.total, 200);
226    }
227
228    #[test]
229    fn test_invalid_cursor_error() {
230        let result = decode_cursor("not-valid-base64!!!");
231        assert!(result.is_err());
232        match result {
233            Err(PaginationError::InvalidCursor(msg)) => {
234                assert!(msg.contains("Base64") || msg.contains("decode"));
235            }
236            _ => panic!("Expected InvalidCursor error"),
237        }
238    }
239}