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