1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct PageCursor {
18 pub v: u8,
20 pub data_type: String,
22 pub offset: usize,
24 pub total: usize,
26 pub strategy: String,
28 pub budget: usize,
30}
31
32impl PageCursor {
33 pub fn new(
35 data_type: impl Into<String>,
36 offset: usize,
37 total: usize,
38 strategy: impl Into<String>,
39 budget: usize,
40 ) -> Self {
41 Self {
42 v: 1,
43 data_type: data_type.into(),
44 offset,
45 total,
46 strategy: strategy.into(),
47 budget,
48 }
49 }
50
51 pub fn encode(&self) -> String {
53 let json = serde_json::to_string(self).expect("PageCursor serialization should not fail");
54 URL_SAFE_NO_PAD.encode(json.as_bytes())
55 }
56
57 pub fn decode(encoded: &str) -> Result<Self, PaginationError> {
59 let bytes = URL_SAFE_NO_PAD
60 .decode(encoded)
61 .map_err(|e| PaginationError::InvalidCursor(format!("base64 decode: {e}")))?;
62
63 let json = String::from_utf8(bytes)
64 .map_err(|e| PaginationError::InvalidCursor(format!("UTF-8 decode: {e}")))?;
65
66 serde_json::from_str(&json)
67 .map_err(|e| PaginationError::InvalidCursor(format!("JSON parse: {e}")))
68 }
69
70 pub fn has_more(&self) -> bool {
72 self.offset < self.total
73 }
74
75 pub fn remaining(&self) -> usize {
77 self.total.saturating_sub(self.offset)
78 }
79
80 pub fn next_page(&self, items_in_current_page: usize) -> Option<Self> {
82 let next_offset = self.offset + items_in_current_page;
83 if next_offset >= self.total {
84 return None;
85 }
86 Some(Self {
87 v: self.v,
88 data_type: self.data_type.clone(),
89 offset: next_offset,
90 total: self.total,
91 strategy: self.strategy.clone(),
92 budget: self.budget,
93 })
94 }
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum PaginationError {
100 #[error("Invalid cursor: {0}")]
101 InvalidCursor(String),
102}
103
104pub fn create_pagination_hint(cursor: &PageCursor, items_shown: usize) -> String {
106 let remaining = cursor.remaining();
107 format!(
108 "Showing {}/{} {}. {} more available. Use `_page_cursor: \"{}\"` to get the next page.",
109 items_shown,
110 cursor.total,
111 cursor.data_type,
112 remaining,
113 cursor.encode()
114 )
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn test_cursor_encode_decode_roundtrip() {
123 let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
124 let encoded = cursor.encode();
125 let decoded = PageCursor::decode(&encoded).unwrap();
126 assert_eq!(cursor, decoded);
127 }
128
129 #[test]
130 fn test_cursor_fields() {
131 let cursor = PageCursor::new("diffs", 10, 30, "size_proportional", 4000);
132 assert_eq!(cursor.v, 1);
133 assert_eq!(cursor.data_type, "diffs");
134 assert_eq!(cursor.offset, 10);
135 assert_eq!(cursor.total, 30);
136 assert_eq!(cursor.strategy, "size_proportional");
137 assert_eq!(cursor.budget, 4000);
138 }
139
140 #[test]
141 fn test_cursor_has_more() {
142 let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
143 assert!(cursor.has_more());
144
145 let cursor = PageCursor::new("issues", 50, 50, "element_count", 8000);
146 assert!(!cursor.has_more());
147 }
148
149 #[test]
150 fn test_cursor_remaining() {
151 let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
152 assert_eq!(cursor.remaining(), 30);
153
154 let cursor = PageCursor::new("issues", 50, 50, "element_count", 8000);
155 assert_eq!(cursor.remaining(), 0);
156 }
157
158 #[test]
159 fn test_cursor_next_page() {
160 let cursor = PageCursor::new("issues", 0, 50, "element_count", 8000);
161 let next = cursor.next_page(20).unwrap();
162 assert_eq!(next.offset, 20);
163 assert_eq!(next.total, 50);
164
165 let next2 = next.next_page(20).unwrap();
166 assert_eq!(next2.offset, 40);
167
168 let next3 = next2.next_page(20);
169 assert!(next3.is_none(), "Should be None when offset >= total");
170 }
171
172 #[test]
173 fn test_cursor_next_page_exact_boundary() {
174 let cursor = PageCursor::new("issues", 30, 50, "element_count", 8000);
175 let next = cursor.next_page(20);
176 assert!(next.is_none(), "30 + 20 = 50 = total, no more pages");
177 }
178
179 #[test]
180 fn test_decode_invalid_base64() {
181 let result = PageCursor::decode("not-valid-base64!!!");
182 assert!(result.is_err());
183 }
184
185 #[test]
186 fn test_decode_invalid_json() {
187 let encoded = URL_SAFE_NO_PAD.encode(b"not json");
188 let result = PageCursor::decode(&encoded);
189 assert!(result.is_err());
190 }
191
192 #[test]
193 fn test_create_pagination_hint() {
194 let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
195 let hint = create_pagination_hint(&cursor, 20);
196 assert!(hint.contains("20/50"));
197 assert!(hint.contains("30 more"));
198 assert!(hint.contains("_page_cursor"));
199 }
200
201 #[test]
202 fn test_cursor_encode_is_compact() {
203 let cursor = PageCursor::new("issues", 0, 100, "element_count", 8000);
204 let encoded = cursor.encode();
205 assert!(
207 encoded.len() < 200,
208 "Encoded cursor should be compact, got {} chars",
209 encoded.len()
210 );
211 }
212
213 #[test]
214 fn test_multi_page_simulation() {
215 let total = 50;
216 let page_size = 15;
217 let mut cursor = PageCursor::new("issues", 0, total, "element_count", 8000);
218 let mut pages = 0;
219 let mut total_items = 0;
220
221 loop {
222 let items_this_page = page_size.min(cursor.remaining());
223 total_items += items_this_page;
224 pages += 1;
225
226 match cursor.next_page(items_this_page) {
227 Some(next) => cursor = next,
228 None => break,
229 }
230 }
231
232 assert_eq!(pages, 4); assert_eq!(total_items, 50);
234 }
235}