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