Skip to main content

tackler_api/
txn_header.rs

1/*
2 * Tackler-NG 2023-2024
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Transaction header
7//!
8use jiff::Zoned;
9use jiff::tz::TimeZone;
10use serde::Serialize;
11use std::cmp::Ordering;
12use std::fmt::Write;
13use std::sync::Arc;
14use uuid::Uuid;
15
16/// Collection of Txn Tags
17pub type Tags = Vec<Arc<String>>;
18
19/// Single Txn Tag
20pub type Tag = String;
21
22/// Collection of Txn comments
23pub type Comments = Vec<String>;
24
25use crate::location::GeoPoint;
26
27/// Transaction Header Structure
28///
29#[derive(Serialize, Debug, Clone, Default)]
30pub struct TxnHeader {
31    /// Txn timestamp with Zone information
32    pub timestamp: Zoned,
33    /// Txn Code field, if any
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub code: Option<String>,
36    /// Txn Description, if any
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub description: Option<String>,
39    /// Txn UUID, if any. This is mandatory, if audit-mode is on
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub uuid: Option<Uuid>,
42    /// Txn ext-id, if any.
43    #[serde(rename = "extId")]
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub extid: Option<String>,
46    /// Txn location, if any
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub location: Option<GeoPoint>,
49    /// Txn tags, if any
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub tags: Option<Tags>,
52    /// Txn comments, if any
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub comments: Option<Comments>,
55}
56
57impl TxnHeader {
58    fn t_to_s(tags: &Tags) -> String {
59        let t = tags.iter().fold((0, String::new()), |mut tags, t| {
60            if tags.0 == 0 {
61                let _ = write!(tags.1, "{t}");
62            } else {
63                let _ = write!(tags.1, ", {t}");
64            }
65            (tags.0 + 1, tags.1)
66        });
67        t.1
68    }
69    /// Get Tags as string.
70    ///
71    /// String will be empty, if there isn't any tag
72    #[must_use]
73    pub fn tags_to_string(&self) -> String {
74        match &self.tags {
75            Some(t) => Self::t_to_s(t),
76            None => String::new(),
77        }
78    }
79}
80
81impl Ord for TxnHeader {
82    fn cmp(&self, other: &Self) -> Ordering {
83        let date_comp = self.timestamp.cmp(&other.timestamp);
84        if date_comp.is_ne() {
85            date_comp
86        } else {
87            let empty = String::new();
88
89            let code_cmp = self
90                .code
91                .as_ref()
92                .unwrap_or(&empty)
93                .cmp(other.code.as_ref().unwrap_or(&empty));
94            if code_cmp.is_ne() {
95                code_cmp
96            } else {
97                let desc_cmp = self
98                    .description
99                    .as_ref()
100                    .unwrap_or(&empty)
101                    .cmp(other.description.as_ref().unwrap_or(&empty));
102                if desc_cmp.is_ne() {
103                    desc_cmp
104                } else {
105                    let uuid_this = self
106                        .uuid
107                        .as_ref()
108                        .map(ToString::to_string)
109                        .unwrap_or_default();
110                    let uuid_other = other
111                        .uuid
112                        .as_ref()
113                        .map(ToString::to_string)
114                        .unwrap_or_default();
115
116                    uuid_this.cmp(&uuid_other)
117                }
118            }
119        }
120    }
121}
122
123impl PartialOrd for TxnHeader {
124    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
125        Some(self.cmp(other))
126    }
127}
128
129impl PartialEq for TxnHeader {
130    fn eq(&self, other: &Self) -> bool {
131        self.timestamp == other.timestamp
132            && self.code == other.code
133            && self.description == other.description
134            && self.uuid == other.uuid
135    }
136}
137
138impl Eq for TxnHeader {}
139
140impl TxnHeader {
141    /// Get Txn header as string, with `indent` and Txn TS formatter
142    ///
143    /// See [`txn_ts`](crate::txn_ts) module for default formatters
144    pub fn to_string_with_indent(
145        &self,
146        indent: &str,
147        ts_formatter: fn(&Zoned, TimeZone) -> String,
148        tz: TimeZone,
149    ) -> String {
150        format!(
151            "{}{}{}\n{}{}{}{}{}",
152            // txn header line: ts, code, desc
153            ts_formatter(&self.timestamp, tz),
154            self.code
155                .as_ref()
156                .map_or_else(String::new, |c| format!(" ({c})")),
157            self.description
158                .as_ref()
159                .map_or_else(String::new, |desc| format!(" '{desc}")),
160            // metadata
161            self.uuid
162                .as_ref()
163                .map_or_else(String::new, |uuid| format!("{indent}# uuid: {uuid}\n")),
164            self.extid
165                .as_ref()
166                .map_or_else(String::new, |extid| format!("{indent}# ext-id: {extid}\n")),
167            self.location
168                .as_ref()
169                .map_or_else(String::new, |geo| format!("{indent}# location: {geo}\n")),
170            self.tags.as_ref().map_or_else(String::new, |tags| format!(
171                "{}# tags: {}\n",
172                indent,
173                Self::t_to_s(tags)
174            )),
175            // txn comments
176            self.comments.as_ref().map_or_else(String::new, |comments| {
177                comments
178                    .iter()
179                    .fold(String::with_capacity(128), |mut output, c| {
180                        let _ = writeln!(output, "{indent}; {c}");
181                        output
182                    })
183            })
184        )
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use indoc::formatdoc;
192    use indoc::indoc;
193    use jiff::fmt::strtime;
194    use rust_decimal_macros::dec;
195    use tackler_rs::IndocUtils;
196
197    use crate::{txn_header::TxnHeader, txn_ts};
198
199    #[test]
200    #[allow(clippy::too_many_lines)]
201    fn txn_header_display() {
202        let ts = strtime::parse(
203            "%Y-%m-%dT%H:%M:%S%.f%:z",
204            "2023-02-04T14:03:05.047974+02:00",
205        )
206        .unwrap()
207        .to_zoned()
208        .unwrap();
209
210        let ts_second = strtime::parse("%Y-%m-%dT%H:%M:%S%.f%:z", "2025-01-08T14:15:16-05:00")
211            .unwrap()
212            .to_zoned()
213            .unwrap();
214
215        let ts_nano = strtime::parse(
216            "%Y-%m-%dT%H:%M:%S%.f%:z",
217            "2025-01-08T14:15:16.123456789-05:00",
218        )
219        .unwrap()
220        .to_zoned()
221        .unwrap();
222
223        let uuid_str = "ed6d4110-f3c0-4770-87fc-b99e46572244";
224        let uuid = Uuid::parse_str(uuid_str).unwrap(/*:test:*/);
225
226        let geo = GeoPoint::from(dec!(60.167), dec!(24.955), Some(dec!(5.0))).unwrap(/*:test:*/);
227
228        let txn_tags = vec![
229            Arc::new("a".to_string()),
230            Arc::new("b".to_string()),
231            Arc::new("c".to_string()),
232            Arc::new("a:b:c".to_string()),
233        ];
234        let comments = vec![
235            "z 1st line".to_string(),
236            "c 2nd line".to_string(),
237            "a 3rd line".to_string(),
238        ];
239
240        let tests: Vec<(TxnHeader, String)> = vec![
241            (
242                TxnHeader {
243                    timestamp: ts.clone(),
244                    code: None,
245                    description: None,
246                    uuid: None,
247                    extid: None,
248                    location: None,
249                    tags: None,
250                    comments: None,
251                },
252                indoc!(
253                    "|2023-02-04T14:03:05.047974+02:00
254                     |"
255                )
256                .strip_margin(),
257            ),
258            (
259                TxnHeader {
260                    timestamp: ts_second.clone(),
261                    code: None,
262                    description: None,
263                    uuid: None,
264                    extid: None,
265                    location: None,
266                    tags: None,
267                    comments: None,
268                },
269                indoc!(
270                    "|2025-01-08T14:15:16-05:00
271                     |"
272                )
273                .strip_margin(),
274            ),
275            (
276                TxnHeader {
277                    timestamp: ts_nano.clone(),
278                    code: None,
279                    description: None,
280                    uuid: None,
281                    extid: None,
282                    location: None,
283                    tags: None,
284                    comments: None,
285                },
286                indoc!(
287                    "|2025-01-08T14:15:16.123456789-05:00
288                     |"
289                )
290                .strip_margin(),
291            ),
292            (
293                TxnHeader {
294                    timestamp: ts.clone(),
295                    code: Some("#123".to_string()),
296                    description: None,
297                    uuid: None,
298                    extid: None,
299                    location: None,
300                    tags: None,
301                    comments: None,
302                },
303                indoc!(
304                    "|2023-02-04T14:03:05.047974+02:00 (#123)
305                     |"
306                )
307                .strip_margin(),
308            ),
309            (
310                TxnHeader {
311                    timestamp: ts.clone(),
312                    code: Some("#123".to_string()),
313                    description: Some("desc".to_string()),
314                    uuid: None,
315                    extid: None,
316                    location: None,
317                    tags: None,
318                    comments: None,
319                },
320                indoc! {
321                    "|2023-02-04T14:03:05.047974+02:00 (#123) 'desc
322                     |"
323                }
324                .strip_margin(),
325            ),
326            (
327                TxnHeader {
328                    timestamp: ts.clone(),
329                    code: None,
330                    description: Some("desc".to_string()),
331                    uuid: None,
332                    extid: None,
333                    location: None,
334                    tags: None,
335                    comments: None,
336                },
337                indoc!(
338                    "|2023-02-04T14:03:05.047974+02:00 'desc
339                     |"
340                )
341                .strip_margin(),
342            ),
343            (
344                TxnHeader {
345                    timestamp: ts.clone(),
346                    code: None,
347                    description: Some("desc".to_string()),
348                    uuid: Some(uuid),
349                    extid: None,
350                    location: None,
351                    tags: None,
352                    comments: None,
353                },
354                formatdoc!(
355                    "|2023-02-04T14:03:05.047974+02:00 'desc
356                     |   # uuid: {uuid_str}
357                     |"
358                )
359                .strip_margin(),
360            ),
361            (
362                TxnHeader {
363                    timestamp: ts.clone(),
364                    code: None,
365                    description: Some("desc".to_string()),
366                    uuid: None,
367                    extid: None,
368                    location: Some(geo.clone()),
369                    tags: None,
370                    comments: None,
371                },
372                indoc!(
373                    "|2023-02-04T14:03:05.047974+02:00 'desc
374                     |   # location: geo:60.167,24.955,5.0
375                     |"
376                )
377                .strip_margin(),
378            ),
379            (
380                TxnHeader {
381                    timestamp: ts.clone(),
382                    code: None,
383                    description: Some("desc".to_string()),
384                    uuid: None,
385                    extid: None,
386                    location: None,
387                    tags: Some(txn_tags.clone()),
388                    comments: None,
389                },
390                indoc!(
391                    "|2023-02-04T14:03:05.047974+02:00 'desc
392                     |   # tags: a, b, c, a:b:c
393                     |"
394                )
395                .strip_margin(),
396            ),
397            (
398                TxnHeader {
399                    timestamp: ts.clone(),
400                    code: None,
401                    description: Some("desc".to_string()),
402                    uuid: None,
403                    extid: None,
404                    location: None,
405                    tags: None,
406                    comments: Some(comments.clone()),
407                },
408                indoc!(
409                    "|2023-02-04T14:03:05.047974+02:00 'desc
410                     |   ; z 1st line
411                     |   ; c 2nd line
412                     |   ; a 3rd line
413                     |"
414                )
415                .strip_margin(),
416            ),
417            (
418                TxnHeader {
419                    timestamp: ts.clone(),
420                    code: None,
421                    description: Some("desc".to_string()),
422                    uuid: Some(uuid),
423                    extid: None, // todo
424                    location: Some(geo),
425                    tags: Some(txn_tags),
426                    comments: Some(comments),
427                },
428                formatdoc!(
429                    "|2023-02-04T14:03:05.047974+02:00 'desc
430                     |   # uuid: {uuid_str}
431                     |   # location: geo:60.167,24.955,5.0
432                     |   # tags: a, b, c, a:b:c
433                     |   ; z 1st line
434                     |   ; c 2nd line
435                     |   ; a 3rd line
436                     |"
437                )
438                .strip_margin(),
439            ),
440        ];
441
442        let mut count = 0;
443        let should_be_count = tests.len();
444        for t in tests {
445            let txn_hdr_str = t.0.to_string_with_indent(
446                "   ",
447                |ts, _tz| txn_ts::rfc_3339(ts),
448                jiff::tz::TimeZone::UTC,
449            );
450            assert_eq!(txn_hdr_str, t.1);
451            count += 1;
452        }
453        assert_eq!(count, should_be_count);
454    }
455}