1use base64::Engine;
7use serde::Deserialize;
8
9pub const MAX_LIMIT: u32 = 100;
11pub const DEFAULT_LIMIT: u32 = 50;
13
14#[derive(Debug, Deserialize)]
16pub struct PaginationParams {
17 pub cursor: Option<String>,
19 pub limit: Option<u32>,
21}
22
23impl PaginationParams {
24 pub fn effective_limit(&self) -> u32 {
26 self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
27 }
28}
29
30pub fn encode_cursor(timestamp_ms: i64, rkey: &str) -> String {
32 let raw = format!("{}:{}", timestamp_ms, rkey);
33 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
34}
35
36pub fn decode_cursor(cursor: &str) -> Result<(i64, String), crate::error::AtrgError> {
38 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
39 .decode(cursor)
40 .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor".to_string()))?;
41
42 let s = String::from_utf8(bytes)
43 .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor encoding".to_string()))?;
44
45 let (ts_str, rkey) = s
46 .split_once(':')
47 .ok_or_else(|| crate::error::AtrgError::BadRequest("malformed cursor".to_string()))?;
48
49 let ts: i64 = ts_str
50 .parse()
51 .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor timestamp".to_string()))?;
52
53 Ok((ts, rkey.to_string()))
54}
55
56pub fn paginated_response<T: serde::Serialize>(
58 items: Vec<T>,
59 cursor: Option<String>,
60) -> serde_json::Value {
61 serde_json::json!({
62 "items": items,
63 "cursor": cursor,
64 })
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 #[test]
72 fn encode_decode_roundtrip() {
73 let encoded = encode_cursor(1234567890, "abc123");
74 let (ts, rkey) = decode_cursor(&encoded).unwrap();
75 assert_eq!(ts, 1234567890);
76 assert_eq!(rkey, "abc123");
77 }
78
79 #[test]
80 fn decode_invalid_base64() {
81 let result = decode_cursor("!!!not-base64!!!");
82 assert!(result.is_err());
83 }
84
85 #[test]
86 fn decode_missing_separator() {
87 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"noseparator");
88 let result = decode_cursor(&encoded);
89 assert!(result.is_err());
90 }
91
92 #[test]
93 fn decode_non_numeric_timestamp() {
94 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abc:key");
95 let result = decode_cursor(&encoded);
96 assert!(result.is_err());
97 }
98
99 #[test]
100 fn effective_limit_clamps() {
101 let p = PaginationParams {
102 cursor: None,
103 limit: Some(200),
104 };
105 assert_eq!(p.effective_limit(), 100);
106
107 let p = PaginationParams {
108 cursor: None,
109 limit: Some(0),
110 };
111 assert_eq!(p.effective_limit(), 1);
112
113 let p = PaginationParams {
114 cursor: None,
115 limit: None,
116 };
117 assert_eq!(p.effective_limit(), 50);
118 }
119
120 #[test]
121 fn paginated_response_shape() {
122 let resp = paginated_response(vec!["a", "b"], Some("cursor123".into()));
123 assert!(resp["items"].is_array());
124 assert_eq!(resp["cursor"], "cursor123");
125
126 let resp2 = paginated_response(vec!["x"], None::<String>);
127 assert!(resp2["cursor"].is_null());
128 }
129}