Skip to main content

clickup_cli/commands/
pagination.rs

1//! CLI-side pagination walkers.
2//!
3//! Mirror of `crate::mcp::pagination`, but tuned for the CLI's output model:
4//! these helpers walk pages and return the accumulated raw items as a
5//! `Vec<Value>` so the existing `OutputConfig::print_items` machinery can
6//! format them as tables, JSON, or CSV. No compaction, no envelope — just
7//! the loop logic and termination handling shared across every paginated
8//! CLI command.
9//!
10//! ## Contract
11//!
12//! Every walker respects the global pagination flags on [`crate::Cli`]:
13//! - `--all`: auto-fetch pages until natural termination or `--limit` hit.
14//! - `--limit N`: cap total items returned across pages.
15//! - `--page N` (page-style only): manual page selection. With `--all`,
16//!   the starting page.
17//! - `--cursor X` (cursor-style only): manual cursor. With `--all`, the
18//!   starting cursor.
19//! - `--start MS` + `--start-id ID` (start-id-style only): manual boundary
20//!   pair. With `--all`, the starting boundary.
21//!
22//! `--limit` is enforced **after** walking, so `--all --limit 500` returns
23//! up to 500 items across N pages rather than truncating the first page to
24//! 500. A hard cap of 100 pages prevents runaway loops.
25
26use crate::client::ClickUpClient;
27use crate::error::CliError;
28use crate::Cli;
29use serde_json::Value;
30
31/// Hard cap on how many pages a single `--all` invocation will fetch.
32/// Guards against runaway loops on misbehaving cursor endpoints.
33const MAX_PAGES: usize = 100;
34
35/// Extract an array from a JSON response, trying multiple candidate keys
36/// in order. Returns `None` if no candidate key holds an array, falling
37/// back to checking whether the whole response is itself a bare array.
38fn extract_array(resp: &Value, keys: &[&str]) -> Option<Vec<Value>> {
39    for key in keys {
40        if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {
41            return Some(arr.clone());
42        }
43    }
44    if let Some(arr) = resp.as_array() {
45        return Some(arr.clone());
46    }
47    None
48}
49
50/// Page-based walker for v2 endpoints (`?page=N`, response carries
51/// `last_page: bool`). `build_path(page)` returns the URL for a given page.
52/// `items_key` is the response field holding the array (e.g. `"tasks"`).
53pub async fn walk_page<F>(
54    cli: &Cli,
55    client: &ClickUpClient,
56    items_key: &str,
57    build_path: F,
58) -> Result<Vec<Value>, CliError>
59where
60    F: Fn(u32) -> String,
61{
62    let start_page = cli.page.unwrap_or(0);
63    let mut collected: Vec<Value> = Vec::new();
64    let mut current_page = start_page;
65    let mut pages_fetched = 0usize;
66
67    loop {
68        let resp = client.get(&build_path(current_page)).await?;
69        let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
70        let last_page = resp
71            .get("last_page")
72            .and_then(|v| v.as_bool())
73            .unwrap_or(items.is_empty());
74        collected.extend(items);
75        pages_fetched += 1;
76
77        if !cli.all {
78            break;
79        }
80        if last_page || pages_fetched >= MAX_PAGES {
81            break;
82        }
83        if let Some(limit) = cli.limit {
84            if collected.len() >= limit {
85                break;
86            }
87        }
88        current_page += 1;
89    }
90
91    if let Some(limit) = cli.limit {
92        collected.truncate(limit);
93    }
94    Ok(collected)
95}
96
97/// Cursor-based walker for v3 endpoints (`?cursor=X`, response carries
98/// `next_cursor: string` or empty). `build_path(Option<&str>)` returns the
99/// URL given an optional cursor. `items_keys` is the priority list of
100/// candidate response keys (e.g. `&["data", "channels"]`).
101pub async fn walk_cursor<F>(
102    cli: &Cli,
103    client: &ClickUpClient,
104    items_keys: &[&str],
105    build_path: F,
106) -> Result<Vec<Value>, CliError>
107where
108    F: Fn(Option<&str>) -> String,
109{
110    let mut cursor = cli.cursor.clone();
111    let mut collected: Vec<Value> = Vec::new();
112    let mut pages_fetched = 0usize;
113
114    loop {
115        let resp = client.get(&build_path(cursor.as_deref())).await?;
116        let items = extract_array(&resp, items_keys).unwrap_or_default();
117        let next_cursor: Option<String> = resp
118            .get("next_cursor")
119            .and_then(|v| v.as_str())
120            .filter(|s| !s.is_empty())
121            .map(String::from);
122        collected.extend(items);
123        pages_fetched += 1;
124
125        if !cli.all {
126            break;
127        }
128        if next_cursor.is_none() || pages_fetched >= MAX_PAGES {
129            break;
130        }
131        if let Some(limit) = cli.limit {
132            if collected.len() >= limit {
133                break;
134            }
135        }
136        cursor = next_cursor;
137    }
138
139    if let Some(limit) = cli.limit {
140        collected.truncate(limit);
141    }
142    Ok(collected)
143}
144
145/// Start-id-based walker for v2 comment endpoints
146/// (`?start=<ms>&start_id=<id>` paired, response carries `{comments: [...]}`
147/// with termination inferred from short page). `build_path(Option<i64>,
148/// Option<&str>)` returns the URL given an optional boundary pair.
149/// `items_key` is the response key (typically `"comments"`).
150pub async fn walk_start_id<F>(
151    cli: &Cli,
152    client: &ClickUpClient,
153    items_key: &str,
154    build_path: F,
155) -> Result<Vec<Value>, CliError>
156where
157    F: Fn(Option<i64>, Option<&str>) -> String,
158{
159    const PAGE_HINT: usize = 25;
160    let mut current_start = cli.start;
161    let mut current_start_id = cli.start_id.clone();
162    let mut collected: Vec<Value> = Vec::new();
163    let mut pages_fetched = 0usize;
164
165    loop {
166        let resp = client
167            .get(&build_path(current_start, current_start_id.as_deref()))
168            .await?;
169        let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
170        let count = items.len();
171
172        // Derive next boundary from the last item BEFORE consuming items.
173        let next_boundary = items.last().and_then(|last| {
174            let date = last
175                .get("date")
176                .and_then(|v| v.as_str())
177                .and_then(|s| s.parse::<i64>().ok())
178                .or_else(|| last.get("date").and_then(|v| v.as_i64()));
179            let id = last.get("id").and_then(|v| v.as_str()).map(String::from);
180            match (date, id) {
181                (Some(d), Some(i)) => Some((d, i)),
182                _ => None,
183            }
184        });
185
186        collected.extend(items);
187        pages_fetched += 1;
188
189        if !cli.all {
190            break;
191        }
192        if count < PAGE_HINT || pages_fetched >= MAX_PAGES {
193            break;
194        }
195        if let Some(limit) = cli.limit {
196            if collected.len() >= limit {
197                break;
198            }
199        }
200        match next_boundary {
201            Some((d, i)) => {
202                current_start = Some(d);
203                current_start_id = Some(i);
204            }
205            None => break,
206        }
207    }
208
209    if let Some(limit) = cli.limit {
210        collected.truncate(limit);
211    }
212    Ok(collected)
213}
214
215/// Body-based walker for the v3 audit-log endpoint. POST to `path` with
216/// `base_body() + pagination block`; advance via `next_timestamp(last_item)`.
217/// `extra_pagination` lets the caller pre-populate `pageRows` /
218/// `pageDirection` fields the helper doesn't manage directly.
219///
220/// The 8-arg signature is intentional: collapsing the four
221/// caller-provided behaviours (URL, body, pagination state, advance) into
222/// a struct would make every call site noisier without adding clarity.
223#[allow(clippy::too_many_arguments)]
224pub async fn walk_body<BB, NT>(
225    cli: &Cli,
226    client: &ClickUpClient,
227    path: &str,
228    items_keys: &[&str],
229    base_body: BB,
230    extra_pagination: serde_json::Map<String, Value>,
231    start_timestamp: Option<i64>,
232    next_timestamp: NT,
233) -> Result<Vec<Value>, CliError>
234where
235    BB: Fn() -> Value,
236    NT: Fn(&Value) -> Option<i64>,
237{
238    let mut current_timestamp = start_timestamp;
239    let mut collected: Vec<Value> = Vec::new();
240    let mut pages_fetched = 0usize;
241
242    loop {
243        let mut body = base_body();
244        let mut pagination = extra_pagination.clone();
245        if let Some(t) = current_timestamp {
246            pagination.insert("pageTimestamp".into(), serde_json::json!(t));
247        }
248        if !pagination.is_empty() {
249            body["pagination"] = Value::Object(pagination);
250        }
251
252        let resp = client.post(path, &body).await?;
253        let items = extract_array(&resp, items_keys).unwrap_or_default();
254        let count = items.len();
255        let next = items.last().and_then(&next_timestamp);
256        collected.extend(items);
257        pages_fetched += 1;
258
259        if !cli.all {
260            break;
261        }
262        if count == 0 || next.is_none() || pages_fetched >= MAX_PAGES {
263            break;
264        }
265        if let Some(limit) = cli.limit {
266            if collected.len() >= limit {
267                break;
268            }
269        }
270        current_timestamp = next;
271    }
272
273    if let Some(limit) = cli.limit {
274        collected.truncate(limit);
275    }
276    Ok(collected)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    #[test]
285    fn extract_array_prefers_first_key() {
286        let resp = json!({"data": [1, 2], "tasks": [3, 4]});
287        let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
288        assert_eq!(arr.len(), 2);
289        assert_eq!(arr[0], json!(1));
290    }
291
292    #[test]
293    fn extract_array_falls_back_to_second_key() {
294        let resp = json!({"tasks": [3, 4]});
295        let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
296        assert_eq!(arr[0], json!(3));
297    }
298
299    #[test]
300    fn extract_array_falls_back_to_bare_array() {
301        let resp = json!([1, 2, 3]);
302        let arr = extract_array(&resp, &["data"]).unwrap();
303        assert_eq!(arr.len(), 3);
304    }
305
306    #[test]
307    fn extract_array_returns_none_when_no_match() {
308        let resp = json!({"foo": "bar"});
309        assert!(extract_array(&resp, &["data", "tasks"]).is_none());
310    }
311}