use serde_json::{json, Value};
use crate::funnel;
#[derive(Debug, Clone)]
pub struct PaginationRule {
pub route: String,
pub page_size: usize,
pub limit: usize,
pub offset: usize,
pub sort_by: String,
pub sort_desc: bool,
pub max_pages: usize,
pub index: bool,
}
impl Default for PaginationRule {
fn default() -> Self {
Self {
route: String::new(),
page_size: 10,
limit: 0,
offset: 0,
sort_by: String::new(),
sort_desc: false,
max_pages: 0,
index: false,
}
}
}
#[derive(Debug, Clone)]
pub struct PageChunk {
pub page: String,
pub value: Value,
}
#[must_use]
pub fn select_items(items: &[Value], rule: &PaginationRule) -> Vec<Value> {
let mut selected: Vec<Value> = items.to_vec();
if !rule.sort_by.is_empty() {
let field = rule.sort_by.as_str();
let desc = rule.sort_desc;
selected.sort_by(|a, b| {
let ka = sort_key(a, field);
let kb = sort_key(b, field);
if desc {
kb.cmp(&ka)
} else {
ka.cmp(&kb)
}
});
}
if rule.offset > 0 {
if rule.offset >= selected.len() {
return Vec::new();
}
selected = selected.split_off(rule.offset);
}
if rule.limit > 0 && selected.len() > rule.limit {
selected.truncate(rule.limit);
}
selected
}
fn sort_key(value: &Value, field: &str) -> String {
funnel::field_as_str(value, field).unwrap_or_default()
}
#[must_use]
pub fn chunk_items(
items: &[Value],
rule: &PaginationRule,
route: &str,
param: &str,
) -> Vec<PageChunk> {
let selected = select_items(items, rule);
let per = rule.page_size.max(1);
if selected.is_empty() {
return Vec::new();
}
let total_items = selected.len();
let mut total_pages = total_items.div_ceil(per);
if rule.max_pages > 0 {
total_pages = total_pages.min(rule.max_pages);
}
const PAGES_NAV_LIMIT: usize = 200;
let page_links = if total_pages <= PAGES_NAV_LIMIT {
build_page_links(route, param, total_pages)
} else {
Vec::new()
};
let mut out = Vec::with_capacity(total_pages);
for page_number in 1..=total_pages {
let start = (page_number - 1) * per;
let end = (start + per).min(total_items);
let chunk = &selected[start..end];
let page = page_number.to_string();
let has_prev = page_number > 1;
let has_next = page_number < total_pages;
let prev = if has_prev {
(page_number - 1).to_string()
} else {
String::new()
};
let next = if has_next {
(page_number + 1).to_string()
} else {
String::new()
};
let path = route_with_param(route, param, &page);
let href = absolute_href(&path);
let prev_href = if has_prev {
absolute_href(&route_with_param(route, param, &prev))
} else {
String::new()
};
let next_href = if has_next {
absolute_href(&route_with_param(route, param, &next))
} else {
String::new()
};
let mut pages = page_links.clone();
for link in &mut pages {
if let Some(obj) = link.as_object_mut() {
let is_current = obj.get("page").and_then(Value::as_str) == Some(page.as_str());
obj.insert("current".into(), Value::Bool(is_current));
}
}
out.push(PageChunk {
page: page.clone(),
value: json!({
"page": page,
"page_number": page_number,
"total_pages": total_pages,
"total_items": total_items,
"source_total": items.len(),
"per_page": per,
"page_size": per,
"limit": rule.limit,
"offset": rule.offset,
"sort_by": rule.sort_by,
"sort_desc": rule.sort_desc,
"max_pages": rule.max_pages,
"has_prev": has_prev,
"has_next": has_next,
"prev": prev,
"next": next,
"path": path,
"href": href,
"prev_href": prev_href,
"next_href": next_href,
"first_href": absolute_href(&route_with_param(route, param, "1")),
"last_href": absolute_href(&route_with_param(
route,
param,
&total_pages.to_string()
)),
"pages": pages,
"items": chunk,
}),
});
}
out
}
#[must_use]
pub fn apply_locale_to_chunk(chunk: &PageChunk, locale: &str) -> PageChunk {
let mut value = chunk.value.clone();
let Some(obj) = value.as_object_mut() else {
return chunk.clone();
};
for key in [
"path",
"href",
"prev_href",
"next_href",
"first_href",
"last_href",
] {
if let Some(s) = obj.get(key).and_then(Value::as_str) {
obj.insert(key.into(), Value::String(localize_route_token(s, locale)));
}
}
if let Some(pages) = obj.get_mut("pages").and_then(Value::as_array_mut) {
for link in pages {
if let Some(link) = link.as_object_mut() {
for key in ["path", "href"] {
if let Some(s) = link.get(key).and_then(Value::as_str) {
link.insert(key.into(), Value::String(localize_route_token(s, locale)));
}
}
}
}
}
PageChunk {
page: chunk.page.clone(),
value,
}
}
fn localize_route_token(s: &str, locale: &str) -> String {
s.replace("[locale]", locale)
}
fn build_page_links(route: &str, param: &str, total_pages: usize) -> Vec<Value> {
(1..=total_pages)
.map(|n| {
let page = n.to_string();
let path = route_with_param(route, param, &page);
json!({
"page": page,
"page_number": n,
"path": path,
"href": absolute_href(&path),
"current": false,
})
})
.collect()
}
fn absolute_href(path: &str) -> String {
if path.is_empty() {
"/".into()
} else {
format!("/{path}/")
}
}
fn route_with_param(route: &str, param: &str, value: &str) -> String {
let key = format!("[{param}]");
route
.split('/')
.filter(|p| !p.is_empty())
.map(|part| if part == key { value } else { part })
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn index_route(route: &str, param: &str) -> String {
let key = format!("[{param}]");
route
.split('/')
.filter(|p| !p.is_empty() && *p != key.as_str())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn rule(page_size: usize) -> PaginationRule {
PaginationRule {
route: "blog/[page]".into(),
page_size,
..PaginationRule::default()
}
}
#[test]
fn chunks_and_meta() {
let items: Vec<Value> = (1..=5).map(|n| json!({ "n": n })).collect();
let pages = chunk_items(&items, &rule(2), "blog/[page]", "page");
assert_eq!(pages.len(), 3);
assert_eq!(pages[0].page, "1");
assert_eq!(pages[0].value["total_pages"], 3);
assert_eq!(pages[0].value["items"].as_array().unwrap().len(), 2);
assert_eq!(pages[0].value["path"], "blog/1");
assert_eq!(pages[0].value["href"], "/blog/1/");
assert_eq!(pages[0].value["next_href"], "/blog/2/");
assert_eq!(pages[0].value["prev_href"], "");
assert_eq!(pages[0].value["pages"].as_array().unwrap().len(), 3);
assert_eq!(pages[0].value["pages"][0]["current"], true);
assert_eq!(pages[2].value["items"].as_array().unwrap().len(), 1);
assert!(!pages[2].value["has_next"].as_bool().unwrap());
}
#[test]
fn limit_and_offset() {
let items: Vec<Value> = (1..=10).map(|n| json!({ "n": n })).collect();
let mut r = rule(3);
r.offset = 2;
r.limit = 5;
let selected = select_items(&items, &r);
assert_eq!(selected.len(), 5);
assert_eq!(selected[0]["n"], 3);
let pages = chunk_items(&items, &r, "blog/[page]", "page");
assert_eq!(pages.len(), 2); assert_eq!(pages[0].value["total_items"], 5);
assert_eq!(pages[0].value["source_total"], 10);
assert_eq!(pages[0].value["offset"], 2);
assert_eq!(pages[0].value["limit"], 5);
}
#[test]
fn sort_desc_and_max_pages() {
let items = vec![
json!({"slug": "a", "published_at": "2026-07-01"}),
json!({"slug": "b", "published_at": "2026-07-17"}),
json!({"slug": "c", "published_at": "2026-07-10"}),
];
let mut r = rule(1);
r.sort_by = "published_at".into();
r.sort_desc = true;
r.max_pages = 2;
let pages = chunk_items(&items, &r, "blog/[page]", "page");
assert_eq!(pages.len(), 2);
assert_eq!(pages[0].value["items"][0]["slug"], "b");
assert_eq!(pages[1].value["items"][0]["slug"], "c");
assert_eq!(pages[0].value["last_href"], "/blog/2/");
}
#[test]
fn index_strips_param() {
assert_eq!(index_route("blog/[page]", "page"), "blog");
assert_eq!(index_route("posts/page/[page]", "page"), "posts/page");
assert_eq!(index_route("[page]", "page"), "");
}
#[test]
fn apply_locale_to_chunk_rewrites_hrefs() {
let items: Vec<Value> = (1..=4).map(|n| json!({ "n": n })).collect();
let pages = chunk_items(&items, &rule(2), "[locale]/blog/[page]", "page");
let localized = apply_locale_to_chunk(&pages[0], "en");
assert_eq!(localized.value["path"], "en/blog/1");
assert_eq!(localized.value["href"], "/en/blog/1/");
assert_eq!(localized.value["next_href"], "/en/blog/2/");
}
}