Skip to main content

code_analyze_core/
pagination.rs

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