Skip to main content

clickup_cli/mcp/
pagination.rs

1//! MCP pagination helpers.
2//!
3//! ClickUp's paginated endpoints come in four flavours:
4//! - **page-based** (v2): `?page=N`, response carries `last_page: bool`.
5//! - **cursor-based** (v3): `?cursor=X`, response carries `next_cursor: string` or null.
6//! - **start-id-based** (v2 comments): `?start=<unix_ms>&start_id=<id>` pair,
7//!   response is a bare array, termination inferred from page-size hint.
8//! - **body-based** (v3 audit-log): pagination state lives inside the POST
9//!   body as `pagination: { pageRows, pageTimestamp, pageDirection }`.
10//!
11//! This module hides the loop logic and response-shape glue so each MCP tool
12//! dispatch can be a one-liner.
13//!
14//! ## Contract
15//!
16//! **Schema.** Every paginated tool's inputSchema gains one of:
17//! - page style: `page` (int ≥0), `limit` (int ≥1), `all` (bool); or
18//! - cursor style: `cursor` (opaque string), `limit` (int ≥1), `all` (bool); or
19//! - start-id style: `start` (int ms), `start_id` (string), `limit` (int ≥1), `all` (bool); or
20//! - body style: `page_rows`, `page_timestamp` (int ms), `page_direction`
21//!   (`"NEXT"`/`"PREVIOUS"`), `limit`, `all`.
22//!
23//! **Output.** The contract is _opt-in_:
24//! - If the caller passes NO pagination arg, the response is unchanged from
25//!   pre-pagination: a bare compact array. Back-compat for existing clients.
26//! - If the caller passes ANY pagination arg, the response becomes an envelope:
27//!
28//!   ```json
29//!   {
30//!     "items": [...],
31//!     "pagination": {
32//!       "style": "page" | "cursor" | "start_id" | "body",
33//!       "page": 0,                       // page style only
34//!       "last_page": false,              // page style only
35//!       "next_cursor": "...",            // cursor style only, omitted when exhausted
36//!       "next_start": 1700000000,        // start_id style only, omitted when exhausted
37//!       "next_start_id": "...",          // start_id style only, omitted when exhausted
38//!       "next_page_timestamp": 1700000,  // body style only, omitted when exhausted
39//!       "page_direction": "NEXT",        // body style only, echoes caller input
40//!       "has_more": true,
41//!       "returned": 42,
42//!       "all": false
43//!     }
44//!   }
45//!   ```
46//!
47//! Calling code uses [`PageArgs::from_args`] / [`CursorArgs::from_args`] /
48//! [`StartIdArgs::from_args`] / [`BodyPaginationArgs::from_args`] to parse
49//! pagination input, then [`page_dispatch`] / [`cursor_dispatch`] /
50//! [`start_id_dispatch`] / [`body_pagination_dispatch`] to run the fetch loop.
51
52use crate::client::ClickUpClient;
53use crate::output::compact_items;
54use serde_json::{json, Value};
55
56/// Hard cap on how many pages a single `all=true` call will fetch. Guards
57/// against runaway loops on misbehaving cursor endpoints.
58const MAX_PAGES: usize = 100;
59
60/// Parsed page-based pagination input.
61#[derive(Debug, Clone, Copy, Default)]
62pub struct PageArgs {
63    pub page: Option<u64>,
64    pub limit: Option<usize>,
65    pub all: bool,
66    /// True if the caller passed ANY of the pagination args. Drives the
67    /// opt-in envelope shape.
68    pub requested: bool,
69}
70
71impl PageArgs {
72    pub fn from_args(args: &Value) -> Self {
73        let page = args.get("page").and_then(|v| v.as_u64());
74        let limit = args
75            .get("limit")
76            .and_then(|v| v.as_u64())
77            .map(|n| n as usize);
78        let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
79        let requested = page.is_some() || limit.is_some() || args.get("all").is_some();
80        Self {
81            page,
82            limit,
83            all,
84            requested,
85        }
86    }
87}
88
89/// Parsed cursor-based pagination input.
90#[derive(Debug, Clone, Default)]
91pub struct CursorArgs {
92    pub cursor: Option<String>,
93    pub limit: Option<usize>,
94    pub all: bool,
95    pub requested: bool,
96}
97
98impl CursorArgs {
99    pub fn from_args(args: &Value) -> Self {
100        let cursor = args
101            .get("cursor")
102            .and_then(|v| v.as_str())
103            .map(String::from);
104        let limit = args
105            .get("limit")
106            .and_then(|v| v.as_u64())
107            .map(|n| n as usize);
108        let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
109        let requested = cursor.is_some() || limit.is_some() || args.get("all").is_some();
110        Self {
111            cursor,
112            limit,
113            all,
114            requested,
115        }
116    }
117}
118
119/// Parsed start-id-based pagination input. ClickUp's v2 comment endpoints
120/// (`/v2/task/{id}/comment`, `/v2/list/{id}/comment`, `/v2/view/{id}/comment`,
121/// `/v2/comment/{id}/reply`) use this style: pass `?start=<unix_ms>&start_id=<id>`
122/// to retrieve items older than that boundary. Both params are required as a
123/// pair when paginating; omitting them returns the first page (newest first).
124/// The response is a bare `{ "comments": [...] }` array — no pagination
125/// metadata — so termination is inferred when the returned array is shorter
126/// than the endpoint's page size (25 for ClickUp's comment endpoints).
127#[derive(Debug, Clone, Default)]
128pub struct StartIdArgs {
129    pub start: Option<i64>,
130    pub start_id: Option<String>,
131    pub limit: Option<usize>,
132    pub all: bool,
133    pub requested: bool,
134}
135
136impl StartIdArgs {
137    pub fn from_args(args: &Value) -> Self {
138        let start = args.get("start").and_then(|v| v.as_i64());
139        let start_id = args
140            .get("start_id")
141            .and_then(|v| v.as_str())
142            .map(String::from);
143        let limit = args
144            .get("limit")
145            .and_then(|v| v.as_u64())
146            .map(|n| n as usize);
147        let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
148        let requested =
149            start.is_some() || start_id.is_some() || limit.is_some() || args.get("all").is_some();
150        Self {
151            start,
152            start_id,
153            limit,
154            all,
155            requested,
156        }
157    }
158}
159
160/// Parsed body-pagination input. ClickUp's v3 audit-log endpoint
161/// (`POST /v3/workspaces/{ws}/auditlogs`) puts pagination state inside the
162/// request **body** as `pagination: { pageRows, pageTimestamp, pageDirection }`,
163/// not in query params. `pageDirection` is `"NEXT"` or `"PREVIOUS"` —
164/// `--all` walks in whatever direction the caller passes, defaulting to
165/// `"NEXT"` (newer events) if unspecified.
166#[derive(Debug, Clone, Default)]
167pub struct BodyPaginationArgs {
168    pub page_rows: Option<i64>,
169    pub page_timestamp: Option<i64>,
170    pub page_direction: Option<String>,
171    pub limit: Option<usize>,
172    pub all: bool,
173    pub requested: bool,
174}
175
176impl BodyPaginationArgs {
177    pub fn from_args(args: &Value) -> Self {
178        let page_rows = args.get("page_rows").and_then(|v| v.as_i64());
179        let page_timestamp = args.get("page_timestamp").and_then(|v| v.as_i64());
180        let page_direction = args
181            .get("page_direction")
182            .and_then(|v| v.as_str())
183            .map(String::from);
184        let limit = args
185            .get("limit")
186            .and_then(|v| v.as_u64())
187            .map(|n| n as usize);
188        let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
189        let requested = page_rows.is_some()
190            || page_timestamp.is_some()
191            || page_direction.is_some()
192            || limit.is_some()
193            || args.get("all").is_some();
194        Self {
195            page_rows,
196            page_timestamp,
197            page_direction,
198            limit,
199            all,
200            requested,
201        }
202    }
203}
204
205/// Run a page-based pagination loop and return either a bare compact array
206/// (no pagination args) or a `{items, pagination}` envelope (any pagination
207/// arg). `build_path(page)` should return a path including any `?page=N`
208/// query, e.g. `/v2/list/123/task?page=2`. `items_key` is the response field
209/// holding the array (e.g. `"tasks"`).
210pub async fn page_dispatch<F>(
211    args: &PageArgs,
212    client: &ClickUpClient,
213    items_key: &str,
214    compact_fields: &[&str],
215    build_path: F,
216) -> Result<Value, String>
217where
218    F: Fn(u64) -> String,
219{
220    let start_page = args.page.unwrap_or(0);
221    let mut collected: Vec<Value> = Vec::new();
222    let mut current_page = start_page;
223    // Initial value is overwritten on the first loop iteration; the variable
224    // exists so the value survives the loop and feeds the pagination envelope.
225    #[allow(unused_assignments)]
226    let mut last_page = false;
227    let mut pages_fetched = 0usize;
228
229    loop {
230        let path = build_path(current_page);
231        let resp = client.get(&path).await.map_err(|e| e.to_string())?;
232
233        let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
234
235        last_page = resp
236            .get("last_page")
237            .and_then(|v| v.as_bool())
238            .unwrap_or(items.is_empty());
239
240        collected.extend(items);
241        pages_fetched += 1;
242
243        // Stop conditions: page-only mode (no `all`), reached `last_page`,
244        // exceeded `limit`, or hit the page-cap guard.
245        if !args.all {
246            break;
247        }
248        if last_page || pages_fetched >= MAX_PAGES {
249            break;
250        }
251        if let Some(limit) = args.limit {
252            if collected.len() >= limit {
253                break;
254            }
255        }
256        current_page += 1;
257    }
258
259    // Honour caller-provided `limit` after collection.
260    if let Some(limit) = args.limit {
261        collected.truncate(limit);
262    }
263
264    let compact = compact_items(&collected, compact_fields);
265
266    if !args.requested {
267        // Back-compat: return the bare array exactly like the pre-pagination
268        // codepath did, so existing clients see no shape change.
269        return Ok(compact);
270    }
271
272    let compact_arr = compact.as_array().cloned().unwrap_or_default();
273    let returned = compact_arr.len();
274    // `has_more` reports whether the SERVER has additional pages — purely a
275    // function of `last_page`. Don't conjoin with limit-truncation: when the
276    // caller passes `limit` and we hit the cap, the server may still have
277    // more pages they could retrieve via a higher limit or a subsequent
278    // page= request. Telling them has_more=false in that case is misleading.
279    let has_more = !last_page;
280    let last_observed_page = if args.all { current_page } else { start_page };
281    Ok(json!({
282        "items": compact_arr,
283        "pagination": {
284            "style": "page",
285            "page": last_observed_page,
286            "last_page": last_page,
287            "has_more": has_more,
288            "returned": returned,
289            "all": args.all,
290        }
291    }))
292}
293
294/// Run a cursor-based pagination loop. `build_path(cursor)` should return a
295/// path including any `?cursor=...` query when `cursor` is Some, or no cursor
296/// query when None. `items_keys` is the list of candidate response keys to
297/// extract the array from; first match wins. Typical: `&["data", "<legacy>"]`
298/// where `<legacy>` is the pre-v3 key (`"channels"`, `"replies"`, etc.) for
299/// back-compat with any older envelope shape.
300pub async fn cursor_dispatch<F>(
301    args: &CursorArgs,
302    client: &ClickUpClient,
303    items_keys: &[&str],
304    compact_fields: &[&str],
305    build_path: F,
306) -> Result<Value, String>
307where
308    F: Fn(Option<&str>) -> String,
309{
310    let mut cursor = args.cursor.clone();
311    let mut collected: Vec<Value> = Vec::new();
312    // Initial value is overwritten on the first loop iteration; the variable
313    // exists so the value survives the loop and feeds the pagination envelope.
314    #[allow(unused_assignments)]
315    let mut next_cursor: Option<String> = None;
316    let mut pages_fetched = 0usize;
317
318    loop {
319        let path = build_path(cursor.as_deref());
320        let resp = client.get(&path).await.map_err(|e| e.to_string())?;
321
322        let items = extract_array(&resp, items_keys).unwrap_or_default();
323
324        next_cursor = resp
325            .get("next_cursor")
326            .and_then(|v| v.as_str())
327            .filter(|s| !s.is_empty())
328            .map(String::from);
329
330        collected.extend(items);
331        pages_fetched += 1;
332
333        if !args.all {
334            break;
335        }
336        if next_cursor.is_none() || pages_fetched >= MAX_PAGES {
337            break;
338        }
339        if let Some(limit) = args.limit {
340            if collected.len() >= limit {
341                break;
342            }
343        }
344        cursor = next_cursor.clone();
345    }
346
347    if let Some(limit) = args.limit {
348        collected.truncate(limit);
349    }
350
351    let compact = compact_items(&collected, compact_fields);
352
353    if !args.requested {
354        return Ok(compact);
355    }
356
357    let compact_arr = compact.as_array().cloned().unwrap_or_default();
358    let returned = compact_arr.len();
359    // `has_more` reports whether the SERVER has additional pages — purely a
360    // function of `next_cursor`. Don't conjoin with limit-truncation: see
361    // the matching comment in `page_dispatch`.
362    let has_more = next_cursor.is_some();
363
364    let mut pagination = serde_json::Map::new();
365    pagination.insert("style".into(), json!("cursor"));
366    pagination.insert("has_more".into(), json!(has_more));
367    pagination.insert("returned".into(), json!(returned));
368    pagination.insert("all".into(), json!(args.all));
369    if let Some(c) = next_cursor {
370        pagination.insert("next_cursor".into(), json!(c));
371    }
372    Ok(json!({
373        "items": compact_arr,
374        "pagination": Value::Object(pagination),
375    }))
376}
377
378/// Default page size used by ClickUp's v2 comment endpoints. The endpoints
379/// don't expose a `last_page` flag and don't accept a custom page size, so
380/// callers infer "more results exist" by checking if the returned array is
381/// shorter than this value. The 25 figure comes from ClickUp's published
382/// API docs; if their server-side default ever changes the helper's only
383/// failure mode is "stops one page too early when `all=true`" — survivable.
384const START_ID_PAGE_HINT: usize = 25;
385
386/// Run a start-id-based pagination loop. `build_path(start, start_id)` should
387/// return a path including the `?start=...&start_id=...` query when both are
388/// Some, or no pagination query when both are None. (ClickUp requires the
389/// params as a pair — passing only one is a caller bug.) `items_key` is the
390/// response array's field name (`"comments"` for both comment endpoints).
391///
392/// The next-page boundary is derived from the LAST item in the returned
393/// array: its `date` field (a stringified unix-ms timestamp) becomes the
394/// next `start`, and its `id` field becomes the next `start_id`. Termination
395/// happens when the array is empty OR shorter than `START_ID_PAGE_HINT`.
396pub async fn start_id_dispatch<F>(
397    args: &StartIdArgs,
398    client: &ClickUpClient,
399    items_key: &str,
400    compact_fields: &[&str],
401    build_path: F,
402) -> Result<Value, String>
403where
404    F: Fn(Option<i64>, Option<&str>) -> String,
405{
406    let mut current_start = args.start;
407    let mut current_start_id = args.start_id.clone();
408    let mut collected: Vec<Value> = Vec::new();
409    // The boundary for the FOLLOWING page after the most recent fetch. Holds
410    // the (date_ms, comment_id) extracted from the last item in the response.
411    // The initial `None` is overwritten on the first iteration; the variable
412    // survives the loop so the value feeds the pagination envelope.
413    #[allow(unused_assignments)]
414    let mut next_boundary: Option<(i64, String)> = None;
415    // Whether the loop terminated because we reached the natural end (empty
416    // response or short page). False when we stopped due to limit/MAX_PAGES
417    // and there might still be more on the server. Drives `has_more`.
418    let mut reached_end = false;
419    let mut pages_fetched = 0usize;
420
421    loop {
422        let path = build_path(current_start, current_start_id.as_deref());
423        let resp = client.get(&path).await.map_err(|e| e.to_string())?;
424        let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
425        let count = items.len();
426
427        // Extract next-page boundary from the last item before consuming the
428        // vec. ClickUp returns `date` as a stringified ms integer.
429        if let Some(last) = items.last() {
430            let date_ms = last
431                .get("date")
432                .and_then(|v| v.as_str())
433                .and_then(|s| s.parse::<i64>().ok())
434                .or_else(|| last.get("date").and_then(|v| v.as_i64()));
435            let id = last.get("id").and_then(|v| v.as_str()).map(String::from);
436            next_boundary = match (date_ms, id) {
437                (Some(d), Some(i)) => Some((d, i)),
438                _ => None,
439            };
440        } else {
441            next_boundary = None;
442        }
443
444        collected.extend(items);
445        pages_fetched += 1;
446
447        // A short or empty page means the server has no more results — record
448        // that so the pagination envelope can report `has_more: false` even
449        // though we still have a boundary from the last item we did receive.
450        if count < START_ID_PAGE_HINT {
451            reached_end = true;
452        }
453
454        if !args.all {
455            break;
456        }
457        if reached_end || pages_fetched >= MAX_PAGES {
458            break;
459        }
460        if let Some(limit) = args.limit {
461            if collected.len() >= limit {
462                break;
463            }
464        }
465
466        // Advance to next page. If we somehow have no boundary (e.g. last item
467        // was missing date/id), bail to avoid an infinite loop with the same
468        // start.
469        match next_boundary.clone() {
470            Some((d, i)) => {
471                current_start = Some(d);
472                current_start_id = Some(i);
473            }
474            None => break,
475        }
476    }
477
478    if let Some(limit) = args.limit {
479        collected.truncate(limit);
480    }
481
482    let compact = compact_items(&collected, compact_fields);
483
484    if !args.requested {
485        return Ok(compact);
486    }
487
488    let compact_arr = compact.as_array().cloned().unwrap_or_default();
489    let returned = compact_arr.len();
490    // `has_more` reports whether the SERVER has additional pages — purely a
491    // function of whether we reached a short page AND we still have a usable
492    // boundary. Don't conjoin with limit-truncation: see the matching
493    // comment in `page_dispatch`.
494    let has_more = !reached_end && next_boundary.is_some();
495
496    let mut pagination = serde_json::Map::new();
497    pagination.insert("style".into(), json!("start_id"));
498    pagination.insert("has_more".into(), json!(has_more));
499    pagination.insert("returned".into(), json!(returned));
500    pagination.insert("all".into(), json!(args.all));
501    if let Some((d, i)) = next_boundary {
502        pagination.insert("next_start".into(), json!(d));
503        pagination.insert("next_start_id".into(), json!(i));
504    }
505    Ok(json!({
506        "items": compact_arr,
507        "pagination": Value::Object(pagination),
508    }))
509}
510
511/// Run a body-pagination POST loop for endpoints like the v3 audit-log query.
512/// Pagination state lives inside the request body as
513/// `pagination: { pageRows, pageTimestamp, pageDirection }`.
514///
515/// Caller responsibilities:
516/// - `path`: POST target, fixed across all iterations.
517/// - `items_keys`: candidate response keys for the items array, in priority
518///   order (e.g. `&["data"]` for v3 audit-log).
519/// - `compact_fields`: fields to project per item via `compact_items`.
520/// - `base_body`: closure returning a fresh non-pagination body each call.
521///   The helper merges the `pagination` block into it.
522/// - `next_timestamp`: closure that takes the last item of the current
523///   response and returns the next request's `pageTimestamp` (typically the
524///   event's own timestamp field). `None` means "no further boundary
525///   available" — the loop ends.
526///
527/// Termination: empty response, `next_timestamp` returns None, `limit` cap
528/// reached, or `MAX_PAGES` reached.
529pub async fn body_pagination_dispatch<BB, NT>(
530    args: &BodyPaginationArgs,
531    client: &ClickUpClient,
532    path: &str,
533    items_keys: &[&str],
534    compact_fields: &[&str],
535    base_body: BB,
536    next_timestamp: NT,
537) -> Result<Value, String>
538where
539    BB: Fn() -> Value,
540    NT: Fn(&Value) -> Option<i64>,
541{
542    let mut current_timestamp = args.page_timestamp;
543    let mut collected: Vec<Value> = Vec::new();
544    // Whether the loop terminated because we ran out of server-side results
545    // (empty response or no next boundary). Drives `has_more`.
546    let mut reached_end = false;
547    // The next-page boundary surfaced in the envelope when caller passed
548    // pagination args. Initial None is overwritten on the first iteration.
549    #[allow(unused_assignments)]
550    let mut next_boundary: Option<i64> = None;
551    let mut pages_fetched = 0usize;
552
553    loop {
554        // Build the body fresh each iteration: the base body, plus a
555        // pagination block reflecting current state. We rebuild on every
556        // iteration rather than mutating in place so the base_body closure
557        // is the single source of truth for the non-pagination fields.
558        let mut body = base_body();
559        let mut pagination = serde_json::Map::new();
560        if let Some(n) = args.page_rows {
561            pagination.insert("pageRows".into(), json!(n));
562        }
563        if let Some(t) = current_timestamp {
564            pagination.insert("pageTimestamp".into(), json!(t));
565        }
566        if let Some(d) = args.page_direction.as_deref() {
567            pagination.insert("pageDirection".into(), json!(d));
568        }
569        if !pagination.is_empty() {
570            body["pagination"] = Value::Object(pagination);
571        }
572
573        let resp = client.post(path, &body).await.map_err(|e| e.to_string())?;
574        let items = extract_array(&resp, items_keys).unwrap_or_default();
575        let count = items.len();
576
577        // Derive next-page boundary from the last item BEFORE consuming it
578        // into the collected vec.
579        next_boundary = items.last().and_then(&next_timestamp);
580
581        collected.extend(items);
582        pages_fetched += 1;
583
584        if count == 0 {
585            reached_end = true;
586        }
587
588        if !args.all {
589            break;
590        }
591        if reached_end || pages_fetched >= MAX_PAGES {
592            break;
593        }
594        if let Some(limit) = args.limit {
595            if collected.len() >= limit {
596                break;
597            }
598        }
599
600        // Advance: walk in caller's chosen direction by updating
601        // `pageTimestamp` from the boundary we just extracted. If the
602        // extractor returned None, bail to avoid an infinite loop.
603        match next_boundary {
604            Some(t) => current_timestamp = Some(t),
605            None => {
606                reached_end = true;
607                break;
608            }
609        }
610    }
611
612    if let Some(limit) = args.limit {
613        collected.truncate(limit);
614    }
615
616    let compact = compact_items(&collected, compact_fields);
617
618    if !args.requested {
619        return Ok(compact);
620    }
621
622    let compact_arr = compact.as_array().cloned().unwrap_or_default();
623    let returned = compact_arr.len();
624    let has_more = !reached_end && next_boundary.is_some();
625
626    let mut pagination = serde_json::Map::new();
627    pagination.insert("style".into(), json!("body"));
628    pagination.insert("has_more".into(), json!(has_more));
629    pagination.insert("returned".into(), json!(returned));
630    pagination.insert("all".into(), json!(args.all));
631    if let Some(t) = next_boundary {
632        pagination.insert("next_page_timestamp".into(), json!(t));
633    }
634    if let Some(d) = args.page_direction.as_deref() {
635        pagination.insert("page_direction".into(), json!(d));
636    }
637    Ok(json!({
638        "items": compact_arr,
639        "pagination": Value::Object(pagination),
640    }))
641}
642
643/// Extract an array from a JSON response, trying multiple candidate keys in
644/// order. Returns `None` if no candidate key holds an array. Used to resolve
645/// the v3 `"data"` envelope vs older list keys.
646fn extract_array(resp: &Value, keys: &[&str]) -> Option<Vec<Value>> {
647    for key in keys {
648        if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {
649            return Some(arr.clone());
650        }
651    }
652    // The response itself may be a bare array (some endpoints).
653    if let Some(arr) = resp.as_array() {
654        return Some(arr.clone());
655    }
656    None
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use wiremock::matchers::{method, path, query_param, query_param_is_missing};
663    use wiremock::{Mock, MockServer, ResponseTemplate};
664
665    fn test_client(server: &MockServer) -> ClickUpClient {
666        ClickUpClient::new("pk_test", 30)
667            .expect("client")
668            .with_base_url(&server.uri())
669    }
670
671    #[tokio::test]
672    async fn page_dispatch_no_pagination_args_returns_bare_array() {
673        let server = MockServer::start().await;
674        Mock::given(method("GET"))
675            .and(path("/v2/list/L1/task"))
676            .and(query_param("page", "0"))
677            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
678                "tasks": [{"id": "t1", "name": "A"}, {"id": "t2", "name": "B"}],
679                "last_page": true,
680            })))
681            .mount(&server)
682            .await;
683        let client = test_client(&server);
684        let args = PageArgs::from_args(&json!({}));
685        let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
686            format!("/v2/list/L1/task?page={}", p)
687        })
688        .await
689        .unwrap();
690        // No pagination requested: bare array, same shape as pre-pagination code.
691        assert!(result.is_array(), "expected bare array, got {}", result);
692        assert_eq!(result.as_array().unwrap().len(), 2);
693    }
694
695    #[tokio::test]
696    async fn page_dispatch_envelope_when_requested() {
697        let server = MockServer::start().await;
698        Mock::given(method("GET"))
699            .and(path("/v2/list/L1/task"))
700            .and(query_param("page", "0"))
701            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
702                "tasks": [{"id": "t1", "name": "A"}],
703                "last_page": false,
704            })))
705            .mount(&server)
706            .await;
707        let client = test_client(&server);
708        let args = PageArgs::from_args(&json!({"page": 0}));
709        let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
710            format!("/v2/list/L1/task?page={}", p)
711        })
712        .await
713        .unwrap();
714        // Pagination requested: envelope shape.
715        assert!(result.is_object(), "expected envelope, got {}", result);
716        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
717        assert_eq!(items.len(), 1);
718        let p = result.get("pagination").unwrap();
719        assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("page"));
720        assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(false));
721        assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(true));
722        assert_eq!(p.get("returned").and_then(|v| v.as_u64()), Some(1));
723        assert_eq!(p.get("all").and_then(|v| v.as_bool()), Some(false));
724    }
725
726    #[tokio::test]
727    async fn page_dispatch_all_true_walks_pages_until_last() {
728        let server = MockServer::start().await;
729        Mock::given(method("GET"))
730            .and(path("/v2/list/L1/task"))
731            .and(query_param("page", "0"))
732            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
733                "tasks": [{"id": "t1"}, {"id": "t2"}],
734                "last_page": false,
735            })))
736            .mount(&server)
737            .await;
738        Mock::given(method("GET"))
739            .and(path("/v2/list/L1/task"))
740            .and(query_param("page", "1"))
741            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
742                "tasks": [{"id": "t3"}],
743                "last_page": true,
744            })))
745            .mount(&server)
746            .await;
747        let client = test_client(&server);
748        let args = PageArgs::from_args(&json!({"all": true}));
749        let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
750            format!("/v2/list/L1/task?page={}", p)
751        })
752        .await
753        .unwrap();
754        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
755        assert_eq!(items.len(), 3, "expected 3 items across 2 pages");
756        let p = result.get("pagination").unwrap();
757        assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(true));
758        assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
759    }
760
761    #[tokio::test]
762    async fn cursor_dispatch_follows_next_cursor() {
763        let server = MockServer::start().await;
764        // First call: no cursor; respond with one item + next_cursor=ABC.
765        Mock::given(method("GET"))
766            .and(path("/v3/workspaces/2648001/docs"))
767            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
768                "data": [{"id": "d1", "name": "First"}],
769                "next_cursor": "ABC",
770            })))
771            .up_to_n_times(1)
772            .mount(&server)
773            .await;
774        // Second call: cursor=ABC; respond with one more item + no cursor.
775        Mock::given(method("GET"))
776            .and(path("/v3/workspaces/2648001/docs"))
777            .and(query_param("cursor", "ABC"))
778            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
779                "data": [{"id": "d2", "name": "Second"}],
780                "next_cursor": null,
781            })))
782            .mount(&server)
783            .await;
784        let client = test_client(&server);
785        let args = CursorArgs::from_args(&json!({"all": true}));
786        let result = cursor_dispatch(&args, &client, &["data"], &["id", "name"], |c| match c {
787            Some(c) => format!("/v3/workspaces/2648001/docs?cursor={}", c),
788            None => "/v3/workspaces/2648001/docs".to_string(),
789        })
790        .await
791        .unwrap();
792        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
793        assert_eq!(items.len(), 2, "expected 2 items across 2 pages");
794        let p = result.get("pagination").unwrap();
795        assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
796        // next_cursor should be absent (exhausted) per the contract docs.
797        assert!(p.get("next_cursor").is_none());
798    }
799
800    #[tokio::test]
801    async fn start_id_dispatch_no_pagination_args_returns_bare_array() {
802        // Caller passes no start/start_id/limit/all -> bare array, matching
803        // the existing pre-pagination shape for comment list/replies.
804        let server = MockServer::start().await;
805        Mock::given(method("GET"))
806            .and(path("/v2/task/T1/comment"))
807            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
808                "comments": [
809                    {"id": "c1", "date": "1700000000000", "comment_text": "a"},
810                    {"id": "c2", "date": "1700000005000", "comment_text": "b"},
811                ],
812            })))
813            .mount(&server)
814            .await;
815        let client = test_client(&server);
816        let args = StartIdArgs::from_args(&json!({}));
817        let result = start_id_dispatch(
818            &args,
819            &client,
820            "comments",
821            &["id", "comment_text"],
822            |start, start_id| match (start, start_id) {
823                (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
824                _ => "/v2/task/T1/comment".to_string(),
825            },
826        )
827        .await
828        .unwrap();
829        assert!(result.is_array(), "expected bare array, got {}", result);
830        assert_eq!(result.as_array().unwrap().len(), 2);
831    }
832
833    #[tokio::test]
834    async fn start_id_dispatch_all_true_walks_pages_via_last_item_boundary() {
835        // First page: 25 items (the page-size hint) -> caller knows to keep
836        // walking. Helper derives boundary from last item's date+id and
837        // requests the next page. Second page: 2 items (< 25) -> termination.
838        let server = MockServer::start().await;
839
840        let mut first_page = Vec::new();
841        for i in 0..25 {
842            first_page.push(json!({
843                "id": format!("c{}", i),
844                "date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
845                "comment_text": format!("comment {}", i),
846            }));
847        }
848        // The last item in page 1 will be the boundary for page 2:
849        //   start = 1700000024000 (date of c24), start_id = "c24"
850        let last_first = &first_page[24];
851        let boundary_date = last_first["date"].as_str().unwrap();
852        let boundary_id = last_first["id"].as_str().unwrap();
853
854        Mock::given(method("GET"))
855            .and(path("/v2/task/T1/comment"))
856            // First call has no start/start_id query.
857            .and(query_param_is_missing("start"))
858            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
859                "comments": first_page,
860            })))
861            .up_to_n_times(1)
862            .mount(&server)
863            .await;
864        Mock::given(method("GET"))
865            .and(path("/v2/task/T1/comment"))
866            .and(query_param("start", boundary_date))
867            .and(query_param("start_id", boundary_id))
868            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
869                "comments": [
870                    {"id": "c25", "date": "1700000025000"},
871                    {"id": "c26", "date": "1700000026000"},
872                ],
873            })))
874            .mount(&server)
875            .await;
876
877        let client = test_client(&server);
878        let args = StartIdArgs::from_args(&json!({"all": true}));
879        let result = start_id_dispatch(
880            &args,
881            &client,
882            "comments",
883            &["id"],
884            |start, start_id| match (start, start_id) {
885                (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
886                _ => "/v2/task/T1/comment".to_string(),
887            },
888        )
889        .await
890        .unwrap();
891        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
892        assert_eq!(items.len(), 27, "expected 25 + 2 across 2 pages");
893        let p = result.get("pagination").unwrap();
894        assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("start_id"));
895        // Has_more should be false: 2nd page returned < page-size hint.
896        assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
897        // next_start / next_start_id reflect the LAST seen item (c26),
898        // because boundary extraction runs on every fetch.
899        assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c26"));
900    }
901
902    // ---- Regression tests for the limit-truncation `has_more` bug ----
903    //
904    // Caught by live smoke-testing PR #44 against ClickUp: when the caller
905    // passes `limit` and the helper truncates a non-terminal page, has_more
906    // was reported as false (because the original logic conjoined "server
907    // has more" with "we didn't hit the cap"). That misleads users into
908    // thinking they got everything when the server still has pages.
909    // has_more must report ONLY whether the server has additional pages.
910
911    #[tokio::test]
912    async fn page_dispatch_limit_truncates_but_has_more_reflects_server() {
913        let server = MockServer::start().await;
914        Mock::given(method("GET"))
915            .and(path("/v2/list/L1/task"))
916            .and(query_param("page", "0"))
917            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
918                "tasks": [{"id": "t1"}, {"id": "t2"}, {"id": "t3"}],
919                "last_page": false,
920            })))
921            .mount(&server)
922            .await;
923        let client = test_client(&server);
924        // limit=2 truncates a 3-item response from a page that ISN'T the last.
925        let args = PageArgs::from_args(&json!({"limit": 2}));
926        let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
927            format!("/v2/list/L1/task?page={}", p)
928        })
929        .await
930        .unwrap();
931        let p = result.get("pagination").unwrap();
932        assert_eq!(
933            p.get("has_more").and_then(|v| v.as_bool()),
934            Some(true),
935            "limit-truncated page with last_page=false should report has_more=true"
936        );
937    }
938
939    #[tokio::test]
940    async fn cursor_dispatch_limit_truncates_but_has_more_reflects_server() {
941        let server = MockServer::start().await;
942        Mock::given(method("GET"))
943            .and(path("/v3/workspaces/2648001/chat/channels"))
944            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
945                "data": [{"id": "c1"}, {"id": "c2"}, {"id": "c3"}],
946                "next_cursor": "MORE",
947            })))
948            .mount(&server)
949            .await;
950        let client = test_client(&server);
951        let args = CursorArgs::from_args(&json!({"limit": 2}));
952        let result = cursor_dispatch(&args, &client, &["data"], &["id"], |c| match c {
953            Some(c) => format!("/v3/workspaces/2648001/chat/channels?cursor={}", c),
954            None => "/v3/workspaces/2648001/chat/channels".to_string(),
955        })
956        .await
957        .unwrap();
958        let p = result.get("pagination").unwrap();
959        assert_eq!(
960            p.get("has_more").and_then(|v| v.as_bool()),
961            Some(true),
962            "limit-truncated page with non-empty next_cursor should report has_more=true"
963        );
964        assert_eq!(
965            p.get("next_cursor").and_then(|v| v.as_str()),
966            Some("MORE"),
967            "next_cursor must still be exposed so caller can fetch more if they want"
968        );
969    }
970
971    #[tokio::test]
972    async fn start_id_dispatch_limit_truncates_but_has_more_reflects_server() {
973        // Server returns a FULL page (25 items, matches the page-size hint),
974        // so reached_end stays false. With limit=10 we truncate to 10. The
975        // helper should still report has_more=true because the next page
976        // would have more items.
977        let server = MockServer::start().await;
978        let mut page = Vec::new();
979        for i in 0..25 {
980            page.push(json!({
981                "id": format!("c{}", i),
982                "date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
983            }));
984        }
985        Mock::given(method("GET"))
986            .and(path("/v2/task/T1/comment"))
987            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
988                "comments": page,
989            })))
990            .mount(&server)
991            .await;
992        let client = test_client(&server);
993        let args = StartIdArgs::from_args(&json!({"limit": 10}));
994        let result = start_id_dispatch(
995            &args,
996            &client,
997            "comments",
998            &["id"],
999            |start, start_id| match (start, start_id) {
1000                (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
1001                _ => "/v2/task/T1/comment".to_string(),
1002            },
1003        )
1004        .await
1005        .unwrap();
1006        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1007        assert_eq!(items.len(), 10, "limit cap honoured");
1008        let p = result.get("pagination").unwrap();
1009        assert_eq!(
1010            p.get("has_more").and_then(|v| v.as_bool()),
1011            Some(true),
1012            "limit-truncated full page should report has_more=true (server has more)"
1013        );
1014        // next_start / next_start_id should still be the last item of the
1015        // UNTRUNCATED response (c24, the 25th item), so the caller can
1016        // continue from where the server left off, not from where we cut.
1017        assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c24"));
1018    }
1019
1020    // ---- body_pagination_dispatch tests ----
1021
1022    fn audit_event(id: &str, ts: i64) -> Value {
1023        json!({"id": id, "eventTime": ts, "eventType": "auth"})
1024    }
1025
1026    #[tokio::test]
1027    async fn body_dispatch_no_pagination_args_returns_bare_array() {
1028        let server = MockServer::start().await;
1029        Mock::given(method("POST"))
1030            .and(path("/v3/workspaces/W1/auditlogs"))
1031            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1032                "data": [audit_event("e1", 1700000000), audit_event("e2", 1700000005)],
1033            })))
1034            .mount(&server)
1035            .await;
1036        let client = test_client(&server);
1037        let args = BodyPaginationArgs::from_args(&json!({}));
1038        let result = body_pagination_dispatch(
1039            &args,
1040            &client,
1041            "/v3/workspaces/W1/auditlogs",
1042            &["data"],
1043            &["id", "eventTime"],
1044            || json!({"applicability": "AUTH"}),
1045            |item| item.get("eventTime").and_then(|v| v.as_i64()),
1046        )
1047        .await
1048        .unwrap();
1049        assert!(result.is_array(), "expected bare array, got {}", result);
1050        assert_eq!(result.as_array().unwrap().len(), 2);
1051    }
1052
1053    #[tokio::test]
1054    async fn body_dispatch_all_true_walks_pages_via_timestamp_boundary() {
1055        // First page: 3 events. Boundary = last event's eventTime (1700000020).
1056        // Second page (with pageTimestamp=1700000020): 2 events. Boundary moves.
1057        // Third page (with pageTimestamp=1700000035): empty -> stop.
1058        let server = MockServer::start().await;
1059
1060        Mock::given(method("POST"))
1061            .and(path("/v3/workspaces/W1/auditlogs"))
1062            .and(wiremock::matchers::body_partial_json(
1063                json!({"applicability": "AUTH"}),
1064            ))
1065            .and(wiremock::matchers::body_partial_json(
1066                json!({"pagination": {"pageTimestamp": 1700000020_i64}}),
1067            ))
1068            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1069                "data": [
1070                    audit_event("e4", 1700000030),
1071                    audit_event("e5", 1700000035),
1072                ],
1073            })))
1074            .mount(&server)
1075            .await;
1076        Mock::given(method("POST"))
1077            .and(path("/v3/workspaces/W1/auditlogs"))
1078            .and(wiremock::matchers::body_partial_json(
1079                json!({"pagination": {"pageTimestamp": 1700000035_i64}}),
1080            ))
1081            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1082                "data": [],
1083            })))
1084            .mount(&server)
1085            .await;
1086        // First call has no pageTimestamp in body; lowest priority match so
1087        // the more-specific matches above are tried first.
1088        Mock::given(method("POST"))
1089            .and(path("/v3/workspaces/W1/auditlogs"))
1090            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1091                "data": [
1092                    audit_event("e1", 1700000010),
1093                    audit_event("e2", 1700000015),
1094                    audit_event("e3", 1700000020),
1095                ],
1096            })))
1097            .mount(&server)
1098            .await;
1099
1100        let client = test_client(&server);
1101        let args = BodyPaginationArgs::from_args(&json!({"all": true}));
1102        let result = body_pagination_dispatch(
1103            &args,
1104            &client,
1105            "/v3/workspaces/W1/auditlogs",
1106            &["data"],
1107            &["id"],
1108            || json!({"applicability": "AUTH"}),
1109            |item| item.get("eventTime").and_then(|v| v.as_i64()),
1110        )
1111        .await
1112        .unwrap();
1113        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1114        assert_eq!(items.len(), 5, "expected 3 + 2 + 0 across 3 pages");
1115        let p = result.get("pagination").unwrap();
1116        assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("body"));
1117        assert_eq!(
1118            p.get("has_more").and_then(|v| v.as_bool()),
1119            Some(false),
1120            "reached natural end (empty page) -> has_more false"
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn body_dispatch_limit_truncates_but_has_more_reflects_server() {
1126        // Server returns a non-empty response and a valid next-boundary;
1127        // limit truncates. has_more must still be true.
1128        let server = MockServer::start().await;
1129        Mock::given(method("POST"))
1130            .and(path("/v3/workspaces/W1/auditlogs"))
1131            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1132                "data": [
1133                    audit_event("e1", 1700000010),
1134                    audit_event("e2", 1700000015),
1135                    audit_event("e3", 1700000020),
1136                ],
1137            })))
1138            .mount(&server)
1139            .await;
1140        let client = test_client(&server);
1141        let args = BodyPaginationArgs::from_args(&json!({"limit": 2}));
1142        let result = body_pagination_dispatch(
1143            &args,
1144            &client,
1145            "/v3/workspaces/W1/auditlogs",
1146            &["data"],
1147            &["id"],
1148            || json!({"applicability": "AUTH"}),
1149            |item| item.get("eventTime").and_then(|v| v.as_i64()),
1150        )
1151        .await
1152        .unwrap();
1153        let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1154        assert_eq!(items.len(), 2, "limit cap honoured");
1155        let p = result.get("pagination").unwrap();
1156        assert_eq!(
1157            p.get("has_more").and_then(|v| v.as_bool()),
1158            Some(true),
1159            "limit-truncated non-empty page should report has_more=true"
1160        );
1161        // next_page_timestamp should be the last item's timestamp from the
1162        // UNTRUNCATED response (e3 = 1700000020), so the caller can continue
1163        // from where the server left off.
1164        assert_eq!(
1165            p.get("next_page_timestamp").and_then(|v| v.as_i64()),
1166            Some(1700000020)
1167        );
1168    }
1169
1170    #[test]
1171    fn body_pagination_args_empty() {
1172        let a = BodyPaginationArgs::from_args(&json!({}));
1173        assert!(!a.requested);
1174        assert!(a.page_rows.is_none());
1175        assert!(a.page_timestamp.is_none());
1176        assert!(a.page_direction.is_none());
1177        assert!(a.limit.is_none());
1178        assert!(!a.all);
1179    }
1180
1181    #[test]
1182    fn body_pagination_args_full() {
1183        let a = BodyPaginationArgs::from_args(&json!({
1184            "page_rows": 100,
1185            "page_timestamp": 1700000000_i64,
1186            "page_direction": "PREVIOUS",
1187            "limit": 50,
1188            "all": true,
1189        }));
1190        assert!(a.requested);
1191        assert_eq!(a.page_rows, Some(100));
1192        assert_eq!(a.page_timestamp, Some(1700000000));
1193        assert_eq!(a.page_direction.as_deref(), Some("PREVIOUS"));
1194        assert_eq!(a.limit, Some(50));
1195        assert!(a.all);
1196    }
1197
1198    #[test]
1199    fn page_args_empty() {
1200        let p = PageArgs::from_args(&json!({}));
1201        assert!(!p.requested);
1202        assert_eq!(p.page, None);
1203        assert_eq!(p.limit, None);
1204        assert!(!p.all);
1205    }
1206
1207    #[test]
1208    fn page_args_full() {
1209        let p = PageArgs::from_args(&json!({"page": 2, "limit": 50, "all": true}));
1210        assert!(p.requested);
1211        assert_eq!(p.page, Some(2));
1212        assert_eq!(p.limit, Some(50));
1213        assert!(p.all);
1214    }
1215
1216    #[test]
1217    fn page_args_just_all_flag() {
1218        let p = PageArgs::from_args(&json!({"all": false}));
1219        // Passing `all: false` is still an explicit pagination intent.
1220        assert!(p.requested);
1221    }
1222
1223    #[test]
1224    fn cursor_args_empty() {
1225        let c = CursorArgs::from_args(&json!({}));
1226        assert!(!c.requested);
1227        assert!(c.cursor.is_none());
1228    }
1229
1230    #[test]
1231    fn cursor_args_full() {
1232        let c = CursorArgs::from_args(&json!({"cursor": "abc", "limit": 10}));
1233        assert!(c.requested);
1234        assert_eq!(c.cursor.as_deref(), Some("abc"));
1235        assert_eq!(c.limit, Some(10));
1236    }
1237
1238    #[test]
1239    fn start_id_args_empty() {
1240        let s = StartIdArgs::from_args(&json!({}));
1241        assert!(!s.requested);
1242        assert!(s.start.is_none());
1243        assert!(s.start_id.is_none());
1244        assert_eq!(s.limit, None);
1245        assert!(!s.all);
1246    }
1247
1248    #[test]
1249    fn start_id_args_full() {
1250        let s = StartIdArgs::from_args(
1251            &json!({"start": 1700000000000_i64, "start_id": "c1", "limit": 20, "all": true}),
1252        );
1253        assert!(s.requested);
1254        assert_eq!(s.start, Some(1700000000000));
1255        assert_eq!(s.start_id.as_deref(), Some("c1"));
1256        assert_eq!(s.limit, Some(20));
1257        assert!(s.all);
1258    }
1259
1260    #[test]
1261    fn start_id_args_partial_start_only_still_requested() {
1262        // Passing only `start` (no `start_id`) is technically invalid as a
1263        // ClickUp request, but should still register as `requested` so the
1264        // helper switches to the envelope shape and the caller's URL-builder
1265        // closure can validate / error out cleanly.
1266        let s = StartIdArgs::from_args(&json!({"start": 1700000000000_i64}));
1267        assert!(s.requested);
1268        assert_eq!(s.start, Some(1700000000000));
1269        assert!(s.start_id.is_none());
1270    }
1271
1272    #[test]
1273    fn extract_array_prefers_first_key() {
1274        let resp = json!({"data": [1, 2], "tasks": [3, 4]});
1275        let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
1276        assert_eq!(arr.len(), 2);
1277        assert_eq!(arr[0], json!(1));
1278    }
1279
1280    #[test]
1281    fn extract_array_falls_back_to_second_key() {
1282        let resp = json!({"tasks": [3, 4]});
1283        let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
1284        assert_eq!(arr.len(), 2);
1285        assert_eq!(arr[0], json!(3));
1286    }
1287
1288    #[test]
1289    fn extract_array_falls_back_to_bare_array() {
1290        let resp = json!([1, 2, 3]);
1291        let arr = extract_array(&resp, &["data"]).unwrap();
1292        assert_eq!(arr.len(), 3);
1293    }
1294
1295    #[test]
1296    fn extract_array_returns_none_when_no_match() {
1297        let resp = json!({"foo": "bar"});
1298        assert!(extract_array(&resp, &["data", "tasks"]).is_none());
1299    }
1300}