Skip to main content

cloud_sdk/
pagination.rs

1//! Explicit provider-neutral pagination state.
2
3use crate::rate_limit::RateLimit;
4
5/// Pagination validation or transition error.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum PaginationError {
8    /// Page numbers are one-based.
9    PageZero,
10    /// Per-page values are one-based.
11    PerPageZero,
12    /// The caller-selected page limit must be nonzero.
13    PageLimitZero,
14    /// The previous-page link is not immediately before the current page.
15    InvalidPreviousPage,
16    /// The next-page link is not immediately after the current page.
17    InvalidNextPage,
18    /// The last page contradicts the current page or continuation state.
19    InvalidLastPage,
20    /// The response page differs from the page the cursor requested.
21    UnexpectedPage,
22    /// A non-terminal empty page advertised another page.
23    EmptyPageWithNextPage,
24    /// The decoded entry count contradicts page or total-entry metadata.
25    InvalidEntryCount,
26    /// Response page size differs from the caller-bound request size.
27    PageSizeChanged,
28    /// Total entries or last page changed during one traversal.
29    TraversalChanged,
30    /// Advancing would exceed the caller-selected page limit.
31    PageLimitExceeded,
32    /// The cursor already reached its terminal page.
33    Complete,
34}
35
36/// One-based provider-neutral page number.
37#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct PageNumber(u64);
39
40impl PageNumber {
41    /// Creates a one-based page number.
42    pub const fn new(value: u64) -> Result<Self, PaginationError> {
43        if value == 0 {
44            return Err(PaginationError::PageZero);
45        }
46        Ok(Self(value))
47    }
48
49    /// Returns the raw page number.
50    #[must_use]
51    pub const fn get(self) -> u64 {
52        self.0
53    }
54}
55
56/// Nonzero limit on pages admitted by one cursor.
57#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct PageLimit(u32);
59
60impl PageLimit {
61    /// Creates a nonzero page limit.
62    pub const fn new(value: u32) -> Result<Self, PaginationError> {
63        if value == 0 {
64            return Err(PaginationError::PageLimitZero);
65        }
66        Ok(Self(value))
67    }
68
69    /// Returns the maximum number of pages.
70    #[must_use]
71    pub const fn get(self) -> u32 {
72        self.0
73    }
74}
75
76/// Validated metadata from one paginated provider response.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct PageMetadata {
79    page: PageNumber,
80    per_page: u64,
81    previous_page: Option<PageNumber>,
82    next_page: Option<PageNumber>,
83    last_page: Option<PageNumber>,
84    total_entries: Option<u64>,
85}
86
87impl PageMetadata {
88    /// Creates coherent page navigation metadata.
89    pub const fn new(
90        page: PageNumber,
91        per_page: u64,
92        previous_page: Option<PageNumber>,
93        next_page: Option<PageNumber>,
94        last_page: Option<PageNumber>,
95        total_entries: Option<u64>,
96    ) -> Result<Self, PaginationError> {
97        if per_page == 0 {
98            return Err(PaginationError::PerPageZero);
99        }
100        if let Some(previous) = previous_page {
101            let Some(expected) = page.0.checked_sub(1) else {
102                return Err(PaginationError::InvalidPreviousPage);
103            };
104            if previous.0 != expected {
105                return Err(PaginationError::InvalidPreviousPage);
106            }
107        }
108        if let Some(next) = next_page {
109            let Some(expected) = page.0.checked_add(1) else {
110                return Err(PaginationError::InvalidNextPage);
111            };
112            if next.0 != expected {
113                return Err(PaginationError::InvalidNextPage);
114            }
115        }
116        if let Some(last) = last_page {
117            if last.0 < page.0 {
118                return Err(PaginationError::InvalidLastPage);
119            }
120            if (page.0 < last.0) != next_page.is_some() {
121                return Err(PaginationError::InvalidLastPage);
122            }
123            if let Some(next) = next_page
124                && next.0 > last.0
125            {
126                return Err(PaginationError::InvalidLastPage);
127            }
128        }
129        Ok(Self {
130            page,
131            per_page,
132            previous_page,
133            next_page,
134            last_page,
135            total_entries,
136        })
137    }
138
139    /// Returns the current page.
140    #[must_use]
141    pub const fn page(self) -> PageNumber {
142        self.page
143    }
144
145    /// Returns the requested maximum entries per page.
146    #[must_use]
147    pub const fn per_page(self) -> u64 {
148        self.per_page
149    }
150
151    /// Returns the previous page when advertised.
152    #[must_use]
153    pub const fn previous_page(self) -> Option<PageNumber> {
154        self.previous_page
155    }
156
157    /// Returns the next page when advertised.
158    #[must_use]
159    pub const fn next_page(self) -> Option<PageNumber> {
160        self.next_page
161    }
162
163    /// Returns the final page when known.
164    #[must_use]
165    pub const fn last_page(self) -> Option<PageNumber> {
166        self.last_page
167    }
168
169    /// Returns the total matching entries when known.
170    #[must_use]
171    pub const fn total_entries(self) -> Option<u64> {
172        self.total_entries
173    }
174}
175
176/// Accepted page boundary returned to a caller.
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub struct PageBoundary {
179    metadata: PageMetadata,
180    entries: usize,
181    rate_limit: Option<RateLimit>,
182}
183
184impl PageBoundary {
185    /// Returns the validated provider metadata.
186    #[must_use]
187    pub const fn metadata(self) -> PageMetadata {
188        self.metadata
189    }
190
191    /// Returns the entries decoded from this page.
192    #[must_use]
193    pub const fn entries(self) -> usize {
194        self.entries
195    }
196
197    /// Returns rate-limit metadata from this response when supplied.
198    #[must_use]
199    pub const fn rate_limit(self) -> Option<RateLimit> {
200        self.rate_limit
201    }
202
203    /// Reports whether this page ended iteration.
204    #[must_use]
205    pub const fn is_terminal(self) -> bool {
206        self.metadata.next_page.is_none()
207    }
208}
209
210/// Bounded explicit cursor that locks page size and traversal metadata.
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub struct PaginationCursor {
213    next_page: Option<PageNumber>,
214    pages_seen: u32,
215    limit: PageLimit,
216    expected_per_page: u64,
217    expected_total_entries: Option<u64>,
218    expected_last_page: Option<PageNumber>,
219}
220
221impl PaginationCursor {
222    /// Starts a cursor with caller-bound page size and hard page limit.
223    pub const fn new(
224        first_page: PageNumber,
225        expected_per_page: u64,
226        limit: PageLimit,
227    ) -> Result<Self, PaginationError> {
228        if expected_per_page == 0 {
229            return Err(PaginationError::PerPageZero);
230        }
231        Ok(Self {
232            next_page: Some(first_page),
233            pages_seen: 0,
234            limit,
235            expected_per_page,
236            expected_total_entries: None,
237            expected_last_page: None,
238        })
239    }
240
241    /// Returns the page the caller must request next.
242    pub const fn next_page(self) -> Result<PageNumber, PaginationError> {
243        match self.next_page {
244            Some(page) => Ok(page),
245            None => Err(PaginationError::Complete),
246        }
247    }
248
249    /// Returns the number of accepted pages.
250    #[must_use]
251    pub const fn pages_seen(self) -> u32 {
252        self.pages_seen
253    }
254
255    /// Validates locked traversal metadata and decoded entries, then records the page.
256    pub fn observe(
257        &mut self,
258        metadata: PageMetadata,
259        entries: usize,
260        rate_limit: Option<RateLimit>,
261    ) -> Result<PageBoundary, PaginationError> {
262        let expected = match self.next_page {
263            Some(page) => page,
264            None => return Err(PaginationError::Complete),
265        };
266        if metadata.page.0 != expected.0 {
267            return Err(PaginationError::UnexpectedPage);
268        }
269        if metadata.per_page != self.expected_per_page {
270            return Err(PaginationError::PageSizeChanged);
271        }
272        if self.pages_seen != 0
273            && (metadata.total_entries != self.expected_total_entries
274                || metadata.last_page != self.expected_last_page)
275        {
276            return Err(PaginationError::TraversalChanged);
277        }
278        let entry_count = u64::try_from(entries).map_err(|_| PaginationError::InvalidEntryCount)?;
279        if entry_count > metadata.per_page {
280            return Err(PaginationError::InvalidEntryCount);
281        }
282        if let Some(total) = metadata.total_entries {
283            let page_offset = metadata
284                .page
285                .0
286                .checked_sub(1)
287                .and_then(|page| page.checked_mul(metadata.per_page))
288                .ok_or(PaginationError::InvalidEntryCount)?;
289            let expected_entries = total.saturating_sub(page_offset).min(metadata.per_page);
290            let expected_continuation = total
291                > page_offset
292                    .checked_add(entry_count)
293                    .ok_or(PaginationError::InvalidEntryCount)?;
294            if entry_count != expected_entries
295                || expected_continuation != metadata.next_page.is_some()
296            {
297                return Err(PaginationError::InvalidEntryCount);
298            }
299        }
300        if entries == 0 && metadata.next_page.is_some() {
301            return Err(PaginationError::EmptyPageWithNextPage);
302        }
303        let pages_seen = match self.pages_seen.checked_add(1) {
304            Some(value) => value,
305            None => return Err(PaginationError::PageLimitExceeded),
306        };
307        if metadata.next_page.is_some() && pages_seen >= self.limit.0 {
308            return Err(PaginationError::PageLimitExceeded);
309        }
310        self.pages_seen = pages_seen;
311        self.next_page = metadata.next_page;
312        if pages_seen == 1 {
313            self.expected_total_entries = metadata.total_entries;
314            self.expected_last_page = metadata.last_page;
315        }
316        Ok(PageBoundary {
317            metadata,
318            entries,
319            rate_limit,
320        })
321    }
322}
323
324#[cfg(test)]
325mod tests;