Skip to main content

polyester/codecs/decode/
address_book.rs

1//! Address book decoders.
2
3use crate::codecs::decode::enums::enum_proto_name;
4use crate::codecs::scalars::{format_id, format_uint64_id};
5use crate::errors::{Error, Result};
6use crate::models::{
7    AddressBookEntriesList, AddressBookEntry, AddressBookTag, AddressBookViewInvalidation,
8    AddressBooksList,
9};
10use crate::proto::auth::v1::{
11    AddressBookEntry as ProtoAddressBookEntry, AddressBookTag as ProtoAddressBookTag,
12    AddressBookViewInvalidated, CopyAddressBookEntryResponse, CreateAddressBookEntryResponse,
13    CreateAddressBookTagResponse, ListAddressBookEntriesResponse, ListAddressBooksResponse,
14    UpdateAddressBookEntryResponse, UpdateAddressBookTagResponse,
15};
16
17pub fn list_books_from_proto(msg: &ListAddressBooksResponse) -> AddressBooksList {
18    AddressBooksList {
19        books: msg
20            .books
21            .iter()
22            .filter_map(|book| serde_json::to_value(book).ok())
23            .collect(),
24    }
25}
26
27fn entry_from_proto(msg: &ProtoAddressBookEntry) -> AddressBookEntry {
28    AddressBookEntry {
29        address_book_entry_id: format_uint64_id(msg.address_book_entry_id),
30        label: msg.label.clone(),
31        kind: enum_proto_name(&msg.kind),
32        revision: msg.revision,
33    }
34}
35
36pub fn list_entries_from_proto(msg: &ListAddressBookEntriesResponse) -> AddressBookEntriesList {
37    AddressBookEntriesList {
38        entries: msg.entries.iter().map(entry_from_proto).collect(),
39        next_page_token: msg.next_page_token.clone(),
40    }
41}
42
43pub fn entry_from_create_proto(msg: &CreateAddressBookEntryResponse) -> Result<AddressBookEntry> {
44    msg.entry
45        .as_option()
46        .map(entry_from_proto)
47        .ok_or_else(|| Error::transport("invalid CreateAddressBookEntry response: missing entry"))
48}
49
50pub fn entry_from_update_proto(msg: &UpdateAddressBookEntryResponse) -> Result<AddressBookEntry> {
51    msg.entry
52        .as_option()
53        .map(entry_from_proto)
54        .ok_or_else(|| Error::transport("invalid UpdateAddressBookEntry response: missing entry"))
55}
56
57pub fn entry_from_copy_proto(msg: &CopyAddressBookEntryResponse) -> Result<AddressBookEntry> {
58    msg.entry
59        .as_option()
60        .map(entry_from_proto)
61        .ok_or_else(|| Error::transport("invalid CopyAddressBookEntry response: missing entry"))
62}
63
64fn tag_from_proto(msg: &ProtoAddressBookTag) -> AddressBookTag {
65    AddressBookTag {
66        tag_id: format_uint64_id(msg.tag_id),
67        name: msg.name.clone(),
68        color: msg.color.clone(),
69    }
70}
71
72pub fn tag_from_create_proto(msg: &CreateAddressBookTagResponse) -> Result<AddressBookTag> {
73    msg.tag
74        .as_option()
75        .map(tag_from_proto)
76        .ok_or_else(|| Error::transport("invalid CreateAddressBookTag response: missing tag"))
77}
78
79pub fn tag_from_update_proto(msg: &UpdateAddressBookTagResponse) -> Result<AddressBookTag> {
80    msg.tag
81        .as_option()
82        .map(tag_from_proto)
83        .ok_or_else(|| Error::transport("invalid UpdateAddressBookTag response: missing tag"))
84}
85
86pub fn address_book_invalidation_from_proto(
87    msg: &AddressBookViewInvalidated,
88) -> AddressBookViewInvalidation {
89    let scope = msg
90        .scope
91        .as_option()
92        .filter(|s| s.root_account_id != 0)
93        .map(|s| format_id(s.root_account_id))
94        .unwrap_or_default();
95    let invalidated_at = msg
96        .invalidated_at
97        .as_option()
98        .map(|ts| format_rfc3339_nano(ts.seconds, ts.nanos))
99        .unwrap_or_default();
100    AddressBookViewInvalidation {
101        scope,
102        invalidated_at,
103    }
104}
105
106fn format_rfc3339_nano(seconds: i64, nanos: i32) -> String {
107    let secs = seconds.max(0) as u64;
108    let days = secs / 86_400;
109    let tod = secs % 86_400;
110    let hour = tod / 3600;
111    let min = (tod % 3600) / 60;
112    let sec = tod % 60;
113    let (y, m, d) = civil_from_days(days as i64);
114    let nanos = nanos.max(0) as u32;
115    if nanos == 0 {
116        format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}Z")
117    } else {
118        format!("{y:04}-{m:02}-{d:02}T{hour:02}:{min:02}:{sec:02}.{nanos:09}Z")
119    }
120}
121
122fn civil_from_days(days: i64) -> (i64, u32, u32) {
123    // Howard Hinnant civil_from_days (unix epoch day offset).
124    let z = days + 719_468;
125    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
126    let doe = (z - era * 146_097) as u64;
127    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
128    let y = yoe as i64 + era * 400;
129    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
130    let mp = (5 * doy + 2) / 153;
131    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
132    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
133    let y = if m <= 2 { y + 1 } else { y };
134    (y, m, d)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::proto::auth::v1::{AccountScopeRef, AddressBook, AddressBookEntryKind};
141    use buffa_types::google::protobuf::Timestamp;
142
143    #[test]
144    fn list_books_serializes_rows() {
145        let msg = ListAddressBooksResponse {
146            books: vec![AddressBook {
147                label: "main".into(),
148                ..Default::default()
149            }],
150            ..Default::default()
151        };
152        let result = list_books_from_proto(&msg);
153        assert_eq!(result.books.len(), 1);
154        assert_eq!(result.books[0]["label"], "main");
155    }
156
157    #[test]
158    fn list_entries_maps_id_label_kind() {
159        let msg = ListAddressBookEntriesResponse {
160            entries: vec![ProtoAddressBookEntry {
161                address_book_entry_id: 7,
162                label: "vault".into(),
163                kind: AddressBookEntryKind::INTERNAL_ACCOUNT.into(),
164                revision: 5,
165                ..Default::default()
166            }],
167            next_page_token: "t".into(),
168            ..Default::default()
169        };
170        let result = list_entries_from_proto(&msg);
171        assert_eq!(result.entries.len(), 1);
172        assert_eq!(result.entries[0].address_book_entry_id, format_uint64_id(7));
173        assert_eq!(result.entries[0].label, "vault");
174        assert!(!result.entries[0].kind.is_empty());
175        assert_eq!(result.entries[0].revision, 5);
176        assert_eq!(result.next_page_token, "t");
177    }
178
179    #[test]
180    fn mutation_responses_reject_missing_required_entities() {
181        assert!(entry_from_create_proto(&CreateAddressBookEntryResponse::default()).is_err());
182        assert!(entry_from_update_proto(&UpdateAddressBookEntryResponse::default()).is_err());
183        assert!(entry_from_copy_proto(&CopyAddressBookEntryResponse::default()).is_err());
184        assert!(tag_from_create_proto(&CreateAddressBookTagResponse::default()).is_err());
185        assert!(tag_from_update_proto(&UpdateAddressBookTagResponse::default()).is_err());
186    }
187
188    #[test]
189    fn invalidation_formats_scope_and_time() {
190        let msg = AddressBookViewInvalidated {
191            scope: AccountScopeRef {
192                root_account_id: 42,
193                ..Default::default()
194            }
195            .into(),
196            invalidated_at: Timestamp {
197                seconds: 0,
198                nanos: 0,
199                ..Default::default()
200            }
201            .into(),
202            ..Default::default()
203        };
204        let out = address_book_invalidation_from_proto(&msg);
205        assert!(!out.scope.is_empty());
206        assert_eq!(out.invalidated_at, "1970-01-01T00:00:00Z");
207    }
208}