Skip to main content

code_analyze_mcp/
pagination.rs

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