deps_core/pagination.rs
1//! Generic paginated-fetch loop for a git-tags-shaped REST API returning `per_page=100` pages.
2//!
3//! Extracted from [`crate::github::paginate_tags`] so a second provider (GitLab CI's
4//! `/repository/tags` and `/releases` endpoints) can share the exact
5//! concurrency/ordering/error-mapping behavior instead of forking it.
6//! [`crate::github::paginate_tags`] is now a thin delegation to [`paginate_pages`].
7
8use crate::EcosystemId;
9use crate::error::Result;
10use bytes::Bytes;
11use std::future::Future;
12
13/// Whether a paginated list reached its true end or was cut off at the page cap.
14///
15/// Absence-driven consumers (e.g. "this SHA is not any release tag") must only trust a
16/// [`ListCoverage::Complete`] list; presence-driven consumers may use either.
17///
18/// # Examples
19///
20/// ```
21/// use deps_core::pagination::ListCoverage;
22///
23/// assert_eq!(ListCoverage::default(), ListCoverage::Complete);
24/// assert_ne!(ListCoverage::Truncated, ListCoverage::Complete);
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
27pub enum ListCoverage {
28 /// The last fetched page was partial, so no entries are missing.
29 #[default]
30 Complete,
31 /// Fetching stopped at the page cap while more pages may exist.
32 Truncated,
33}
34
35/// Items collected by [`paginate_pages`] together with how completely they cover the list.
36///
37/// # Examples
38///
39/// ```
40/// use deps_core::pagination::{ListCoverage, Paginated};
41///
42/// fn absent_means_missing(tags: &Paginated<String>, wanted: &str) -> bool {
43/// tags.coverage == ListCoverage::Complete && !tags.items.iter().any(|t| t == wanted)
44/// }
45///
46/// let page = Paginated::new(vec!["v1".to_string()], ListCoverage::Complete);
47/// assert!(absent_means_missing(&page, "v2"));
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct Paginated<T> {
52 /// Items in page order.
53 pub items: Vec<T>,
54 /// Whether `items` is the whole list.
55 pub coverage: ListCoverage,
56}
57
58impl<T> Paginated<T> {
59 /// Bundles `items` with the `coverage` of the list they were taken from.
60 ///
61 /// Needed because the struct is `#[non_exhaustive]`: other crates cannot use a literal.
62 #[must_use]
63 pub const fn new(items: Vec<T>, coverage: ListCoverage) -> Self {
64 Self { items, coverage }
65 }
66}
67
68/// Number of pages fetched concurrently per batch, once page 1 is confirmed full.
69///
70/// See [`paginate_pages`]'s doc comment for why page 1 is always fetched alone first.
71const CONCURRENCY: usize = 5;
72
73/// Returns `true` when a fetched page came back full (`per_page=100` entries), meaning a
74/// subsequent page may exist and should be fetched too. A page with fewer entries is
75/// necessarily the last one.
76#[must_use]
77pub const fn page_has_more(page_len: usize) -> bool {
78 page_len >= 100
79}
80
81/// Logs a warning when pagination for `name` stops at `max_pages` while `provider` still had
82/// more pages available (`page_has_more(page_len)`).
83///
84/// Without this, hitting the safety ceiling on a pathological repo/project is
85/// indistinguishable in logs from "there is genuinely no matching version" — this makes
86/// truncation diagnosable. `provider` names the upstream API (`"GitHub"`, `"GitLab"`) — a
87/// human-facing display name with no [`EcosystemId`] equivalent, so it stays a plain
88/// `&'static str`. `ecosystem` names the caller ecosystem and is logged as a structured
89/// `%ecosystem` field, matching every other ecosystem-labeled `tracing` call in this crate
90/// (#1425); `noun` names what is being paginated (`"tags"`, `"releases"`) in the warning text
91/// — a caller pagintating a non-tags endpoint (e.g. GitLab CI's `/releases`) must not have
92/// its warning hardcode "tags" pagination.
93pub fn warn_if_pagination_truncated(
94 provider: &str,
95 ecosystem: EcosystemId,
96 noun: &str,
97 name: &str,
98 page: u32,
99 page_len: usize,
100 max_pages: u32,
101) {
102 if page == max_pages && page_has_more(page_len) {
103 let name = crate::redact::redact_declaration_key(name);
104 tracing::warn!(
105 package = %name,
106 pages_fetched = max_pages,
107 %ecosystem,
108 "{ecosystem} {noun} pagination for '{name}' stopped at the {max_pages}-page cap \
109 while {provider} reported more pages available; the fetched version list may be \
110 truncated"
111 );
112 }
113}
114
115/// Drives a paginated-fetch loop against a `per_page=100`-shaped REST endpoint: page 1
116/// alone, then subsequent pages in batches of up to `CONCURRENCY` pages.
117///
118/// Page 1 is always fetched by itself before any batching starts, for two reasons: most
119/// repos/projects fit in one page, so this keeps the common case at exactly the one request
120/// it took before this function gained concurrency; and an error on page 1 (bad auth,
121/// tripped rate limit, unknown project) is surfaced from a single request instead of fanning
122/// a doomed request out to `CONCURRENCY` pages at once.
123///
124/// Once page 1 is confirmed full, pages 2+ are fetched in batches of `CONCURRENCY`,
125/// stopping once a partial/empty page is seen or `max_pages` is reached. Pages within a
126/// batch are fetched concurrently, but always processed in page order — the pages
127/// dispatched *after* the batch's partial page are simply discarded once found, not
128/// avoided, since by the time a batch's first result comes back the rest of that batch's
129/// requests are already in flight and cannot be un-sent. This bounds, but does not
130/// eliminate, extra requests: at most `CONCURRENCY - 1` pages beyond the true last page may
131/// be fetched and discarded, only when that last page doesn't land on a batch boundary.
132/// A caller that dedups "first item wins" on page order depends on out-of-order *processing*
133/// never happening — hence ordered `buffered`, not `buffer_unordered`.
134///
135/// `provider`/`ecosystem`/`noun`/`name` are forwarded to [`warn_if_pagination_truncated`] to
136/// name the API, the caller, and what is being paginated in the truncation warning.
137///
138/// # Errors
139///
140/// Propagates the first error seen among `fetch_page`'s results (page 1's own error, or the
141/// first in page order within a batch — any other in-flight futures in that batch are
142/// dropped), or the error from `parse_page` when a page's body cannot be parsed.
143///
144/// The result is [`ListCoverage::Truncated`] when `max_pages` is exhausted without seeing a
145/// partial page (conservatively including a list of exactly `max_pages * 100` entries).
146pub async fn paginate_pages<T, F, Fut, P>(
147 provider: &str,
148 ecosystem: EcosystemId,
149 noun: &str,
150 name: &str,
151 max_pages: u32,
152 mut fetch_page: F,
153 mut parse_page: P,
154) -> Result<Paginated<T>>
155where
156 F: FnMut(u32) -> Fut,
157 Fut: Future<Output = Result<Bytes>>,
158 P: FnMut(&Bytes) -> Result<Vec<T>>,
159{
160 use futures::stream::{self, StreamExt};
161
162 let mut items = Vec::new();
163
164 let first_page = fetch_page(1).await?;
165 let first_items = parse_page(&first_page)?;
166 let first_page_len = first_items.len();
167 items.extend(first_items);
168 if !page_has_more(first_page_len) {
169 // No call to `warn_if_pagination_truncated` here: it only fires at
170 // `page == max_pages`, which page 1 can never equal since `max_pages > 1`.
171 return Ok(Paginated {
172 items,
173 coverage: ListCoverage::Complete,
174 });
175 }
176
177 let mut coverage = ListCoverage::Truncated;
178 let mut page = 2u32;
179 #[expect(
180 clippy::cast_possible_truncation,
181 reason = "CONCURRENCY is a small hardcoded constant (5), never close to u32::MAX"
182 )]
183 'batches: while page <= max_pages {
184 let batch_end = (page + CONCURRENCY as u32 - 1).min(max_pages);
185 let mut stream = stream::iter(page..=batch_end)
186 .map(&mut fetch_page)
187 .buffered(CONCURRENCY);
188
189 let mut current_page = page;
190 while let Some(data) = stream.next().await {
191 let page_items = parse_page(&data?)?;
192 let page_len = page_items.len();
193 items.extend(page_items);
194 if !page_has_more(page_len) {
195 coverage = ListCoverage::Complete;
196 break 'batches;
197 }
198 warn_if_pagination_truncated(
199 provider,
200 ecosystem,
201 noun,
202 name,
203 current_page,
204 page_len,
205 max_pages,
206 );
207 current_page += 1;
208 }
209 page = batch_end + 1;
210 }
211 Ok(Paginated { items, coverage })
212}
213
214#[cfg(test)]
215#[expect(
216 clippy::cast_possible_truncation,
217 reason = "#673: fixed test-fixture lengths cast to u32 never approach truncation range"
218)]
219mod tests {
220 use super::*;
221 #[cfg(feature = "test-util")]
222 use crate::test_util::capture_tracing_output_async;
223
224 #[test]
225 fn test_page_has_more_full_page_continues() {
226 assert!(page_has_more(100));
227 }
228
229 #[test]
230 fn test_page_has_more_partial_page_stops() {
231 assert!(!page_has_more(99));
232 assert!(!page_has_more(0));
233 }
234
235 /// Regression for #472 critic M8 (GitLab CI plan): the warning must key its fire
236 /// condition and its `pages_fetched` field off the *passed* `max_pages`, not a
237 /// hardcoded constant — a caller with a cap other than 30 must still warn on its own
238 /// last page and never warn early.
239 #[cfg(feature = "test-util")]
240 #[tokio::test]
241 async fn test_warn_if_pagination_truncated_uses_passed_max_pages_not_a_constant() {
242 let output = capture_tracing_output_async(async {
243 warn_if_pagination_truncated(
244 "GitLab",
245 EcosystemId::GitlabCi,
246 "tags",
247 "org/repo",
248 3,
249 100,
250 3,
251 );
252 })
253 .await;
254 assert!(output.contains("org/repo"), "output was: {output}");
255 assert!(output.contains("GitLab"), "output was: {output}");
256 assert!(output.contains('3'), "output was: {output}");
257
258 let silent = capture_tracing_output_async(async {
259 warn_if_pagination_truncated(
260 "GitLab",
261 EcosystemId::GitlabCi,
262 "tags",
263 "org/repo",
264 2,
265 100,
266 3,
267 );
268 })
269 .await;
270 assert!(
271 silent.is_empty(),
272 "must not warn below the passed cap: {silent}"
273 );
274 }
275
276 fn page_json(count: usize) -> Bytes {
277 let entries: Vec<String> = (0..count).map(|i| format!(r#"{{"n":{i}}}"#)).collect();
278 Bytes::from(format!("[{}]", entries.join(",")))
279 }
280
281 fn parse_page(data: &Bytes) -> Result<Vec<u32>> {
282 let value: serde_json::Value = crate::parser::parse_json_checked(data)?;
283 Ok(value
284 .as_array()
285 .map(|arr| (0..arr.len() as u32).collect())
286 .unwrap_or_default())
287 }
288
289 #[tokio::test]
290 async fn test_paginate_pages_single_page_fetches_exactly_once() {
291 use std::sync::atomic::{AtomicU32, Ordering};
292
293 let calls = AtomicU32::new(0);
294 let result = paginate_pages(
295 "GitLab",
296 EcosystemId::GitlabCi,
297 "tags",
298 "org/repo",
299 30,
300 |page| {
301 calls.fetch_add(1, Ordering::SeqCst);
302 async move {
303 match page {
304 1 => Ok(page_json(42)),
305 _ => panic!("page {page} must not be fetched"),
306 }
307 }
308 },
309 parse_page,
310 )
311 .await
312 .unwrap();
313
314 assert_eq!(calls.load(Ordering::SeqCst), 1);
315 assert_eq!(result.items.len(), 42);
316 }
317
318 #[tokio::test]
319 async fn test_paginate_pages_stops_after_partial_page_at_custom_cap() {
320 let result = paginate_pages(
321 "GitLab",
322 EcosystemId::GitlabCi,
323 "tags",
324 "org/repo",
325 5,
326 |page| async move {
327 match page {
328 1 => Ok(page_json(100)),
329 2..=5 => Ok(page_json(10)),
330 _ => panic!("page {page} must not be fetched beyond the 5-page cap"),
331 }
332 },
333 parse_page,
334 )
335 .await
336 .unwrap();
337
338 assert_eq!(result.items.len(), 110);
339 assert_eq!(result.coverage, ListCoverage::Complete);
340 }
341
342 #[tokio::test]
343 async fn test_paginate_pages_single_partial_page_is_complete() {
344 let result = paginate_pages(
345 "GitLab",
346 EcosystemId::GitlabCi,
347 "tags",
348 "org/repo",
349 5,
350 |_page| async move { Ok(page_json(3)) },
351 parse_page,
352 )
353 .await
354 .unwrap();
355
356 assert_eq!(result.coverage, ListCoverage::Complete);
357 }
358
359 #[tokio::test]
360 async fn test_paginate_pages_cap_reached_with_full_last_page_is_truncated() {
361 let result = paginate_pages(
362 "GitLab",
363 EcosystemId::GitlabCi,
364 "tags",
365 "org/repo",
366 3,
367 |_page| async move { Ok(page_json(100)) },
368 parse_page,
369 )
370 .await
371 .unwrap();
372
373 assert_eq!(result.items.len(), 300);
374 assert_eq!(result.coverage, ListCoverage::Truncated);
375 }
376
377 #[tokio::test]
378 async fn test_paginate_pages_partial_page_exactly_at_cap_is_complete() {
379 let result = paginate_pages(
380 "GitLab",
381 EcosystemId::GitlabCi,
382 "tags",
383 "org/repo",
384 3,
385 |page| async move {
386 match page {
387 1 | 2 => Ok(page_json(100)),
388 3 => Ok(page_json(7)),
389 _ => panic!("page {page} must not be fetched beyond the 3-page cap"),
390 }
391 },
392 parse_page,
393 )
394 .await
395 .unwrap();
396
397 assert_eq!(result.items.len(), 207);
398 assert_eq!(result.coverage, ListCoverage::Complete);
399 }
400
401 #[test]
402 fn test_list_coverage_default_is_complete() {
403 assert_eq!(ListCoverage::default(), ListCoverage::Complete);
404 }
405}