amadeus_node/utils/
misc.rs

1use eetf::convert::TryAsRef;
2use eetf::{Atom, Binary, List, Term};
3use num_traits::ToPrimitive;
4use std::collections::HashMap;
5use std::ops::{Deref, DerefMut};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7use tracing::warn;
8
9/// Trait for types that can provide their type name as a static string
10pub trait Typename {
11    /// Get the type name for this instance
12    /// For enums, this can return different names based on the variant
13    fn typename(&self) -> &'static str;
14}
15
16// FIXME: u32 is fine until early 2106, after that it will overflow
17pub fn get_unix_secs_now() -> u32 {
18    SystemTime::now().duration_since(UNIX_EPOCH).as_ref().map(Duration::as_secs).unwrap_or(0) as u32
19}
20
21pub fn get_unix_millis_now() -> u64 {
22    SystemTime::now().duration_since(UNIX_EPOCH).as_ref().map(Duration::as_millis).unwrap_or(0) as u64
23}
24
25pub fn get_unix_nanos_now() -> u128 {
26    SystemTime::now().duration_since(UNIX_EPOCH).as_ref().map(Duration::as_nanos).unwrap_or(0)
27}
28
29pub fn pk_hex(pk: &[u8]) -> String {
30    let mut s = String::with_capacity(pk.len() * 2);
31    for b in pk {
32        s.push_str(&format!("{:02x}", b));
33    }
34    s
35}
36
37/// Produce a hex dump similar to `hexdump -C` for a binary slice.
38pub fn hexdump(data: &[u8]) -> String {
39    let mut out = String::new();
40    for (i, chunk) in data.chunks(16).enumerate() {
41        let address = i * 16;
42        // 8-digit upper-case hex address
43        let offset_str = format!("{address:08X}");
44
45        // hex bytes (2 hex chars per byte + 1 space => up to 48 chars)
46        let mut hex_bytes = String::new();
47        for b in chunk {
48            hex_bytes.push_str(&format!("{:02X} ", b));
49        }
50        // pad to 48 characters to align ASCII column
51        while hex_bytes.len() < 48 {
52            hex_bytes.push(' ');
53        }
54
55        // ASCII representation (32..=126 printable)
56        let ascii: String = chunk.iter().map(|&b| if (32..=126).contains(&b) { b as char } else { '.' }).collect();
57
58        out.push_str(&format!("{offset_str}  {hex_bytes}  {ascii}\n"));
59    }
60    if out.ends_with('\n') {
61        out.pop();
62    }
63    out
64}
65
66/// Keep only ASCII characters considered printable for our use-case.
67pub fn ascii(input: &str) -> String {
68    input
69        .chars()
70        .filter(|&c| {
71            let code = c as u32;
72            code == 32
73                || (123..=126).contains(&code)
74                || (('!' as u32)..=('@' as u32)).contains(&code)
75                || (('[' as u32)..=('_' as u32)).contains(&code)
76                || (('0' as u32)..=('9' as u32)).contains(&code)
77                || (('A' as u32)..=('Z' as u32)).contains(&code)
78                || (('a' as u32)..=('z' as u32)).contains(&code)
79        })
80        .collect()
81}
82
83pub fn is_ascii_clean(input: &str) -> bool {
84    ascii(input) == input
85}
86
87pub fn alphanumeric(input: &str) -> String {
88    input.chars().filter(|c| c.is_ascii_alphanumeric()).collect()
89}
90
91pub fn is_alphanumeric(input: &str) -> bool {
92    alphanumeric(input) == input
93}
94
95/// Trim trailing slash from url
96pub fn url(url: &str) -> String {
97    url.trim_end_matches('/').to_string()
98}
99
100/// Trim trailing slash on base and append path verbatim
101pub fn url_with(url: &str, path: &str) -> String {
102    format!("{}{}", url, path)
103}
104
105/// Lightweight helpers so you can keep calling `.atom()`, `.integer()`, etc.
106pub trait TermExt {
107    fn as_atom(&self) -> Option<&Atom>;
108    fn get_integer(&self) -> Option<i128>;
109    fn get_binary(&self) -> Option<&[u8]>;
110    fn get_list(&self) -> Option<&[Term]>;
111    fn get_string(&self) -> Option<String>;
112    fn get_term_map(&self) -> Option<TermMap>;
113    fn parse_list<T, E>(&self, parser: impl Fn(&[u8]) -> Result<T, E>) -> Vec<T>
114    where
115        E: std::fmt::Display;
116}
117
118impl TermExt for Term {
119    fn as_atom(&self) -> Option<&Atom> {
120        TryAsRef::<Atom>::try_as_ref(self)
121    }
122
123    fn get_integer(&self) -> Option<i128> {
124        match self {
125            Term::FixInteger(i) => Some(i.value as i128),
126            Term::BigInteger(bi) => bi.value.to_i128(),
127            _ => None,
128        }
129    }
130
131    fn get_binary(&self) -> Option<&[u8]> {
132        TryAsRef::<Binary>::try_as_ref(self).map(|b| b.bytes.as_slice())
133    }
134
135    fn get_list(&self) -> Option<&[Term]> {
136        TryAsRef::<List>::try_as_ref(self).map(|l| l.elements.as_slice())
137    }
138
139    fn get_string(&self) -> Option<String> {
140        // Erlang strings come across either as ByteList or Binary
141        if let Term::ByteList(bl) = self {
142            std::str::from_utf8(&bl.bytes).ok().map(|s| s.to_owned())
143        } else if let Term::Binary(b) = self {
144            std::str::from_utf8(&b.bytes).ok().map(|s| s.to_owned())
145        } else if let Term::Atom(a) = self {
146            Some(a.name.clone())
147        } else {
148            None
149        }
150    }
151
152    fn get_term_map(&self) -> Option<TermMap> {
153        match self {
154            Term::Map(m) => Some(TermMap(m.map.clone())),
155            _ => None,
156        }
157    }
158
159    fn parse_list<T, E>(&self, parser: impl Fn(&[u8]) -> Result<T, E>) -> Vec<T>
160    where
161        E: std::fmt::Display,
162    {
163        self.get_list().map(|list| parse_list(list, parser)).unwrap_or_default()
164    }
165}
166
167#[derive(Default, Clone, Debug)]
168pub struct TermMap(pub HashMap<Term, Term>);
169
170impl Deref for TermMap {
171    type Target = HashMap<Term, Term>;
172    fn deref(&self) -> &Self::Target {
173        &self.0
174    }
175}
176
177impl DerefMut for TermMap {
178    fn deref_mut(&mut self) -> &mut Self::Target {
179        &mut self.0
180    }
181}
182
183impl TermMap {
184    pub fn get_term_map(&self, key: &str) -> Option<Self> {
185        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::get_term_map)
186    }
187
188    pub fn get_binary<'a, A>(&'a self, key: &str) -> Option<A>
189    where
190        A: TryFrom<&'a [u8]>,
191    {
192        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::get_binary).and_then(|b| A::try_from(b).ok())
193    }
194
195    pub fn get_integer<I>(&self, key: &str) -> Option<I>
196    where
197        I: TryFrom<i128>,
198    {
199        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::get_integer).and_then(|b| I::try_from(b).ok())
200    }
201
202    pub fn get_list(&self, key: &str) -> Option<&[Term]> {
203        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::get_list)
204    }
205
206    pub fn get_atom(&self, key: &str) -> Option<&Atom> {
207        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::as_atom)
208    }
209
210    pub fn get_string(&self, key: &str) -> Option<String> {
211        self.0.get(&Term::Atom(Atom::from(key))).and_then(TermExt::get_string)
212    }
213
214    pub fn parse_list<T, E>(&self, key: &str, parser: impl Fn(&[u8]) -> Result<T, E>) -> Vec<T>
215    where
216        E: std::fmt::Display,
217    {
218        self.0.get(&Term::Atom(Atom::from(key))).map(|term| term.parse_list(parser)).unwrap_or_default()
219    }
220
221    pub fn into_term(self) -> Term {
222        Term::Map(eetf::Map { map: self.0 })
223    }
224}
225
226pub fn bools_to_bitvec(mask: &[bool]) -> Vec<u8> {
227    let mut out = vec![0u8; mask.len().div_ceil(8)];
228    for (i, &b) in mask.iter().enumerate() {
229        if b {
230            out[i / 8] |= 1 << (7 - (i % 8));
231        }
232    }
233    out
234}
235
236pub fn bitvec_to_bools(bytes: Vec<u8>) -> Vec<bool> {
237    let mut out = Vec::with_capacity(bytes.len() * 8);
238    for b in bytes {
239        // TODO: double-check if this is MSB-first or LSB-first
240        for i in (0..8).rev() {
241            // MSB -> LSB; use 0..8 for LSB-first
242            out.push(((b >> i) & 1) != 0);
243        }
244    }
245    out
246}
247// fn bitvec_to_bools(bytes: &[u8]) -> Vec<bool> {
248//     let mut out = Vec::with_capacity(bytes.len() * 8);
249//     for (_, byte) in bytes.iter().enumerate() {
250//         for bit in 0..8 {
251//             let val = (byte >> (7 - bit)) & 1u8;
252//             out.push(val == 1u8);
253//         }
254//     }
255//     out
256// }
257
258/// Creates string representation as bytes, compatible with Erlang's :erlang.integer_to_binary/1
259
260/// Format a duration into human-readable form following the requirements:
261/// - seconds if less than a minute
262/// - minutes plus seconds if less than hour
263/// - hours and minutes if less than day
264/// - days and hours if less than month
265/// - months and days if less than year
266/// - years, months and days if bigger than year
267pub fn format_duration(total_seconds: u32) -> String {
268    if total_seconds < 60 {
269        return format!("{}s", total_seconds);
270    }
271
272    let minutes = total_seconds / 60;
273    let seconds = total_seconds % 60;
274
275    if minutes < 60 {
276        return format!("{}m {}s", minutes, seconds);
277    }
278
279    let hours = minutes / 60;
280    let minutes = minutes % 60;
281
282    if hours < 24 {
283        return format!("{}h {}m", hours, minutes);
284    }
285
286    let days = hours / 24;
287    let hours = hours % 24;
288
289    if days < 30 {
290        return format!("{}d {}h", days, hours);
291    }
292
293    let months = days / 30; // Approximate months as 30 days
294    let days = days % 30;
295
296    if months < 12 {
297        return format!("{}mo {}d", months, days);
298    }
299
300    let years = months / 12;
301    let months = months % 12;
302
303    format!("{}y {}mo {}d", years, months, days)
304}
305
306/// Parse a list of ETF terms into structured data using the provided parser
307pub fn parse_list<T, E>(list: &[Term], parser: impl Fn(&[u8]) -> Result<T, E>) -> Vec<T>
308where
309    E: std::fmt::Display,
310{
311    list.iter()
312        .filter_map(|term| {
313            term.get_binary().and_then(|bytes| parser(bytes).map_err(|e| warn!("Failed to parse item: {}", e)).ok())
314        })
315        .collect()
316}
317
318/// Serialize a list of items into an ETF List term using the provided serializer
319pub fn serialize_list<T, E>(items: &[T], serializer: impl Fn(&T) -> Result<Vec<u8>, E>) -> Option<Term>
320where
321    E: std::fmt::Display,
322{
323    let terms: Result<Vec<_>, _> =
324        items.iter().map(|item| serializer(item).map(|bytes| Term::Binary(Binary::from(bytes)))).collect();
325    terms.ok().map(|terms| Term::List(List::from(terms)))
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn hexdump_basic() {
334        let s = hexdump(&[0x41, 0x00, 0x7F]);
335        assert!(s.starts_with("00000000  "));
336        assert!(s.contains("41 00 7F"));
337        assert!(s.ends_with("A.."));
338    }
339
340    #[test]
341    fn string_helpers() {
342        assert!(is_ascii_clean("AZaz09_-!"));
343        assert!(!is_ascii_clean("hi🙂"));
344        assert_eq!(alphanumeric("Abc-123"), "Abc123");
345        assert!(is_alphanumeric("abc123"));
346        assert!(!is_alphanumeric("a_b"));
347    }
348
349    #[test]
350    fn ext_and_urls() {
351        assert_eq!(url("http://a/b/"), "http://a/b");
352        assert_eq!(url("http://a/b"), "http://a/b");
353        assert_eq!(url_with("http://a/b", "/c"), "http://a/b/c");
354    }
355
356    #[test]
357    fn bitvec_roundtrip_prefix() {
358        let mask = vec![true, false, true, true, false, false, false, true, true];
359        let bytes = bools_to_bitvec(&mask);
360        assert_eq!(bytes.len(), 2);
361        let bools = bitvec_to_bools(bytes.clone());
362        assert_eq!(&bools[..mask.len()], &mask[..]);
363        for b in &bools[mask.len()..8 * bytes.len()] {
364            assert!(!*b);
365        }
366    }
367}