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