paginate-core 1.0.0

Pure, language-agnostic pagination / filter / sort / search engine shared by the pypaginate (Python) and @cyblow/paginate (TypeScript) packages.
Documentation
use super::*;

fn round_trip(values: Vec<Value>) {
    let encoded = encode_cursor(&values).expect("encode");
    let decoded = decode_cursor(&encoded).expect("decode");
    assert_eq!(decoded, values, "round-trip mismatch for {encoded}");
}

#[test]
fn golden_vectors_match_python_codec() {
    // Generated by running pypaginate.engine.cursor_codec.encode_cursor on
    // the real package. Byte-identical output proves cross-language cursor
    // compatibility (a cursor minted by Python decodes here and vice versa).
    let cases: &[(Vec<Value>, &str)] = &[
            (vec![Value::Int(1)], "WzFd"),
            (vec![Value::Str("a".into())], "WyJhIl0"),
            (vec![Value::Int(42), Value::Str("hello".into())], "WzQyLCJoZWxsbyJd"),
            (
                vec![Value::Null, Value::Bool(true), Value::Bool(false)],
                "W251bGwsdHJ1ZSxmYWxzZV0",
            ),
            (vec![Value::Str("é".into())], "WyJcdTAwZTkiXQ"),
            (
                vec![Value::Str("café ☕ 🚀".into())],
                "WyJjYWZcdTAwZTkgXHUyNjE1IFx1ZDgzZFx1ZGU4MCJd",
            ),
            (
                vec![Value::Decimal("9.99".into())],
                "W3siX190eXBlX18iOiJkZWNpbWFsIiwidiI6IjkuOTkifV0",
            ),
            (
                vec![Value::DateTime("2025-06-01T12:30:00".into())],
                "W3siX190eXBlX18iOiJkYXRldGltZSIsInYiOiIyMDI1LTA2LTAxVDEyOjMwOjAwIn1d",
            ),
            (
                vec![Value::Date("2025-06-01".into())],
                "W3siX190eXBlX18iOiJkYXRlIiwidiI6IjIwMjUtMDYtMDEifV0",
            ),
            (
                vec![Value::Uuid("12345678-1234-5678-1234-567812345678".into())],
                "W3siX190eXBlX18iOiJ1dWlkIiwidiI6IjEyMzQ1Njc4LTEyMzQtNTY3OC0xMjM0LTU2NzgxMjM0NTY3OCJ9XQ",
            ),
            (vec![Value::Int(-7), Value::Float(1.5)], "Wy03LDEuNV0"),
        ];
    for (values, expected) in cases {
        assert_eq!(
            &encode_cursor(values).expect("encode"),
            expected,
            "encode {values:?}"
        );
        assert_eq!(
            &decode_cursor(expected).unwrap(),
            values,
            "decode {expected}"
        );
    }
}

#[test]
fn round_trips_scalars() {
    round_trip(vec![Value::Int(42), Value::Str("hello".into())]);
    round_trip(vec![Value::Null, Value::Bool(true), Value::Bool(false)]);
    round_trip(vec![Value::Float(1.5), Value::Int(-7)]);
}

#[test]
fn round_trips_typed_scalars() {
    round_trip(vec![
        Value::DateTime("2025-06-01T12:30:00".into()),
        Value::Date("2025-06-01".into()),
        Value::Decimal("3.14".into()),
        Value::Uuid("12345678-1234-5678-1234-567812345678".into()),
    ]);
}

#[test]
fn non_ascii_is_escaped_like_ensure_ascii() {
    // json.dumps(ensure_ascii=True) escapes non-ASCII, so the encoded
    // payload stays pure ASCII (é becomes a \uXXXX escape, never raw UTF-8).
    let encoded = encode_cursor(&[Value::Str("é".into())]).expect("encode");
    let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).unwrap();
    assert!(bytes.is_ascii(), "payload must be pure ASCII");
    // The raw UTF-8 of 'é' (0xC3 0xA9) must be absent — it was escaped.
    assert!(!bytes.windows(2).any(|w| w == [0xc3, 0xa9]));
    // Byte-for-byte identical to the Python codec's output.
    assert_eq!(encoded, "WyJcdTAwZTkiXQ");
    round_trip(vec![Value::Str("café ☕ 🚀".into())]);
}

#[test]
fn typed_tag_wire_shape() {
    let encoded = encode_cursor(&[Value::Decimal("9.99".into())]).expect("encode");
    let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).unwrap();
    assert_eq!(
        std::str::from_utf8(&bytes).unwrap(),
        r#"[{"__type__":"decimal","v":"9.99"}]"#
    );
}

#[test]
fn rejects_malformed_cursors() {
    assert!(matches!(
        decode_cursor("!!!not-base64!!!"),
        Err(CoreError::InvalidCursor { .. })
    ));
    // Valid base64 of `{}` (an object, not a list).
    let not_a_list = URL_SAFE_NO_PAD.encode(b"{}");
    assert!(matches!(
        decode_cursor(&not_a_list),
        Err(CoreError::InvalidCursor { .. })
    ));
    // Unknown type tag.
    let bad_tag = URL_SAFE_NO_PAD.encode(br#"[{"__type__":"complex","v":"1"}]"#);
    assert!(matches!(
        decode_cursor(&bad_tag),
        Err(CoreError::InvalidCursor { reason }) if reason.contains("complex")
    ));
}

#[test]
fn encode_rejects_non_finite_floats() {
    assert!(matches!(
        encode_cursor(&[Value::Float(f64::NAN)]),
        Err(CoreError::InvalidCursor { .. })
    ));
    assert!(encode_cursor(&[Value::Float(f64::INFINITY)]).is_err());
    // A finite float still encodes fine.
    assert!(encode_cursor(&[Value::Float(1.5)]).is_ok());
}

#[test]
fn decode_rejects_integer_above_i64() {
    // i64::MAX + 1 — a u64 that can't be an exact `Value::Int`.
    let payload = URL_SAFE_NO_PAD.encode(b"[9223372036854775808]");
    assert!(matches!(
        decode_cursor(&payload),
        Err(CoreError::InvalidCursor { .. })
    ));
    // i64::MAX itself still decodes exactly.
    let ok = URL_SAFE_NO_PAD.encode(b"[9223372036854775807]");
    assert_eq!(decode_cursor(&ok).unwrap(), vec![Value::Int(i64::MAX)]);
}

#[test]
fn decodes_padded_and_unpadded() {
    // The Python decoder re-pads; ensure both forms work.
    let unpadded = encode_cursor(&[Value::Str("ab".into())]).expect("encode");
    let padded = format!("{unpadded}{}", "=".repeat((4 - unpadded.len() % 4) % 4));
    assert_eq!(
        decode_cursor(&unpadded).unwrap(),
        decode_cursor(&padded).unwrap()
    );
}