Skip to main content

api_bones/
pagination.rs

1//! Pagination types for list endpoints.
2//!
3//! Supports both offset-based and cursor-based pagination patterns.
4//! All types are framework-agnostic — consumers add their own framework
5//! derives (e.g. `utoipa::ToSchema`, `utoipa::IntoParams`).
6//!
7//! # Choosing a pagination strategy
8//!
9//! ## Offset-based (`PaginatedResponse` + `PaginationParams`)
10//! - Best for: admin dashboards, internal tools, small bounded datasets
11//! - Supports: random page access (jump to page N), total count
12//! - Trade-off: pages can shift when rows are inserted/deleted between requests
13//! - Use when: dataset is small (<10k rows), real-time consistency is not critical
14//!
15//! ## Cursor-based (`CursorPaginatedResponse` + `CursorPaginationParams`)
16//! - Best for: public APIs, feeds, large or live datasets
17//! - Supports: stable iteration (no skipped/duplicate items on insert)
18//! - Trade-off: no random page access, no total count
19//! - Use when: dataset is large or frequently mutated, API is public-facing
20//! - Industry standard: Stripe, GitHub, Slack all use cursor-based for list endpoints
21
22#[cfg(all(not(feature = "std"), feature = "alloc"))]
23use alloc::{string::String, vec::Vec};
24#[cfg(feature = "serde")]
25use serde::{Deserialize, Serialize};
26#[cfg(feature = "validator")]
27use validator::Validate;
28
29// ---------------------------------------------------------------------------
30// Offset-based pagination (flat, limit/offset contract)
31// ---------------------------------------------------------------------------
32
33/// Offset-based paginated response envelope with a flat shape.
34///
35/// Requires `std` or `alloc`.
36///
37/// All list endpoints that use `PaginationParams` should wrap their result
38/// with this type so SDK consumers always see the same contract.
39///
40/// ```json
41/// {
42///   "items": [...],
43///   "total_count": 42,
44///   "has_more": true,
45///   "limit": 20,
46///   "offset": 0
47/// }
48/// ```
49#[cfg(any(feature = "std", feature = "alloc"))]
50#[derive(Debug, Clone, PartialEq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
53#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
54#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
55#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
56pub struct PaginatedResponse<T> {
57    /// The items on this page.
58    pub items: Vec<T>,
59    /// Total number of items across all pages.
60    pub total_count: u64,
61    /// Whether more items exist beyond this page.
62    pub has_more: bool,
63    /// Maximum number of items returned per page.
64    pub limit: u64,
65    /// Number of items skipped before this page.
66    pub offset: u64,
67}
68
69#[cfg(any(feature = "std", feature = "alloc"))]
70impl<T> PaginatedResponse<T> {
71    /// Build a paginated response from items, total count, and the query params.
72    ///
73    /// `has_more` is set to `true` when `offset + items.len() < total_count`.
74    ///
75    /// # Examples
76    ///
77    /// ```
78    /// use api_bones::pagination::{PaginatedResponse, PaginationParams};
79    ///
80    /// let params = PaginationParams::default();
81    /// let resp = PaginatedResponse::new(vec![1, 2, 3], 25, &params);
82    /// assert!(resp.has_more);
83    /// assert_eq!(resp.total_count, 25);
84    /// assert_eq!(resp.limit, 20);
85    /// assert_eq!(resp.offset, 0);
86    ///
87    /// let resp = PaginatedResponse::new(vec![1, 2, 3], 3, &params);
88    /// assert!(!resp.has_more);
89    /// ```
90    #[must_use]
91    pub fn new(items: Vec<T>, total_count: u64, params: &PaginationParams) -> Self {
92        let limit = params.limit();
93        let offset = params.offset();
94        let has_more = offset + (items.len() as u64) < total_count;
95        Self {
96            items,
97            total_count,
98            has_more,
99            limit,
100            offset,
101        }
102    }
103}
104
105/// Query parameters for offset-based list endpoints.
106///
107/// `limit` must be between 1 and 100 (inclusive) and defaults to 20.
108/// `offset` defaults to 0.
109///
110/// When the `validator` feature is enabled (the default), calling
111/// `.validate()` enforces these constraints before the values are used.
112///
113/// # Examples
114///
115/// ```
116/// use api_bones::pagination::PaginationParams;
117///
118/// let p = PaginationParams::default();
119/// assert_eq!(p.limit(), 20);
120/// assert_eq!(p.offset(), 0);
121/// ```
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
124#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
125#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
126#[cfg_attr(feature = "validator", derive(Validate))]
127#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
128pub struct PaginationParams {
129    /// Maximum number of items to return (1–100). Defaults to 20.
130    #[cfg_attr(feature = "serde", serde(default))]
131    #[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
132    #[cfg_attr(
133        feature = "proptest",
134        proptest(strategy = "proptest::option::of(1u64..=100u64)")
135    )]
136    pub limit: Option<u64>,
137    /// Number of items to skip. Defaults to 0.
138    #[cfg_attr(feature = "serde", serde(default))]
139    pub offset: Option<u64>,
140}
141
142impl Default for PaginationParams {
143    fn default() -> Self {
144        Self {
145            limit: Some(20),
146            offset: Some(0),
147        }
148    }
149}
150
151#[cfg(any(feature = "std", feature = "alloc"))]
152impl PaginationParams {
153    /// Create validated pagination params.
154    ///
155    /// Returns `Err` if `limit` is outside 1–100.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use api_bones::pagination::PaginationParams;
161    ///
162    /// let p = PaginationParams::new(20, 0).unwrap();
163    /// assert_eq!(p.limit(), 20);
164    /// assert_eq!(p.offset(), 0);
165    ///
166    /// assert!(PaginationParams::new(0, 0).is_err());
167    /// assert!(PaginationParams::new(101, 0).is_err());
168    /// ```
169    pub fn new(limit: u64, offset: u64) -> Result<Self, crate::error::ValidationError> {
170        if !(1..=100).contains(&limit) {
171            return Err(crate::error::ValidationError {
172                field: "/limit".into(),
173                message: "must be between 1 and 100".into(),
174                rule: Some("range".into()),
175            });
176        }
177        Ok(Self {
178            limit: Some(limit),
179            offset: Some(offset),
180        })
181    }
182}
183
184impl PaginationParams {
185    /// Resolved limit value (falls back to the default of 20).
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use api_bones::pagination::PaginationParams;
191    ///
192    /// let p = PaginationParams { limit: None, offset: None };
193    /// assert_eq!(p.limit(), 20);
194    ///
195    /// let p = PaginationParams { limit: Some(50), offset: None };
196    /// assert_eq!(p.limit(), 50);
197    /// ```
198    #[must_use]
199    pub fn limit(&self) -> u64 {
200        self.limit.unwrap_or(20)
201    }
202
203    /// Resolved offset value (falls back to the default of 0).
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use api_bones::pagination::PaginationParams;
209    ///
210    /// let p = PaginationParams { limit: None, offset: None };
211    /// assert_eq!(p.offset(), 0);
212    ///
213    /// let p = PaginationParams { limit: None, offset: Some(100) };
214    /// assert_eq!(p.offset(), 100);
215    /// ```
216    #[must_use]
217    pub fn offset(&self) -> u64 {
218        self.offset.unwrap_or(0)
219    }
220}
221
222// ---------------------------------------------------------------------------
223// Cursor-based pagination
224// ---------------------------------------------------------------------------
225
226/// Cursor-based paginated response envelope (PLATFORM-003).
227///
228/// Requires `std` or `alloc`.
229///
230/// Cursor values are opaque tokens. Clients MUST NOT interpret their contents.
231///
232/// ```json
233/// {"data": [...], "pagination": {"has_more": true, "next_cursor": "eyJpZCI6NDJ9"}}
234/// ```
235#[cfg(any(feature = "std", feature = "alloc"))]
236#[derive(Debug, Clone, PartialEq)]
237#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
238#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
239#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
240#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
241#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
242pub struct CursorPaginatedResponse<T> {
243    /// The page of results.
244    pub data: Vec<T>,
245    /// Cursor pagination metadata.
246    pub pagination: CursorPagination,
247}
248
249#[cfg(any(feature = "std", feature = "alloc"))]
250impl<T> CursorPaginatedResponse<T> {
251    /// Create a new cursor-paginated response.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use api_bones::pagination::{CursorPaginatedResponse, CursorPagination};
257    ///
258    /// let resp = CursorPaginatedResponse::new(
259    ///     vec!["a", "b"],
260    ///     CursorPagination::more("next_token"),
261    /// );
262    /// assert_eq!(resp.data.len(), 2);
263    /// assert!(resp.pagination.has_more);
264    /// ```
265    #[must_use]
266    pub fn new(data: Vec<T>, pagination: CursorPagination) -> Self {
267        Self { data, pagination }
268    }
269}
270
271/// Cursor-based pagination metadata (PLATFORM-003).
272///
273/// Requires `std` or `alloc`.
274///
275/// `next_cursor` is an opaque token. Clients MUST NOT interpret its contents.
276#[cfg(any(feature = "std", feature = "alloc"))]
277#[derive(Debug, Clone, PartialEq, Eq)]
278#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
279#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
282#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
283pub struct CursorPagination {
284    /// Whether more results exist beyond this page.
285    pub has_more: bool,
286    /// Opaque cursor for the next page. `None` when `has_more` is false.
287    #[cfg_attr(
288        feature = "serde",
289        serde(default, skip_serializing_if = "Option::is_none")
290    )]
291    pub next_cursor: Option<String>,
292}
293
294#[cfg(any(feature = "std", feature = "alloc"))]
295impl CursorPagination {
296    /// Create cursor metadata indicating more results.
297    ///
298    /// # Examples
299    ///
300    /// ```
301    /// use api_bones::pagination::CursorPagination;
302    ///
303    /// let c = CursorPagination::more("eyJpZCI6NDJ9");
304    /// assert!(c.has_more);
305    /// assert_eq!(c.next_cursor.as_deref(), Some("eyJpZCI6NDJ9"));
306    /// ```
307    #[must_use]
308    pub fn more(cursor: impl Into<String>) -> Self {
309        Self {
310            has_more: true,
311            next_cursor: Some(cursor.into()),
312        }
313    }
314
315    /// Create cursor metadata indicating this is the last page.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use api_bones::pagination::CursorPagination;
321    ///
322    /// let c = CursorPagination::last_page();
323    /// assert!(!c.has_more);
324    /// assert!(c.next_cursor.is_none());
325    /// ```
326    #[must_use]
327    pub fn last_page() -> Self {
328        Self {
329            has_more: false,
330            next_cursor: None,
331        }
332    }
333}
334
335#[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
336#[allow(clippy::unnecessary_wraps)]
337fn default_cursor_limit() -> Option<u64> {
338    Some(20)
339}
340
341/// Query parameters for cursor-based list endpoints.
342///
343/// `limit` must be between 1 and 100 (inclusive) and defaults to 20.
344/// `after` is an opaque cursor token; omit it (or pass `None`) for the first page.
345///
346/// Requires `std` or `alloc` (`after` field contains `String`).
347#[cfg(any(feature = "std", feature = "alloc"))]
348#[derive(Debug, Clone, PartialEq, Eq)]
349#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
350#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
351#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
352#[cfg_attr(feature = "validator", derive(Validate))]
353#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
354pub struct CursorPaginationParams {
355    /// Maximum number of items to return (1–100). Defaults to 20.
356    #[cfg_attr(feature = "serde", serde(default = "default_cursor_limit"))]
357    #[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
358    #[cfg_attr(
359        feature = "proptest",
360        proptest(strategy = "proptest::option::of(1u64..=100u64)")
361    )]
362    pub limit: Option<u64>,
363    /// Opaque cursor for the next page. `None` requests the first page.
364    #[cfg_attr(
365        feature = "serde",
366        serde(default, skip_serializing_if = "Option::is_none")
367    )]
368    pub after: Option<String>,
369}
370
371#[cfg(any(feature = "std", feature = "alloc"))]
372impl Default for CursorPaginationParams {
373    fn default() -> Self {
374        Self {
375            limit: Some(20),
376            after: None,
377        }
378    }
379}
380
381#[cfg(any(feature = "std", feature = "alloc"))]
382impl CursorPaginationParams {
383    /// Create validated cursor pagination params.
384    ///
385    /// Returns `Err` if `limit` is outside 1–100.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// use api_bones::pagination::CursorPaginationParams;
391    ///
392    /// let p = CursorPaginationParams::new(50, None).unwrap();
393    /// assert_eq!(p.limit(), 50);
394    ///
395    /// assert!(CursorPaginationParams::new(0, None).is_err());
396    /// assert!(CursorPaginationParams::new(101, None).is_err());
397    /// ```
398    pub fn new(limit: u64, after: Option<String>) -> Result<Self, crate::error::ValidationError> {
399        if !(1..=100).contains(&limit) {
400            return Err(crate::error::ValidationError {
401                field: "/limit".into(),
402                message: "must be between 1 and 100".into(),
403                rule: Some("range".into()),
404            });
405        }
406        Ok(Self {
407            limit: Some(limit),
408            after,
409        })
410    }
411
412    /// Resolved limit value (falls back to the default of 20).
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// use api_bones::pagination::CursorPaginationParams;
418    ///
419    /// let p = CursorPaginationParams::default();
420    /// assert_eq!(p.limit(), 20);
421    ///
422    /// let p = CursorPaginationParams { limit: Some(50), after: None };
423    /// assert_eq!(p.limit(), 50);
424    /// ```
425    #[must_use]
426    pub fn limit(&self) -> u64 {
427        self.limit.unwrap_or(20)
428    }
429
430    /// The cursor token, if any.
431    #[must_use]
432    pub fn after(&self) -> Option<&str> {
433        self.after.as_deref()
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Keyset (seek) pagination
439// ---------------------------------------------------------------------------
440
441/// Query parameters for keyset (seek-based) pagination.
442///
443/// Keyset pagination is more efficient than offset pagination for large datasets
444/// because the database anchors the query on an indexed column value rather than
445/// skipping rows.
446///
447/// - `after` — fetch items whose sort key is **greater than** this value
448/// - `before` — fetch items whose sort key is **less than** this value
449/// - `limit` — maximum number of items to return (1–100, default 20)
450///
451/// Typically only one of `after` / `before` is supplied per request.
452///
453/// Requires `std` or `alloc`.
454#[cfg(any(feature = "std", feature = "alloc"))]
455#[derive(Debug, Clone, PartialEq, Eq)]
456#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
457#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
458#[cfg_attr(feature = "schemars", schemars(bound = "K: schemars::JsonSchema"))]
459#[cfg_attr(feature = "validator", derive(Validate))]
460pub struct KeysetPaginationParams<K> {
461    /// Fetch items after (exclusive) this key value.
462    #[cfg_attr(
463        feature = "serde",
464        serde(default, skip_serializing_if = "Option::is_none")
465    )]
466    pub after: Option<K>,
467    /// Fetch items before (exclusive) this key value.
468    #[cfg_attr(
469        feature = "serde",
470        serde(default, skip_serializing_if = "Option::is_none")
471    )]
472    pub before: Option<K>,
473    /// Maximum number of items to return (1–100). Defaults to 20.
474    #[cfg_attr(feature = "serde", serde(default = "default_keyset_limit"))]
475    #[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
476    pub limit: Option<u64>,
477}
478
479#[cfg(any(feature = "std", feature = "alloc"))]
480impl<K> Default for KeysetPaginationParams<K> {
481    fn default() -> Self {
482        Self {
483            after: None,
484            before: None,
485            limit: Some(20),
486        }
487    }
488}
489
490#[cfg(any(feature = "std", feature = "alloc"))]
491impl<K> KeysetPaginationParams<K> {
492    /// Create validated keyset pagination params.
493    ///
494    /// Returns `Err` if `limit` is outside 1–100.
495    ///
496    /// # Examples
497    ///
498    /// ```
499    /// use api_bones::pagination::KeysetPaginationParams;
500    ///
501    /// let p = KeysetPaginationParams::<String>::new(10, None, None).unwrap();
502    /// assert_eq!(p.limit(), 10);
503    ///
504    /// assert!(KeysetPaginationParams::<String>::new(0, None, None).is_err());
505    /// assert!(KeysetPaginationParams::<String>::new(101, None, None).is_err());
506    /// ```
507    pub fn new(
508        limit: u64,
509        after: Option<K>,
510        before: Option<K>,
511    ) -> Result<Self, crate::error::ValidationError> {
512        if !(1..=100).contains(&limit) {
513            return Err(crate::error::ValidationError {
514                field: "/limit".into(),
515                message: "must be between 1 and 100".into(),
516                rule: Some("range".into()),
517            });
518        }
519        Ok(Self {
520            after,
521            before,
522            limit: Some(limit),
523        })
524    }
525
526    /// Resolved limit value (falls back to 20).
527    ///
528    /// # Examples
529    ///
530    /// ```
531    /// use api_bones::pagination::KeysetPaginationParams;
532    ///
533    /// let p = KeysetPaginationParams::<String>::default();
534    /// assert_eq!(p.limit(), 20);
535    /// ```
536    #[must_use]
537    pub fn limit(&self) -> u64 {
538        self.limit.unwrap_or(20)
539    }
540}
541
542#[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
543#[allow(clippy::unnecessary_wraps)]
544fn default_keyset_limit() -> Option<u64> {
545    Some(20)
546}
547
548/// A page of results from a keyset-paginated endpoint.
549///
550/// `has_next` / `has_prev` reflect whether further pages exist in each direction.
551/// Cursors for navigation are opaque strings — typically the serialised key of
552/// the first/last item in `items`.
553///
554/// Requires `std` or `alloc`.
555#[cfg(any(feature = "std", feature = "alloc"))]
556#[derive(Debug, Clone, PartialEq)]
557#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
558#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560pub struct KeysetPaginatedResponse<T> {
561    /// The items on this page.
562    pub items: Vec<T>,
563    /// Whether a next page exists (there are items after the last item).
564    pub has_next: bool,
565    /// Whether a previous page exists (there are items before the first item).
566    pub has_prev: bool,
567    /// Opaque cursor pointing to the item just before the first item in `items`.
568    ///
569    /// Pass this as `before` to retrieve the previous page.
570    #[cfg_attr(
571        feature = "serde",
572        serde(default, skip_serializing_if = "Option::is_none")
573    )]
574    pub prev_cursor: Option<String>,
575    /// Opaque cursor pointing to the item just after the last item in `items`.
576    ///
577    /// Pass this as `after` to retrieve the next page.
578    #[cfg_attr(
579        feature = "serde",
580        serde(default, skip_serializing_if = "Option::is_none")
581    )]
582    pub next_cursor: Option<String>,
583}
584
585#[cfg(any(feature = "std", feature = "alloc"))]
586impl<T> KeysetPaginatedResponse<T> {
587    /// Create a new keyset-paginated response.
588    ///
589    /// # Examples
590    ///
591    /// ```
592    /// use api_bones::pagination::KeysetPaginatedResponse;
593    ///
594    /// let resp = KeysetPaginatedResponse::new(
595    ///     vec![1i32, 2, 3],
596    ///     true,
597    ///     false,
598    ///     None,
599    ///     Some("cursor_after_3".to_string()),
600    /// );
601    /// assert!(resp.has_next);
602    /// assert!(!resp.has_prev);
603    /// ```
604    #[must_use]
605    pub fn new(
606        items: Vec<T>,
607        has_next: bool,
608        has_prev: bool,
609        prev_cursor: Option<String>,
610        next_cursor: Option<String>,
611    ) -> Self {
612        Self {
613            items,
614            has_next,
615            has_prev,
616            prev_cursor,
617            next_cursor,
618        }
619    }
620
621    /// Convenience: first page with no previous cursor.
622    ///
623    /// # Examples
624    ///
625    /// ```
626    /// use api_bones::pagination::KeysetPaginatedResponse;
627    ///
628    /// let resp = KeysetPaginatedResponse::first_page(
629    ///     vec!["a", "b", "c"],
630    ///     true,
631    ///     Some("cursor_after_c".to_string()),
632    /// );
633    /// assert!(!resp.has_prev);
634    /// assert!(resp.has_next);
635    /// ```
636    #[must_use]
637    pub fn first_page(items: Vec<T>, has_next: bool, next_cursor: Option<String>) -> Self {
638        Self::new(items, has_next, false, None, next_cursor)
639    }
640}
641
642// ---------------------------------------------------------------------------
643// Axum extractors — `axum` feature
644// ---------------------------------------------------------------------------
645
646#[cfg(feature = "axum")]
647#[allow(clippy::result_large_err)]
648mod axum_extractors {
649    use super::{CursorPaginationParams, PaginationParams};
650    use crate::error::ApiError;
651    use axum::extract::{FromRequestParts, Query};
652    use axum::http::request::Parts;
653    #[cfg(feature = "validator")]
654    use validator::Validate;
655
656    impl<S: Send + Sync> FromRequestParts<S> for PaginationParams {
657        type Rejection = ApiError;
658
659        async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
660            let Query(params) = Query::<Self>::from_request_parts(parts, state)
661                .await
662                .map_err(|e| ApiError::bad_request(e.to_string()))?;
663            #[cfg(feature = "validator")]
664            params
665                .validate()
666                .map_err(|e| ApiError::bad_request(e.to_string()))?;
667            Ok(params)
668        }
669    }
670
671    impl<S: Send + Sync> FromRequestParts<S> for CursorPaginationParams {
672        type Rejection = ApiError;
673
674        async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
675            let Query(params) = Query::<Self>::from_request_parts(parts, state)
676                .await
677                .map_err(|e| ApiError::bad_request(e.to_string()))?;
678            #[cfg(feature = "validator")]
679            params
680                .validate()
681                .map_err(|e| ApiError::bad_request(e.to_string()))?;
682            Ok(params)
683        }
684    }
685}
686
687// ---------------------------------------------------------------------------
688// arbitrary::Arbitrary manual impls — constrained limit (1–100)
689// ---------------------------------------------------------------------------
690
691#[cfg(feature = "arbitrary")]
692impl<'a> arbitrary::Arbitrary<'a> for PaginationParams {
693    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
694        use arbitrary::Arbitrary;
695        // limit is None or Some(1..=100)
696        let limit = if bool::arbitrary(u)? {
697            Some(u.int_in_range(1u64..=100)?)
698        } else {
699            None
700        };
701        Ok(Self {
702            limit,
703            offset: Arbitrary::arbitrary(u)?,
704        })
705    }
706}
707
708#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
709impl<'a> arbitrary::Arbitrary<'a> for CursorPaginationParams {
710    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
711        use arbitrary::Arbitrary;
712        let limit = if bool::arbitrary(u)? {
713            Some(u.int_in_range(1u64..=100)?)
714        } else {
715            None
716        };
717        Ok(Self {
718            limit,
719            after: Arbitrary::arbitrary(u)?,
720        })
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    // -----------------------------------------------------------------------
729    // PaginatedResponse::new — has_more logic
730    // -----------------------------------------------------------------------
731
732    #[test]
733    fn paginated_response_has_more_true() {
734        let params = PaginationParams::default();
735        let resp = PaginatedResponse::new(vec![1i32; 20], 25, &params);
736        assert!(resp.has_more);
737        assert_eq!(resp.total_count, 25);
738        assert_eq!(resp.limit, 20);
739        assert_eq!(resp.offset, 0);
740    }
741
742    #[test]
743    fn paginated_response_has_more_false() {
744        let params = PaginationParams::default();
745        let resp = PaginatedResponse::new(vec![1i32; 5], 5, &params);
746        assert!(!resp.has_more);
747    }
748
749    #[test]
750    fn paginated_response_exact_last_page_boundary() {
751        // offset=20, 5 items, total=25 → offset(20) + items(5) == total(25) → no more
752        let params = PaginationParams {
753            limit: Some(20),
754            offset: Some(20),
755        };
756        let resp = PaginatedResponse::new(vec![1i32; 5], 25, &params);
757        assert!(!resp.has_more);
758    }
759
760    #[test]
761    fn paginated_response_second_page_has_more() {
762        let params = PaginationParams {
763            limit: Some(10),
764            offset: Some(10),
765        };
766        let resp = PaginatedResponse::new(vec![1i32; 10], 50, &params);
767        assert!(resp.has_more);
768    }
769
770    // -----------------------------------------------------------------------
771    // PaginationParams defaults and accessors
772    // -----------------------------------------------------------------------
773
774    #[test]
775    fn pagination_params_defaults() {
776        let p = PaginationParams::default();
777        assert_eq!(p.limit(), 20);
778        assert_eq!(p.offset(), 0);
779    }
780
781    #[test]
782    fn pagination_params_none_falls_back_to_defaults() {
783        let p = PaginationParams {
784            limit: None,
785            offset: None,
786        };
787        assert_eq!(p.limit(), 20);
788        assert_eq!(p.offset(), 0);
789    }
790
791    #[test]
792    fn pagination_params_custom_values() {
793        let p = PaginationParams {
794            limit: Some(50),
795            offset: Some(100),
796        };
797        assert_eq!(p.limit(), 50);
798        assert_eq!(p.offset(), 100);
799    }
800
801    // -----------------------------------------------------------------------
802    // validator feature — range constraints
803    // -----------------------------------------------------------------------
804
805    #[cfg(feature = "validator")]
806    #[test]
807    fn pagination_params_validate_min_limit() {
808        use validator::Validate;
809        let p = PaginationParams {
810            limit: Some(0),
811            offset: Some(0),
812        };
813        assert!(p.validate().is_err());
814    }
815
816    #[cfg(feature = "validator")]
817    #[test]
818    fn pagination_params_validate_max_limit() {
819        use validator::Validate;
820        let p = PaginationParams {
821            limit: Some(101),
822            offset: Some(0),
823        };
824        assert!(p.validate().is_err());
825    }
826
827    #[cfg(feature = "validator")]
828    #[test]
829    fn pagination_params_validate_boundary_values() {
830        use validator::Validate;
831        let min = PaginationParams {
832            limit: Some(1),
833            offset: Some(0),
834        };
835        assert!(min.validate().is_ok());
836        let max = PaginationParams {
837            limit: Some(100),
838            offset: Some(0),
839        };
840        assert!(max.validate().is_ok());
841    }
842
843    #[cfg(feature = "validator")]
844    #[test]
845    fn pagination_params_validate_none_limit_uses_default() {
846        use validator::Validate;
847        // None is treated as default (20) — no field to validate → ok
848        let p = PaginationParams {
849            limit: None,
850            offset: None,
851        };
852        assert!(p.validate().is_ok());
853    }
854
855    // -----------------------------------------------------------------------
856    // PaginationParams::new — fallible constructor
857    // -----------------------------------------------------------------------
858
859    #[test]
860    fn pagination_params_new_valid() {
861        let p = PaginationParams::new(1, 0).unwrap();
862        assert_eq!(p.limit(), 1);
863        assert_eq!(p.offset(), 0);
864
865        let p = PaginationParams::new(100, 500).unwrap();
866        assert_eq!(p.limit(), 100);
867        assert_eq!(p.offset(), 500);
868    }
869
870    #[test]
871    fn pagination_params_new_limit_zero_fails() {
872        let err = PaginationParams::new(0, 0).unwrap_err();
873        assert_eq!(err.field, "/limit");
874        assert_eq!(err.rule.as_deref(), Some("range"));
875    }
876
877    #[test]
878    fn pagination_params_new_limit_101_fails() {
879        let err = PaginationParams::new(101, 0).unwrap_err();
880        assert_eq!(err.field, "/limit");
881    }
882
883    // -----------------------------------------------------------------------
884    // CursorPaginationParams::new — fallible constructor
885    // -----------------------------------------------------------------------
886
887    #[test]
888    fn cursor_pagination_params_new_valid() {
889        let p = CursorPaginationParams::new(1, None).unwrap();
890        assert_eq!(p.limit(), 1);
891
892        let p = CursorPaginationParams::new(100, Some("tok".to_string())).unwrap();
893        assert_eq!(p.limit(), 100);
894        assert_eq!(p.after(), Some("tok"));
895    }
896
897    #[test]
898    fn cursor_pagination_params_new_limit_zero_fails() {
899        assert!(CursorPaginationParams::new(0, None).is_err());
900    }
901
902    #[test]
903    fn cursor_pagination_params_new_limit_101_fails() {
904        let err = CursorPaginationParams::new(101, None).unwrap_err();
905        assert_eq!(err.field, "/limit");
906    }
907
908    // -----------------------------------------------------------------------
909    // KeysetPaginationParams::new — fallible constructor
910    // -----------------------------------------------------------------------
911
912    #[test]
913    fn keyset_pagination_params_new_valid() {
914        let p = KeysetPaginationParams::<u64>::new(10, Some(5), None).unwrap();
915        assert_eq!(p.limit(), 10);
916        assert_eq!(p.after, Some(5));
917        assert!(p.before.is_none());
918    }
919
920    #[test]
921    fn keyset_pagination_params_new_limit_zero_fails() {
922        assert!(KeysetPaginationParams::<u64>::new(0, None, None).is_err());
923    }
924
925    #[test]
926    fn keyset_pagination_params_new_limit_101_fails() {
927        let err = KeysetPaginationParams::<u64>::new(101, None, None).unwrap_err();
928        assert_eq!(err.field, "/limit");
929    }
930
931    // -----------------------------------------------------------------------
932    // Cursor-based types
933    // -----------------------------------------------------------------------
934
935    #[test]
936    fn cursor_pagination_more() {
937        let c = CursorPagination::more("abc123");
938        assert!(c.has_more);
939        assert_eq!(c.next_cursor.as_deref(), Some("abc123"));
940    }
941
942    #[test]
943    fn cursor_pagination_last() {
944        let c = CursorPagination::last_page();
945        assert!(!c.has_more);
946        assert!(c.next_cursor.is_none());
947    }
948
949    #[test]
950    fn cursor_paginated_response_new() {
951        let resp = CursorPaginatedResponse::new(vec!["a", "b"], CursorPagination::more("next"));
952        assert_eq!(resp.data.len(), 2);
953        assert!(resp.pagination.has_more);
954    }
955
956    // -----------------------------------------------------------------------
957    // Serde round-trips
958    // -----------------------------------------------------------------------
959
960    #[cfg(feature = "serde")]
961    #[test]
962    fn paginated_response_serde_round_trip() {
963        let params = PaginationParams {
964            limit: Some(10),
965            offset: Some(20),
966        };
967        let resp = PaginatedResponse::new(vec![1i32, 2, 3], 50, &params);
968        let json = serde_json::to_value(&resp).unwrap();
969        assert_eq!(json["total_count"], 50);
970        assert_eq!(json["has_more"], true);
971        assert_eq!(json["limit"], 10);
972        assert_eq!(json["offset"], 20);
973        assert_eq!(json["items"], serde_json::json!([1, 2, 3]));
974
975        let back: PaginatedResponse<i32> = serde_json::from_value(json).unwrap();
976        assert_eq!(back, resp);
977    }
978
979    #[cfg(feature = "serde")]
980    #[test]
981    fn snapshot_offset_paginated_response() {
982        let params = PaginationParams {
983            limit: Some(20),
984            offset: Some(0),
985        };
986        let resp = PaginatedResponse::new(vec![1i32, 2, 3], 25, &params);
987        let json = serde_json::to_value(&resp).unwrap();
988        let expected = serde_json::json!({
989            "items": [1, 2, 3],
990            "total_count": 25,
991            "has_more": true,
992            "limit": 20,
993            "offset": 0
994        });
995        assert_eq!(json, expected);
996    }
997
998    #[cfg(feature = "serde")]
999    #[test]
1000    fn pagination_params_serde_defaults() {
1001        let json = serde_json::json!({});
1002        let p: PaginationParams = serde_json::from_value(json).unwrap();
1003        assert_eq!(p.limit(), 20);
1004        assert_eq!(p.offset(), 0);
1005    }
1006
1007    #[cfg(feature = "serde")]
1008    #[test]
1009    fn pagination_params_serde_custom() {
1010        let json = serde_json::json!({"limit": 50, "offset": 100});
1011        let p: PaginationParams = serde_json::from_value(json).unwrap();
1012        assert_eq!(p.limit(), 50);
1013        assert_eq!(p.offset(), 100);
1014    }
1015
1016    #[cfg(feature = "serde")]
1017    #[test]
1018    fn cursor_response_serde_omits_null_cursor() {
1019        let resp = CursorPaginatedResponse::new(vec!["x"], CursorPagination::last_page());
1020        let json = serde_json::to_value(&resp).unwrap();
1021        assert!(json["pagination"].get("next_cursor").is_none());
1022    }
1023
1024    #[cfg(feature = "serde")]
1025    #[test]
1026    fn cursor_response_serde_includes_cursor() {
1027        let resp = CursorPaginatedResponse::new(vec!["x"], CursorPagination::more("eyJpZCI6NDJ9"));
1028        let json = serde_json::to_value(&resp).unwrap();
1029        assert_eq!(json["pagination"]["next_cursor"], "eyJpZCI6NDJ9");
1030    }
1031
1032    #[cfg(feature = "serde")]
1033    #[test]
1034    fn snapshot_cursor_paginated_response() {
1035        let resp =
1036            CursorPaginatedResponse::new(vec!["a", "b"], CursorPagination::more("eyJpZCI6NDJ9"));
1037        let json = serde_json::to_value(&resp).unwrap();
1038        let expected = serde_json::json!({
1039            "data": ["a", "b"],
1040            "pagination": {
1041                "has_more": true,
1042                "next_cursor": "eyJpZCI6NDJ9"
1043            }
1044        });
1045        assert_eq!(json, expected);
1046    }
1047
1048    // -----------------------------------------------------------------------
1049    // CursorPaginationParams defaults and accessors
1050    // -----------------------------------------------------------------------
1051
1052    #[test]
1053    fn cursor_pagination_params_defaults() {
1054        let p = CursorPaginationParams::default();
1055        assert_eq!(p.limit(), 20);
1056        assert!(p.after().is_none());
1057    }
1058
1059    #[test]
1060    fn cursor_pagination_params_none_falls_back_to_defaults() {
1061        let p = CursorPaginationParams {
1062            limit: None,
1063            after: None,
1064        };
1065        assert_eq!(p.limit(), 20);
1066        assert!(p.after().is_none());
1067    }
1068
1069    #[test]
1070    fn cursor_pagination_params_custom_values() {
1071        let p = CursorPaginationParams {
1072            limit: Some(50),
1073            after: Some("eyJpZCI6NDJ9".to_string()),
1074        };
1075        assert_eq!(p.limit(), 50);
1076        assert_eq!(p.after(), Some("eyJpZCI6NDJ9"));
1077    }
1078
1079    // -----------------------------------------------------------------------
1080    // CursorPaginationParams — validator feature
1081    // -----------------------------------------------------------------------
1082
1083    #[cfg(feature = "validator")]
1084    #[test]
1085    fn cursor_pagination_params_validate_min_limit() {
1086        use validator::Validate;
1087        let p = CursorPaginationParams {
1088            limit: Some(0),
1089            after: None,
1090        };
1091        assert!(p.validate().is_err());
1092    }
1093
1094    #[cfg(feature = "validator")]
1095    #[test]
1096    fn cursor_pagination_params_validate_max_limit() {
1097        use validator::Validate;
1098        let p = CursorPaginationParams {
1099            limit: Some(101),
1100            after: None,
1101        };
1102        assert!(p.validate().is_err());
1103    }
1104
1105    #[cfg(feature = "validator")]
1106    #[test]
1107    fn cursor_pagination_params_validate_boundary_values() {
1108        use validator::Validate;
1109        let min = CursorPaginationParams {
1110            limit: Some(1),
1111            after: None,
1112        };
1113        assert!(min.validate().is_ok());
1114        let max = CursorPaginationParams {
1115            limit: Some(100),
1116            after: None,
1117        };
1118        assert!(max.validate().is_ok());
1119    }
1120
1121    // -----------------------------------------------------------------------
1122    // CursorPaginationParams — serde feature
1123    // -----------------------------------------------------------------------
1124
1125    #[cfg(feature = "serde")]
1126    #[test]
1127    fn cursor_pagination_params_serde_defaults() {
1128        let json = serde_json::json!({});
1129        let p: CursorPaginationParams = serde_json::from_value(json).unwrap();
1130        assert_eq!(p.limit(), 20);
1131        assert!(p.after().is_none());
1132    }
1133
1134    #[cfg(feature = "serde")]
1135    #[test]
1136    fn cursor_pagination_params_serde_custom() {
1137        let json = serde_json::json!({"limit": 50, "after": "eyJpZCI6NDJ9"});
1138        let p: CursorPaginationParams = serde_json::from_value(json).unwrap();
1139        assert_eq!(p.limit(), 50);
1140        assert_eq!(p.after(), Some("eyJpZCI6NDJ9"));
1141    }
1142
1143    #[cfg(feature = "schemars")]
1144    #[test]
1145    fn pagination_params_schema_is_valid() {
1146        let schema = schemars::schema_for!(PaginationParams);
1147        let json = serde_json::to_value(&schema).expect("schema serializable");
1148        assert!(json.is_object());
1149    }
1150
1151    #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
1152    #[test]
1153    fn cursor_pagination_schema_is_valid() {
1154        let schema = schemars::schema_for!(CursorPagination);
1155        let json = serde_json::to_value(&schema).expect("schema serializable");
1156        assert!(json.is_object());
1157    }
1158
1159    #[cfg(feature = "axum")]
1160    mod axum_extractor_tests {
1161        use super::super::{CursorPaginationParams, PaginationParams};
1162        use axum::extract::FromRequestParts;
1163        use axum::http::Request;
1164
1165        async fn extract_offset(q: &str) -> Result<PaginationParams, u16> {
1166            let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
1167            let (mut parts, ()) = req.into_parts();
1168            PaginationParams::from_request_parts(&mut parts, &())
1169                .await
1170                .map_err(|e| e.status)
1171        }
1172
1173        async fn extract_cursor(q: &str) -> Result<CursorPaginationParams, u16> {
1174            let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
1175            let (mut parts, ()) = req.into_parts();
1176            CursorPaginationParams::from_request_parts(&mut parts, &())
1177                .await
1178                .map_err(|e| e.status)
1179        }
1180
1181        #[tokio::test]
1182        async fn default_params() {
1183            let p = extract_offset("").await.unwrap();
1184            assert_eq!(p.limit(), 20);
1185            assert_eq!(p.offset(), 0);
1186        }
1187
1188        #[tokio::test]
1189        async fn custom_params() {
1190            let p = extract_offset("limit=50&offset=100").await.unwrap();
1191            assert_eq!(p.limit(), 50);
1192            assert_eq!(p.offset(), 100);
1193        }
1194
1195        #[cfg(feature = "validator")]
1196        #[tokio::test]
1197        async fn limit_zero_rejected() {
1198            assert_eq!(extract_offset("limit=0").await.unwrap_err(), 400);
1199        }
1200
1201        #[cfg(feature = "validator")]
1202        #[tokio::test]
1203        async fn limit_101_rejected() {
1204            assert_eq!(extract_offset("limit=101").await.unwrap_err(), 400);
1205        }
1206
1207        #[tokio::test]
1208        async fn cursor_default() {
1209            let p = extract_cursor("").await.unwrap();
1210            assert_eq!(p.limit(), 20);
1211            assert!(p.after().is_none());
1212        }
1213
1214        #[tokio::test]
1215        async fn cursor_custom() {
1216            let p = extract_cursor("limit=10&after=abc").await.unwrap();
1217            assert_eq!(p.limit(), 10);
1218            assert_eq!(p.after(), Some("abc"));
1219        }
1220
1221        #[cfg(feature = "validator")]
1222        #[tokio::test]
1223        async fn cursor_limit_101_rejected() {
1224            assert_eq!(extract_cursor("limit=101").await.unwrap_err(), 400);
1225        }
1226
1227        #[tokio::test]
1228        async fn offset_invalid_query_type_rejected() {
1229            // Non-numeric limit fails axum Query deserialization → 400 branch.
1230            assert_eq!(extract_offset("limit=abc").await.unwrap_err(), 400);
1231        }
1232
1233        #[tokio::test]
1234        async fn cursor_invalid_query_type_rejected() {
1235            assert_eq!(extract_cursor("limit=abc").await.unwrap_err(), 400);
1236        }
1237    }
1238
1239    #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
1240    #[test]
1241    fn paginated_response_schema_is_valid() {
1242        let schema = schemars::schema_for!(PaginatedResponse<String>);
1243        let json = serde_json::to_value(&schema).expect("schema serializable");
1244        assert!(json.is_object());
1245    }
1246
1247    // -----------------------------------------------------------------------
1248    // KeysetPaginationParams
1249    // -----------------------------------------------------------------------
1250
1251    #[test]
1252    fn keyset_params_default() {
1253        let p = KeysetPaginationParams::<String>::default();
1254        assert_eq!(p.limit(), 20);
1255        assert!(p.after.is_none());
1256        assert!(p.before.is_none());
1257    }
1258
1259    #[test]
1260    fn keyset_params_limit_none_falls_back() {
1261        let p = KeysetPaginationParams::<u64> {
1262            after: None,
1263            before: None,
1264            limit: None,
1265        };
1266        assert_eq!(p.limit(), 20);
1267    }
1268
1269    #[test]
1270    fn keyset_params_custom_values() {
1271        let p = KeysetPaginationParams::<u64> {
1272            after: Some(10),
1273            before: Some(1),
1274            limit: Some(50),
1275        };
1276        assert_eq!(p.limit(), 50);
1277        assert_eq!(p.after, Some(10));
1278        assert_eq!(p.before, Some(1));
1279    }
1280
1281    // -----------------------------------------------------------------------
1282    // KeysetPaginatedResponse
1283    // -----------------------------------------------------------------------
1284
1285    #[test]
1286    fn keyset_paginated_response_new() {
1287        let resp = KeysetPaginatedResponse::new(
1288            vec![1i32, 2, 3],
1289            true,
1290            false,
1291            None,
1292            Some("cursor_after_3".to_string()),
1293        );
1294        assert_eq!(resp.items, vec![1, 2, 3]);
1295        assert!(resp.has_next);
1296        assert!(!resp.has_prev);
1297        assert!(resp.prev_cursor.is_none());
1298        assert_eq!(resp.next_cursor.as_deref(), Some("cursor_after_3"));
1299    }
1300
1301    #[test]
1302    fn keyset_paginated_response_first_page() {
1303        let resp = KeysetPaginatedResponse::first_page(
1304            vec!["a", "b"],
1305            true,
1306            Some("cursor_after_b".to_string()),
1307        );
1308        assert!(!resp.has_prev);
1309        assert!(resp.has_next);
1310        assert!(resp.prev_cursor.is_none());
1311        assert_eq!(resp.next_cursor.as_deref(), Some("cursor_after_b"));
1312    }
1313
1314    #[test]
1315    fn keyset_paginated_response_last_page() {
1316        let resp = KeysetPaginatedResponse::first_page(vec![1i32], false, None);
1317        assert!(!resp.has_next);
1318        assert!(!resp.has_prev);
1319        assert!(resp.next_cursor.is_none());
1320    }
1321
1322    #[cfg(feature = "serde")]
1323    #[test]
1324    fn keyset_params_serde_round_trip() {
1325        let json = serde_json::json!({"after": 5, "limit": 10});
1326        let p: KeysetPaginationParams<u64> = serde_json::from_value(json).unwrap();
1327        assert_eq!(p.after, Some(5));
1328        assert_eq!(p.limit(), 10);
1329        assert!(p.before.is_none());
1330    }
1331
1332    #[cfg(feature = "serde")]
1333    #[test]
1334    fn keyset_params_serde_defaults() {
1335        let json = serde_json::json!({});
1336        let p: KeysetPaginationParams<u64> = serde_json::from_value(json).unwrap();
1337        assert_eq!(p.limit(), 20);
1338        assert!(p.after.is_none());
1339    }
1340
1341    #[cfg(feature = "arbitrary")]
1342    #[test]
1343    fn arbitrary_pagination_params() {
1344        use arbitrary::{Arbitrary, Unstructured};
1345        // Use all-zeros data: bool::arbitrary returns false → limit=None branch
1346        let data = [0u8; 64];
1347        let mut u = Unstructured::new(&data);
1348        let p = PaginationParams::arbitrary(&mut u).unwrap();
1349        assert!(p.limit.is_none());
1350    }
1351
1352    #[cfg(feature = "arbitrary")]
1353    #[test]
1354    fn arbitrary_pagination_params_with_limit() {
1355        use arbitrary::{Arbitrary, Unstructured};
1356        // 0xFF makes bool::arbitrary return true → limit=Some(...) branch (line 613)
1357        let mut data = [0xFFu8; 64];
1358        // The limit value bytes need to be in range — use small values
1359        data[1..9].copy_from_slice(&50u64.to_le_bytes());
1360        let mut u = Unstructured::new(&data);
1361        let p = PaginationParams::arbitrary(&mut u).unwrap();
1362        assert!(p.limit.is_some_and(|l| (1..=100).contains(&l)));
1363    }
1364
1365    #[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
1366    #[test]
1367    fn arbitrary_cursor_pagination_params() {
1368        use arbitrary::{Arbitrary, Unstructured};
1369        let data = [0u8; 128];
1370        let mut u = Unstructured::new(&data);
1371        let p = CursorPaginationParams::arbitrary(&mut u).unwrap();
1372        assert!(p.limit.is_none());
1373    }
1374
1375    #[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
1376    #[test]
1377    fn arbitrary_cursor_pagination_params_with_limit() {
1378        use arbitrary::{Arbitrary, Unstructured};
1379        // 0xFF makes bool::arbitrary return true → limit=Some(...) branch (line 629)
1380        let mut data = [0xFFu8; 128];
1381        data[1..9].copy_from_slice(&50u64.to_le_bytes());
1382        let mut u = Unstructured::new(&data);
1383        let p = CursorPaginationParams::arbitrary(&mut u).unwrap();
1384        assert!(p.limit.is_some_and(|l| (1..=100).contains(&l)));
1385    }
1386}