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
159#[expect(unused_imports, reason = "Soon to be used in `mod generator`")]
160use string::CiString;
161use warning::IntoCaveat;
162use weekday::Weekday;
163
164#[doc(inline)]
165pub use duration::{ToDuration, ToHoursDecimal};
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
379/// A debug utility to `Display` an `Option<T>` as either `Display::fmt(T)` or the null set `∅`.
380pub(crate) struct DisplayOption<T>(Option<T>)
381where
382    T: fmt::Display;
383
384impl<T> fmt::Display for DisplayOption<T>
385where
386    T: fmt::Display,
387{
388    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
389        match &self.0 {
390            Some(v) => fmt::Display::fmt(v, f),
391            None => f.write_str("∅"),
392        }
393    }
394}
395
396#[cfg(test)]
397mod test {
398    #![allow(
399        clippy::unwrap_in_result,
400        reason = "unwraps are allowed anywhere in tests"
401    )]
402
403    use std::{env, fmt, io::IsTerminal as _, path::Path, sync::Once};
404
405    use chrono::{DateTime, Utc};
406    use rust_decimal::Decimal;
407    use serde::{
408        de::{value::StrDeserializer, IntoDeserializer as _},
409        Deserialize,
410    };
411    use tracing::debug;
412    use tracing_subscriber::util::SubscriberInitExt as _;
413
414    use crate::{datetime, json, number};
415
416    /// Creates and sets the default tracing subscriber if not already done.
417    #[track_caller]
418    pub fn setup() {
419        static INITIALIZED: Once = Once::new();
420
421        INITIALIZED.call_once_force(|state| {
422            if state.is_poisoned() {
423                return;
424            }
425
426            let is_tty = std::io::stderr().is_terminal();
427
428            let level = match env::var("RUST_LOG") {
429                Ok(s) => s.parse().unwrap_or(tracing::Level::INFO),
430                Err(err) => match err {
431                    env::VarError::NotPresent => tracing::Level::INFO,
432                    env::VarError::NotUnicode(_) => {
433                        panic!("`RUST_LOG` is not unicode");
434                    }
435                },
436            };
437
438            let subscriber = tracing_subscriber::fmt()
439                .with_ansi(is_tty)
440                .with_file(true)
441                .with_level(false)
442                .with_line_number(true)
443                .with_max_level(level)
444                .with_target(false)
445                .with_test_writer()
446                .without_time()
447                .finish();
448
449            subscriber
450                .try_init()
451                .expect("Init tracing_subscriber::Subscriber");
452        });
453    }
454
455    /// Approximately compares two objects in tests.
456    ///
457    /// We need to approximately compare values in tests as we are not concerned with bitwise
458    /// accuracy. Only that the values are equal within an object specific tolerance.
459    ///
460    /// # Examples
461    ///
462    /// - A `Money` object considers an amount equal if there is only 2 cent difference.
463    /// - A `HoursDecimal` object considers a duration equal if there is only 3 second difference.
464    pub trait ApproxEq<Rhs = Self> {
465        #[must_use]
466        fn approx_eq(&self, other: &Rhs) -> bool;
467    }
468
469    impl<T> ApproxEq for Option<T>
470    where
471        T: ApproxEq,
472    {
473        fn approx_eq(&self, other: &Self) -> bool {
474            match (self, other) {
475                (Some(a), Some(b)) => a.approx_eq(b),
476                (None, None) => true,
477                _ => false,
478            }
479        }
480    }
481
482    /// Approximately compare two `Decimal` values.
483    pub fn approx_eq_dec(a: Decimal, mut b: Decimal, tolerance: Decimal, precision: u32) -> bool {
484        let a = a.round_dp(precision);
485        b.rescale(number::SCALE);
486        let b = b.round_dp(precision);
487        (a - b).abs() <= tolerance
488    }
489
490    #[track_caller]
491    pub fn assert_no_unexpected_fields(unexpected_fields: &json::UnexpectedFields<'_>) {
492        if !unexpected_fields.is_empty() {
493            const MAX_FIELD_DISPLAY: usize = 20;
494
495            if unexpected_fields.len() > MAX_FIELD_DISPLAY {
496                let truncated_fields = unexpected_fields
497                    .iter()
498                    .take(MAX_FIELD_DISPLAY)
499                    .map(|path| path.to_string())
500                    .collect::<Vec<_>>();
501
502                panic!(
503                    "Unexpected fields found({}); displaying the first ({}): \n{}\n... and {} more",
504                    unexpected_fields.len(),
505                    truncated_fields.len(),
506                    truncated_fields.join(",\n"),
507                    unexpected_fields.len() - truncated_fields.len(),
508                )
509            } else {
510                panic!(
511                    "Unexpected fields found({}):\n{}",
512                    unexpected_fields.len(),
513                    unexpected_fields.to_strings().join(",\n")
514                )
515            };
516        }
517    }
518
519    #[track_caller]
520    pub fn assert_unexpected_fields(
521        unexpected_fields: &json::UnexpectedFields<'_>,
522        expected: &[&'static str],
523    ) {
524        if unexpected_fields.len() != expected.len() {
525            let unexpected_fields = unexpected_fields
526                .into_iter()
527                .map(|path| path.to_string())
528                .collect::<Vec<_>>();
529
530            panic!(
531                "The unexpected fields and expected fields lists have different lengths.\n\nUnexpected fields found:\n{}",
532                unexpected_fields.join(",\n")
533            );
534        }
535
536        let unmatched_paths = unexpected_fields
537            .into_iter()
538            .zip(expected.iter())
539            .filter(|(a, b)| a != *b)
540            .collect::<Vec<_>>();
541
542        if !unmatched_paths.is_empty() {
543            let unmatched_paths = unmatched_paths
544                .into_iter()
545                .map(|(a, b)| format!("{a} != {b}"))
546                .collect::<Vec<_>>();
547
548            panic!(
549                "The unexpected fields don't match the expected fields.\n\nUnexpected fields found:\n{}",
550                unmatched_paths.join(",\n")
551            );
552        }
553    }
554
555    /// A Field in the expect JSON.
556    ///
557    /// We need to distinguish between a field that's present and null and absent.
558    #[derive(Debug, Default)]
559    pub enum Expectation<T> {
560        /// The field is present.
561        Present(ExpectValue<T>),
562
563        /// The field is not preent.
564        #[default]
565        Absent,
566    }
567
568    /// The value of an expectation field.
569    #[derive(Debug)]
570    pub enum ExpectValue<T> {
571        /// The field has a value.
572        Some(T),
573
574        /// The field is set to `null`.
575        Null,
576    }
577
578    impl<T> ExpectValue<T>
579    where
580        T: fmt::Debug,
581    {
582        /// Convert the expectation into an `Option`.
583        pub fn into_option(self) -> Option<T> {
584            match self {
585                Self::Some(v) => Some(v),
586                Self::Null => None,
587            }
588        }
589
590        /// Consume the expectation and return the inner value of type `T`.
591        ///
592        /// # Panics
593        ///
594        /// Panics if the `FieldValue` is `Null`.
595        #[track_caller]
596        pub fn expect_value(self) -> T {
597            match self {
598                ExpectValue::Some(v) => v,
599                ExpectValue::Null => panic!("the field expects a value"),
600            }
601        }
602    }
603
604    impl<'de, T> Deserialize<'de> for Expectation<T>
605    where
606        T: Deserialize<'de>,
607    {
608        #[expect(clippy::unwrap_in_result, reason = "This is test util code")]
609        #[track_caller]
610        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
611        where
612            D: serde::Deserializer<'de>,
613        {
614            let value = serde_json::Value::deserialize(deserializer)?;
615
616            if value.is_null() {
617                return Ok(Expectation::Present(ExpectValue::Null));
618            }
619
620            let v = T::deserialize(value).unwrap();
621            Ok(Expectation::Present(ExpectValue::Some(v)))
622        }
623    }
624
625    /// Create a `DateTime` from a RFC 3339 formatted string.
626    #[track_caller]
627    pub fn datetime_from_str(s: &str) -> DateTime<Utc> {
628        let de: StrDeserializer<'_, serde::de::value::Error> = s.into_deserializer();
629        datetime::deser_to_utc(de).unwrap()
630    }
631
632    /// Try read an expectation JSON file based on the name of the given object JSON file.
633    ///
634    /// If the JSON object file is called `cdr.json` with a feature of `price` an expectation file
635    /// called `expect_cdr_price.json` is searched for.
636    #[track_caller]
637    pub fn read_expect_json(json_file_path: &Path, feature: &str) -> Option<String> {
638        let json_dir = json_file_path
639            .parent()
640            .expect("The given file should live in a dir");
641
642        let json_file_name = json_file_path
643            .file_stem()
644            .expect("The `json_file_path` should be a file")
645            .to_str()
646            .expect("The `json_file_path` should have a valid name");
647
648        // An underscore is prefixed to the filename to exclude the file from being included
649        // as input for a `test_each` glob driven test.
650        let expect_file_name = format!("output_{feature}__{json_file_name}.json");
651
652        debug!("Try to read expectation file: `{expect_file_name}`");
653
654        let s = std::fs::read_to_string(json_dir.join(&expect_file_name))
655            .ok()
656            .map(|mut s| {
657                json_strip_comments::strip(&mut s).ok();
658                s
659            });
660
661        debug!("Successfully Read expectation file: `{expect_file_name}`");
662        s
663    }
664
665    #[track_caller]
666    pub fn assert_approx_eq_failed(
667        left: &dyn fmt::Debug,
668        right: &dyn fmt::Debug,
669        args: Option<fmt::Arguments<'_>>,
670    ) -> ! {
671        match args {
672            Some(args) => panic!(
673                "assertion `left == right` failed: {args}
674left: {left:?}
675right: {right:?}"
676            ),
677            None => panic!(
678                "assertion `left == right` failed
679left: {left:?}
680right: {right:?}"
681            ),
682        }
683    }
684
685    /// This code s copied from the std lib `assert_eq!` and modified for use with `ApproxEq`.
686    #[macro_export]
687    macro_rules! assert_approx_eq {
688        ($left:expr, $right:expr $(,)?) => ({
689            use $crate::test::ApproxEq;
690
691            match (&$left, &$right) {
692                (left_val, right_val) => {
693                    if !((*left_val).approx_eq(&*right_val)) {
694                        // The reborrows below are intentional. Without them, the stack slot for the
695                        // borrow is initialized even before the values are compared, leading to a
696                        // noticeable slow down.
697                        $crate::test::assert_approx_eq_failed(
698                            &*left_val,
699                            &*right_val,
700                            std::option::Option::None
701                        );
702                    }
703                }
704            }
705        });
706        ($left:expr, $right:expr, $($arg:tt)+) => ({
707            use $crate::test::ApproxEq;
708
709            match (&$left, &$right) {
710                (left_val, right_val) => {
711                    if !((*left_val).approx_eq(&*right_val)) {
712                        // The reborrows below are intentional. Without them, the stack slot for the
713                        // borrow is initialized even before the values are compared, leading to a
714                        // noticeable slow down.
715                        $crate::test::assert_approx_eq_failed(
716                            &*left_val,
717                            &*right_val,
718                            std::option::Option::Some(std::format_args!($($arg)+))
719                        );
720                    }
721                }
722            }
723        });
724    }
725}
726
727#[cfg(test)]
728mod test_rust_decimal_arbitrary_precision {
729    use rust_decimal_macros::dec;
730
731    #[test]
732    fn should_serialize_decimal_with_12_fraction_digits() {
733        let dec = dec!(0.123456789012);
734        let actual = serde_json::to_string(&dec).unwrap();
735        assert_eq!(actual, r#""0.123456789012""#.to_owned());
736    }
737
738    #[test]
739    fn should_serialize_decimal_with_8_fraction_digits() {
740        let dec = dec!(37.12345678);
741        let actual = serde_json::to_string(&dec).unwrap();
742        assert_eq!(actual, r#""37.12345678""#.to_owned());
743    }
744
745    #[test]
746    fn should_serialize_0_decimal_without_fraction_digits() {
747        let dec = dec!(0);
748        let actual = serde_json::to_string(&dec).unwrap();
749        assert_eq!(actual, r#""0""#.to_owned());
750    }
751
752    #[test]
753    fn should_serialize_12_num_with_4_fraction_digits() {
754        let num = dec!(0.1234);
755        let actual = serde_json::to_string(&num).unwrap();
756        assert_eq!(actual, r#""0.1234""#.to_owned());
757    }
758
759    #[test]
760    fn should_serialize_8_num_with_4_fraction_digits() {
761        let num = dec!(37.1234);
762        let actual = serde_json::to_string(&num).unwrap();
763        assert_eq!(actual, r#""37.1234""#.to_owned());
764    }
765
766    #[test]
767    fn should_serialize_0_num_without_fraction_digits() {
768        let num = dec!(0);
769        let actual = serde_json::to_string(&num).unwrap();
770        assert_eq!(actual, r#""0""#.to_owned());
771    }
772}