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(skip_serializing_if = "Option::is_none")]
44 pub location: Option<GeoPoint>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub tags: Option<Tags>,
48 #[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 #[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 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 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 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 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();
218
219 let geo = GeoPoint::from(dec!(60.167), dec!(24.955), Some(dec!(5.0))).unwrap();
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}