1use 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
16pub type Tags = Vec<Arc<String>>;
18
19pub type Tag = String;
21
22pub type Comments = Vec<String>;
24
25use crate::location::GeoPoint;
26
27#[derive(Serialize, Debug, Clone, Default)]
30pub struct TxnHeader {
31 pub timestamp: Zoned,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub code: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub uuid: Option<Uuid>,
42 #[serde(rename = "extId")]
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub extid: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub location: Option<GeoPoint>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub tags: Option<Tags>,
52 #[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 #[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 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 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 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 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();
225
226 let geo = GeoPoint::from(dec!(60.167), dec!(24.955), Some(dec!(5.0))).unwrap();
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, 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}