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