ticksupply 0.1.0

Official Rust client for the Ticksupply market data API
Documentation
//! decimal — Wire-format decimal primitive.
//!
//! Some API response fields carry decimal amounts serialized as JSON strings
//! to avoid float-precision loss. Modeled by [`Decimal`], which holds the
//! server-supplied string losslessly and exposes typed accessors:
//!
//! - [`Decimal::to_f64`] — lossy convenience parse to [`f64`], always
//!   available.
//! - [`Decimal::to_decimal`] — lossless parse to [`rust_decimal::Decimal`],
//!   gated behind the `rust_decimal` Cargo feature.
//!
//! Today only [`crate::resources::billing::BillingUsage::export_gb_total`]
//! uses this shape.

use serde::{Deserialize, Serialize};

/// A decimal amount as returned by the Ticksupply API.
///
/// Holds the server-supplied decimal string losslessly; typed access is
/// available via [`Self::to_f64`] (lossy) and [`Self::to_decimal`] (lossless,
/// under the `rust_decimal` feature). Parse errors surface only when a typed
/// accessor is called, so deserialization never fails on an unexpected
/// decimal format — you still get the raw string via [`Self::as_str`].
///
/// # Examples
///
/// ```
/// use ticksupply::Decimal;
/// let d = Decimal::from("12.75");
/// assert_eq!(d.as_str(), "12.75");
/// ```
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Decimal(String);

impl Decimal {
    /// Constructs a [`Decimal`] from a string-like value.
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::Decimal;
    /// let d = Decimal::new("0.10");
    /// assert_eq!(d.as_str(), "0.10");
    /// ```
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }

    /// Returns the raw decimal string as received from the API.
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::Decimal;
    /// assert_eq!(Decimal::from("12.75").as_str(), "12.75");
    /// ```
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Consumes the [`Decimal`] and returns the underlying [`String`].
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::Decimal;
    /// assert_eq!(Decimal::from("12.75").into_string(), "12.75".to_string());
    /// ```
    pub fn into_string(self) -> String {
        self.0
    }

    /// Parses the wire string as an [`f64`].
    ///
    /// Returns [`None`] if the string is not a valid decimal. The result is
    /// subject to floating-point precision loss; use [`Self::to_decimal`]
    /// (under the `rust_decimal` feature) for lossless arithmetic.
    ///
    /// # Examples
    ///
    /// ```
    /// use ticksupply::Decimal;
    /// assert_eq!(Decimal::from("12.75").to_f64(), Some(12.75));
    /// assert_eq!(Decimal::from("not a decimal").to_f64(), None);
    /// ```
    pub fn to_f64(&self) -> Option<f64> {
        self.0.parse().ok()
    }

    /// Parses the wire string as a [`rust_decimal::Decimal`].
    ///
    /// Available when the `rust_decimal` feature is enabled.
    ///
    /// # Errors
    ///
    /// Returns [`rust_decimal::Error`] if the wire string is not a valid
    /// decimal representation.
    ///
    /// # Examples
    ///
    /// ```
    /// # #[cfg(feature = "rust_decimal")]
    /// # {
    /// use std::str::FromStr;
    /// use ticksupply::Decimal;
    /// let d = Decimal::from("12.75").to_decimal().unwrap();
    /// assert_eq!(d, rust_decimal::Decimal::from_str("12.75").unwrap());
    /// # }
    /// ```
    #[cfg(feature = "rust_decimal")]
    #[cfg_attr(docsrs, doc(cfg(feature = "rust_decimal")))]
    pub fn to_decimal(&self) -> std::result::Result<rust_decimal::Decimal, rust_decimal::Error> {
        use std::str::FromStr;
        rust_decimal::Decimal::from_str(&self.0)
    }
}

impl std::fmt::Debug for Decimal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Debug::fmt(&self.0, f)
    }
}

impl std::fmt::Display for Decimal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl AsRef<str> for Decimal {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl From<String> for Decimal {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for Decimal {
    fn from(s: &str) -> Self {
        Self(s.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decimal_roundtrip_json() {
        let d = Decimal::from("12.75");
        let s = serde_json::to_string(&d).unwrap();
        assert_eq!(s, "\"12.75\"");
        let back: Decimal = serde_json::from_str(&s).unwrap();
        assert_eq!(back, d);
    }

    #[test]
    fn decimal_deserializes_preserves_format() {
        // Server may send trailing zeros etc.; we hold the string unchanged.
        let d: Decimal = serde_json::from_str("\"0.10\"").unwrap();
        assert_eq!(d.as_str(), "0.10");
    }

    #[test]
    fn decimal_debug_prints_as_string() {
        let d = Decimal::from("12.75");
        assert_eq!(format!("{d:?}"), "\"12.75\"");
        assert_eq!(format!("{d}"), "12.75");
    }

    #[test]
    fn decimal_to_f64_parses() {
        assert_eq!(Decimal::from("12.75").to_f64(), Some(12.75));
    }

    #[test]
    fn decimal_to_f64_surfaces_parse_error() {
        assert_eq!(Decimal::from("not a decimal").to_f64(), None);
    }

    #[cfg(feature = "rust_decimal")]
    #[test]
    fn decimal_to_decimal_parses() {
        use std::str::FromStr;
        let d = Decimal::from("12.75").to_decimal().unwrap();
        assert_eq!(d, rust_decimal::Decimal::from_str("12.75").unwrap());
    }

    #[cfg(feature = "rust_decimal")]
    #[test]
    fn decimal_to_decimal_preserves_scale() {
        use std::str::FromStr;
        // rust_decimal tracks scale separately, so `0.10` round-trips with
        // its trailing zero intact.
        let d = Decimal::from("0.10").to_decimal().unwrap();
        assert_eq!(d, rust_decimal::Decimal::from_str("0.10").unwrap());
    }

    #[cfg(feature = "rust_decimal")]
    #[test]
    fn decimal_to_decimal_surfaces_parse_error() {
        assert!(Decimal::from("not a decimal").to_decimal().is_err());
    }
}