Skip to main content

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}