1use 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 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 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}