use crate::builder::Value;
use super::encoding::{base64_decode, base64_encode, escape_json, split_json_pairs, unescape_json};
const MAX_CURSOR_SIZE: usize = 4 * 1024;
const MAX_CURSOR_FIELDS: usize = 16;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[must_use = "cursor must be encoded with .encode() or used with a query builder"]
pub struct Cursor {
pub fields: Vec<(String, Value)>,
}
impl Cursor {
#[must_use]
pub const fn new() -> Self {
Self { fields: Vec::new() }
}
pub fn field(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
self.fields.push((name.into(), value.into()));
self
}
pub fn int(self, name: impl Into<String>, value: i64) -> Self {
self.field(name, Value::Int(value))
}
pub fn string(self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.field(name, Value::String(value.into()))
}
#[must_use]
pub fn encode(&self) -> String {
let json = self.to_json();
base64_encode(&json)
}
pub fn decode(encoded: &str) -> Result<Self, CursorError> {
if encoded.len() > MAX_CURSOR_SIZE {
return Err(CursorError::TooLarge);
}
let json = base64_decode(encoded).map_err(|()| CursorError::InvalidBase64)?;
Self::from_json(&json)
}
fn to_json(&self) -> String {
let mut parts = Vec::new();
for (name, value) in &self.fields {
let val_str = match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => format!("\"{}\"", escape_json(s)),
Value::Array(_) => continue, };
parts.push(format!("\"{name}\":{val_str}"));
}
format!("{{{}}}", parts.join(","))
}
fn from_json(json: &str) -> Result<Self, CursorError> {
let mut cursor = Self::new();
let json = json.trim();
if !json.starts_with('{') || !json.ends_with('}') {
return Err(CursorError::InvalidFormat);
}
let inner = &json[1..json.len() - 1];
if inner.is_empty() {
return Ok(cursor);
}
for pair in split_json_pairs(inner) {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let colon_idx = pair.find(':').ok_or(CursorError::InvalidFormat)?;
let key = pair[..colon_idx].trim();
let value = pair[colon_idx + 1..].trim();
if !key.starts_with('"') || !key.ends_with('"') {
return Err(CursorError::InvalidFormat);
}
let key = &key[1..key.len() - 1];
let parsed_value = if value == "null" {
Value::Null
} else if value == "true" {
Value::Bool(true)
} else if value == "false" {
Value::Bool(false)
} else if value.starts_with('"') && value.ends_with('"') {
Value::String(unescape_json(&value[1..value.len() - 1]))
} else if value.contains('.') {
value
.parse::<f64>()
.map(Value::Float)
.map_err(|_| CursorError::InvalidFormat)?
} else {
value
.parse::<i64>()
.map(Value::Int)
.map_err(|_| CursorError::InvalidFormat)?
};
cursor.fields.push((key.to_string(), parsed_value));
if cursor.fields.len() > MAX_CURSOR_FIELDS {
return Err(CursorError::TooManyFields);
}
}
Ok(cursor)
}
}
impl Default for Cursor {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CursorError {
InvalidBase64,
InvalidFormat,
TooLarge,
TooManyFields,
}
impl std::fmt::Display for CursorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBase64 => write!(f, "invalid base64 encoding in cursor"),
Self::InvalidFormat => write!(f, "invalid cursor format (expected JSON object)"),
Self::TooLarge => write!(
f,
"cursor exceeds maximum size ({}KB limit)",
MAX_CURSOR_SIZE / 1024
),
Self::TooManyFields => {
write!(f, "cursor has too many fields (max {MAX_CURSOR_FIELDS})")
},
}
}
}
impl std::error::Error for CursorError {}
impl CursorError {
#[inline]
#[must_use]
pub const fn is_format_error(&self) -> bool {
matches!(self, Self::InvalidBase64 | Self::InvalidFormat)
}
#[inline]
#[must_use]
pub const fn is_limit_error(&self) -> bool {
matches!(self, Self::TooLarge | Self::TooManyFields)
}
}
pub trait IntoCursor {
fn into_cursor(self) -> Option<Cursor>;
}
impl IntoCursor for Cursor {
fn into_cursor(self) -> Option<Cursor> {
if self.fields.is_empty() {
None
} else {
Some(self)
}
}
}
impl IntoCursor for &str {
fn into_cursor(self) -> Option<Cursor> {
if self.is_empty() || self.len() > MAX_CURSOR_SIZE {
return None;
}
Cursor::decode(self).ok()
}
}
impl IntoCursor for String {
fn into_cursor(self) -> Option<Cursor> {
self.as_str().into_cursor()
}
}
impl IntoCursor for &String {
fn into_cursor(self) -> Option<Cursor> {
self.as_str().into_cursor()
}
}
impl<T: IntoCursor> IntoCursor for Option<T> {
fn into_cursor(self) -> Option<Cursor> {
self.and_then(IntoCursor::into_cursor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pagination::encoding::base64_encode;
#[test]
fn test_cursor_encode_decode() {
let cursor = Cursor::new().int("id", 100).string("name", "Alice");
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert_eq!(cursor.fields, decoded.fields);
}
#[test]
fn test_cursor_empty() {
let cursor = Cursor::new();
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert!(decoded.fields.is_empty());
}
#[test]
fn test_cursor_with_special_chars() {
let cursor = Cursor::new().string("name", "Hello \"World\"");
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert_eq!(cursor.fields, decoded.fields);
}
#[test]
fn test_cursor_with_float() {
let cursor = Cursor::new().field("score", 1.234f64);
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert_eq!(decoded.fields.len(), 1);
let Value::Float(f) = &decoded.fields[0].1 else {
panic!("expected Value::Float, got {:?}", decoded.fields[0].1)
};
assert!((f - 1.234).abs() < 0.001);
}
#[test]
fn test_cursor_invalid_base64() {
let result = Cursor::decode("not valid base64!!!");
assert!(matches!(result, Err(CursorError::InvalidBase64)));
}
#[test]
fn test_cursor_too_large() {
let oversized = "a".repeat(5 * 1024);
let result = Cursor::decode(&oversized);
assert!(matches!(result, Err(CursorError::TooLarge)));
let cursor: Option<Cursor> = oversized.as_str().into_cursor();
assert!(cursor.is_none());
}
#[test]
fn test_cursor_too_many_fields() {
let mut fields = Vec::new();
for i in 0..20 {
fields.push(format!("\"f{i}\":1"));
}
let json = format!("{{{}}}", fields.join(","));
let encoded = base64_encode(&json);
let result = Cursor::decode(&encoded);
assert!(matches!(result, Err(CursorError::TooManyFields)));
let cursor: Option<Cursor> = encoded.as_str().into_cursor();
assert!(cursor.is_none());
}
#[test]
fn test_cursor_exactly_at_max_fields() {
let mut fields = Vec::new();
for i in 0..16 {
fields.push(format!("\"f{i}\":1"));
}
let json = format!("{{{}}}", fields.join(","));
let encoded = base64_encode(&json);
let result = Cursor::decode(&encoded);
assert!(
result.is_ok(),
"Cursor with exactly 16 fields should succeed"
);
assert_eq!(result.unwrap().fields.len(), 16);
}
#[test]
fn test_cursor_one_under_max_fields() {
let mut fields = Vec::new();
for i in 0..15 {
fields.push(format!("\"f{i}\":1"));
}
let json = format!("{{{}}}", fields.join(","));
let encoded = base64_encode(&json);
let result = Cursor::decode(&encoded);
assert!(result.is_ok(), "Cursor with 15 fields should succeed");
assert_eq!(result.unwrap().fields.len(), 15);
}
#[test]
fn test_cursor_one_over_max_fields() {
let mut fields = Vec::new();
for i in 0..17 {
fields.push(format!("\"f{i}\":1"));
}
let json = format!("{{{}}}", fields.join(","));
let encoded = base64_encode(&json);
let result = Cursor::decode(&encoded);
assert!(matches!(result, Err(CursorError::TooManyFields)));
}
#[test]
fn test_cursor_near_max_size() {
let long_value = "x".repeat(200);
let cursor = Cursor::new()
.string("f1", &long_value)
.string("f2", &long_value)
.string("f3", &long_value)
.string("f4", &long_value);
let encoded = cursor.encode();
assert!(encoded.len() < 4096, "Cursor should be under 4KB limit");
let decoded = Cursor::decode(&encoded);
assert!(decoded.is_ok());
}
#[test]
fn test_cursor_exactly_at_max_size_boundary() {
let oversized = "a".repeat(4097);
let result = Cursor::decode(&oversized);
assert!(matches!(result, Err(CursorError::TooLarge)));
let at_limit = "a".repeat(4096);
let result = Cursor::decode(&at_limit);
assert!(!matches!(result, Err(CursorError::TooLarge)));
}
#[test]
fn test_into_cursor_boundary_behavior() {
let cursor: Option<Cursor> = "".into_cursor();
assert!(cursor.is_none(), "Empty string should return None");
let oversized = "a".repeat(4097);
let cursor: Option<Cursor> = oversized.as_str().into_cursor();
assert!(cursor.is_none(), "Oversized cursor should return None");
}
#[test]
fn test_cursor_with_various_value_types() {
let cursor = Cursor::new()
.int("int_field", 42)
.string("str_field", "hello")
.field("float_field", 1.234f64)
.field("bool_field", true);
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert_eq!(decoded.fields.len(), 4);
assert!(matches!(
decoded.fields.iter().find(|(k, _)| k == "int_field"),
Some((_, Value::Int(42)))
));
assert!(matches!(
decoded.fields.iter().find(|(k, _)| k == "str_field"),
Some((_, Value::String(s))) if s == "hello"
));
}
#[test]
fn test_cursor_with_special_json_characters() {
let cursor = Cursor::new()
.string("quotes", "say \"hello\"")
.string("backslash", "path\\to\\file")
.string("newline", "line1\nline2");
let encoded = cursor.encode();
let decoded = Cursor::decode(&encoded).unwrap();
assert_eq!(decoded.fields.len(), 3);
}
#[test]
fn test_cursor_from_helper() {
use super::super::PageInfo;
#[derive(Debug)]
struct User {
id: i64,
}
let user = User { id: 42 };
let cursor = PageInfo::cursor_from(Some(&user), |u| Cursor::new().int("id", u.id));
assert!(cursor.is_some());
let decoded = Cursor::decode(&cursor.unwrap()).unwrap();
assert_eq!(decoded.fields[0], ("id".to_string(), Value::Int(42)));
}
}