1use crate::NaiveDate;
10use rust_decimal::Decimal;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::Amount;
15use crate::intern::InternedStr;
16
17#[cfg(feature = "rkyv")]
24use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate};
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[cfg_attr(
49 feature = "rkyv",
50 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
51)]
52pub struct Cost {
53 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
55 pub number: Decimal,
56 #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
58 pub currency: InternedStr,
59 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
61 pub date: Option<NaiveDate>,
62 pub label: Option<String>,
64}
65
66impl Cost {
67 #[must_use]
72 pub fn new(number: Decimal, currency: impl Into<InternedStr>) -> Self {
73 Self {
74 number,
75 currency: currency.into(),
76 date: None,
77 label: None,
78 }
79 }
80
81 #[must_use]
86 pub fn new_calculated(number: Decimal, currency: impl Into<InternedStr>) -> Self {
87 Self::new(number, currency)
88 }
89
90 #[must_use]
92 pub const fn with_date(mut self, date: NaiveDate) -> Self {
93 self.date = Some(date);
94 self
95 }
96
97 #[must_use]
99 pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
100 self.date = date;
101 self
102 }
103
104 #[must_use]
106 pub fn with_label(mut self, label: impl Into<String>) -> Self {
107 self.label = Some(label.into());
108 self
109 }
110
111 #[must_use]
113 pub fn with_label_opt(mut self, label: Option<String>) -> Self {
114 self.label = label;
115 self
116 }
117
118 #[must_use]
120 pub fn as_amount(&self) -> Amount {
121 Amount::new(self.number, self.currency.clone())
122 }
123
124 #[must_use]
126 pub fn total_cost(&self, units: Decimal) -> Amount {
127 Amount::new(units * self.number, self.currency.clone())
128 }
129}
130
131impl fmt::Display for Cost {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(f, "{{ {} {}", self.number, self.currency)?;
146 if let Some(date) = self.date {
147 write!(f, ", {date}")?;
148 }
149 if let Some(label) = &self.label {
150 write!(f, ", \"{}\"", crate::format::escape_string(label))?;
156 }
157 write!(f, "}}")
158 }
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
193#[cfg_attr(
194 feature = "rkyv",
195 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
196)]
197pub struct CostSpec {
198 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
200 pub number_per: Option<Decimal>,
201 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
203 pub number_total: Option<Decimal>,
204 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
206 pub currency: Option<InternedStr>,
207 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
209 pub date: Option<NaiveDate>,
210 pub label: Option<String>,
212 pub merge: bool,
214}
215
216impl CostSpec {
217 #[must_use]
219 pub fn empty() -> Self {
220 Self::default()
221 }
222
223 #[must_use]
225 pub const fn with_number_per(mut self, number: Decimal) -> Self {
226 self.number_per = Some(number);
227 self
228 }
229
230 #[must_use]
232 pub const fn with_number_total(mut self, number: Decimal) -> Self {
233 self.number_total = Some(number);
234 self
235 }
236
237 #[must_use]
239 pub fn with_currency(mut self, currency: impl Into<InternedStr>) -> Self {
240 self.currency = Some(currency.into());
241 self
242 }
243
244 #[must_use]
246 pub const fn with_date(mut self, date: NaiveDate) -> Self {
247 self.date = Some(date);
248 self
249 }
250
251 #[must_use]
253 pub fn with_label(mut self, label: impl Into<String>) -> Self {
254 self.label = Some(label.into());
255 self
256 }
257
258 #[must_use]
260 pub const fn with_merge(mut self) -> Self {
261 self.merge = true;
262 self
263 }
264
265 #[must_use]
267 pub const fn is_empty(&self) -> bool {
268 self.number_per.is_none()
269 && self.number_total.is_none()
270 && self.currency.is_none()
271 && self.date.is_none()
272 && self.label.is_none()
273 && !self.merge
274 }
275
276 #[must_use]
280 pub fn matches(&self, cost: &Cost) -> bool {
281 if let Some(n) = &self.number_per
283 && n != &cost.number
284 {
285 return false;
286 }
287 if let Some(c) = &self.currency
289 && c != &cost.currency
290 {
291 return false;
292 }
293 if let Some(d) = &self.date
295 && cost.date.as_ref() != Some(d)
296 {
297 return false;
298 }
299 if let Some(l) = &self.label
301 && cost.label.as_ref() != Some(l)
302 {
303 return false;
304 }
305 true
306 }
307
308 #[must_use]
316 pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
317 let currency = self.currency.clone()?;
318
319 let number = if let Some(per) = self.number_per {
320 per
322 } else if let Some(total) = self.number_total {
323 total / units.abs()
325 } else {
326 return None;
327 };
328
329 Some(Cost {
330 number,
331 currency,
332 date: self.date.or(Some(date)),
333 label: self.label.clone(),
334 })
335 }
336}
337
338impl fmt::Display for CostSpec {
339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340 write!(f, "{{")?;
341 let mut parts = Vec::with_capacity(6);
343
344 if let Some(n) = self.number_per {
345 parts.push(format!("{n}"));
346 }
347 if let Some(n) = self.number_total {
348 parts.push(format!("# {n}"));
349 }
350 if let Some(c) = &self.currency {
351 parts.push(c.to_string());
352 }
353 if let Some(d) = self.date {
354 parts.push(d.to_string());
355 }
356 if let Some(l) = &self.label {
357 parts.push(format!("\"{l}\""));
358 }
359 if self.merge {
360 parts.push("*".to_string());
361 }
362
363 write!(f, "{}", parts.join(", "))?;
364 write!(f, "}}")
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use rust_decimal_macros::dec;
372
373 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
374 crate::naive_date(year, month, day).unwrap()
375 }
376
377 #[test]
378 fn test_cost_new() {
379 let cost = Cost::new(dec!(150.00), "USD");
380 assert_eq!(cost.number, dec!(150.00));
381 assert_eq!(cost.currency, "USD");
382 assert!(cost.date.is_none());
383 assert!(cost.label.is_none());
384 }
385
386 #[test]
387 fn test_cost_builder() {
388 let cost = Cost::new(dec!(150.00), "USD")
389 .with_date(date(2024, 1, 15))
390 .with_label("lot1");
391
392 assert_eq!(cost.date, Some(date(2024, 1, 15)));
393 assert_eq!(cost.label, Some("lot1".to_string()));
394 }
395
396 #[test]
397 fn test_cost_total() {
398 let cost = Cost::new(dec!(150.00), "USD");
399 let total = cost.total_cost(dec!(10));
400 assert_eq!(total.number, dec!(1500.00));
401 assert_eq!(total.currency, "USD");
402 }
403
404 #[test]
405 fn test_cost_display() {
406 let cost = Cost::new(dec!(150.00), "USD")
407 .with_date(date(2024, 1, 15))
408 .with_label("lot1");
409 let s = format!("{cost}");
410 assert!(s.contains("150.00"));
411 assert!(s.contains("USD"));
412 assert!(s.contains("2024-01-15"));
413 assert!(s.contains("lot1"));
414 }
415
416 #[test]
420 fn test_cost_display_escapes_special_characters_in_label() {
421 let bare = Cost::new(dec!(520), "USD");
423 assert_eq!(format!("{bare}"), "{ 520 USD}");
424
425 let dated = Cost::new(dec!(520.00), "USD").with_date(date(2024, 1, 15));
427 assert_eq!(format!("{dated}"), "{ 520.00 USD, 2024-01-15}");
428
429 let quoted = Cost::new(dec!(100.00), "USD")
431 .with_date(date(2024, 1, 15))
432 .with_label("say \"hi\"");
433 assert_eq!(
434 format!("{quoted}"),
435 "{ 100.00 USD, 2024-01-15, \"say \\\"hi\\\"\"}"
436 );
437
438 let backslash = Cost::new(dec!(50.00), "USD").with_label("path\\to\\lot");
440 assert_eq!(
441 format!("{backslash}"),
442 "{ 50.00 USD, \"path\\\\to\\\\lot\"}"
443 );
444
445 let newline = Cost::new(dec!(75.00), "USD").with_label("line1\nline2");
447 assert_eq!(format!("{newline}"), "{ 75.00 USD, \"line1\\nline2\"}");
448
449 let plain = Cost::new(dec!(540.00), "USD")
451 .with_date(date(2024, 2, 15))
452 .with_label("lot-A");
453 assert_eq!(format!("{plain}"), "{ 540.00 USD, 2024-02-15, \"lot-A\"}");
454 }
455
456 #[test]
457 fn test_cost_spec_empty() {
458 let spec = CostSpec::empty();
459 assert!(spec.is_empty());
460 }
461
462 #[test]
463 fn test_cost_spec_matches() {
464 let cost = Cost::new(dec!(150.00), "USD")
465 .with_date(date(2024, 1, 15))
466 .with_label("lot1");
467
468 assert!(CostSpec::empty().matches(&cost));
470
471 let spec = CostSpec::empty().with_number_per(dec!(150.00));
473 assert!(spec.matches(&cost));
474
475 let spec = CostSpec::empty().with_number_per(dec!(160.00));
477 assert!(!spec.matches(&cost));
478
479 let spec = CostSpec::empty().with_currency("USD");
481 assert!(spec.matches(&cost));
482
483 let spec = CostSpec::empty().with_date(date(2024, 1, 15));
485 assert!(spec.matches(&cost));
486
487 let spec = CostSpec::empty().with_label("lot1");
489 assert!(spec.matches(&cost));
490
491 let spec = CostSpec::empty()
493 .with_number_per(dec!(150.00))
494 .with_currency("USD")
495 .with_date(date(2024, 1, 15))
496 .with_label("lot1");
497 assert!(spec.matches(&cost));
498 }
499
500 #[test]
501 fn test_cost_spec_resolve() {
502 let spec = CostSpec::empty()
503 .with_number_per(dec!(150.00))
504 .with_currency("USD");
505
506 let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
507 assert_eq!(cost.number, dec!(150.00));
508 assert_eq!(cost.currency, "USD");
509 assert_eq!(cost.date, Some(date(2024, 1, 15)));
510 }
511
512 #[test]
513 fn test_cost_spec_resolve_total() {
514 let spec = CostSpec::empty()
515 .with_number_total(dec!(1500.00))
516 .with_currency("USD");
517
518 let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
519 assert_eq!(cost.number, dec!(150.00)); assert_eq!(cost.currency, "USD");
521 }
522}