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