Skip to main content

pidge_client/
cursor.rs

1//! Opaque pagination/delta cursors for agents.
2//!
3//! A cursor wraps each account's Graph continuation URL (`@odata.nextLink`
4//! or `@odata.deltaLink`) in a versioned, base64-encoded JSON envelope.
5//! Agents treat it as opaque; pidge validates version and kind on decode.
6
7use std::collections::BTreeMap;
8
9use base64::Engine;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, thiserror::Error)]
13pub enum CursorError {
14    #[error("invalid cursor (not a pidge cursor)")]
15    Malformed,
16
17    #[error("cursor version {0} is not supported by this pidge build")]
18    Version(u8),
19
20    #[error("cursor is for '{actual}' but this command expects '{expected}'")]
21    Kind { expected: String, actual: String },
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct Cursor {
26    pub v: u8,
27    pub kind: String,
28    /// account email -> continuation URL; `None` = that account is exhausted.
29    pub per_account: BTreeMap<String, Option<String>>,
30}
31
32impl Cursor {
33    pub fn new(kind: &str) -> Self {
34        Self {
35            v: 1,
36            kind: kind.to_string(),
37            per_account: BTreeMap::new(),
38        }
39    }
40
41    pub fn encode(&self) -> String {
42        base64::engine::general_purpose::URL_SAFE_NO_PAD
43            .encode(serde_json::to_vec(self).expect("cursor serializes"))
44    }
45
46    pub fn decode(token: &str, expected_kind: &str) -> Result<Self, CursorError> {
47        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
48            .decode(token.trim())
49            .map_err(|_| CursorError::Malformed)?;
50        let cursor: Cursor = serde_json::from_slice(&bytes).map_err(|_| CursorError::Malformed)?;
51        if cursor.v != 1 {
52            return Err(CursorError::Version(cursor.v));
53        }
54        if cursor.kind != expected_kind {
55            return Err(CursorError::Kind {
56                expected: expected_kind.to_string(),
57                actual: cursor.kind,
58            });
59        }
60        Ok(cursor)
61    }
62
63    /// True when every account is exhausted (no further pages anywhere).
64    pub fn exhausted(&self) -> bool {
65        self.per_account.values().all(|v| v.is_none())
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn round_trip() {
75        let mut cursor = Cursor::new("mail-list");
76        cursor
77            .per_account
78            .insert("a@b.se".into(), Some("https://graph/next?x=1".into()));
79        cursor.per_account.insert("c@d.se".into(), None);
80        let token = cursor.encode();
81        let back = Cursor::decode(&token, "mail-list").unwrap();
82        assert_eq!(back, cursor);
83        assert!(!back.exhausted());
84    }
85
86    #[test]
87    fn rejects_garbage_wrong_version_wrong_kind() {
88        assert!(matches!(
89            Cursor::decode("not base64 at all!!", "mail-list"),
90            Err(CursorError::Malformed)
91        ));
92        let mut wrong_version = Cursor::new("mail-list");
93        wrong_version.v = 9;
94        let token = base64::engine::general_purpose::URL_SAFE_NO_PAD
95            .encode(serde_json::to_vec(&wrong_version).unwrap());
96        assert!(matches!(
97            Cursor::decode(&token, "mail-list"),
98            Err(CursorError::Version(9))
99        ));
100        let cal = Cursor::new("cal-list").encode();
101        assert!(matches!(
102            Cursor::decode(&cal, "mail-list"),
103            Err(CursorError::Kind { .. })
104        ));
105    }
106
107    #[test]
108    fn exhausted_when_all_none() {
109        let mut cursor = Cursor::new("mail-delta");
110        cursor.per_account.insert("a@b.se".into(), None);
111        assert!(cursor.exhausted());
112    }
113}