Skip to main content

devboy_format_pipeline/
pagination.rs

1//! Cursor-based pagination for budget-trimmed responses.
2//!
3//! Since the MCP spec (2025-06-18) does not support pagination for tool responses,
4//! we implement pagination at the middleware level through a self-modifying tool interface.
5//!
6//! The `_page_cursor` parameter is added transparently by the pipeline enricher.
7//! The cursor encodes position in the overflow set so subsequent requests
8//! can retrieve remaining data.
9
10use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
11use serde::{Deserialize, Serialize};
12
13/// Cursor for navigating paginated overflow data.
14///
15/// Encoded as base64(JSON) for compactness in tool parameters.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct PageCursor {
18    /// Cursor format version (for forward compatibility).
19    pub v: u8,
20    /// Data type (e.g. "issues", "diffs", "discussions").
21    pub data_type: String,
22    /// Offset into the full dataset.
23    pub offset: usize,
24    /// Total number of items in the full dataset.
25    pub total: usize,
26    /// Strategy used for trimming (for consistent pagination).
27    pub strategy: String,
28    /// Budget tokens used.
29    pub budget: usize,
30}
31
32impl PageCursor {
33    /// Create a new cursor for the first overflow page.
34    pub fn new(
35        data_type: impl Into<String>,
36        offset: usize,
37        total: usize,
38        strategy: impl Into<String>,
39        budget: usize,
40    ) -> Self {
41        Self {
42            v: 1,
43            data_type: data_type.into(),
44            offset,
45            total,
46            strategy: strategy.into(),
47            budget,
48        }
49    }
50
51    /// Encode cursor to a URL-safe base64 string.
52    pub fn encode(&self) -> String {
53        let json = serde_json::to_string(self).expect("PageCursor serialization should not fail");
54        URL_SAFE_NO_PAD.encode(json.as_bytes())
55    }
56
57    /// Decode cursor from a base64 string.
58    pub fn decode(encoded: &str) -> Result<Self, PaginationError> {
59        let bytes = URL_SAFE_NO_PAD
60            .decode(encoded)
61            .map_err(|e| PaginationError::InvalidCursor(format!("base64 decode: {e}")))?;
62
63        let json = String::from_utf8(bytes)
64            .map_err(|e| PaginationError::InvalidCursor(format!("UTF-8 decode: {e}")))?;
65
66        serde_json::from_str(&json)
67            .map_err(|e| PaginationError::InvalidCursor(format!("JSON parse: {e}")))
68    }
69
70    /// Whether there are more pages after this one.
71    pub fn has_more(&self) -> bool {
72        self.offset < self.total
73    }
74
75    /// Number of remaining items.
76    pub fn remaining(&self) -> usize {
77        self.total.saturating_sub(self.offset)
78    }
79
80    /// Create cursor for the next page.
81    pub fn next_page(&self, items_in_current_page: usize) -> Option<Self> {
82        let next_offset = self.offset + items_in_current_page;
83        if next_offset >= self.total {
84            return None;
85        }
86        Some(Self {
87            v: self.v,
88            data_type: self.data_type.clone(),
89            offset: next_offset,
90            total: self.total,
91            strategy: self.strategy.clone(),
92            budget: self.budget,
93        })
94    }
95}
96
97/// Pagination-specific errors.
98#[derive(Debug, thiserror::Error)]
99pub enum PaginationError {
100    #[error("Invalid cursor: {0}")]
101    InvalidCursor(String),
102}
103
104/// Generate a pagination hint for the agent to include in the response.
105pub fn create_pagination_hint(cursor: &PageCursor, items_shown: usize) -> String {
106    let remaining = cursor.remaining();
107    format!(
108        "Showing {}/{} {}. {} more available. Use `_page_cursor: \"{}\"` to get the next page.",
109        items_shown,
110        cursor.total,
111        cursor.data_type,
112        remaining,
113        cursor.encode()
114    )
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_cursor_encode_decode_roundtrip() {
123        let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
124        let encoded = cursor.encode();
125        let decoded = PageCursor::decode(&encoded).unwrap();
126        assert_eq!(cursor, decoded);
127    }
128
129    #[test]
130    fn test_cursor_fields() {
131        let cursor = PageCursor::new("diffs", 10, 30, "size_proportional", 4000);
132        assert_eq!(cursor.v, 1);
133        assert_eq!(cursor.data_type, "diffs");
134        assert_eq!(cursor.offset, 10);
135        assert_eq!(cursor.total, 30);
136        assert_eq!(cursor.strategy, "size_proportional");
137        assert_eq!(cursor.budget, 4000);
138    }
139
140    #[test]
141    fn test_cursor_has_more() {
142        let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
143        assert!(cursor.has_more());
144
145        let cursor = PageCursor::new("issues", 50, 50, "element_count", 8000);
146        assert!(!cursor.has_more());
147    }
148
149    #[test]
150    fn test_cursor_remaining() {
151        let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
152        assert_eq!(cursor.remaining(), 30);
153
154        let cursor = PageCursor::new("issues", 50, 50, "element_count", 8000);
155        assert_eq!(cursor.remaining(), 0);
156    }
157
158    #[test]
159    fn test_cursor_next_page() {
160        let cursor = PageCursor::new("issues", 0, 50, "element_count", 8000);
161        let next = cursor.next_page(20).unwrap();
162        assert_eq!(next.offset, 20);
163        assert_eq!(next.total, 50);
164
165        let next2 = next.next_page(20).unwrap();
166        assert_eq!(next2.offset, 40);
167
168        let next3 = next2.next_page(20);
169        assert!(next3.is_none(), "Should be None when offset >= total");
170    }
171
172    #[test]
173    fn test_cursor_next_page_exact_boundary() {
174        let cursor = PageCursor::new("issues", 30, 50, "element_count", 8000);
175        let next = cursor.next_page(20);
176        assert!(next.is_none(), "30 + 20 = 50 = total, no more pages");
177    }
178
179    #[test]
180    fn test_decode_invalid_base64() {
181        let result = PageCursor::decode("not-valid-base64!!!");
182        assert!(result.is_err());
183    }
184
185    #[test]
186    fn test_decode_invalid_json() {
187        let encoded = URL_SAFE_NO_PAD.encode(b"not json");
188        let result = PageCursor::decode(&encoded);
189        assert!(result.is_err());
190    }
191
192    #[test]
193    fn test_create_pagination_hint() {
194        let cursor = PageCursor::new("issues", 20, 50, "element_count", 8000);
195        let hint = create_pagination_hint(&cursor, 20);
196        assert!(hint.contains("20/50"));
197        assert!(hint.contains("30 more"));
198        assert!(hint.contains("_page_cursor"));
199    }
200
201    #[test]
202    fn test_cursor_encode_is_compact() {
203        let cursor = PageCursor::new("issues", 0, 100, "element_count", 8000);
204        let encoded = cursor.encode();
205        // Should be reasonably compact (< 200 chars)
206        assert!(
207            encoded.len() < 200,
208            "Encoded cursor should be compact, got {} chars",
209            encoded.len()
210        );
211    }
212
213    #[test]
214    fn test_multi_page_simulation() {
215        let total = 50;
216        let page_size = 15;
217        let mut cursor = PageCursor::new("issues", 0, total, "element_count", 8000);
218        let mut pages = 0;
219        let mut total_items = 0;
220
221        loop {
222            let items_this_page = page_size.min(cursor.remaining());
223            total_items += items_this_page;
224            pages += 1;
225
226            match cursor.next_page(items_this_page) {
227                Some(next) => cursor = next,
228                None => break,
229            }
230        }
231
232        assert_eq!(pages, 4); // 15+15+15+5
233        assert_eq!(total_items, 50);
234    }
235}