#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paginated<T, C = String> {
pub items: Vec<T>,
pub next_cursor: Option<C>,
}
pub fn paginate_scanned<T, C>(
scanned: Vec<T>,
has_more: bool,
keep: impl Fn(&T) -> bool,
cursor_of: impl Fn(&T) -> C,
) -> Paginated<T, C> {
let next_cursor = if has_more {
scanned.last().map(&cursor_of)
} else {
None
};
let items = scanned.into_iter().filter(keep).collect();
Paginated { items, next_cursor }
}
pub fn try_paginate_rows<R, T, C, E>(
rows: Vec<R>,
limit: usize,
decode: impl Fn(R) -> Result<T, E>,
keep: impl Fn(&T) -> bool,
cursor_of: impl Fn(&T) -> C,
) -> Result<Paginated<T, C>, E> {
let has_more = rows.len() > limit;
let mut scanned = Vec::with_capacity(rows.len().min(limit));
for row in rows.into_iter().take(limit) {
scanned.push(decode(row)?);
}
Ok(paginate_scanned(scanned, has_more, keep, cursor_of))
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Row {
cursor: u32,
keep: bool,
}
fn row(cursor: u32, keep: bool) -> Row {
Row { cursor, keep }
}
fn paginate(scanned: Vec<Row>, has_more: bool) -> Paginated<Row, u32> {
paginate_scanned(scanned, has_more, |r| r.keep, |r| r.cursor)
}
#[test]
fn empty_input_yields_no_items_and_no_cursor() {
let page = paginate(vec![], false);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, None);
}
#[test]
fn no_more_rows_means_no_cursor_even_with_items() {
let page = paginate(vec![row(1, true), row(2, true)], false);
assert_eq!(page.items, vec![row(1, true), row(2, true)]);
assert_eq!(page.next_cursor, None, "last page must not emit a cursor");
}
#[test]
fn cursor_emitted_when_more_rows_exist() {
let page = paginate(vec![row(1, true), row(2, true)], true);
assert_eq!(page.items.len(), 2);
assert_eq!(
page.next_cursor,
Some(2),
"cursor anchors on last scanned row"
);
}
#[test]
fn cursor_anchors_on_last_scanned_not_last_kept() {
let page = paginate(vec![row(1, true), row(2, false)], true);
assert_eq!(page.items, vec![row(1, true)]);
assert_eq!(
page.next_cursor,
Some(2),
"cursor must anchor on the last scanned row, even when filtered out"
);
}
#[test]
fn fully_filtered_page_still_advances_the_cursor() {
let page = paginate(vec![row(1, false), row(2, false), row(3, false)], true);
assert!(page.items.is_empty());
assert_eq!(page.next_cursor, Some(3));
}
#[test]
fn try_paginate_drops_the_sentinel_row() {
let rows = vec![10u32, 20, 30];
let page: Paginated<u32, u32> =
try_paginate_rows(rows, 2, Ok::<u32, ()>, |_| true, |v| *v).unwrap();
assert_eq!(page.items, vec![10, 20]);
assert_eq!(page.next_cursor, Some(20));
}
#[test]
fn try_paginate_no_more_rows_when_exactly_limit() {
let rows = vec![10u32, 20];
let page: Paginated<u32, u32> =
try_paginate_rows(rows, 2, Ok::<u32, ()>, |_| true, |v| *v).unwrap();
assert_eq!(page.items, vec![10, 20]);
assert_eq!(page.next_cursor, None);
}
#[test]
fn try_paginate_propagates_decode_errors() {
let rows = vec![1u32, 2, 3];
let result: Result<Paginated<u32, u32>, &'static str> = try_paginate_rows(
rows,
2,
|v| if v == 2 { Err("boom") } else { Ok(v) },
|_| true,
|v| *v,
);
assert_eq!(result, Err("boom"));
}
}