use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy)]
pub struct PageInfo {
pub current_page: i64,
pub total_pages: i64,
}
pub struct LinkHeaderBuilder {
base_url: String,
extra_query: BTreeMap<String, String>,
rels: Vec<(String, String)>, }
impl LinkHeaderBuilder {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
extra_query: BTreeMap::new(),
rels: Vec::new(),
}
}
#[must_use]
pub fn keep_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_query.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_page_info(mut self, info: PageInfo) -> Self {
if info.total_pages > 1 {
self.rels.push(("first".into(), "1".into()));
self.rels
.push(("last".into(), info.total_pages.to_string()));
if info.current_page > 1 {
self.rels
.push(("prev".into(), (info.current_page - 1).to_string()));
}
if info.current_page < info.total_pages {
self.rels
.push(("next".into(), (info.current_page + 1).to_string()));
}
}
self
}
#[must_use]
pub fn rel(mut self, rel: impl Into<String>, page: i64) -> Self {
self.rels.push((rel.into(), page.to_string()));
self
}
#[must_use]
pub fn cursor_rel(mut self, rel: impl Into<String>, cursor: impl Into<String>) -> Self {
self.rels
.push((format!("cursor:{}", rel.into()), cursor.into()));
self
}
#[must_use]
pub fn build(&self) -> String {
let mut entries: Vec<String> = Vec::new();
for (rel, value) in &self.rels {
let (param_key, rel_str) = if let Some(stripped) = rel.strip_prefix("cursor:") {
("cursor", stripped)
} else {
("page", rel.as_str())
};
let mut url = self.base_url.clone();
let mut query = self.extra_query.clone();
query.insert(param_key.to_owned(), value.clone());
url.push('?');
let qs: Vec<String> = query
.iter()
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
.collect();
url.push_str(&qs.join("&"));
entries.push(format!("<{url}>; rel=\"{rel_str}\""));
}
entries.join(", ")
}
}
fn url_encode(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
const MAX_PAGE_SIZE: usize = 1000;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PageLinks {
pub current: Option<String>,
pub first: Option<String>,
pub prev: Option<String>,
pub next: Option<String>,
pub last: Option<String>,
}
impl PageLinks {
#[must_use]
pub fn to_value(&self) -> serde_json::Value {
let mut m = serde_json::Map::new();
for (k, v) in [
("current", &self.current),
("first", &self.first),
("prev", &self.prev),
("next", &self.next),
("last", &self.last),
] {
if let Some(url) = v {
m.insert(k.into(), serde_json::Value::String(url.clone()));
}
}
serde_json::Value::Object(m)
}
#[must_use]
pub fn to_link_header(&self) -> Option<String> {
let mut parts = Vec::new();
for (rel, url) in [
("first", &self.first),
("prev", &self.prev),
("next", &self.next),
("last", &self.last),
] {
if let Some(u) = url {
parts.push(format!(r#"<{u}>; rel="{rel}""#));
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(", "))
}
}
}
#[must_use]
pub fn page_number_links(base: &str, page: usize, page_size: usize, count: usize) -> PageLinks {
let page = page.max(1);
let page_size = page_size.max(1).min(MAX_PAGE_SIZE);
let last_page = count.div_ceil(page_size);
let mut links = PageLinks {
current: Some(page_number_url(base, page, page_size)),
..PageLinks::default()
};
if last_page > 0 {
links.first = Some(page_number_url(base, 1, page_size));
links.last = Some(page_number_url(base, last_page, page_size));
}
if page > 1 {
links.prev = Some(page_number_url(base, page - 1, page_size));
}
if page < last_page {
links.next = Some(page_number_url(base, page + 1, page_size));
}
links
}
#[must_use]
pub fn cursor_links(
base: &str,
current_cursor: Option<&str>,
next_cursor: Option<&str>,
page_size: usize,
) -> PageLinks {
let page_size = page_size.max(1).min(MAX_PAGE_SIZE);
PageLinks {
current: current_cursor.map(|c| cursor_url(base, Some(c), page_size)),
first: Some(cursor_url(base, None, page_size)),
prev: None,
next: next_cursor.map(|c| cursor_url(base, Some(c), page_size)),
last: None,
}
}
fn page_number_url(base: &str, page: usize, page_size: usize) -> String {
let mut params = base_params(base);
params.insert("page".into(), page.to_string());
params.insert("page_size".into(), page_size.to_string());
join_url(strip_query(base), ¶ms)
}
fn cursor_url(base: &str, cursor: Option<&str>, page_size: usize) -> String {
let mut params = base_params(base);
if let Some(c) = cursor {
params.insert("cursor".into(), c.to_owned());
} else {
params.remove("cursor");
}
params.insert("page_size".into(), page_size.to_string());
join_url(strip_query(base), ¶ms)
}
fn base_params(base: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
if let Some(qs) = base.split('?').nth(1) {
for pair in qs.split('&') {
if pair.is_empty() {
continue;
}
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
if !matches!(k, "page" | "page_size" | "cursor") {
out.insert(k.to_owned(), v.to_owned());
}
}
}
out
}
fn strip_query(base: &str) -> &str {
base.split_once('?').map_or(base, |(b, _)| b)
}
fn join_url(path: &str, params: &BTreeMap<String, String>) -> String {
if params.is_empty() {
return path.to_owned();
}
let qs = params
.iter()
.map(|(k, v)| {
if v.is_empty() {
k.clone()
} else {
format!("{}={}", url_encode(k), url_encode(v))
}
})
.collect::<Vec<_>>()
.join("&");
format!("{path}?{qs}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_info_middle_emits_all_four_rels() {
let h = LinkHeaderBuilder::new("/api/posts")
.with_page_info(PageInfo {
current_page: 3,
total_pages: 5,
})
.build();
assert!(h.contains(r#"rel="first""#));
assert!(h.contains(r#"rel="prev""#));
assert!(h.contains(r#"rel="next""#));
assert!(h.contains(r#"rel="last""#));
assert!(h.contains("page=2")); assert!(h.contains("page=4")); }
#[test]
fn page_info_first_page_omits_prev() {
let h = LinkHeaderBuilder::new("/api/posts")
.with_page_info(PageInfo {
current_page: 1,
total_pages: 5,
})
.build();
assert!(!h.contains(r#"rel="prev""#));
assert!(h.contains(r#"rel="next""#));
}
#[test]
fn page_info_last_page_omits_next() {
let h = LinkHeaderBuilder::new("/api/posts")
.with_page_info(PageInfo {
current_page: 5,
total_pages: 5,
})
.build();
assert!(h.contains(r#"rel="prev""#));
assert!(!h.contains(r#"rel="next""#));
}
#[test]
fn page_info_single_page_emits_nothing() {
let h = LinkHeaderBuilder::new("/api/posts")
.with_page_info(PageInfo {
current_page: 1,
total_pages: 1,
})
.build();
assert_eq!(h, "");
}
#[test]
fn keep_param_preserves_filter() {
let h = LinkHeaderBuilder::new("/api/posts")
.keep_param("search", "rust")
.with_page_info(PageInfo {
current_page: 1,
total_pages: 3,
})
.build();
assert!(h.contains("search=rust"));
assert!(h.contains("page=2"));
}
#[test]
fn keep_param_url_encodes_values() {
let h = LinkHeaderBuilder::new("/api/posts")
.keep_param("q", "hello world & friends")
.rel("next", 2)
.build();
assert!(h.contains("q=hello%20world%20%26%20friends"));
}
#[test]
fn cursor_rel_uses_cursor_param() {
let h = LinkHeaderBuilder::new("/api/posts")
.cursor_rel("next", "MTIzNDU")
.build();
assert!(h.contains("cursor=MTIzNDU"));
assert!(h.contains(r#"rel="next""#));
}
#[test]
fn manual_rel_emits_page_param() {
let h = LinkHeaderBuilder::new("/api/posts").rel("self", 3).build();
assert!(h.contains("page=3"));
assert!(h.contains(r#"rel="self""#));
}
#[test]
fn multiple_entries_comma_separated() {
let h = LinkHeaderBuilder::new("/api/posts")
.with_page_info(PageInfo {
current_page: 2,
total_pages: 5,
})
.build();
let count = h.matches("rel=").count();
assert_eq!(count, 4);
assert!(h.contains(", "));
}
#[test]
fn first_page_no_prev() {
let l = page_number_links("/posts", 1, 20, 200);
assert!(l.prev.is_none());
assert_eq!(l.next.as_deref(), Some("/posts?page=2&page_size=20"));
assert_eq!(l.first.as_deref(), Some("/posts?page=1&page_size=20"));
assert_eq!(l.last.as_deref(), Some("/posts?page=10&page_size=20"));
}
#[test]
fn last_page_no_next() {
let l = page_number_links("/posts", 10, 20, 200);
assert_eq!(l.prev.as_deref(), Some("/posts?page=9&page_size=20"));
assert!(l.next.is_none());
}
#[test]
fn middle_page_has_all_links() {
let l = page_number_links("/posts", 5, 20, 200);
assert!(l.first.is_some());
assert!(l.prev.is_some());
assert!(l.next.is_some());
assert!(l.last.is_some());
assert_eq!(l.current.as_deref(), Some("/posts?page=5&page_size=20"));
}
#[test]
fn empty_count_omits_first_and_last() {
let l = page_number_links("/posts", 1, 20, 0);
assert!(l.first.is_none());
assert!(l.last.is_none());
assert!(l.next.is_none());
assert!(l.prev.is_none());
}
#[test]
fn last_page_calculated_with_div_ceil() {
let l = page_number_links("/posts", 1, 20, 201);
assert_eq!(l.last.as_deref(), Some("/posts?page=11&page_size=20"));
}
#[test]
fn page_size_clamped_to_max_1000() {
let l = page_number_links("/posts", 1, 999_999, 100);
assert!(l.current.unwrap().contains("page_size=1000"));
}
#[test]
fn page_zero_treated_as_page_one() {
let l = page_number_links("/posts", 0, 20, 200);
assert_eq!(l.current.as_deref(), Some("/posts?page=1&page_size=20"));
}
#[test]
fn existing_filter_params_are_preserved() {
let l = page_number_links("/posts?author_id=7&search=rust", 2, 10, 50);
let cur = l.current.unwrap();
assert!(cur.contains("author_id=7"));
assert!(cur.contains("search=rust"));
assert!(cur.contains("page=2"));
assert!(cur.contains("page_size=10"));
}
#[test]
fn existing_pagination_params_get_overridden() {
let l = page_number_links("/posts?page=99&page_size=5&author_id=7", 2, 20, 100);
let cur = l.current.unwrap();
assert!(cur.contains("page=2"));
assert!(cur.contains("page_size=20"));
assert!(!cur.contains("page=99"));
assert!(!cur.contains("page_size=5"));
assert!(cur.contains("author_id=7"));
}
#[test]
fn cursor_with_next_token() {
let l = cursor_links("/posts", Some("c1"), Some("c2"), 20);
assert_eq!(l.current.as_deref(), Some("/posts?cursor=c1&page_size=20"));
assert_eq!(l.next.as_deref(), Some("/posts?cursor=c2&page_size=20"));
assert_eq!(l.first.as_deref(), Some("/posts?page_size=20"));
assert!(l.prev.is_none());
assert!(l.last.is_none());
}
#[test]
fn cursor_at_end_no_next() {
let l = cursor_links("/posts", Some("c1"), None, 20);
assert!(l.next.is_none());
}
#[test]
fn cursor_initial_request_no_current() {
let l = cursor_links("/posts", None, Some("c1"), 20);
assert!(l.current.is_none());
assert_eq!(l.next.as_deref(), Some("/posts?cursor=c1&page_size=20"));
}
#[test]
fn cursor_url_encodes_special_chars() {
let l = cursor_links("/posts", None, Some("a+b=c/d"), 20);
let next = l.next.unwrap();
assert!(next.contains("cursor=a%2Bb%3Dc%2Fd"));
}
#[test]
fn page_links_to_value_omits_none_keys() {
let l = PageLinks {
current: Some("/x".into()),
first: Some("/x?page=1".into()),
..PageLinks::default()
};
let v = l.to_value();
assert_eq!(v["current"], "/x");
assert_eq!(v["first"], "/x?page=1");
assert!(v.get("next").is_none());
}
#[test]
fn page_links_to_link_header_renders_rfc5988_form() {
let l = page_number_links("/posts", 5, 20, 200);
let h = l.to_link_header().unwrap();
assert!(h.contains(r#"; rel="next""#));
assert!(h.contains(r#"; rel="prev""#));
assert!(h.contains(r#"; rel="first""#));
assert!(h.contains(r#"; rel="last""#));
assert!(h.contains(", "));
}
#[test]
fn page_links_to_link_header_returns_none_when_empty() {
let l = PageLinks::default();
assert!(l.to_link_header().is_none());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageMark {
Number(usize),
Ellipsis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PaginatorError {
#[error("page number must be a positive integer (got 0)")]
PageNotAnInteger,
#[error("page {0} is empty (count = 0 and allow_empty_first_page is disabled)")]
EmptyPage(usize),
#[error("page {requested} is out of range; last page is {last}")]
OutOfRange { requested: usize, last: usize },
}
#[derive(Debug, Clone, Copy)]
pub struct Paginator {
pub count: usize,
pub per_page: usize,
pub orphans: usize,
pub allow_empty_first_page: bool,
}
impl Paginator {
#[must_use]
pub fn new(count: usize, per_page: usize) -> Self {
Self {
count,
per_page: per_page.max(1),
orphans: 0,
allow_empty_first_page: true,
}
}
#[must_use]
pub fn orphans(mut self, orphans: usize) -> Self {
self.orphans = orphans;
self
}
#[must_use]
pub fn allow_empty_first_page(mut self, allow: bool) -> Self {
self.allow_empty_first_page = allow;
self
}
#[must_use]
pub fn num_pages(&self) -> usize {
if self.count == 0 {
return usize::from(self.allow_empty_first_page);
}
let hits = self.count.saturating_sub(self.orphans).max(1);
hits.div_ceil(self.per_page)
}
#[must_use]
pub fn page_range(&self) -> std::ops::RangeInclusive<usize> {
1..=self.num_pages()
}
pub fn validate_number(&self, number: usize) -> Result<usize, PaginatorError> {
if number < 1 {
return Err(PaginatorError::PageNotAnInteger);
}
let last = self.num_pages();
if last == 0 {
return Err(PaginatorError::EmptyPage(number));
}
if number > last {
return Err(PaginatorError::OutOfRange {
requested: number,
last,
});
}
Ok(number)
}
pub fn page(&self, number: usize) -> Result<Page<'_>, PaginatorError> {
let valid = self.validate_number(number)?;
Ok(Page {
number: valid,
paginator: self,
})
}
#[must_use]
pub fn get_page(&self, number: i64) -> Page<'_> {
let last = self.num_pages().max(1);
let n = if number < 1 {
1
} else {
(number as usize).min(last)
};
Page {
number: n,
paginator: self,
}
}
#[must_use]
pub fn get_elided_page_range(
&self,
number: usize,
on_each_side: usize,
on_ends: usize,
) -> Vec<PageMark> {
let last = self.num_pages();
if last == 0 {
return Vec::new();
}
let number = self.validate_number(number).unwrap_or(1);
let threshold = on_each_side.saturating_add(on_ends).saturating_mul(2);
if last <= threshold {
return (1..=last).map(PageMark::Number).collect();
}
let mut out = Vec::new();
let left_gap_trigger = 1usize
.saturating_add(on_each_side)
.saturating_add(on_ends)
.saturating_add(1);
if number > left_gap_trigger {
for n in 1..=on_ends {
out.push(PageMark::Number(n));
}
out.push(PageMark::Ellipsis);
for n in number.saturating_sub(on_each_side)..=number {
out.push(PageMark::Number(n));
}
} else {
for n in 1..=number {
out.push(PageMark::Number(n));
}
}
let right_gap_trigger = last
.saturating_sub(on_each_side)
.saturating_sub(on_ends)
.saturating_sub(1);
if number < right_gap_trigger {
for n in (number + 1)..=(number + on_each_side) {
out.push(PageMark::Number(n));
}
out.push(PageMark::Ellipsis);
for n in (last + 1 - on_ends.min(last))..=last {
out.push(PageMark::Number(n));
}
} else {
for n in (number + 1)..=last {
out.push(PageMark::Number(n));
}
}
out
}
}
#[derive(Debug, Clone, Copy)]
pub struct Page<'a> {
pub number: usize,
pub paginator: &'a Paginator,
}
impl<'a> Page<'a> {
#[must_use]
pub fn has_next(&self) -> bool {
self.number < self.paginator.num_pages()
}
#[must_use]
pub fn has_previous(&self) -> bool {
self.number > 1
}
#[must_use]
pub fn has_other_pages(&self) -> bool {
self.has_next() || self.has_previous()
}
pub fn next_page_number(&self) -> Result<usize, PaginatorError> {
if !self.has_next() {
return Err(PaginatorError::OutOfRange {
requested: self.number + 1,
last: self.paginator.num_pages(),
});
}
Ok(self.number + 1)
}
pub fn previous_page_number(&self) -> Result<usize, PaginatorError> {
if !self.has_previous() {
return Err(PaginatorError::OutOfRange {
requested: 0,
last: self.paginator.num_pages(),
});
}
Ok(self.number - 1)
}
#[must_use]
pub fn start_index(&self) -> usize {
if self.paginator.count == 0 {
return 0;
}
(self.number - 1) * self.paginator.per_page + 1
}
#[must_use]
pub fn end_index(&self) -> usize {
if self.paginator.count == 0 {
return 0;
}
if self.number == self.paginator.num_pages() {
return self.paginator.count;
}
self.number * self.paginator.per_page
}
#[must_use]
pub fn limit(&self) -> usize {
self.paginator.per_page
}
#[must_use]
pub fn offset(&self) -> usize {
(self.number - 1) * self.paginator.per_page
}
#[must_use]
pub fn slice<'b, T>(&self, items: &'b [T]) -> &'b [T] {
let start = self.offset().min(items.len());
let end = (start + self.limit()).min(items.len());
&items[start..end]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum CursorDirection {
#[serde(rename = "f")]
#[default]
Forward,
#[serde(rename = "b")]
Backward,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CursorError {
#[error("invalid cursor encoding: {0}")]
Decode(String),
#[error("invalid cursor payload: {0}")]
Json(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor<T> {
pub position: T,
pub direction: CursorDirection,
}
impl<T> Cursor<T> {
pub fn forward(position: T) -> Self {
Self {
position,
direction: CursorDirection::Forward,
}
}
pub fn backward(position: T) -> Self {
Self {
position,
direction: CursorDirection::Backward,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CursorWire<T> {
p: T,
#[serde(default)]
d: CursorDirection,
}
impl<T> Cursor<T>
where
T: serde::Serialize,
{
#[must_use]
pub fn encode(&self) -> String {
let wire = CursorWire {
p: &self.position,
d: self.direction,
};
let json = serde_json::to_vec(&wire).expect("cursor position must serialize cleanly");
hex_encode(&json)
}
}
impl<T> Cursor<T>
where
T: serde::de::DeserializeOwned,
{
pub fn decode(s: &str) -> Result<Self, CursorError> {
let bytes = hex_decode(s).map_err(CursorError::Decode)?;
let wire: CursorWire<T> =
serde_json::from_slice(&bytes).map_err(|e| CursorError::Json(e.to_string()))?;
Ok(Self {
position: wire.p,
direction: wire.d,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct CursorPaginator {
pub page_size: usize,
}
impl CursorPaginator {
#[must_use]
pub fn new(page_size: usize) -> Self {
Self {
page_size: page_size.max(1),
}
}
#[must_use]
pub fn fetch_limit(&self) -> usize {
self.page_size + 1
}
#[must_use]
pub fn build_page<T, P, F>(self, rows: Vec<T>, extract_position: F) -> CursorPage<T, P>
where
F: Fn(&T) -> P,
{
self.build_page_with(rows, extract_position, None)
}
#[must_use]
pub fn build_page_with<T, P, F>(
self,
mut rows: Vec<T>,
extract_position: F,
previous: Option<Cursor<P>>,
) -> CursorPage<T, P>
where
F: Fn(&T) -> P,
{
let has_next = rows.len() > self.page_size;
if has_next {
rows.truncate(self.page_size);
}
let next = if has_next {
rows.last().map(|r| Cursor::forward(extract_position(r)))
} else {
None
};
CursorPage {
items: rows,
next,
previous,
page_size: self.page_size,
}
}
}
#[derive(Debug, Clone)]
pub struct CursorPage<T, P> {
pub items: Vec<T>,
pub next: Option<Cursor<P>>,
pub previous: Option<Cursor<P>>,
pub page_size: usize,
}
impl<T, P> CursorPage<T, P> {
#[must_use]
pub fn has_next(&self) -> bool {
self.next.is_some()
}
#[must_use]
pub fn has_previous(&self) -> bool {
self.previous.is_some()
}
}
impl<T, P: serde::Serialize> CursorPage<T, P> {
#[must_use]
pub fn next_token(&self) -> Option<String> {
self.next.as_ref().map(Cursor::encode)
}
#[must_use]
pub fn previous_token(&self) -> Option<String> {
self.previous.as_ref().map(Cursor::encode)
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
if s.len() % 2 != 0 {
return Err(format!("odd length: {}", s.len()));
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(s.len() / 2);
for chunk in bytes.chunks_exact(2) {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_nibble(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
other => Err(format!("non-hex byte: 0x{other:02x}")),
}
}
#[cfg(test)]
mod paginator_tests {
use super::*;
#[test]
fn num_pages_simple_math() {
assert_eq!(Paginator::new(100, 20).num_pages(), 5);
assert_eq!(Paginator::new(101, 20).num_pages(), 6);
assert_eq!(Paginator::new(99, 20).num_pages(), 5);
assert_eq!(Paginator::new(20, 20).num_pages(), 1);
assert_eq!(Paginator::new(1, 20).num_pages(), 1);
}
#[test]
fn num_pages_zero_count_respects_allow_empty_first_page() {
assert_eq!(Paginator::new(0, 20).num_pages(), 1);
assert_eq!(
Paginator::new(0, 20)
.allow_empty_first_page(false)
.num_pages(),
0
);
}
#[test]
fn num_pages_rolls_orphans_into_previous_page() {
let p = Paginator::new(23, 20).orphans(5);
assert_eq!(p.num_pages(), 1);
let p2 = Paginator::new(26, 20).orphans(5);
assert_eq!(p2.num_pages(), 2);
}
#[test]
fn per_page_zero_clamps_to_one() {
let p = Paginator::new(5, 0);
assert_eq!(p.per_page, 1);
assert_eq!(p.num_pages(), 5);
}
#[test]
fn page_zero_is_page_not_an_integer() {
let p = Paginator::new(100, 20);
assert_eq!(p.page(0).unwrap_err(), PaginatorError::PageNotAnInteger);
}
#[test]
fn page_out_of_range_errors_with_last_page() {
let p = Paginator::new(100, 20);
let err = p.page(99).unwrap_err();
assert_eq!(
err,
PaginatorError::OutOfRange {
requested: 99,
last: 5,
}
);
}
#[test]
fn page_on_disallowed_empty_first_page_errors() {
let p = Paginator::new(0, 20).allow_empty_first_page(false);
assert_eq!(p.page(1).unwrap_err(), PaginatorError::EmptyPage(1));
}
#[test]
fn page_one_on_default_empty_paginator_is_valid_empty() {
let p = Paginator::new(0, 20);
let page = p.page(1).expect("page 1 valid on empty paginator");
assert_eq!(page.number, 1);
assert!(p.page(2).is_err());
}
#[test]
fn get_page_clamps_negative_to_first() {
let p = Paginator::new(100, 20);
assert_eq!(p.get_page(-5).number, 1);
assert_eq!(p.get_page(0).number, 1);
}
#[test]
fn get_page_clamps_too_large_to_last() {
let p = Paginator::new(100, 20);
assert_eq!(p.get_page(9_999).number, 5);
}
#[test]
fn page_navigation_flags() {
let p = Paginator::new(50, 20); let page1 = p.page(1).unwrap();
assert!(page1.has_next());
assert!(!page1.has_previous());
assert!(page1.has_other_pages());
let page2 = p.page(2).unwrap();
assert!(page2.has_next());
assert!(page2.has_previous());
let page3 = p.page(3).unwrap();
assert!(!page3.has_next());
assert!(page3.has_previous());
}
#[test]
fn next_previous_page_number_error_on_boundaries() {
let p = Paginator::new(50, 20);
assert!(p.page(1).unwrap().previous_page_number().is_err());
assert_eq!(p.page(1).unwrap().next_page_number().unwrap(), 2);
assert!(p.page(3).unwrap().next_page_number().is_err());
assert_eq!(p.page(3).unwrap().previous_page_number().unwrap(), 2);
}
#[test]
fn start_end_index_exact_on_partial_last_page() {
let p = Paginator::new(50, 20); let page1 = p.page(1).unwrap();
assert_eq!(page1.start_index(), 1);
assert_eq!(page1.end_index(), 20);
let page3 = p.page(3).unwrap();
assert_eq!(page3.start_index(), 41);
assert_eq!(page3.end_index(), 50); }
#[test]
fn start_end_index_zero_when_count_zero() {
let p = Paginator::new(0, 20);
let page = p.page(1).unwrap();
assert_eq!(page.start_index(), 0);
assert_eq!(page.end_index(), 0);
}
#[test]
fn limit_offset_drives_sql_pagination() {
let p = Paginator::new(100, 20);
let page = p.page(3).unwrap();
assert_eq!(page.limit(), 20);
assert_eq!(page.offset(), 40); }
#[test]
fn slice_returns_correct_window() {
let items: Vec<i32> = (1..=50).collect();
let p = Paginator::new(items.len(), 20);
let page2 = p.page(2).unwrap();
let window = page2.slice(&items);
assert_eq!(window, &(21..=40).collect::<Vec<_>>()[..]);
}
#[test]
fn slice_clamps_when_data_shorter_than_announced_count() {
let items: Vec<i32> = (1..=10).collect();
let p = Paginator::new(50, 20); let page1 = p.page(1).unwrap();
let window = page1.slice(&items);
assert_eq!(window, &(1..=10).collect::<Vec<_>>()[..]);
let page2 = p.page(2).unwrap();
assert!(page2.slice(&items).is_empty());
}
#[test]
fn elided_short_circuit_when_below_threshold() {
let p = Paginator::new(120, 20); let marks = p.get_elided_page_range(3, 2, 1);
assert_eq!(
marks,
(1..=6).map(PageMark::Number).collect::<Vec<_>>(),
"small page count must short-circuit to all-pages-no-ellipsis"
);
}
#[test]
fn elided_canonical_example() {
let p = Paginator::new(20_000, 20); let marks = p.get_elided_page_range(500, 3, 2);
use PageMark::{Ellipsis, Number};
assert_eq!(
marks,
vec![
Number(1),
Number(2),
Ellipsis,
Number(497),
Number(498),
Number(499),
Number(500),
Number(501),
Number(502),
Number(503),
Ellipsis,
Number(999),
Number(1000),
]
);
}
#[test]
fn elided_near_left_edge() {
let p = Paginator::new(1000, 20); let marks = p.get_elided_page_range(2, 2, 1);
use PageMark::{Ellipsis, Number};
assert_eq!(
marks,
vec![
Number(1),
Number(2),
Number(3),
Number(4),
Ellipsis,
Number(50)
]
);
}
#[test]
fn elided_near_right_edge() {
let p = Paginator::new(1000, 20); let marks = p.get_elided_page_range(49, 2, 1);
use PageMark::{Ellipsis, Number};
assert_eq!(
marks,
vec![
Number(1),
Ellipsis,
Number(47),
Number(48),
Number(49),
Number(50),
]
);
}
#[test]
fn elided_invalid_number_clamps_to_first() {
let p = Paginator::new(1000, 20);
let marks_invalid = p.get_elided_page_range(99_999, 3, 2);
let marks_clamped = p.get_elided_page_range(1, 3, 2);
assert_eq!(marks_invalid, marks_clamped);
}
#[test]
fn elided_empty_paginator_returns_empty_vec() {
let p = Paginator::new(0, 20).allow_empty_first_page(false);
assert!(p.get_elided_page_range(1, 3, 2).is_empty());
}
}
#[cfg(test)]
mod cursor_tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Pos {
id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CompositePos {
created_at: String,
id: i64,
}
#[test]
fn hex_round_trip_random_bytes() {
let bytes: Vec<u8> = (0u8..=255).collect();
let encoded = hex_encode(&bytes);
assert_eq!(encoded.len(), 512);
assert_eq!(hex_decode(&encoded).unwrap(), bytes);
}
#[test]
fn hex_decode_accepts_mixed_case() {
assert_eq!(hex_decode("AbCdEf").unwrap(), vec![0xab, 0xcd, 0xef]);
}
#[test]
fn hex_decode_rejects_odd_length() {
assert!(hex_decode("abc").is_err());
}
#[test]
fn hex_decode_rejects_non_hex_byte() {
assert!(hex_decode("zz").is_err());
}
#[test]
fn cursor_encode_decode_round_trip() {
let c = Cursor::forward(Pos { id: 42 });
let s = c.encode();
let back: Cursor<Pos> = Cursor::decode(&s).unwrap();
assert_eq!(back, c);
}
#[test]
fn cursor_url_safe() {
let c = Cursor::forward(Pos { id: 9_999_999 });
let s = c.encode();
assert!(
s.chars().all(|ch| ch.is_ascii_hexdigit()),
"cursor should be lowercase hex: {s}"
);
}
#[test]
fn cursor_preserves_direction() {
let fwd = Cursor::forward(Pos { id: 1 });
let bwd = Cursor::backward(Pos { id: 1 });
assert_ne!(fwd.encode(), bwd.encode());
assert_eq!(
Cursor::<Pos>::decode(&fwd.encode()).unwrap().direction,
CursorDirection::Forward
);
assert_eq!(
Cursor::<Pos>::decode(&bwd.encode()).unwrap().direction,
CursorDirection::Backward
);
}
#[test]
fn cursor_composite_position_round_trip() {
let c = Cursor::forward(CompositePos {
created_at: "2026-05-13T10:30:00Z".into(),
id: 7,
});
let s = c.encode();
let back: Cursor<CompositePos> = Cursor::decode(&s).unwrap();
assert_eq!(back, c);
}
#[test]
fn cursor_decode_garbage_returns_decode_error() {
let err = Cursor::<Pos>::decode("not-hex!").unwrap_err();
assert!(matches!(err, CursorError::Decode(_)));
}
#[test]
fn cursor_decode_wrong_shape_returns_json_error() {
let other = Cursor::forward("not-a-struct".to_string());
let s = other.encode();
let err = Cursor::<Pos>::decode(&s).unwrap_err();
assert!(matches!(err, CursorError::Json(_)));
}
#[test]
fn build_page_under_page_size_no_next() {
let paginator = CursorPaginator::new(20);
let rows: Vec<i64> = (1..=10).collect();
let page = paginator.build_page(rows, |r| Pos { id: *r });
assert_eq!(page.items.len(), 10);
assert!(!page.has_next());
assert!(page.next.is_none());
}
#[test]
fn build_page_exactly_page_size_no_next() {
let paginator = CursorPaginator::new(20);
let rows: Vec<i64> = (1..=20).collect();
let page = paginator.build_page(rows, |r| Pos { id: *r });
assert_eq!(page.items.len(), 20);
assert!(page.next.is_none());
}
#[test]
fn build_page_over_fetched_drops_extra_and_sets_next() {
let paginator = CursorPaginator::new(20);
let rows: Vec<i64> = (1..=21).collect();
let page = paginator.build_page(rows, |r| Pos { id: *r });
assert_eq!(page.items.len(), 20);
let next = page.next.expect("next cursor must be present");
assert_eq!(
next.position.id, 20,
"next cursor anchored on last visible row"
);
assert_eq!(next.direction, CursorDirection::Forward);
}
#[test]
fn build_page_with_previous_propagates() {
let paginator = CursorPaginator::new(20);
let rows: Vec<i64> = (51..=70).collect();
let prev = Some(Cursor::backward(Pos { id: 50 }));
let page = paginator.build_page_with(rows, |r| Pos { id: *r }, prev.clone());
assert!(page.has_previous());
assert_eq!(page.previous, prev);
}
#[test]
fn paginator_page_size_clamps_to_one() {
let p = CursorPaginator::new(0);
assert_eq!(p.page_size, 1);
assert_eq!(p.fetch_limit(), 2);
}
#[test]
fn fetch_limit_is_page_size_plus_one() {
assert_eq!(CursorPaginator::new(50).fetch_limit(), 51);
}
#[test]
fn cursor_page_tokens_are_decodable() {
let paginator = CursorPaginator::new(2);
let page = paginator.build_page_with(
vec![10_i64, 20, 30],
|r| Pos { id: *r },
Some(Cursor::backward(Pos { id: 5 })),
);
let next_token = page.next_token().unwrap();
let prev_token = page.previous_token().unwrap();
let next: Cursor<Pos> = Cursor::decode(&next_token).unwrap();
let prev: Cursor<Pos> = Cursor::decode(&prev_token).unwrap();
assert_eq!(next.position.id, 20);
assert_eq!(next.direction, CursorDirection::Forward);
assert_eq!(prev.position.id, 5);
assert_eq!(prev.direction, CursorDirection::Backward);
}
}