aptu_coder_core/
pagination.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum PaginationMode {
20 Default,
22 Callers,
24 Callees,
26 DefUse,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CursorData {
33 pub mode: PaginationMode,
35 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
57pub fn encode_cursor(data: &CursorData) -> Result<String, PaginationError> {
63 let json = serde_json::to_string(data)?;
64 Ok(STANDARD.encode(json))
65}
66
67pub 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
86pub 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}