nautilus_core/
nanos.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! A `UnixNanos` type for working with timestamps in nanoseconds since the UNIX epoch.
17//!
18//! This module provides a strongly-typed representation of timestamps as nanoseconds
19//! since the UNIX epoch (January 1, 1970, 00:00:00 UTC). The `UnixNanos` type offers
20//! conversion utilities, arithmetic operations, and comparison methods.
21//!
22//! # Features
23//!
24//! - Zero-cost abstraction with appropriate operator implementations.
25//! - Conversion to/from `DateTime<Utc>`.
26//! - RFC 3339 string formatting.
27//! - Duration calculations.
28//! - Flexible parsing and serialization.
29//!
30//! # Parsing and Serialization
31#![allow(
32    clippy::cast_possible_truncation,
33    clippy::cast_sign_loss,
34    clippy::cast_precision_loss,
35    clippy::cast_possible_wrap
36)]
37//!
38//! `UnixNanos` can be created from and serialized to various formats:
39//!
40//! * Integer values are interpreted as nanoseconds since the UNIX epoch.
41//! * Floating-point values are interpreted as seconds since the UNIX epoch (converted to nanoseconds).
42//! * String values may be:
43//!   - A numeric string (interpreted as nanoseconds).
44//!   - A floating-point string (interpreted as seconds, converted to nanoseconds).
45//!   - An RFC 3339 formatted timestamp (ISO 8601 with timezone).
46//!   - A simple date string in YYYY-MM-DD format (interpreted as midnight UTC on that date).
47//!
48//! # Limitations
49//!
50//! * Negative timestamps are invalid and will result in an error.
51//! * Arithmetic operations will panic on overflow/underflow rather than wrapping.
52
53use std::{
54    cmp::Ordering,
55    fmt::Display,
56    ops::{Add, AddAssign, Deref, Sub, SubAssign},
57    str::FromStr,
58};
59
60use chrono::{DateTime, NaiveDate, Utc};
61use serde::{
62    Deserialize, Deserializer, Serialize,
63    de::{self, Visitor},
64};
65
66/// Represents a duration in nanoseconds.
67pub type DurationNanos = u64;
68
69/// Represents a timestamp in nanoseconds since the UNIX epoch.
70#[repr(C)]
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
72pub struct UnixNanos(u64);
73
74impl UnixNanos {
75    /// Creates a new [`UnixNanos`] instance.
76    #[must_use]
77    pub const fn new(value: u64) -> Self {
78        Self(value)
79    }
80
81    /// Creates a new [`UnixNanos`] instance with the maximum valid value.
82    #[must_use]
83    pub const fn max() -> Self {
84        Self(u64::MAX)
85    }
86
87    /// Returns `true` if the value of this instance is zero.
88    #[must_use]
89    pub const fn is_zero(&self) -> bool {
90        self.0 == 0
91    }
92
93    /// Returns the underlying value as `u64`.
94    #[must_use]
95    pub const fn as_u64(&self) -> u64 {
96        self.0
97    }
98
99    /// Returns the underlying value as `i64`.
100    ///
101    /// # Panics
102    ///
103    /// Panics if the value exceeds `i64::MAX` (approximately year 2262).
104    #[must_use]
105    pub const fn as_i64(&self) -> i64 {
106        assert!(
107            self.0 <= i64::MAX as u64,
108            "UnixNanos value exceeds i64::MAX"
109        );
110        self.0 as i64
111    }
112
113    /// Returns the underlying value as `f64`.
114    #[must_use]
115    pub const fn as_f64(&self) -> f64 {
116        self.0 as f64
117    }
118
119    /// Converts the underlying value to a datetime (UTC).
120    ///
121    /// # Panics
122    ///
123    /// Panics if the value exceeds `i64::MAX` (approximately year 2262).
124    #[must_use]
125    pub const fn to_datetime_utc(&self) -> DateTime<Utc> {
126        DateTime::from_timestamp_nanos(self.as_i64())
127    }
128
129    /// Converts the underlying value to an ISO 8601 (RFC 3339) string.
130    #[must_use]
131    pub fn to_rfc3339(&self) -> String {
132        self.to_datetime_utc().to_rfc3339()
133    }
134
135    /// Calculates the duration in nanoseconds since another [`UnixNanos`] instance.
136    ///
137    /// Returns `Some(duration)` if `self` is later than `other`, otherwise `None` if `other` is
138    /// greater than `self` (indicating a negative duration is not possible with `DurationNanos`).
139    #[must_use]
140    pub const fn duration_since(&self, other: &Self) -> Option<DurationNanos> {
141        self.0.checked_sub(other.0)
142    }
143
144    fn parse_string(s: &str) -> Result<Self, String> {
145        // Try parsing as an integer (nanoseconds)
146        if let Ok(int_value) = s.parse::<u64>() {
147            return Ok(Self(int_value));
148        }
149
150        // If the string is composed solely of digits but didn't fit in a u64 we
151        // treat that as an overflow error rather than attempting to interpret
152        // it as seconds in floating-point form. This avoids the surprising
153        // situation where a caller provides nanoseconds but gets an out-of-
154        // range float interpretation instead.
155        if s.chars().all(|c| c.is_ascii_digit()) {
156            return Err("Unix timestamp is out of range".into());
157        }
158
159        // Try parsing as a floating point number (seconds)
160        if let Ok(float_value) = s.parse::<f64>() {
161            if !float_value.is_finite() {
162                return Err("Unix timestamp must be finite".into());
163            }
164
165            if float_value < 0.0 {
166                return Err("Unix timestamp cannot be negative".into());
167            }
168
169            // Convert seconds to nanoseconds while checking for overflow
170            // We perform the multiplication in `f64`, then validate the
171            // result fits inside `u64` *before* rounding / casting.
172            const MAX_NS_F64: f64 = u64::MAX as f64;
173            let nanos_f64 = float_value * 1_000_000_000.0;
174
175            if nanos_f64 > MAX_NS_F64 {
176                return Err("Unix timestamp is out of range".into());
177            }
178
179            let nanos = nanos_f64.round() as u64;
180            return Ok(Self(nanos));
181        }
182
183        // Try parsing as an RFC 3339 timestamp
184        if let Ok(datetime) = DateTime::parse_from_rfc3339(s) {
185            let nanos = datetime
186                .timestamp_nanos_opt()
187                .ok_or_else(|| "Timestamp out of range".to_string())?;
188
189            if nanos < 0 {
190                return Err("Unix timestamp cannot be negative".into());
191            }
192
193            // SAFETY: Checked that nanos >= 0, so cast to u64 is safe
194            return Ok(Self(nanos as u64));
195        }
196
197        // Try parsing as a simple date string (YYYY-MM-DD format)
198        if let Ok(datetime) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
199            // SAFETY: unwrap() is safe here because and_hms_opt(0, 0, 0) always succeeds
200            // for valid dates (midnight is always a valid time)
201            .map(|date| date.and_hms_opt(0, 0, 0).unwrap())
202            .map(|naive_dt| DateTime::<Utc>::from_naive_utc_and_offset(naive_dt, Utc))
203        {
204            let nanos = datetime
205                .timestamp_nanos_opt()
206                .ok_or_else(|| "Timestamp out of range".to_string())?;
207            if nanos < 0 {
208                return Err("Unix timestamp cannot be negative".into());
209            }
210            return Ok(Self(nanos as u64));
211        }
212
213        Err(format!("Invalid format: {s}"))
214    }
215
216    /// Returns `Some(self + rhs)` or `None` if the addition would overflow
217    #[must_use]
218    pub fn checked_add<T: Into<u64>>(self, rhs: T) -> Option<Self> {
219        self.0.checked_add(rhs.into()).map(Self)
220    }
221
222    /// Returns `Some(self - rhs)` or `None` if the subtraction would underflow
223    #[must_use]
224    pub fn checked_sub<T: Into<u64>>(self, rhs: T) -> Option<Self> {
225        self.0.checked_sub(rhs.into()).map(Self)
226    }
227
228    /// Saturating addition – if overflow occurs the value is clamped to `u64::MAX`.
229    #[must_use]
230    pub fn saturating_add_ns<T: Into<u64>>(self, rhs: T) -> Self {
231        Self(self.0.saturating_add(rhs.into()))
232    }
233
234    /// Saturating subtraction – if underflow occurs the value is clamped to `0`.
235    #[must_use]
236    pub fn saturating_sub_ns<T: Into<u64>>(self, rhs: T) -> Self {
237        Self(self.0.saturating_sub(rhs.into()))
238    }
239}
240
241impl Deref for UnixNanos {
242    type Target = u64;
243
244    fn deref(&self) -> &Self::Target {
245        &self.0
246    }
247}
248
249impl PartialEq<u64> for UnixNanos {
250    fn eq(&self, other: &u64) -> bool {
251        self.0 == *other
252    }
253}
254
255impl PartialOrd<u64> for UnixNanos {
256    fn partial_cmp(&self, other: &u64) -> Option<Ordering> {
257        self.0.partial_cmp(other)
258    }
259}
260
261impl PartialEq<Option<u64>> for UnixNanos {
262    fn eq(&self, other: &Option<u64>) -> bool {
263        match other {
264            Some(value) => self.0 == *value,
265            None => false,
266        }
267    }
268}
269
270impl PartialOrd<Option<u64>> for UnixNanos {
271    fn partial_cmp(&self, other: &Option<u64>) -> Option<Ordering> {
272        match other {
273            Some(value) => self.0.partial_cmp(value),
274            None => Some(Ordering::Greater),
275        }
276    }
277}
278
279impl PartialEq<UnixNanos> for u64 {
280    fn eq(&self, other: &UnixNanos) -> bool {
281        *self == other.0
282    }
283}
284
285impl PartialOrd<UnixNanos> for u64 {
286    fn partial_cmp(&self, other: &UnixNanos) -> Option<Ordering> {
287        self.partial_cmp(&other.0)
288    }
289}
290
291impl From<u64> for UnixNanos {
292    fn from(value: u64) -> Self {
293        Self(value)
294    }
295}
296
297impl From<UnixNanos> for u64 {
298    fn from(value: UnixNanos) -> Self {
299        value.0
300    }
301}
302
303/// Converts a string slice to [`UnixNanos`].
304///
305/// # Panics
306///
307/// This implementation will panic if the string cannot be parsed into a valid [`UnixNanos`].
308/// This is intentional fail-fast behavior where invalid timestamps indicate a critical
309/// logic error that should halt execution rather than silently propagate incorrect data.
310///
311/// For error handling without panicking, use [`str::parse::<UnixNanos>()`] which returns
312/// a [`Result`].
313///
314/// # Examples
315///
316/// ```
317/// use nautilus_core::UnixNanos;
318///
319/// let nanos = UnixNanos::from("1234567890");
320/// assert_eq!(nanos.as_u64(), 1234567890);
321/// ```
322impl From<&str> for UnixNanos {
323    fn from(value: &str) -> Self {
324        value
325            .parse()
326            .unwrap_or_else(|e| panic!("Failed to parse string '{value}' into UnixNanos: {e}. Use str::parse() for non-panicking error handling."))
327    }
328}
329
330/// Converts a [`String`] to [`UnixNanos`].
331///
332/// # Panics
333///
334/// This implementation will panic if the string cannot be parsed into a valid [`UnixNanos`].
335/// This is intentional fail-fast behavior where invalid timestamps indicate a critical
336/// logic error that should halt execution rather than silently propagate incorrect data.
337///
338/// For error handling without panicking, use [`str::parse::<UnixNanos>()`] which returns
339/// a [`Result`].
340impl From<String> for UnixNanos {
341    fn from(value: String) -> Self {
342        value
343            .parse()
344            .unwrap_or_else(|e| panic!("Failed to parse string '{value}' into UnixNanos: {e}. Use str::parse() for non-panicking error handling."))
345    }
346}
347
348impl From<DateTime<Utc>> for UnixNanos {
349    fn from(value: DateTime<Utc>) -> Self {
350        let nanos = value
351            .timestamp_nanos_opt()
352            .expect("DateTime timestamp out of range for UnixNanos");
353
354        assert!(nanos >= 0, "DateTime timestamp cannot be negative: {nanos}");
355
356        Self::from(nanos as u64)
357    }
358}
359
360impl FromStr for UnixNanos {
361    type Err = Box<dyn std::error::Error>;
362
363    fn from_str(s: &str) -> Result<Self, Self::Err> {
364        Self::parse_string(s).map_err(std::convert::Into::into)
365    }
366}
367
368/// Adds two [`UnixNanos`] values.
369///
370/// # Panics
371///
372/// Panics on overflow. This is intentional fail-fast behavior: overflow in timestamp
373/// arithmetic indicates a logic error in calculations that would corrupt data.
374/// Use [`UnixNanos::checked_add()`] or [`UnixNanos::saturating_add_ns()`] if you need
375/// explicit overflow handling.
376impl Add for UnixNanos {
377    type Output = Self;
378
379    fn add(self, rhs: Self) -> Self::Output {
380        Self(
381            self.0
382                .checked_add(rhs.0)
383                .expect("UnixNanos overflow in addition - invalid timestamp calculation"),
384        )
385    }
386}
387
388/// Subtracts one [`UnixNanos`] from another.
389///
390/// # Panics
391///
392/// Panics on underflow. This is intentional fail-fast behavior: underflow in timestamp
393/// arithmetic indicates a logic error in calculations that would corrupt data.
394/// Use [`UnixNanos::checked_sub()`] or [`UnixNanos::saturating_sub_ns()`] if you need
395/// explicit underflow handling.
396impl Sub for UnixNanos {
397    type Output = Self;
398
399    fn sub(self, rhs: Self) -> Self::Output {
400        Self(
401            self.0
402                .checked_sub(rhs.0)
403                .expect("UnixNanos underflow in subtraction - invalid timestamp calculation"),
404        )
405    }
406}
407
408/// Adds a `u64` nanosecond value to [`UnixNanos`].
409///
410/// # Panics
411///
412/// Panics on overflow. This is intentional fail-fast behavior for timestamp arithmetic.
413/// Use [`UnixNanos::checked_add()`] for explicit overflow handling.
414impl Add<u64> for UnixNanos {
415    type Output = Self;
416
417    fn add(self, rhs: u64) -> Self::Output {
418        Self(
419            self.0
420                .checked_add(rhs)
421                .expect("UnixNanos overflow in addition"),
422        )
423    }
424}
425
426/// Subtracts a `u64` nanosecond value from [`UnixNanos`].
427///
428/// # Panics
429///
430/// Panics on underflow. This is intentional fail-fast behavior for timestamp arithmetic.
431/// Use [`UnixNanos::checked_sub()`] for explicit underflow handling.
432impl Sub<u64> for UnixNanos {
433    type Output = Self;
434
435    fn sub(self, rhs: u64) -> Self::Output {
436        Self(
437            self.0
438                .checked_sub(rhs)
439                .expect("UnixNanos underflow in subtraction"),
440        )
441    }
442}
443
444/// Add-assigns a value to [`UnixNanos`].
445///
446/// # Panics
447///
448/// Panics on overflow. This is intentional fail-fast behavior for timestamp arithmetic.
449impl<T: Into<u64>> AddAssign<T> for UnixNanos {
450    fn add_assign(&mut self, other: T) {
451        let other_u64 = other.into();
452        self.0 = self
453            .0
454            .checked_add(other_u64)
455            .expect("UnixNanos overflow in add_assign");
456    }
457}
458
459/// Sub-assigns a value from [`UnixNanos`].
460///
461/// # Panics
462///
463/// Panics on underflow. This is intentional fail-fast behavior for timestamp arithmetic.
464impl<T: Into<u64>> SubAssign<T> for UnixNanos {
465    fn sub_assign(&mut self, other: T) {
466        let other_u64 = other.into();
467        self.0 = self
468            .0
469            .checked_sub(other_u64)
470            .expect("UnixNanos underflow in sub_assign");
471    }
472}
473
474impl Display for UnixNanos {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        write!(f, "{}", self.0)
477    }
478}
479
480impl From<UnixNanos> for DateTime<Utc> {
481    fn from(value: UnixNanos) -> Self {
482        value.to_datetime_utc()
483    }
484}
485
486impl<'de> Deserialize<'de> for UnixNanos {
487    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
488    where
489        D: Deserializer<'de>,
490    {
491        struct UnixNanosVisitor;
492
493        impl Visitor<'_> for UnixNanosVisitor {
494            type Value = UnixNanos;
495
496            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
497                formatter.write_str("an integer, a string integer, or an RFC 3339 timestamp")
498            }
499
500            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
501            where
502                E: de::Error,
503            {
504                Ok(UnixNanos(value))
505            }
506
507            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
508            where
509                E: de::Error,
510            {
511                if value < 0 {
512                    return Err(E::custom("Unix timestamp cannot be negative"));
513                }
514                Ok(UnixNanos(value as u64))
515            }
516
517            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
518            where
519                E: de::Error,
520            {
521                if !value.is_finite() {
522                    return Err(E::custom(format!(
523                        "Unix timestamp must be finite, got {}",
524                        value
525                    )));
526                }
527                if value < 0.0 {
528                    return Err(E::custom("Unix timestamp cannot be negative"));
529                }
530                // Convert from seconds to nanoseconds with overflow check
531                const MAX_NS_F64: f64 = u64::MAX as f64;
532                let nanos_f64 = value * 1_000_000_000.0;
533                if nanos_f64 > MAX_NS_F64 {
534                    return Err(E::custom(format!(
535                        "Unix timestamp {} seconds is out of range",
536                        value
537                    )));
538                }
539                let nanos = nanos_f64.round() as u64;
540                Ok(UnixNanos(nanos))
541            }
542
543            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
544            where
545                E: de::Error,
546            {
547                UnixNanos::parse_string(value).map_err(E::custom)
548            }
549        }
550
551        deserializer.deserialize_any(UnixNanosVisitor)
552    }
553}
554
555////////////////////////////////////////////////////////////////////////////////
556// Tests
557////////////////////////////////////////////////////////////////////////////////
558#[cfg(test)]
559mod tests {
560    use chrono::{Duration, TimeZone};
561    use rstest::rstest;
562
563    use super::*;
564
565    #[rstest]
566    fn test_new() {
567        let nanos = UnixNanos::new(123);
568        assert_eq!(nanos.as_u64(), 123);
569        assert_eq!(nanos.as_i64(), 123);
570    }
571
572    #[rstest]
573    fn test_max() {
574        let nanos = UnixNanos::max();
575        assert_eq!(nanos.as_u64(), u64::MAX);
576    }
577
578    #[rstest]
579    fn test_is_zero() {
580        assert!(UnixNanos::default().is_zero());
581        assert!(!UnixNanos::max().is_zero());
582    }
583
584    #[rstest]
585    fn test_from_u64() {
586        let nanos = UnixNanos::from(123);
587        assert_eq!(nanos.as_u64(), 123);
588        assert_eq!(nanos.as_i64(), 123);
589    }
590
591    #[rstest]
592    fn test_default() {
593        let nanos = UnixNanos::default();
594        assert_eq!(nanos.as_u64(), 0);
595        assert_eq!(nanos.as_i64(), 0);
596    }
597
598    #[rstest]
599    fn test_into_from() {
600        let nanos: UnixNanos = 456.into();
601        let value: u64 = nanos.into();
602        assert_eq!(value, 456);
603    }
604
605    #[rstest]
606    #[case(0, "1970-01-01T00:00:00+00:00")]
607    #[case(1_000_000_000, "1970-01-01T00:00:01+00:00")]
608    #[case(1_000_000_000_000_000_000, "2001-09-09T01:46:40+00:00")]
609    #[case(1_500_000_000_000_000_000, "2017-07-14T02:40:00+00:00")]
610    #[case(1_707_577_123_456_789_000, "2024-02-10T14:58:43.456789+00:00")]
611    fn test_to_datetime_utc(#[case] nanos: u64, #[case] expected: &str) {
612        let nanos = UnixNanos::from(nanos);
613        let datetime = nanos.to_datetime_utc();
614        assert_eq!(datetime.to_rfc3339(), expected);
615    }
616
617    #[rstest]
618    #[case(0, "1970-01-01T00:00:00+00:00")]
619    #[case(1_000_000_000, "1970-01-01T00:00:01+00:00")]
620    #[case(1_000_000_000_000_000_000, "2001-09-09T01:46:40+00:00")]
621    #[case(1_500_000_000_000_000_000, "2017-07-14T02:40:00+00:00")]
622    #[case(1_707_577_123_456_789_000, "2024-02-10T14:58:43.456789+00:00")]
623    fn test_to_rfc3339(#[case] nanos: u64, #[case] expected: &str) {
624        let nanos = UnixNanos::from(nanos);
625        assert_eq!(nanos.to_rfc3339(), expected);
626    }
627
628    #[rstest]
629    fn test_from_str() {
630        let nanos: UnixNanos = "123".parse().unwrap();
631        assert_eq!(nanos.as_u64(), 123);
632    }
633
634    #[rstest]
635    fn test_from_str_invalid() {
636        let result = "abc".parse::<UnixNanos>();
637        assert!(result.is_err());
638    }
639
640    #[rstest]
641    fn test_from_str_pre_epoch_date() {
642        let err = "1969-12-31".parse::<UnixNanos>().unwrap_err();
643        assert_eq!(err.to_string(), "Unix timestamp cannot be negative");
644    }
645
646    #[rstest]
647    fn test_from_str_pre_epoch_rfc3339() {
648        let err = "1969-12-31T23:59:59Z".parse::<UnixNanos>().unwrap_err();
649        assert_eq!(err.to_string(), "Unix timestamp cannot be negative");
650    }
651
652    #[rstest]
653    fn test_try_from_datetime_valid() {
654        use chrono::TimeZone;
655        let datetime = Utc.timestamp_opt(1_000_000_000, 0).unwrap(); // 1 billion seconds since epoch
656        let nanos = UnixNanos::from(datetime);
657        assert_eq!(nanos.as_u64(), 1_000_000_000_000_000_000);
658    }
659
660    #[rstest]
661    fn test_eq() {
662        let nanos = UnixNanos::from(100);
663        assert_eq!(nanos, 100);
664        assert_eq!(nanos, Some(100));
665        assert_ne!(nanos, 200);
666        assert_ne!(nanos, Some(200));
667        assert_ne!(nanos, None);
668    }
669
670    #[rstest]
671    fn test_partial_cmp() {
672        let nanos = UnixNanos::from(100);
673        assert_eq!(nanos.partial_cmp(&100), Some(Ordering::Equal));
674        assert_eq!(nanos.partial_cmp(&200), Some(Ordering::Less));
675        assert_eq!(nanos.partial_cmp(&50), Some(Ordering::Greater));
676        assert_eq!(nanos.partial_cmp(&None), Some(Ordering::Greater));
677    }
678
679    #[rstest]
680    fn test_edge_case_max_value() {
681        let nanos = UnixNanos::from(u64::MAX);
682        assert_eq!(format!("{nanos}"), format!("{}", u64::MAX));
683    }
684
685    #[rstest]
686    fn test_display() {
687        let nanos = UnixNanos::from(123);
688        assert_eq!(format!("{nanos}"), "123");
689    }
690
691    #[rstest]
692    fn test_addition() {
693        let nanos1 = UnixNanos::from(100);
694        let nanos2 = UnixNanos::from(200);
695        let result = nanos1 + nanos2;
696        assert_eq!(result.as_u64(), 300);
697    }
698
699    #[rstest]
700    fn test_add_assign() {
701        let mut nanos = UnixNanos::from(100);
702        nanos += 50_u64;
703        assert_eq!(nanos.as_u64(), 150);
704    }
705
706    #[rstest]
707    fn test_subtraction() {
708        let nanos1 = UnixNanos::from(200);
709        let nanos2 = UnixNanos::from(100);
710        let result = nanos1 - nanos2;
711        assert_eq!(result.as_u64(), 100);
712    }
713
714    #[rstest]
715    fn test_sub_assign() {
716        let mut nanos = UnixNanos::from(200);
717        nanos -= 50_u64;
718        assert_eq!(nanos.as_u64(), 150);
719    }
720
721    #[rstest]
722    #[should_panic(expected = "UnixNanos overflow")]
723    fn test_overflow_add() {
724        let nanos = UnixNanos::from(u64::MAX);
725        let _ = nanos + UnixNanos::from(1); // This should panic due to overflow
726    }
727
728    #[rstest]
729    #[should_panic(expected = "UnixNanos overflow")]
730    fn test_overflow_add_u64() {
731        let nanos = UnixNanos::from(u64::MAX);
732        let _ = nanos + 1_u64; // This should panic due to overflow
733    }
734
735    #[rstest]
736    #[should_panic(expected = "UnixNanos underflow")]
737    fn test_overflow_sub() {
738        let _ = UnixNanos::default() - UnixNanos::from(1); // This should panic due to underflow
739    }
740
741    #[rstest]
742    #[should_panic(expected = "UnixNanos underflow")]
743    fn test_overflow_sub_u64() {
744        let _ = UnixNanos::default() - 1_u64; // This should panic due to underflow
745    }
746
747    #[rstest]
748    #[case(100, 50, Some(50))]
749    #[case(1_000_000_000, 500_000_000, Some(500_000_000))]
750    #[case(u64::MAX, u64::MAX - 1, Some(1))]
751    #[case(50, 50, Some(0))]
752    #[case(50, 100, None)]
753    #[case(0, 1, None)]
754    fn test_duration_since(
755        #[case] time1: u64,
756        #[case] time2: u64,
757        #[case] expected: Option<DurationNanos>,
758    ) {
759        let nanos1 = UnixNanos::from(time1);
760        let nanos2 = UnixNanos::from(time2);
761        assert_eq!(nanos1.duration_since(&nanos2), expected);
762    }
763
764    #[rstest]
765    fn test_duration_since_same_moment() {
766        let moment = UnixNanos::from(1_707_577_123_456_789_000);
767        assert_eq!(moment.duration_since(&moment), Some(0));
768    }
769
770    #[rstest]
771    fn test_duration_since_chronological() {
772        // Create a reference time (Feb 10, 2024)
773        let earlier = Utc.with_ymd_and_hms(2024, 2, 10, 12, 0, 0).unwrap();
774
775        // Create a time 1 hour, 30 minutes, and 45 seconds later (with nanoseconds)
776        let later = earlier
777            + Duration::hours(1)
778            + Duration::minutes(30)
779            + Duration::seconds(45)
780            + Duration::nanoseconds(500_000_000);
781
782        let earlier_nanos = UnixNanos::from(earlier);
783        let later_nanos = UnixNanos::from(later);
784
785        // Calculate expected duration in nanoseconds
786        let expected_duration = 60 * 60 * 1_000_000_000 + // 1 hour
787        30 * 60 * 1_000_000_000 + // 30 minutes
788        45 * 1_000_000_000 + // 45 seconds
789        500_000_000; // 500 million nanoseconds
790
791        assert_eq!(
792            later_nanos.duration_since(&earlier_nanos),
793            Some(expected_duration)
794        );
795        assert_eq!(earlier_nanos.duration_since(&later_nanos), None);
796    }
797
798    #[rstest]
799    fn test_duration_since_with_edge_cases() {
800        // Test with maximum value
801        let max = UnixNanos::from(u64::MAX);
802        let smaller = UnixNanos::from(u64::MAX - 1000);
803
804        assert_eq!(max.duration_since(&smaller), Some(1000));
805        assert_eq!(smaller.duration_since(&max), None);
806
807        // Test with minimum value
808        let min = UnixNanos::default(); // Zero timestamp
809        let larger = UnixNanos::from(1000);
810
811        assert_eq!(min.duration_since(&min), Some(0));
812        assert_eq!(larger.duration_since(&min), Some(1000));
813        assert_eq!(min.duration_since(&larger), None);
814    }
815
816    #[rstest]
817    fn test_serde_json() {
818        let nanos = UnixNanos::from(123);
819        let json = serde_json::to_string(&nanos).unwrap();
820        let deserialized: UnixNanos = serde_json::from_str(&json).unwrap();
821        assert_eq!(deserialized, nanos);
822    }
823
824    #[rstest]
825    fn test_serde_edge_cases() {
826        let nanos = UnixNanos::from(u64::MAX);
827        let json = serde_json::to_string(&nanos).unwrap();
828        let deserialized: UnixNanos = serde_json::from_str(&json).unwrap();
829        assert_eq!(deserialized, nanos);
830    }
831
832    #[rstest]
833    #[case("123", 123)] // Integer string
834    #[case("1234.567", 1_234_567_000_000)] // Float string (seconds to nanos)
835    #[case("2024-02-10", 1_707_523_200_000_000_000)] // Simple date (midnight UTC)
836    #[case("2024-02-10T14:58:43Z", 1_707_577_123_000_000_000)] // RFC3339 without fractions
837    #[case("2024-02-10T14:58:43.456789Z", 1_707_577_123_456_789_000)] // RFC3339 with fractions
838    fn test_from_str_formats(#[case] input: &str, #[case] expected: u64) {
839        let parsed: UnixNanos = input.parse().unwrap();
840        assert_eq!(parsed.as_u64(), expected);
841    }
842
843    #[rstest]
844    #[case("abc")] // Random string
845    #[case("not a timestamp")] // Non-timestamp string
846    #[case("2024-02-10 14:58:43")] // Space-separated format (not RFC3339)
847    fn test_from_str_invalid_formats(#[case] input: &str) {
848        let result = input.parse::<UnixNanos>();
849        assert!(result.is_err());
850    }
851
852    #[rstest]
853    fn test_from_str_integer_overflow() {
854        // One more digit than u64::MAX (20 digits) so definitely overflows
855        let input = "184467440737095516160";
856        let result = input.parse::<UnixNanos>();
857        assert!(result.is_err());
858    }
859
860    // ---------- checked / saturating arithmetic ----------
861
862    #[rstest]
863    fn test_checked_add_overflow_returns_none() {
864        let max = UnixNanos::from(u64::MAX);
865        assert_eq!(max.checked_add(1_u64), None);
866    }
867
868    #[rstest]
869    fn test_checked_sub_underflow_returns_none() {
870        let zero = UnixNanos::default();
871        assert_eq!(zero.checked_sub(1_u64), None);
872    }
873
874    #[rstest]
875    fn test_saturating_add_overflow() {
876        let max = UnixNanos::from(u64::MAX);
877        let result = max.saturating_add_ns(1_u64);
878        assert_eq!(result, UnixNanos::from(u64::MAX));
879    }
880
881    #[rstest]
882    fn test_saturating_sub_underflow() {
883        let zero = UnixNanos::default();
884        let result = zero.saturating_sub_ns(1_u64);
885        assert_eq!(result, UnixNanos::default());
886    }
887
888    #[rstest]
889    fn test_from_str_float_overflow() {
890        // Use scientific notation so we take the floating-point parsing path.
891        let input = "2e10"; // 20 billion seconds ~ 634 years (> u64::MAX nanoseconds)
892        let result = input.parse::<UnixNanos>();
893        assert!(result.is_err());
894    }
895
896    #[rstest]
897    fn test_deserialize_u64() {
898        let json = "123456789";
899        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
900        assert_eq!(deserialized.as_u64(), 123_456_789);
901    }
902
903    #[rstest]
904    fn test_deserialize_string_with_int() {
905        let json = "\"123456789\"";
906        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
907        assert_eq!(deserialized.as_u64(), 123_456_789);
908    }
909
910    #[rstest]
911    fn test_deserialize_float() {
912        let json = "1234.567";
913        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
914        assert_eq!(deserialized.as_u64(), 1_234_567_000_000);
915    }
916
917    #[rstest]
918    fn test_deserialize_string_with_float() {
919        let json = "\"1234.567\"";
920        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
921        assert_eq!(deserialized.as_u64(), 1_234_567_000_000);
922    }
923
924    #[rstest]
925    #[case("\"2024-02-10T14:58:43.456789Z\"", 1_707_577_123_456_789_000)]
926    #[case("\"2024-02-10T14:58:43Z\"", 1_707_577_123_000_000_000)]
927    fn test_deserialize_timestamp_strings(#[case] input: &str, #[case] expected: u64) {
928        let deserialized: UnixNanos = serde_json::from_str(input).unwrap();
929        assert_eq!(deserialized.as_u64(), expected);
930    }
931
932    #[rstest]
933    fn test_deserialize_negative_int_fails() {
934        let json = "-123456789";
935        let result: Result<UnixNanos, _> = serde_json::from_str(json);
936        assert!(result.is_err());
937    }
938
939    #[rstest]
940    fn test_deserialize_negative_float_fails() {
941        let json = "-1234.567";
942        let result: Result<UnixNanos, _> = serde_json::from_str(json);
943        assert!(result.is_err());
944    }
945
946    #[rstest]
947    fn test_deserialize_nan_fails() {
948        // JSON doesn't support NaN directly, test the internal deserializer
949        use serde::de::{
950            IntoDeserializer,
951            value::{Error as ValueError, F64Deserializer},
952        };
953        let deserializer: F64Deserializer<ValueError> = f64::NAN.into_deserializer();
954        let result: Result<UnixNanos, _> = UnixNanos::deserialize(deserializer);
955        assert!(result.is_err());
956        assert!(result.unwrap_err().to_string().contains("must be finite"));
957    }
958
959    #[rstest]
960    fn test_deserialize_infinity_fails() {
961        use serde::de::{
962            IntoDeserializer,
963            value::{Error as ValueError, F64Deserializer},
964        };
965        let deserializer: F64Deserializer<ValueError> = f64::INFINITY.into_deserializer();
966        let result: Result<UnixNanos, _> = UnixNanos::deserialize(deserializer);
967        assert!(result.is_err());
968        assert!(result.unwrap_err().to_string().contains("must be finite"));
969    }
970
971    #[rstest]
972    fn test_deserialize_negative_infinity_fails() {
973        use serde::de::{
974            IntoDeserializer,
975            value::{Error as ValueError, F64Deserializer},
976        };
977        let deserializer: F64Deserializer<ValueError> = f64::NEG_INFINITY.into_deserializer();
978        let result: Result<UnixNanos, _> = UnixNanos::deserialize(deserializer);
979        assert!(result.is_err());
980        assert!(result.unwrap_err().to_string().contains("must be finite"));
981    }
982
983    #[rstest]
984    fn test_deserialize_overflow_float_fails() {
985        // Test a float that would overflow u64 when converted to nanoseconds
986        // u64::MAX is ~18.4e18, so u64::MAX / 1e9 = ~18.4e9 seconds
987        let result: Result<UnixNanos, _> = serde_json::from_str("1e20");
988        assert!(result.is_err());
989        assert!(result.unwrap_err().to_string().contains("out of range"));
990    }
991
992    #[rstest]
993    fn test_deserialize_invalid_string_fails() {
994        let json = "\"not a timestamp\"";
995        let result: Result<UnixNanos, _> = serde_json::from_str(json);
996        assert!(result.is_err());
997    }
998
999    #[rstest]
1000    fn test_deserialize_edge_cases() {
1001        // Test zero
1002        let json = "0";
1003        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
1004        assert_eq!(deserialized.as_u64(), 0);
1005
1006        // Test large value
1007        let json = "18446744073709551615"; // u64::MAX
1008        let deserialized: UnixNanos = serde_json::from_str(json).unwrap();
1009        assert_eq!(deserialized.as_u64(), u64::MAX);
1010    }
1011
1012    #[rstest]
1013    #[should_panic(expected = "UnixNanos value exceeds i64::MAX")]
1014    fn test_as_i64_overflow_panics() {
1015        let nanos = UnixNanos::from(u64::MAX);
1016        let _ = nanos.as_i64(); // Should panic
1017    }
1018
1019    ////////////////////////////////////////////////////////////////////////////////
1020    // Property-based testing
1021    ////////////////////////////////////////////////////////////////////////////////
1022
1023    use proptest::prelude::*;
1024
1025    fn unix_nanos_strategy() -> impl Strategy<Value = UnixNanos> {
1026        prop_oneof![
1027            // Small values
1028            0u64..1_000_000u64,
1029            // Medium values (microseconds range)
1030            1_000_000u64..1_000_000_000_000u64,
1031            // Large values (nanoseconds since 1970, but safe for arithmetic)
1032            1_000_000_000_000u64..=i64::MAX as u64,
1033            // Edge cases
1034            Just(0u64),
1035            Just(1u64),
1036            Just(1_000_000_000u64),             // 1 second in nanos
1037            Just(1_000_000_000_000u64),         // ~2001 timestamp
1038            Just(1_700_000_000_000_000_000u64), // ~2023 timestamp
1039            Just((i64::MAX / 2) as u64),        // Safe for doubling
1040        ]
1041        .prop_map(UnixNanos::from)
1042    }
1043
1044    fn unix_nanos_pair_strategy() -> impl Strategy<Value = (UnixNanos, UnixNanos)> {
1045        (unix_nanos_strategy(), unix_nanos_strategy())
1046    }
1047
1048    proptest! {
1049        #[rstest]
1050        fn prop_unix_nanos_construction_roundtrip(value in 0u64..=i64::MAX as u64) {
1051            let nanos = UnixNanos::from(value);
1052            prop_assert_eq!(nanos.as_u64(), value);
1053            prop_assert_eq!(nanos.as_f64(), value as f64);
1054
1055            // Test i64 conversion only for values within i64 range
1056            if i64::try_from(value).is_ok() {
1057                prop_assert_eq!(nanos.as_i64(), value as i64);
1058            }
1059        }
1060
1061        #[rstest]
1062        fn prop_unix_nanos_addition_commutative(
1063            (nanos1, nanos2) in unix_nanos_pair_strategy()
1064        ) {
1065            // Addition should be commutative when no overflow occurs
1066            if let (Some(sum1), Some(sum2)) = (
1067                nanos1.checked_add(nanos2.as_u64()),
1068                nanos2.checked_add(nanos1.as_u64())
1069            ) {
1070                prop_assert_eq!(sum1, sum2, "Addition should be commutative");
1071            }
1072        }
1073
1074        #[rstest]
1075        fn prop_unix_nanos_addition_associative(
1076            nanos1 in unix_nanos_strategy(),
1077            nanos2 in unix_nanos_strategy(),
1078            nanos3 in unix_nanos_strategy(),
1079        ) {
1080            // Addition should be associative when no overflow occurs
1081            if let (Some(sum1), Some(sum2)) = (
1082                nanos1.as_u64().checked_add(nanos2.as_u64()),
1083                nanos2.as_u64().checked_add(nanos3.as_u64())
1084            )
1085                && let (Some(left), Some(right)) = (
1086                    sum1.checked_add(nanos3.as_u64()),
1087                    nanos1.as_u64().checked_add(sum2)
1088                ) {
1089                    let left_result = UnixNanos::from(left);
1090                    let right_result = UnixNanos::from(right);
1091                    prop_assert_eq!(left_result, right_result, "Addition should be associative");
1092                }
1093        }
1094
1095        #[rstest]
1096        fn prop_unix_nanos_subtraction_inverse(
1097            (nanos1, nanos2) in unix_nanos_pair_strategy()
1098        ) {
1099            // Subtraction should be the inverse of addition when no underflow occurs
1100            if let Some(sum) = nanos1.checked_add(nanos2.as_u64()) {
1101                let diff = sum - nanos2;
1102                prop_assert_eq!(diff, nanos1, "Subtraction should be inverse of addition");
1103            }
1104        }
1105
1106        #[rstest]
1107        fn prop_unix_nanos_zero_identity(nanos in unix_nanos_strategy()) {
1108            // Zero should be additive identity
1109            let zero = UnixNanos::default();
1110            prop_assert_eq!(nanos + zero, nanos, "Zero should be additive identity");
1111            prop_assert_eq!(zero + nanos, nanos, "Zero should be additive identity (commutative)");
1112            prop_assert!(zero.is_zero(), "Zero should be recognized as zero");
1113        }
1114
1115        #[rstest]
1116        fn prop_unix_nanos_ordering_consistency(
1117            (nanos1, nanos2) in unix_nanos_pair_strategy()
1118        ) {
1119            // Ordering operations should be consistent
1120            let eq = nanos1 == nanos2;
1121            let lt = nanos1 < nanos2;
1122            let gt = nanos1 > nanos2;
1123            let le = nanos1 <= nanos2;
1124            let ge = nanos1 >= nanos2;
1125
1126            // Exactly one of eq, lt, gt should be true
1127            let exclusive_count = [eq, lt, gt].iter().filter(|&&x| x).count();
1128            prop_assert_eq!(exclusive_count, 1, "Exactly one of ==, <, > should be true");
1129
1130            // Consistency checks
1131            prop_assert_eq!(le, eq || lt, "<= should equal == || <");
1132            prop_assert_eq!(ge, eq || gt, ">= should equal == || >");
1133            prop_assert_eq!(lt, nanos2 > nanos1, "< should be symmetric with >");
1134            prop_assert_eq!(le, nanos2 >= nanos1, "<= should be symmetric with >=");
1135        }
1136
1137        #[rstest]
1138        fn prop_unix_nanos_string_roundtrip(nanos in unix_nanos_strategy()) {
1139            // String serialization should round-trip correctly
1140            let string_repr = nanos.to_string();
1141            let parsed = UnixNanos::from_str(&string_repr);
1142            prop_assert!(parsed.is_ok(), "String parsing should succeed for valid UnixNanos");
1143            if let Ok(parsed_nanos) = parsed {
1144                prop_assert_eq!(parsed_nanos, nanos, "String should round-trip exactly");
1145            }
1146        }
1147
1148        #[rstest]
1149        fn prop_unix_nanos_datetime_conversion(nanos in unix_nanos_strategy()) {
1150            // DateTime conversion should be consistent (only test values within i64 range)
1151            if i64::try_from(nanos.as_u64()).is_ok() {
1152                let datetime = nanos.to_datetime_utc();
1153                let converted_back = UnixNanos::from(datetime);
1154                prop_assert_eq!(converted_back, nanos, "DateTime conversion should round-trip");
1155
1156                // RFC3339 string should also round-trip for valid dates
1157                let rfc3339 = nanos.to_rfc3339();
1158                if let Ok(parsed_from_rfc3339) = UnixNanos::from_str(&rfc3339) {
1159                    prop_assert_eq!(parsed_from_rfc3339, nanos, "RFC3339 string should round-trip");
1160                }
1161            }
1162        }
1163
1164        #[rstest]
1165        fn prop_unix_nanos_duration_since(
1166            (nanos1, nanos2) in unix_nanos_pair_strategy()
1167        ) {
1168            // duration_since should be consistent with comparison and arithmetic
1169            let duration = nanos1.duration_since(&nanos2);
1170
1171            if nanos1 >= nanos2 {
1172                // If nanos1 >= nanos2, duration should be Some and equal to difference
1173                prop_assert!(duration.is_some(), "Duration should be Some when first >= second");
1174                if let Some(dur) = duration {
1175                    prop_assert_eq!(dur, nanos1.as_u64() - nanos2.as_u64(),
1176                        "Duration should equal the difference");
1177                    prop_assert_eq!(nanos2 + dur, nanos1.as_u64(),
1178                        "second + duration should equal first");
1179                }
1180            } else {
1181                // If nanos1 < nanos2, duration should be None
1182                prop_assert!(duration.is_none(), "Duration should be None when first < second");
1183            }
1184        }
1185
1186        #[rstest]
1187        fn prop_unix_nanos_checked_arithmetic(
1188            (nanos1, nanos2) in unix_nanos_pair_strategy()
1189        ) {
1190            // Checked arithmetic should be consistent with regular arithmetic when no overflow/underflow
1191            let checked_add = nanos1.checked_add(nanos2.as_u64());
1192            let checked_sub = nanos1.checked_sub(nanos2.as_u64());
1193
1194            // If checked_add succeeds, regular addition should produce the same result
1195            if let Some(sum) = checked_add
1196                && nanos1.as_u64().checked_add(nanos2.as_u64()).is_some() {
1197                    prop_assert_eq!(sum, nanos1 + nanos2, "Checked add should match regular add when no overflow");
1198                }
1199
1200            // If checked_sub succeeds, regular subtraction should produce the same result
1201            if let Some(diff) = checked_sub
1202                && nanos1.as_u64() >= nanos2.as_u64() {
1203                    prop_assert_eq!(diff, nanos1 - nanos2, "Checked sub should match regular sub when no underflow");
1204                }
1205        }
1206
1207        #[rstest]
1208        fn prop_unix_nanos_saturating_arithmetic(
1209            (nanos1, nanos2) in unix_nanos_pair_strategy()
1210        ) {
1211            // Saturating arithmetic should never panic and produce reasonable results
1212            let sat_add = nanos1.saturating_add_ns(nanos2.as_u64());
1213            let sat_sub = nanos1.saturating_sub_ns(nanos2.as_u64());
1214
1215            // Saturating add should be >= both operands
1216            prop_assert!(sat_add >= nanos1, "Saturating add result should be >= first operand");
1217            prop_assert!(sat_add.as_u64() >= nanos2.as_u64(), "Saturating add result should be >= second operand");
1218
1219            // Saturating sub should be <= first operand
1220            prop_assert!(sat_sub <= nanos1, "Saturating sub result should be <= first operand");
1221
1222            // If no overflow/underflow would occur, saturating should match checked
1223            if let Some(checked_sum) = nanos1.checked_add(nanos2.as_u64()) {
1224                prop_assert_eq!(sat_add, checked_sum, "Saturating add should match checked add when no overflow");
1225            } else {
1226                prop_assert_eq!(sat_add, UnixNanos::from(u64::MAX), "Saturating add should be MAX on overflow");
1227            }
1228
1229            if let Some(checked_diff) = nanos1.checked_sub(nanos2.as_u64()) {
1230                prop_assert_eq!(sat_sub, checked_diff, "Saturating sub should match checked sub when no underflow");
1231            } else {
1232                prop_assert_eq!(sat_sub, UnixNanos::default(), "Saturating sub should be zero on underflow");
1233            }
1234        }
1235    }
1236}