Skip to main content

fakecloud_core/
pagination.rs

1/// Offset-based pagination helper for AWS list operations.
2///
3/// Parses `next_token` as a numeric offset (defaulting to 0 if `None` or unparseable),
4/// slices `items` starting at that offset, and returns at most `max_results` items
5/// along with an optional next token for the following page.
6///
7/// Prefer [`paginate_checked`] for client-facing list ops: this variant treats a
8/// malformed `next_token` as offset 0, which can drive an infinite client
9/// pagination loop. It remains for callers whose service model declares no
10/// invalid-token error (returning one would be an undeclared error).
11#[must_use]
12pub fn paginate<T: Clone>(
13    items: &[T],
14    next_token: Option<&str>,
15    max_results: usize,
16) -> (Vec<T>, Option<String>) {
17    if max_results == 0 {
18        return (Vec::new(), None);
19    }
20    let offset: usize = next_token.and_then(|s| s.parse().ok()).unwrap_or(0);
21    let page = if offset < items.len() {
22        &items[offset..]
23    } else {
24        &[][..]
25    };
26    let has_more = page.len() > max_results;
27    let result: Vec<T> = page.iter().take(max_results).cloned().collect();
28    let token = if has_more {
29        Some((offset + max_results).to_string())
30    } else {
31        None
32    };
33    (result, token)
34}
35
36/// Error from [`paginate_checked`]: `next_token` was present but is not a valid
37/// offset token (not produced by a prior page of the same list op). AWS rejects
38/// such tokens with `InvalidNextToken` (or a service-specific equivalent);
39/// callers map this to their wire error (bug-audit 2026-05-28, 1.7).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct InvalidNextToken;
42
43/// Strict variant of [`paginate`]: a `next_token` that is present but does not
44/// parse as a non-negative offset is rejected with [`InvalidNextToken`] instead
45/// of being silently treated as offset 0 (which can drive an infinite client
46/// pagination loop). `None` still means "first page".
47pub fn paginate_checked<T: Clone>(
48    items: &[T],
49    next_token: Option<&str>,
50    max_results: usize,
51) -> Result<(Vec<T>, Option<String>), InvalidNextToken> {
52    let offset: usize = match next_token {
53        None => 0,
54        Some(tok) => tok.parse().map_err(|_| InvalidNextToken)?,
55    };
56    if max_results == 0 {
57        return Ok((Vec::new(), None));
58    }
59    let page = if offset < items.len() {
60        &items[offset..]
61    } else {
62        &[][..]
63    };
64    let has_more = page.len() > max_results;
65    let result: Vec<T> = page.iter().take(max_results).cloned().collect();
66    let token = if has_more {
67        Some((offset + max_results).to_string())
68    } else {
69        None
70    };
71    Ok((result, token))
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn first_page() {
80        let items: Vec<i32> = (0..10).collect();
81        let (page, token) = paginate(&items, None, 3);
82        assert_eq!(page, vec![0, 1, 2]);
83        assert_eq!(token, Some("3".to_string()));
84    }
85
86    #[test]
87    fn middle_page() {
88        let items: Vec<i32> = (0..10).collect();
89        let (page, token) = paginate(&items, Some("3"), 3);
90        assert_eq!(page, vec![3, 4, 5]);
91        assert_eq!(token, Some("6".to_string()));
92    }
93
94    #[test]
95    fn last_page() {
96        let items: Vec<i32> = (0..10).collect();
97        let (page, token) = paginate(&items, Some("9"), 3);
98        assert_eq!(page, vec![9]);
99        assert_eq!(token, None);
100    }
101
102    #[test]
103    fn exact_page_boundary() {
104        let items: Vec<i32> = (0..6).collect();
105        let (page, token) = paginate(&items, Some("3"), 3);
106        assert_eq!(page, vec![3, 4, 5]);
107        assert_eq!(token, None);
108    }
109
110    #[test]
111    fn offset_beyond_items() {
112        let items: Vec<i32> = (0..3).collect();
113        let (page, token) = paginate(&items, Some("100"), 3);
114        assert!(page.is_empty());
115        assert_eq!(token, None);
116    }
117
118    #[test]
119    fn invalid_token_defaults_to_zero() {
120        let items: Vec<i32> = (0..5).collect();
121        let (page, token) = paginate(&items, Some("not_a_number"), 3);
122        assert_eq!(page, vec![0, 1, 2]);
123        assert_eq!(token, Some("3".to_string()));
124    }
125
126    #[test]
127    fn zero_max_results_returns_empty_page_without_token() {
128        // AWS list ops reject MaxResults=0 at the validation layer; if the helper
129        // ever sees zero it returns an empty page with no continuation token so
130        // callers can't accidentally paginate forever on a non-advancing offset.
131        let items: Vec<i32> = (0..5).collect();
132        let (page, token) = paginate(&items, None, 0);
133        assert!(page.is_empty());
134        assert_eq!(token, None);
135    }
136
137    #[test]
138    fn empty_items() {
139        let items: Vec<i32> = vec![];
140        let (page, token) = paginate(&items, None, 10);
141        assert!(page.is_empty());
142        assert_eq!(token, None);
143    }
144
145    // bug-audit 2026-05-28, 1.7: paginate_checked rejects a malformed next_token
146    // instead of silently treating it as offset 0.
147    #[test]
148    fn checked_none_is_first_page() {
149        let items: Vec<i32> = (0..5).collect();
150        let (page, token) = paginate_checked(&items, None, 3).unwrap();
151        assert_eq!(page, vec![0, 1, 2]);
152        assert_eq!(token, Some("3".to_string()));
153    }
154
155    #[test]
156    fn checked_valid_token_advances() {
157        let items: Vec<i32> = (0..5).collect();
158        let (page, token) = paginate_checked(&items, Some("3"), 3).unwrap();
159        assert_eq!(page, vec![3, 4]);
160        assert_eq!(token, None);
161    }
162
163    #[test]
164    fn checked_garbage_token_is_rejected() {
165        let items: Vec<i32> = (0..5).collect();
166        assert_eq!(
167            paginate_checked(&items, Some("not_a_number"), 3),
168            Err(InvalidNextToken)
169        );
170    }
171
172    #[test]
173    fn checked_negative_token_is_rejected() {
174        let items: Vec<i32> = (0..5).collect();
175        assert_eq!(
176            paginate_checked(&items, Some("-1"), 3),
177            Err(InvalidNextToken)
178        );
179    }
180}