ocpi_tariffs/
lib.rs

1//! # OCPI Tariffs library
2//!
3//! Calculate the (sub)totals of a [charge session](https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc)
4//! using the [`cdr::price`] function and use the generated [`price::Report`] to review and compare the calculated
5//! totals versus the sources from the `CDR`.
6//!
7//! See the [`price::Report`] for a detailed list of all the fields that help analyze and valitate the pricing of a `CDR`.
8//!
9//! - Use the [`cdr::parse`] and [`tariff::parse`] function to parse and guess which OCPI version of a CDR or tariff you have.
10//! - Use the [`cdr::parse_with_version`] and [`tariff::parse_with_version`] functions to parse a CDR of tariff as the given version.
11//! - Use the [`tariff::lint`] to lint a tariff: flag common errors, bugs, dangerous constructs and stylistic flaws in the tariff.
12//!
13//! # Examples
14//!
15//! ## Price a CDR with embedded tariff
16//!
17//! If you have a CDR JSON with an embedded tariff you can price the CDR with the following code:
18//!
19//! ```rust
20//! # use ocpi_tariffs::{cdr, price, Version};
21//! #
22//! # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time/cdr.json");
23//!
24//! let report = cdr::parse_with_version(CDR_JSON, Version::V211)?;
25//! let cdr::ParseReport {
26//!     cdr,
27//!     unexpected_fields,
28//! } = report;
29//!
30//! # if !unexpected_fields.is_empty() {
31//! #     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
32//! #
33//! #     for path in &unexpected_fields {
34//! #         eprintln!("{path}");
35//! #     }
36//! # }
37//!
38//! let report = cdr::price(cdr, price::TariffSource::UseCdr, chrono_tz::Tz::Europe__Amsterdam)?;
39//!
40//! if !report.warnings.is_empty() {
41//!     eprintln!("Pricing the CDR resulted in `{}` warnings", report.warnings.len());
42//!
43//!     for price::WarningReport { kind } in &report.warnings {
44//!         eprintln!("  - {kind}");
45//!     }
46//! }
47//!
48//! # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
49//! ```
50//!
51//! ## Price a CDR using tariff in separate JSON file
52//!
53//! If you have a CDR JSON with a tariff in a separate JSON file you can price the CDR with the
54//! following code:
55//!
56//! ```rust
57//! # use ocpi_tariffs::{cdr, price, Version};
58//! #
59//! # const CDR_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/cdr.json");
60//! # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
61//!
62//! let report = cdr::parse_with_version(CDR_JSON, Version::V211)?;
63//! let cdr::ParseReport {
64//!     cdr,
65//!     unexpected_fields,
66//! } = report;
67//!
68//! # if !unexpected_fields.is_empty() {
69//! #     eprintln!("Strange... there are fields in the CDR that are not defined in the spec.");
70//! #
71//! #     for path in &unexpected_fields {
72//! #         eprintln!("{path}");
73//! #     }
74//! # }
75//!
76//! let report = cdr::price(cdr, price::TariffSource::Override(vec![TARIFF_JSON.to_string()]), chrono_tz::Tz::Europe__Amsterdam)?;
77//!
78//! if !report.warnings.is_empty() {
79//!     eprintln!("Pricing the CDR resulted in `{}` warnings", report.warnings.len());
80//!
81//!     for price::WarningReport { kind } in &report.warnings {
82//!         eprintln!("  - {kind}");
83//!     }
84//! }
85//!
86//! # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
87//! ```
88//!
89//! ## Lint a tariff
90//!
91//! ```rust
92//! # use ocpi_tariffs::{guess, tariff, warning};
93//! #
94//! # const TARIFF_JSON: &str = include_str!("../test_data/v211/real_world/time_and_parking_time_separate_tariff/tariff.json");
95//!
96//! let report = tariff::parse_and_report(TARIFF_JSON)?;
97//! let guess::Report {
98//!     unexpected_fields,
99//!     version,
100//! } = report;
101//!
102//! if !unexpected_fields.is_empty() {
103//!     eprintln!("Strange... there are fields in the tariff that are not defined in the spec.");
104//!
105//!     for path in &unexpected_fields {
106//!         eprintln!("  * {path}");
107//!     }
108//!
109//!     eprintln!();
110//! }
111//!
112//! let guess::Version::Certain(tariff) = version else {
113//!     return Err("Unable to guess the version of given CDR JSON.".into());
114//! };
115//!
116//! let report = tariff::lint(&tariff)?;
117//! let warnings = report.into_warning_report();
118//!
119//! eprintln!("`{}` lint warnings found", warnings.len());
120//!
121//! for warning::ElementReport { element, warnings } in warnings.iter(tariff.as_element()) {
122//!     eprintln!(
123//!         "Warnings reported for `json::Element` at path: `{}`",
124//!         element.path()
125//!     );
126//!
127//!     for warning in warnings {
128//!         eprintln!("  * {warning}");
129//!     }
130//!
131//!     eprintln!();
132//! }
133//!
134//! # Ok::<(), Box<dyn std::error::Error + Send + Sync + 'static>>(())
135//! ```
136
137pub mod cdr;
138pub mod country;
139pub mod currency;
140pub mod datetime;
141pub mod duration;
142mod energy;
143pub mod guess;
144pub mod json;
145pub mod lint;
146pub mod money;
147pub mod number;
148pub mod price;
149pub mod string;
150pub mod tariff;
151pub mod timezone;
152mod v211;
153mod v221;
154pub mod warning;
155pub mod weekday;
156
157use std::{collections::BTreeSet, fmt};
158
159use serde::{Deserialize, Deserializer};
160
161#[expect(unused_imports, reason = "Soon to be used in `mod generator`")]
162use string::CiString;
163use warning::IntoCaveat;
164use weekday::Weekday;
165
166#[doc(inline)]
167pub use energy::{Ampere, Kw, Kwh};
168#[doc(inline)]
169pub use money::{Cost, Money, Price, Vat, VatApplicable};
170#[doc(inline)]
171pub use warning::{Caveat, Verdict, VerdictExt, Warning};
172
173/// Set of unexpected fields encountered while parsing a CDR or tariff.
174pub type UnexpectedFields = BTreeSet<String>;
175
176/// The Id for a tariff used in the pricing of a CDR.
177pub type TariffId = String;
178
179/// The OCPI versions supported by this crate
180#[derive(Clone, Copy, Debug, PartialEq)]
181pub enum Version {
182    V221,
183    V211,
184}
185
186impl Versioned for Version {
187    fn version(&self) -> Version {
188        *self
189    }
190}
191
192impl fmt::Display for Version {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Version::V221 => f.write_str("v221"),
196            Version::V211 => f.write_str("v211"),
197        }
198    }
199}
200
201/// An object for a specific OCPI [`Version`].
202pub trait Versioned: fmt::Debug {
203    /// Return the OCPI `Version` of this object.
204    fn version(&self) -> Version;
205}
206
207/// An object with an uncertain [`Version`].
208pub trait Unversioned: fmt::Debug {
209    /// The concrete [`Versioned`] type.
210    type Versioned: Versioned;
211
212    /// Forced an [`Unversioned`] object to be the given [`Version`].
213    ///
214    /// This does not change the structure of the OCPI object.
215    /// It simply relabels the object as a different OCPI Version.
216    ///
217    /// Use this with care.
218    fn force_into_versioned(self, version: Version) -> Self::Versioned;
219}
220
221/// Out of range error type used in various converting APIs
222#[derive(Clone, Copy, Hash, PartialEq, Eq)]
223pub struct OutOfRange(());
224
225impl std::error::Error for OutOfRange {}
226
227impl OutOfRange {
228    const fn new() -> OutOfRange {
229        OutOfRange(())
230    }
231}
232
233impl fmt::Display for OutOfRange {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        write!(f, "out of range")
236    }
237}
238
239impl fmt::Debug for OutOfRange {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        write!(f, "out of range")
242    }
243}
244
245/// Errors that can happen if a JSON str is parsed.
246pub struct ParseError {
247    /// The type of object we were trying to deserialize.
248    object: ObjectType,
249
250    /// The error that occurred while deserializing.
251    kind: ParseErrorKind,
252}
253
254/// The kind of Error that occurred.
255pub enum ParseErrorKind {
256    /// Some Error types are erased to abvoid leaking dependencies.
257    Erased(Box<dyn std::error::Error + Send + Sync + 'static>),
258
259    /// The integrated JSON parser was unable to parse a JSON str.
260    Json(json::Error),
261
262    /// The OCPI object should be a JSON object.
263    ShouldBeAnObject,
264}
265
266impl std::error::Error for ParseError {
267    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
268        match &self.kind {
269            ParseErrorKind::Erased(err) => Some(&**err),
270            ParseErrorKind::Json(err) => Some(err),
271            ParseErrorKind::ShouldBeAnObject => None,
272        }
273    }
274}
275
276impl fmt::Debug for ParseError {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        fmt::Display::fmt(self, f)
279    }
280}
281
282impl fmt::Display for ParseError {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        write!(f, "while deserializing {:?}: ", self.object)?;
285
286        match &self.kind {
287            ParseErrorKind::Erased(err) => write!(f, "{err}"),
288            ParseErrorKind::Json(err) => write!(f, "{err}"),
289            ParseErrorKind::ShouldBeAnObject => f.write_str("The root element should be an object"),
290        }
291    }
292}
293
294impl ParseError {
295    /// Create a [`ParseError`] from a generic std Error for a CDR object.
296    fn from_cdr_err(err: json::Error) -> Self {
297        Self {
298            object: ObjectType::Tariff,
299            kind: ParseErrorKind::Json(err),
300        }
301    }
302
303    /// Create a [`ParseError`] from a generic std Error for a tariff object.
304    fn from_tariff_err(err: json::Error) -> Self {
305        Self {
306            object: ObjectType::Tariff,
307            kind: ParseErrorKind::Json(err),
308        }
309    }
310
311    /// Create a [`ParseError`] from a generic std Error for a CDR object.
312    fn from_cdr_serde_err(err: serde_json::Error) -> Self {
313        Self {
314            object: ObjectType::Cdr,
315            kind: ParseErrorKind::Erased(err.into()),
316        }
317    }
318
319    /// Create a [`ParseError`] from a generic std Error for a tariff object.
320    fn from_tariff_serde_err(err: serde_json::Error) -> Self {
321        Self {
322            object: ObjectType::Tariff,
323            kind: ParseErrorKind::Erased(err.into()),
324        }
325    }
326
327    fn cdr_should_be_object() -> ParseError {
328        Self {
329            object: ObjectType::Cdr,
330            kind: ParseErrorKind::ShouldBeAnObject,
331        }
332    }
333
334    fn tariff_should_be_object() -> ParseError {
335        Self {
336            object: ObjectType::Tariff,
337            kind: ParseErrorKind::ShouldBeAnObject,
338        }
339    }
340
341    /// Deconstruct the error.
342    pub fn into_parts(self) -> (ObjectType, ParseErrorKind) {
343        (self.object, self.kind)
344    }
345}
346
347/// The type of OCPI objects that can be parsed.
348#[derive(Copy, Clone, Debug, Eq, PartialEq)]
349pub enum ObjectType {
350    /// An OCPI Charge Detail Record.
351    ///
352    /// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
353    Cdr,
354
355    /// An OCPI tariff.
356    ///
357    /// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc>)
358    Tariff,
359}
360
361/// Add two types together and saturate to max if the addition operation overflows.
362///
363/// This is private to the crate as `ocpi-tarifffs` does not want to provide numerical types for use by other crates.
364pub(crate) trait SaturatingAdd {
365    /// Add two types together and saturate to max if the addition operation overflows.
366    #[must_use]
367    fn saturating_add(self, other: Self) -> Self;
368}
369
370/// Subtract two types from each other and saturate to zero if the subtraction operation overflows.
371///
372/// This is private to the crate as `ocpi-tarifffs` does not want to provide numerical types for use by other crates.
373pub(crate) trait SaturatingSub {
374    /// Subtract two types from each other and saturate to zero if the subtraction operation overflows.
375    #[must_use]
376    fn saturating_sub(self, other: Self) -> Self;
377}
378
379fn null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
380where
381    T: Default + Deserialize<'de>,
382    D: Deserializer<'de>,
383{
384    let opt = Option::deserialize(deserializer)?;
385    Ok(opt.unwrap_or_default())
386}
387
388#[cfg(test)]
389mod test {
390    #![allow(
391        clippy::unwrap_in_result,
392        reason = "unwraps are allowed anywhere in tests"
393    )]
394
395    use std::{env, fmt, io::IsTerminal as _, path::Path, sync::Once};
396
397    use chrono::{DateTime, Utc};
398    use rust_decimal::Decimal;
399    use serde::{
400        de::{value::StrDeserializer, IntoDeserializer as _},
401        Deserialize,
402    };
403    use tracing::debug;
404    use tracing_subscriber::util::SubscriberInitExt as _;
405
406    use crate::{datetime, json, number};
407
408    /// Creates and sets the default tracing subscriber if not already done.
409    #[track_caller]
410    pub fn setup() {
411        static INITIALIZED: Once = Once::new();
412
413        INITIALIZED.call_once_force(|state| {
414            if state.is_poisoned() {
415                return;
416            }
417
418            let is_tty = std::io::stderr().is_terminal();
419
420            let level = match env::var("RUST_LOG") {
421                Ok(s) => s.parse().unwrap_or(tracing::Level::INFO),
422                Err(err) => match err {
423                    env::VarError::NotPresent => tracing::Level::INFO,
424                    env::VarError::NotUnicode(_) => {
425                        panic!("`RUST_LOG` is not unicode");
426                    }
427                },
428            };
429
430            let subscriber = tracing_subscriber::fmt()
431                .with_ansi(is_tty)
432                .with_file(true)
433                .with_level(false)
434                .with_line_number(true)
435                .with_max_level(level)
436                .with_target(false)
437                .with_test_writer()
438                .without_time()
439                .finish();
440
441            subscriber
442                .try_init()
443                .expect("Init tracing_subscriber::Subscriber");
444        });
445    }
446
447    /// Approximately compares two objects in tests.
448    ///
449    /// We need to approximately compare values in tests as we are not concerned with bitwise
450    /// accuracy. Only that the values are equal within an object specific tolerance.
451    ///
452    /// # Examples
453    ///
454    /// - A `Money` object considers an amount equal if there is only 2 cent difference.
455    /// - A `HoursDecimal` object considers a duration equal if there is only 3 second difference.
456    pub trait ApproxEq<Rhs = Self> {
457        #[must_use]
458        fn approx_eq(&self, other: &Rhs) -> bool;
459    }
460
461    impl<T> ApproxEq for Option<T>
462    where
463        T: ApproxEq,
464    {
465        fn approx_eq(&self, other: &Self) -> bool {
466            match (self, other) {
467                (Some(a), Some(b)) => a.approx_eq(b),
468                (None, None) => true,
469                _ => false,
470            }
471        }
472    }
473
474    /// Approximately compare two `Decimal` values.
475    pub fn approx_eq_dec(a: Decimal, mut b: Decimal, tolerance: Decimal, precision: u32) -> bool {
476        let a = a.round_dp(precision);
477        b.rescale(number::SCALE);
478        let b = b.round_dp(precision);
479        (a - b).abs() <= tolerance
480    }
481
482    #[track_caller]
483    pub fn assert_no_unexpected_fields(unexpected_fields: &json::UnexpectedFields<'_>) {
484        if !unexpected_fields.is_empty() {
485            const MAX_FIELD_DISPLAY: usize = 20;
486
487            if unexpected_fields.len() > MAX_FIELD_DISPLAY {
488                let truncated_fields = unexpected_fields
489                    .iter()
490                    .take(MAX_FIELD_DISPLAY)
491                    .map(|path| path.to_string())
492                    .collect::<Vec<_>>();
493
494                panic!(
495                    "Unexpected fields found({}); displaying the first ({}): \n{}\n... and {} more",
496                    unexpected_fields.len(),
497                    truncated_fields.len(),
498                    truncated_fields.join(",\n"),
499                    unexpected_fields.len() - truncated_fields.len(),
500                )
501            } else {
502                panic!(
503                    "Unexpected fields found({}):\n{}",
504                    unexpected_fields.len(),
505                    unexpected_fields.to_strings().join(",\n")
506                )
507            };
508        }
509    }
510
511    #[track_caller]
512    pub fn assert_unexpected_fields(
513        unexpected_fields: &json::UnexpectedFields<'_>,
514        expected: &[&'static str],
515    ) {
516        if unexpected_fields.len() != expected.len() {
517            let unexpected_fields = unexpected_fields
518                .into_iter()
519                .map(|path| path.to_string())
520                .collect::<Vec<_>>();
521
522            panic!(
523                "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
524                unexpected_fields.join(",\n")
525            );
526        }
527
528        let unmatched_paths = unexpected_fields
529            .into_iter()
530            .zip(expected.iter())
531            .filter(|(a, b)| a != *b)
532            .collect::<Vec<_>>();
533
534        if !unmatched_paths.is_empty() {
535            let unmatched_paths = unmatched_paths
536                .into_iter()
537                .map(|(a, b)| format!("{a} != {b}"))
538                .collect::<Vec<_>>();
539
540            panic!(
541                "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
542                unmatched_paths.join(",\n")
543            );
544        }
545    }
546
547    /// A Field in the expect JSON.
548    ///
549    /// We need to distinguish between a field that's present and null and absent.
550    #[derive(Debug, Default)]
551    pub enum Expectation<T> {
552        /// The field is present.
553        Present(ExpectValue<T>),
554
555        /// The field is not preent.
556        #[default]
557        Absent,
558    }
559
560    /// The value of an expectation field.
561    #[derive(Debug)]
562    pub enum ExpectValue<T> {
563        /// The field has a value.
564        Some(T),
565
566        /// The field is set to `null`.
567        Null,
568    }
569
570    impl<T> ExpectValue<T>
571    where
572        T: fmt::Debug,
573    {
574        /// Convert the expectation into an `Option`.
575        pub fn into_option(self) -> Option<T> {
576            match self {
577                Self::Some(v) => Some(v),
578                Self::Null => None,
579            }
580        }
581
582        /// Consume the expectation and return the inner value of type `T`.
583        ///
584        /// # Panics
585        ///
586        /// Panics if the `FieldValue` is `Null`.
587        #[track_caller]
588        pub fn expect_value(self) -> T {
589            match self {
590                ExpectValue::Some(v) => v,
591                ExpectValue::Null => panic!("the field expects a value"),
592            }
593        }
594    }
595
596    impl<'de, T> Deserialize<'de> for Expectation<T>
597    where
598        T: Deserialize<'de>,
599    {
600        #[expect(clippy::unwrap_in_result, reason = "This is test util code")]
601        #[track_caller]
602        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
603        where
604            D: serde::Deserializer<'de>,
605        {
606            let value = serde_json::Value::deserialize(deserializer)?;
607
608            if value.is_null() {
609                return Ok(Expectation::Present(ExpectValue::Null));
610            }
611
612            let v = T::deserialize(value).unwrap();
613            Ok(Expectation::Present(ExpectValue::Some(v)))
614        }
615    }
616
617    /// Create a `DateTime` from a RFC 3339 formatted string.
618    #[track_caller]
619    pub fn datetime_from_str(s: &str) -> DateTime<Utc> {
620        let de: StrDeserializer<'_, serde::de::value::Error> = s.into_deserializer();
621        datetime::deser_to_utc(de).unwrap()
622    }
623
624    /// Try read an expectation JSON file based on the name of the given object JSON file.
625    ///
626    /// If the JSON object file is called `cdr.json` with a feature of `price` an expectation file
627    /// called `expect_cdr_price.json` is searched for.
628    #[track_caller]
629    pub fn read_expect_json(json_file_path: &Path, feature: &str) -> Option<String> {
630        let json_dir = json_file_path
631            .parent()
632            .expect("The given file should live in a dir");
633
634        let json_file_name = json_file_path
635            .file_stem()
636            .expect("The `json_file_path` should be a file")
637            .to_str()
638            .expect("The `json_file_path` should have a valid name");
639
640        // An underscore is prefixed to the filename to exclude the file from being included
641        // as input for a `test_each` glob driven test.
642        let expect_file_name = format!("output_{feature}__{json_file_name}.json");
643
644        debug!("Try to read expectation file: `{expect_file_name}`");
645
646        let s = std::fs::read_to_string(json_dir.join(&expect_file_name))
647            .ok()
648            .map(|mut s| {
649                json_strip_comments::strip(&mut s).ok();
650                s
651            });
652
653        debug!("Successfully Read expectation file: `{expect_file_name}`");
654        s
655    }
656
657    #[track_caller]
658    pub fn assert_approx_eq_failed(
659        left: &dyn fmt::Debug,
660        right: &dyn fmt::Debug,
661        args: Option<fmt::Arguments<'_>>,
662    ) -> ! {
663        match args {
664            Some(args) => panic!(
665                "assertion `left == right` failed: {args}
666left: {left:?}
667right: {right:?}"
668            ),
669            None => panic!(
670                "assertion `left == right` failed
671left: {left:?}
672right: {right:?}"
673            ),
674        }
675    }
676
677    /// This code s copied from the std lib `assert_eq!` and modified for use with `ApproxEq`.
678    #[macro_export]
679    macro_rules! assert_approx_eq {
680        ($left:expr, $right:expr $(,)?) => ({
681            use $crate::test::ApproxEq;
682
683            match (&$left, &$right) {
684                (left_val, right_val) => {
685                    if !((*left_val).approx_eq(&*right_val)) {
686                        // The reborrows below are intentional. Without them, the stack slot for the
687                        // borrow is initialized even before the values are compared, leading to a
688                        // noticeable slow down.
689                        $crate::test::assert_approx_eq_failed(
690                            &*left_val,
691                            &*right_val,
692                            std::option::Option::None
693                        );
694                    }
695                }
696            }
697        });
698        ($left:expr, $right:expr, $($arg:tt)+) => ({
699            use $crate::test::ApproxEq;
700
701            match (&$left, &$right) {
702                (left_val, right_val) => {
703                    if !((*left_val).approx_eq(&*right_val)) {
704                        // The reborrows below are intentional. Without them, the stack slot for the
705                        // borrow is initialized even before the values are compared, leading to a
706                        // noticeable slow down.
707                        $crate::test::assert_approx_eq_failed(
708                            &*left_val,
709                            &*right_val,
710                            std::option::Option::Some(std::format_args!($($arg)+))
711                        );
712                    }
713                }
714            }
715        });
716    }
717}
718
719#[cfg(test)]
720mod test_rust_decimal_arbitrary_precision {
721    use rust_decimal_macros::dec;
722
723    #[test]
724    fn should_serialize_decimal_with_12_fraction_digits() {
725        let dec = dec!(0.123456789012);
726        let actual = serde_json::to_string(&dec).unwrap();
727        assert_eq!(actual, r#""0.123456789012""#.to_owned());
728    }
729
730    #[test]
731    fn should_serialize_decimal_with_8_fraction_digits() {
732        let dec = dec!(37.12345678);
733        let actual = serde_json::to_string(&dec).unwrap();
734        assert_eq!(actual, r#""37.12345678""#.to_owned());
735    }
736
737    #[test]
738    fn should_serialize_0_decimal_without_fraction_digits() {
739        let dec = dec!(0);
740        let actual = serde_json::to_string(&dec).unwrap();
741        assert_eq!(actual, r#""0""#.to_owned());
742    }
743
744    #[test]
745    fn should_serialize_12_num_with_4_fraction_digits() {
746        let num = dec!(0.1234);
747        let actual = serde_json::to_string(&num).unwrap();
748        assert_eq!(actual, r#""0.1234""#.to_owned());
749    }
750
751    #[test]
752    fn should_serialize_8_num_with_4_fraction_digits() {
753        let num = dec!(37.1234);
754        let actual = serde_json::to_string(&num).unwrap();
755        assert_eq!(actual, r#""37.1234""#.to_owned());
756    }
757
758    #[test]
759    fn should_serialize_0_num_without_fraction_digits() {
760        let num = dec!(0);
761        let actual = serde_json::to_string(&num).unwrap();
762        assert_eq!(actual, r#""0""#.to_owned());
763    }
764}