Skip to main content

tempoch_core/foundation/
duration.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Exact-precision duration container.
5//!
6//! [`ExactDuration`] is the canonical duration type for `tempoch`. Its
7//! representation is **deliberately opaque**: today it is backed by a single
8//! `i128` of nanoseconds (range ≈ ±5.4 × 10²¹ yr at 1 ns resolution). A future
9//! internal migration to `i128` attoseconds or another sub-nanosecond
10//! representation is a non-breaking change as long as callers go through the
11//! named accessors (`as_seconds_i64_nanos`, `as_seconds_f64`, …).
12//!
13//! # Design choices
14//!
15//! * **Sign convention** — a single signed integer carries the sign uniformly.
16//!   This avoids the classic `{whole_seconds: i64, sub_nanos: u32}` pitfall
17//!   where `-0.5 s` must be represented as `{-1, 500_000_000}` and negation
18//!   becomes asymmetric near zero.
19//! * **No `f64` in the public exact API** — `f64` boundaries are reachable only
20//!   through explicitly named methods (`from_seconds_f64_lossy`,
21//!   `as_seconds_f64`) so users see the lossy step in code review.
22//! * **qtty interop** — [`ExactDuration::from_quantity`] /
23//!   [`ExactDuration::as_quantity`] bridge to typed `Quantity<U>` for any
24//!   [`crate::qtty::time::TimeUnit`]. The bridge through `f64` is intentional: `qtty`
25//!   itself is a floating-point quantity system; users wanting exact duration
26//!   math should keep values inside [`ExactDuration`].
27//! * **Overflow** — arithmetic uses checked operations and reports
28//!   [`DurationError::Overflow`] when the result leaves the i128 range; the
29//!   public `+`/`-` operators panic on overflow (debug + release) to match
30//!   `Duration`/`std::time` ergonomics. Use [`ExactDuration::checked_add`] /
31//!   [`ExactDuration::checked_sub`] / [`ExactDuration::checked_neg`] for
32//!   non-panicking callers (FFI, parsers, formal-verification harnesses).
33//!
34//! # Future-proofing
35//!
36//! Because the storage is opaque and the boundary projection
37//! `(seconds: i64, nanos: u32)` is the only serde shape, migrating to a
38//! sub-nanosecond representation is non-breaking; callers requesting
39//! attosecond precision in serde will opt in through a future
40//! `serde-attos` feature.
41
42use core::cmp::Ordering;
43use core::ops::{Add, AddAssign, Neg, Sub, SubAssign};
44
45use crate::qtty::time::TimeUnit;
46use crate::qtty::{self, Quantity, Second};
47
48/// Nanoseconds per second; convenience constant for boundary code.
49pub const NANOS_PER_SECOND: i128 = 1_000_000_000;
50
51/// Error type for fallible [`ExactDuration`] operations.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DurationError {
54    /// Arithmetic overflowed the `i128`-nanosecond representation.
55    Overflow,
56    /// Input scalar was NaN or infinite.
57    NonFinite,
58    /// `(seconds, nanos)` pair violates the canonical sign invariant:
59    /// `seconds > 0` requires `nanos >= 0`, and `seconds < 0` requires `nanos <= 0`.
60    NonCanonical,
61}
62
63impl core::fmt::Display for DurationError {
64    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
65        match self {
66            Self::Overflow => f.write_str("ExactDuration arithmetic overflowed i128 nanoseconds"),
67            Self::NonFinite => f.write_str("ExactDuration input was NaN or infinite"),
68            Self::NonCanonical => f.write_str(
69                "ExactDuration (seconds, nanos) pair is non-canonical: \
70                 signs must agree (seconds > 0 ⇒ nanos ≥ 0; seconds < 0 ⇒ nanos ≤ 0)",
71            ),
72        }
73    }
74}
75
76#[cfg(feature = "std")]
77impl std::error::Error for DurationError {}
78
79/// Exact-precision signed duration.
80///
81/// Internally an `i128` of nanoseconds. Range ≈ ±5.4 × 10²¹ yr at 1 ns
82/// resolution (i128::MAX ≈ 1.7 × 10³⁸ ns ÷ 3.156 × 10¹⁶ ns/yr ≈ 5.4 × 10²¹ yr).
83///
84/// Construction:
85///
86/// * [`ExactDuration::ZERO`]
87/// * [`ExactDuration::from_nanos`]
88/// * [`ExactDuration::from_seconds_and_nanos`]
89/// * [`ExactDuration::from_canonical_seconds_nanos`] (strict canonical form)
90/// * [`ExactDuration::from_quantity`] / [`ExactDuration::try_from_quantity`]
91/// * [`ExactDuration::from_seconds_f64_lossy`] (explicit lossy boundary)
92///
93/// Accessors:
94///
95/// * [`ExactDuration::as_nanos_i128`]
96/// * [`ExactDuration::as_seconds_i64_nanos`] (panics if seconds outside i64 range)
97/// * [`ExactDuration::as_seconds_i64_nanos_checked`] (returns Err on overflow)
98/// * [`ExactDuration::as_seconds_i64_nanos_saturating`] (saturates; lossy for extreme values)
99/// * [`ExactDuration::as_seconds_f64`]
100/// * [`ExactDuration::as_quantity`]
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub struct ExactDuration {
103    nanos: i128,
104}
105
106impl ExactDuration {
107    /// Zero duration.
108    pub const ZERO: Self = Self { nanos: 0 };
109
110    /// Smallest representable positive duration (1 ns).
111    pub const NANOSECOND: Self = Self { nanos: 1 };
112
113    /// One second.
114    pub const SECOND: Self = Self {
115        nanos: NANOS_PER_SECOND,
116    };
117
118    /// Maximum representable duration.
119    pub const MAX: Self = Self { nanos: i128::MAX };
120
121    /// Minimum (most negative) representable duration.
122    pub const MIN: Self = Self { nanos: i128::MIN };
123
124    /// Build from a raw nanosecond count.
125    #[inline]
126    pub const fn from_nanos(nanos: i128) -> Self {
127        Self { nanos }
128    }
129
130    /// Build from `(seconds, nanos)` boundary projection.
131    ///
132    /// The fractional `nanos` is interpreted with the same sign as `seconds`
133    /// when `seconds != 0`; when `seconds == 0`, `nanos` carries the sign
134    /// directly. This matches the unambiguous total
135    /// `result_nanos = seconds * 1e9 + nanos`.
136    ///
137    /// Returns [`DurationError::Overflow`] if the multiplication overflows.
138    #[inline]
139    pub const fn from_seconds_and_nanos(seconds: i64, nanos: i32) -> Result<Self, DurationError> {
140        // `seconds as i128 * NANOS_PER_SECOND` cannot overflow i128 because
141        // i64::MAX * 1e9 < i128::MAX, but the addition can if nanos has the
142        // same sign and a sufficiently extreme value — practically not, but we
143        // still go through checked_add for correctness.
144        let secs_nanos = (seconds as i128).wrapping_mul(NANOS_PER_SECOND);
145        match secs_nanos.checked_add(nanos as i128) {
146            Some(n) => Ok(Self { nanos: n }),
147            None => Err(DurationError::Overflow),
148        }
149    }
150
151    /// Strict canonical constructor: requires `nanos ∈ (-1_000_000_000, 1_000_000_000)` and
152    /// that `seconds` and `nanos` share the same sign (or `nanos == 0`).
153    ///
154    /// The full invariant:
155    /// - `|nanos| < 1_000_000_000`
156    /// - if `seconds > 0`, then `nanos >= 0`
157    /// - if `seconds < 0`, then `nanos <= 0`
158    /// - if `seconds == 0`, either sign of `nanos` is valid
159    ///
160    /// Returns [`DurationError::Overflow`] if `|nanos| >= 1_000_000_000` or if the
161    /// multiplication overflows, and [`DurationError::NonCanonical`] if the sign
162    /// invariant is violated.
163    #[inline]
164    pub const fn from_canonical_seconds_nanos(
165        seconds: i64,
166        nanos: i32,
167    ) -> Result<Self, DurationError> {
168        if nanos <= -(NANOS_PER_SECOND as i32) || nanos >= NANOS_PER_SECOND as i32 {
169            return Err(DurationError::Overflow);
170        }
171        // Sign invariant: seconds and nanos must agree in sign (or one is zero).
172        if (seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0) {
173            return Err(DurationError::NonCanonical);
174        }
175        let secs_nanos = (seconds as i128).wrapping_mul(NANOS_PER_SECOND);
176        match secs_nanos.checked_add(nanos as i128) {
177            Some(n) => Ok(Self { nanos: n }),
178            None => Err(DurationError::Overflow),
179        }
180    }
181
182    /// Build from a `crate::qtty::Quantity<U>` of any time unit. Returns
183    /// [`DurationError::NonFinite`] for NaN/inf inputs and
184    /// [`DurationError::Overflow`] if the value does not fit in i128 ns.
185    #[inline]
186    pub fn try_from_quantity<U: TimeUnit>(q: Quantity<U>) -> Result<Self, DurationError> {
187        let secs = q.to::<qtty::unit::Second>().value();
188        if !secs.is_finite() {
189            return Err(DurationError::NonFinite);
190        }
191        // f64 mantissa is 53 bits; for |secs| < 2^53 / 1e9 ≈ 9.0e6 the conversion
192        // is exact. Outside that range we still produce the closest i128 ns
193        // representation; callers needing better precision should construct via
194        // `from_seconds_and_nanos` or `from_nanos`.
195        let nanos_f = secs * (NANOS_PER_SECOND as f64);
196        if nanos_f >= (i128::MAX as f64) || nanos_f <= (i128::MIN as f64) {
197            return Err(DurationError::Overflow);
198        }
199        Ok(Self {
200            nanos: nanos_f as i128,
201        })
202    }
203
204    /// Infallible variant for callers that already know the input is finite
205    /// and in-range. Panics on non-finite or overflowing input.
206    /// For fallible conversion, use [`try_from_quantity`](Self::try_from_quantity).
207    #[inline]
208    pub fn from_quantity<U: TimeUnit>(q: Quantity<U>) -> Self {
209        Self::try_from_quantity(q).unwrap_or_else(|e| panic!("ExactDuration::from_quantity: {e}"))
210    }
211
212    /// Explicit lossy `f64` → `ExactDuration` boundary. Named so the lossy
213    /// step is visible in code review. Returns `None` on non-finite input or
214    /// when the value does not fit in i128 ns.
215    #[inline]
216    pub fn from_seconds_f64_lossy(seconds: f64) -> Option<Self> {
217        if !seconds.is_finite() {
218            return None;
219        }
220        let nanos_f = seconds * (NANOS_PER_SECOND as f64);
221        if nanos_f >= (i128::MAX as f64) || nanos_f <= (i128::MIN as f64) {
222            return None;
223        }
224        Some(Self {
225            nanos: nanos_f as i128,
226        })
227    }
228
229    /// Raw signed nanosecond count.
230    #[inline]
231    pub const fn as_nanos_i128(self) -> i128 {
232        self.nanos
233    }
234
235    /// Exact boundary projection `(seconds, nanos)` such that
236    /// `seconds * 1_000_000_000 + nanos == self.as_nanos_i128()` and
237    /// `nanos ∈ (-1_000_000_000, 1_000_000_000)`.
238    ///
239    /// Returns [`DurationError::Overflow`] when the seconds component does not
240    /// fit in `i64` (i.e. `|self| > i64::MAX * 1e9 ns ≈ ±292 billion years`).
241    ///
242    /// Use this as the canonical API when the invariant must be preserved.
243    /// For guaranteed-small durations you may use
244    /// [`as_seconds_i64_nanos`](Self::as_seconds_i64_nanos) (panics on overflow).
245    #[inline]
246    pub const fn as_seconds_i64_nanos_checked(self) -> Result<(i64, i32), DurationError> {
247        let secs = self.nanos / NANOS_PER_SECOND;
248        let rem = (self.nanos - secs * NANOS_PER_SECOND) as i32;
249        if secs > i64::MAX as i128 || secs < i64::MIN as i128 {
250            Err(DurationError::Overflow)
251        } else {
252            Ok((secs as i64, rem))
253        }
254    }
255
256    /// Boundary projection `(seconds, nanos)` that saturates the seconds
257    /// component to `i64::MAX` / `i64::MIN` for extreme values.
258    ///
259    /// **Lossy / non-canonical for durations outside the `i64` seconds range
260    /// (≈ ±292 billion years).** The invariant
261    /// `seconds * 1_000_000_000 + nanos == as_nanos_i128()` is **not** preserved
262    /// when saturation occurs. Use [`as_seconds_i64_nanos_checked`](Self::as_seconds_i64_nanos_checked)
263    /// for exact behaviour.
264    #[inline]
265    pub const fn as_seconds_i64_nanos_saturating(self) -> (i64, i32) {
266        let secs = self.nanos / NANOS_PER_SECOND;
267        let rem = (self.nanos - secs * NANOS_PER_SECOND) as i32;
268        let secs_i64 = if secs > i64::MAX as i128 {
269            i64::MAX
270        } else if secs < i64::MIN as i128 {
271            i64::MIN
272        } else {
273            secs as i64
274        };
275        (secs_i64, rem)
276    }
277
278    /// Boundary projection `(seconds, nanos)`. Panics if the seconds component
279    /// does not fit in `i64`.
280    ///
281    /// For durations within ≈ ±292 billion years this never panics in practice. Use
282    /// [`as_seconds_i64_nanos_checked`](Self::as_seconds_i64_nanos_checked) to
283    /// handle the overflow case explicitly.
284    #[inline]
285    pub const fn as_seconds_i64_nanos(self) -> (i64, i32) {
286        match self.as_seconds_i64_nanos_checked() {
287            Ok(pair) => pair,
288            Err(_) => panic!("ExactDuration::as_seconds_i64_nanos: seconds out of i64 range"),
289        }
290    }
291
292    /// Explicit lossy `ExactDuration` → `f64 seconds` boundary.
293    #[inline]
294    pub fn as_seconds_f64(self) -> f64 {
295        (self.nanos as f64) / (NANOS_PER_SECOND as f64)
296    }
297
298    /// Build from a typed `crate::qtty::i64::Nanosecond` integer quantity.
299    ///
300    /// The `i64` value is widened to `i128` without loss; this conversion is
301    /// always exact. For the low-level raw interface, see [`from_nanos`](Self::from_nanos).
302    #[inline]
303    pub fn from_nanoseconds_i(nanos: crate::qtty::i64::Nanosecond) -> Self {
304        Self::from_nanos(nanos.value() as i128)
305    }
306
307    /// Build from a typed `crate::qtty::i64::Second` integer quantity (whole-second precision).
308    ///
309    /// The second value is multiplied by 1 × 10⁹ and widened to `i128` without
310    /// loss for any `i64` input. For sub-second precision use
311    /// [`from_canonical_seconds_nanos`](Self::from_canonical_seconds_nanos) or
312    /// [`from_nanoseconds_i`](Self::from_nanoseconds_i).
313    #[inline]
314    pub fn from_seconds_i(seconds: crate::qtty::i64::Second) -> Self {
315        Self::from_nanos(seconds.value() as i128 * NANOS_PER_SECOND)
316    }
317
318    /// Project to a typed `crate::qtty::i64::Nanosecond` integer quantity.
319    ///
320    /// Returns [`DurationError::Overflow`] when the stored nanosecond count does
321    /// not fit in `i64` (durations outside ≈ ±292 billion years at 1 ns resolution).
322    #[inline]
323    pub fn as_nanoseconds_i(self) -> Result<crate::qtty::i64::Nanosecond, DurationError> {
324        if self.nanos > i64::MAX as i128 || self.nanos < i64::MIN as i128 {
325            Err(DurationError::Overflow)
326        } else {
327            Ok(crate::qtty::i64::Nanosecond::new(self.nanos as i64))
328        }
329    }
330
331    /// Project back into a `crate::qtty::Quantity<U>`. Lossy in general (f64).
332    #[inline]
333    pub fn as_quantity<U: TimeUnit>(self) -> Quantity<U> {
334        Second::new(self.as_seconds_f64()).to::<U>()
335    }
336
337    /// True iff exactly zero.
338    #[inline]
339    pub const fn is_zero(self) -> bool {
340        self.nanos == 0
341    }
342
343    /// True iff strictly negative.
344    #[inline]
345    pub const fn is_negative(self) -> bool {
346        self.nanos < 0
347    }
348
349    /// Absolute value. Returns [`DurationError::Overflow`] on
350    /// [`ExactDuration::MIN`] (i128::MIN has no representable positive).
351    #[inline]
352    pub const fn checked_abs(self) -> Result<Self, DurationError> {
353        match self.nanos.checked_abs() {
354            Some(n) => Ok(Self { nanos: n }),
355            None => Err(DurationError::Overflow),
356        }
357    }
358
359    /// Checked addition.
360    #[inline]
361    pub const fn checked_add(self, rhs: Self) -> Result<Self, DurationError> {
362        match self.nanos.checked_add(rhs.nanos) {
363            Some(n) => Ok(Self { nanos: n }),
364            None => Err(DurationError::Overflow),
365        }
366    }
367
368    /// Checked subtraction.
369    #[inline]
370    pub const fn checked_sub(self, rhs: Self) -> Result<Self, DurationError> {
371        match self.nanos.checked_sub(rhs.nanos) {
372            Some(n) => Ok(Self { nanos: n }),
373            None => Err(DurationError::Overflow),
374        }
375    }
376
377    /// Checked negation.
378    #[inline]
379    pub const fn checked_neg(self) -> Result<Self, DurationError> {
380        match self.nanos.checked_neg() {
381            Some(n) => Ok(Self { nanos: n }),
382            None => Err(DurationError::Overflow),
383        }
384    }
385
386    /// Saturating addition.
387    #[inline]
388    pub const fn saturating_add(self, rhs: Self) -> Self {
389        Self {
390            nanos: self.nanos.saturating_add(rhs.nanos),
391        }
392    }
393
394    /// Saturating subtraction.
395    #[inline]
396    pub const fn saturating_sub(self, rhs: Self) -> Self {
397        Self {
398            nanos: self.nanos.saturating_sub(rhs.nanos),
399        }
400    }
401
402    /// Round this duration to the nearest multiple of `quantum` (banker's
403    /// rounding / half-to-even). `quantum` must be strictly positive; a
404    /// non-positive quantum returns `self` unchanged to avoid surprising
405    /// errors in formatting paths.
406    #[inline]
407    pub const fn round_to(self, quantum: ExactDuration) -> Self {
408        let q = quantum.nanos;
409        if q <= 0 {
410            return self;
411        }
412        let n = self.nanos;
413        // Round-half-to-even on positive quantum, treating negative `n` symmetrically.
414        let div = n / q;
415        let rem = n - div * q;
416        let abs_rem = if rem < 0 { -rem } else { rem };
417        let half = q / 2;
418        let result = if abs_rem.saturating_mul(2) < q {
419            div
420        } else if abs_rem.saturating_mul(2) > q {
421            if n >= 0 {
422                div.saturating_add(1)
423            } else {
424                div.saturating_sub(1)
425            }
426        } else {
427            // Exact half — banker's rounding to even.
428            let _ = half;
429            if div % 2 == 0 {
430                div
431            } else if n >= 0 {
432                div.saturating_add(1)
433            } else {
434                div.saturating_sub(1)
435            }
436        };
437        Self {
438            nanos: result.saturating_mul(q),
439        }
440    }
441
442    /// Floor this duration toward negative infinity at `quantum`.
443    #[inline]
444    pub const fn floor_to(self, quantum: ExactDuration) -> Self {
445        let q = quantum.nanos;
446        if q <= 0 {
447            return self;
448        }
449        let n = self.nanos;
450        let div = n / q;
451        let rem = n - div * q;
452        let floor_div = if rem < 0 { div.saturating_sub(1) } else { div };
453        Self {
454            nanos: floor_div.saturating_mul(q),
455        }
456    }
457
458    /// Ceil this duration toward positive infinity at `quantum`.
459    #[inline]
460    pub const fn ceil_to(self, quantum: ExactDuration) -> Self {
461        let q = quantum.nanos;
462        if q <= 0 {
463            return self;
464        }
465        let n = self.nanos;
466        let div = n / q;
467        let rem = n - div * q;
468        let ceil_div = if rem > 0 { div.saturating_add(1) } else { div };
469        Self {
470            nanos: ceil_div.saturating_mul(q),
471        }
472    }
473}
474
475impl Default for ExactDuration {
476    #[inline]
477    fn default() -> Self {
478        Self::ZERO
479    }
480}
481
482impl PartialOrd for ExactDuration {
483    #[inline]
484    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
485        Some(self.cmp(other))
486    }
487}
488
489impl Ord for ExactDuration {
490    #[inline]
491    fn cmp(&self, other: &Self) -> Ordering {
492        self.nanos.cmp(&other.nanos)
493    }
494}
495
496impl core::fmt::Display for ExactDuration {
497    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
498        match self.as_seconds_i64_nanos_checked() {
499            Ok((s, n)) => {
500                if n == 0 {
501                    write!(f, "{s} s")
502                } else if s == 0 {
503                    if n < 0 {
504                        write!(f, "-0.{:09} s", -n)
505                    } else {
506                        write!(f, "0.{:09} s", n)
507                    }
508                } else {
509                    write!(f, "{s}.{:09} s", n.abs())
510                }
511            }
512            Err(_) => {
513                // Extreme duration outside i64 seconds range — fall back to raw nanos.
514                write!(f, "{} ns", self.nanos)
515            }
516        }
517    }
518}
519
520// ───────────────── Operators ─────────────────
521// Panics on overflow to match `Duration` ergonomics; use `checked_*` to opt out.
522
523impl Add for ExactDuration {
524    type Output = Self;
525    #[inline]
526    fn add(self, rhs: Self) -> Self {
527        self.checked_add(rhs)
528            .expect("ExactDuration::add overflowed i128 ns")
529    }
530}
531
532impl Sub for ExactDuration {
533    type Output = Self;
534    #[inline]
535    fn sub(self, rhs: Self) -> Self {
536        self.checked_sub(rhs)
537            .expect("ExactDuration::sub overflowed i128 ns")
538    }
539}
540
541impl Neg for ExactDuration {
542    type Output = Self;
543    #[inline]
544    fn neg(self) -> Self {
545        self.checked_neg()
546            .expect("ExactDuration::neg overflowed i128 ns")
547    }
548}
549
550impl AddAssign for ExactDuration {
551    #[inline]
552    fn add_assign(&mut self, rhs: Self) {
553        *self = *self + rhs;
554    }
555}
556
557impl SubAssign for ExactDuration {
558    #[inline]
559    fn sub_assign(&mut self, rhs: Self) {
560        *self = *self - rhs;
561    }
562}
563
564#[cfg(feature = "serde")]
565mod serde_impl {
566    use super::ExactDuration;
567    use serde::{Deserialize, Deserializer, Serialize, Serializer};
568
569    #[derive(Serialize, Deserialize)]
570    struct Boundary {
571        sec: i64,
572        ns: i32,
573    }
574
575    impl Serialize for ExactDuration {
576        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
577            let (sec, ns) = self
578                .as_seconds_i64_nanos_checked()
579                .map_err(|e| serde::ser::Error::custom(e.to_string()))?;
580            Boundary { sec, ns }.serialize(serializer)
581        }
582    }
583
584    impl<'de> Deserialize<'de> for ExactDuration {
585        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
586            let b = Boundary::deserialize(deserializer)?;
587            ExactDuration::from_canonical_seconds_nanos(b.sec, b.ns)
588                .map_err(|e| serde::de::Error::custom(e.to_string()))
589        }
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn zero_and_constants() {
599        assert_eq!(ExactDuration::ZERO.as_nanos_i128(), 0);
600        assert_eq!(ExactDuration::NANOSECOND.as_nanos_i128(), 1);
601        assert_eq!(ExactDuration::SECOND.as_nanos_i128(), NANOS_PER_SECOND);
602        assert!(ExactDuration::ZERO.is_zero());
603        assert!(!ExactDuration::SECOND.is_negative());
604        assert!((-ExactDuration::SECOND).is_negative());
605    }
606
607    #[test]
608    fn from_seconds_and_nanos_signs() {
609        let half_neg = ExactDuration::from_seconds_and_nanos(-1, 500_000_000).unwrap();
610        assert_eq!(half_neg.as_nanos_i128(), -NANOS_PER_SECOND + 500_000_000);
611        let half_pos = ExactDuration::from_seconds_and_nanos(0, 500_000_000).unwrap();
612        assert_eq!(half_pos.as_nanos_i128(), 500_000_000);
613    }
614
615    #[test]
616    fn boundary_projection_round_trip() {
617        for nanos in [
618            0_i128,
619            1,
620            -1,
621            NANOS_PER_SECOND,
622            -NANOS_PER_SECOND,
623            1_234_567_890,
624            -9_876_543_210,
625        ] {
626            let d = ExactDuration::from_nanos(nanos);
627            let (s, n) = d.as_seconds_i64_nanos();
628            let recovered = (s as i128) * NANOS_PER_SECOND + n as i128;
629            assert_eq!(recovered, nanos, "round trip failed for {nanos}");
630        }
631    }
632
633    #[test]
634    fn neg_round_trip_and_min_overflow() {
635        let d = ExactDuration::from_nanos(1_500_000_000);
636        assert_eq!((-(-d)), d);
637        assert!(matches!(
638            ExactDuration::MIN.checked_neg(),
639            Err(DurationError::Overflow)
640        ));
641    }
642
643    #[test]
644    fn ordering_matches_i128() {
645        let a = ExactDuration::from_nanos(-5);
646        let b = ExactDuration::from_nanos(0);
647        let c = ExactDuration::from_nanos(5);
648        assert!(a < b && b < c);
649        assert_eq!(a.cmp(&a), Ordering::Equal);
650    }
651
652    #[test]
653    fn checked_add_sub_overflow() {
654        assert_eq!(
655            ExactDuration::MAX.checked_add(ExactDuration::NANOSECOND),
656            Err(DurationError::Overflow)
657        );
658        assert_eq!(
659            ExactDuration::MIN.checked_sub(ExactDuration::NANOSECOND),
660            Err(DurationError::Overflow)
661        );
662        assert_eq!(
663            ExactDuration::ZERO
664                .checked_add(ExactDuration::SECOND)
665                .unwrap(),
666            ExactDuration::SECOND
667        );
668    }
669
670    #[test]
671    fn saturating_add_sub() {
672        assert_eq!(
673            ExactDuration::MAX.saturating_add(ExactDuration::SECOND),
674            ExactDuration::MAX
675        );
676        assert_eq!(
677            ExactDuration::MIN.saturating_sub(ExactDuration::SECOND),
678            ExactDuration::MIN
679        );
680    }
681
682    #[test]
683    fn quantity_round_trip_within_mantissa() {
684        let q = Second::new(123.456_789_012_345);
685        let d = ExactDuration::try_from_quantity(q).unwrap();
686        let back = d.as_quantity::<qtty::unit::Second>();
687        assert!((back.value() - q.value()).abs() < 1e-9);
688    }
689
690    #[test]
691    fn quantity_non_finite_errors() {
692        assert_eq!(
693            ExactDuration::try_from_quantity(Second::new(f64::NAN)),
694            Err(DurationError::NonFinite)
695        );
696        assert_eq!(
697            ExactDuration::try_from_quantity(Second::new(f64::INFINITY)),
698            Err(DurationError::NonFinite)
699        );
700    }
701
702    #[test]
703    fn quantity_overflow_errors() {
704        // 1e25 seconds is far outside i128 ns range (1.7e29 ns max).
705        // Use a value that triggers overflow when multiplied by 1e9.
706        let q = Second::new(1.0e30);
707        assert_eq!(
708            ExactDuration::try_from_quantity(q),
709            Err(DurationError::Overflow)
710        );
711    }
712
713    #[test]
714    fn quantity_unit_conversion() {
715        let ms = Quantity::<qtty::unit::Millisecond>::new(1500.0);
716        let d = ExactDuration::try_from_quantity(ms).unwrap();
717        assert_eq!(d.as_nanos_i128(), 1_500_000_000);
718
719        let day = Quantity::<qtty::unit::Day>::new(1.0);
720        let d2 = ExactDuration::try_from_quantity(day).unwrap();
721        assert_eq!(d2.as_nanos_i128(), 86_400 * NANOS_PER_SECOND);
722    }
723
724    #[test]
725    fn from_seconds_f64_lossy_handles_edges() {
726        assert!(ExactDuration::from_seconds_f64_lossy(f64::NAN).is_none());
727        assert!(ExactDuration::from_seconds_f64_lossy(f64::INFINITY).is_none());
728        assert_eq!(
729            ExactDuration::from_seconds_f64_lossy(1.5)
730                .unwrap()
731                .as_nanos_i128(),
732            1_500_000_000
733        );
734    }
735
736    #[test]
737    fn display_basic() {
738        assert_eq!(ExactDuration::SECOND.to_string(), "1 s");
739        assert_eq!(ExactDuration::from_nanos(0).to_string(), "0 s");
740        assert_eq!(
741            ExactDuration::from_seconds_and_nanos(3, 250_000_000)
742                .unwrap()
743                .to_string(),
744            "3.250000000 s"
745        );
746    }
747
748    #[test]
749    fn add_sub_neg_operators() {
750        let a = ExactDuration::SECOND;
751        let b = ExactDuration::NANOSECOND;
752        assert_eq!((a + b).as_nanos_i128(), 1_000_000_001);
753        assert_eq!((a - b).as_nanos_i128(), 999_999_999);
754        assert_eq!((-a).as_nanos_i128(), -1_000_000_000);
755
756        let mut c = a;
757        c += b;
758        assert_eq!(c.as_nanos_i128(), 1_000_000_001);
759        c -= b;
760        assert_eq!(c.as_nanos_i128(), 1_000_000_000);
761    }
762
763    #[test]
764    #[should_panic(expected = "overflowed")]
765    fn add_panics_on_overflow() {
766        let _ = ExactDuration::MAX + ExactDuration::NANOSECOND;
767    }
768
769    #[test]
770    fn checked_abs_works() {
771        assert_eq!(
772            ExactDuration::from_nanos(-5)
773                .checked_abs()
774                .unwrap()
775                .as_nanos_i128(),
776            5
777        );
778        assert!(matches!(
779            ExactDuration::MIN.checked_abs(),
780            Err(DurationError::Overflow)
781        ));
782    }
783
784    #[cfg(feature = "serde")]
785    #[test]
786    fn serde_round_trip() {
787        let cases = [0_i128, 1, -1, 1_500_000_000, -2_345_678_901];
788        for n in cases {
789            let d = ExactDuration::from_nanos(n);
790            let s = serde_json::to_string(&d).unwrap();
791            let back: ExactDuration = serde_json::from_str(&s).unwrap();
792            assert_eq!(back, d, "serde round-trip {n}");
793        }
794    }
795
796    #[test]
797    fn floor_ceil_round_basic() {
798        let q = ExactDuration::from_nanos(1_000_000_000); // 1 s
799        assert_eq!(
800            ExactDuration::from_nanos(1_500_000_000)
801                .floor_to(q)
802                .as_nanos_i128(),
803            1_000_000_000
804        );
805        assert_eq!(
806            ExactDuration::from_nanos(1_500_000_000)
807                .ceil_to(q)
808                .as_nanos_i128(),
809            2_000_000_000
810        );
811        // Half-to-even: 1.5 rounds to 2 (even); 2.5 rounds to 2 (even); 0.5 rounds to 0 (even).
812        assert_eq!(
813            ExactDuration::from_nanos(1_500_000_000)
814                .round_to(q)
815                .as_nanos_i128(),
816            2_000_000_000
817        );
818        assert_eq!(
819            ExactDuration::from_nanos(2_500_000_000)
820                .round_to(q)
821                .as_nanos_i128(),
822            2_000_000_000
823        );
824        assert_eq!(
825            ExactDuration::from_nanos(500_000_000)
826                .round_to(q)
827                .as_nanos_i128(),
828            0
829        );
830    }
831
832    #[test]
833    fn floor_ceil_round_negative() {
834        let q = ExactDuration::from_nanos(1_000_000_000);
835        // -1.5 s
836        let n = ExactDuration::from_nanos(-1_500_000_000);
837        assert_eq!(n.floor_to(q).as_nanos_i128(), -2_000_000_000);
838        assert_eq!(n.ceil_to(q).as_nanos_i128(), -1_000_000_000);
839        // half-to-even on -1.5 → -2 (even)
840        assert_eq!(n.round_to(q).as_nanos_i128(), -2_000_000_000);
841    }
842
843    #[test]
844    fn round_with_non_positive_quantum_is_identity() {
845        let n = ExactDuration::from_nanos(123);
846        assert_eq!(n.round_to(ExactDuration::ZERO), n);
847        assert_eq!(n.floor_to(ExactDuration::from_nanos(-1)), n);
848        assert_eq!(n.ceil_to(ExactDuration::ZERO), n);
849    }
850
851    #[test]
852    fn round_floor_ceil_saturate_at_extremes() {
853        let q = ExactDuration::SECOND;
854        // Near i128::MAX: result should not panic, may saturate.
855        let near_max = ExactDuration::MAX;
856        let _ = near_max.round_to(q);
857        let _ = near_max.floor_to(q);
858        let _ = near_max.ceil_to(q);
859        let near_min = ExactDuration::MIN;
860        let _ = near_min.round_to(q);
861        let _ = near_min.floor_to(q);
862        let _ = near_min.ceil_to(q);
863    }
864
865    #[test]
866    #[should_panic(expected = "ExactDuration::from_quantity")]
867    fn from_quantity_panics_on_nan() {
868        let _ = ExactDuration::from_quantity(Second::new(f64::NAN));
869    }
870
871    #[test]
872    #[should_panic(expected = "ExactDuration::from_quantity")]
873    fn from_quantity_panics_on_overflow() {
874        let _ = ExactDuration::from_quantity(Second::new(1.0e40));
875    }
876
877    #[cfg(feature = "serde")]
878    #[test]
879    fn serde_serialize_fails_on_out_of_range() {
880        // Duration of ~300 billion years — exceeds i64 seconds range.
881        let huge = ExactDuration::MAX;
882        let result = serde_json::to_string(&huge);
883        assert!(
884            result.is_err(),
885            "expected serde error for out-of-range duration"
886        );
887    }
888
889    #[test]
890    fn checked_projection_overflow_on_max() {
891        assert_eq!(
892            ExactDuration::MAX.as_seconds_i64_nanos_checked(),
893            Err(DurationError::Overflow)
894        );
895        assert_eq!(
896            ExactDuration::MIN.as_seconds_i64_nanos_checked(),
897            Err(DurationError::Overflow)
898        );
899    }
900
901    #[test]
902    fn checked_projection_small_round_trips() {
903        for nanos in [0_i128, 1, -1, 999_999_999, -999_999_999, 1_500_000_000] {
904            let d = ExactDuration::from_nanos(nanos);
905            let (s, n) = d.as_seconds_i64_nanos_checked().unwrap();
906            let recovered = (s as i128) * NANOS_PER_SECOND + n as i128;
907            assert_eq!(recovered, nanos, "checked round-trip failed for {nanos}");
908        }
909    }
910
911    #[test]
912    fn saturating_projection_extremes() {
913        let (s_max, _) = ExactDuration::MAX.as_seconds_i64_nanos_saturating();
914        assert_eq!(s_max, i64::MAX);
915        let (s_min, _) = ExactDuration::MIN.as_seconds_i64_nanos_saturating();
916        assert_eq!(s_min, i64::MIN);
917    }
918
919    #[test]
920    #[should_panic(expected = "as_seconds_i64_nanos: seconds out of i64 range")]
921    fn panicking_projection_panics_on_max() {
922        let _ = ExactDuration::MAX.as_seconds_i64_nanos();
923    }
924
925    #[test]
926    fn canonical_constructor_validates_nanos() {
927        // Valid canonical forms
928        assert!(ExactDuration::from_canonical_seconds_nanos(5, 0).is_ok());
929        assert!(ExactDuration::from_canonical_seconds_nanos(0, 999_999_999).is_ok());
930        assert!(ExactDuration::from_canonical_seconds_nanos(-1, -999_999_999).is_ok());
931        assert!(ExactDuration::from_canonical_seconds_nanos(0, 0).is_ok());
932        assert!(ExactDuration::from_canonical_seconds_nanos(0, -999_999_999).is_ok());
933        // Invalid: |nanos| >= 1_000_000_000
934        assert_eq!(
935            ExactDuration::from_canonical_seconds_nanos(0, 1_000_000_000),
936            Err(DurationError::Overflow)
937        );
938        assert_eq!(
939            ExactDuration::from_canonical_seconds_nanos(0, -1_000_000_000),
940            Err(DurationError::Overflow)
941        );
942        // Invalid: sign mismatch — returns NonCanonical
943        assert_eq!(
944            ExactDuration::from_canonical_seconds_nanos(1, -1),
945            Err(DurationError::NonCanonical)
946        );
947        assert_eq!(
948            ExactDuration::from_canonical_seconds_nanos(-1, 1),
949            Err(DurationError::NonCanonical)
950        );
951        assert_eq!(
952            ExactDuration::from_canonical_seconds_nanos(100, -500_000_000),
953            Err(DurationError::NonCanonical)
954        );
955        assert_eq!(
956            ExactDuration::from_canonical_seconds_nanos(-100, 500_000_000),
957            Err(DurationError::NonCanonical)
958        );
959    }
960
961    #[test]
962    fn display_extreme_falls_back_to_raw_nanos() {
963        // ExactDuration::MAX seconds > i64 range; display must not panic.
964        let s = ExactDuration::MAX.to_string();
965        assert!(s.contains("ns"), "expected raw-ns fallback, got: {s}");
966    }
967
968    #[cfg(feature = "serde")]
969    #[test]
970    fn serde_rejects_non_canonical_pairs() {
971        // sec=0, ns=1_000_000_000: nanos out of range
972        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":0,"ns":1000000000}"#);
973        assert!(r.is_err(), "expected Err for ns=1e9, got {:?}", r);
974
975        // sec=0, ns=-1_000_000_000: nanos out of range
976        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":0,"ns":-1000000000}"#);
977        assert!(r.is_err(), "expected Err for ns=-1e9, got {:?}", r);
978
979        // sec=1, ns=-1: sign mismatch
980        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":1,"ns":-1}"#);
981        assert!(r.is_err(), "expected Err for sec=1,ns=-1, got {:?}", r);
982
983        // sec=-1, ns=1: sign mismatch
984        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":-1,"ns":1}"#);
985        assert!(r.is_err(), "expected Err for sec=-1,ns=1, got {:?}", r);
986    }
987
988    #[test]
989    fn qtty_integer_nanosecond_round_trip() {
990        // Small positive
991        let d = ExactDuration::from_nanos(123_456_789);
992        let q = d.as_nanoseconds_i().unwrap();
993        assert_eq!(q.value(), 123_456_789_i64);
994        let back = ExactDuration::from_nanoseconds_i(q);
995        assert_eq!(back, d);
996
997        // Small negative
998        let d2 = ExactDuration::from_nanos(-999_000_000);
999        let q2 = d2.as_nanoseconds_i().unwrap();
1000        assert_eq!(q2.value(), -999_000_000_i64);
1001        assert_eq!(ExactDuration::from_nanoseconds_i(q2), d2);
1002
1003        // Zero
1004        let q0 = ExactDuration::ZERO.as_nanoseconds_i().unwrap();
1005        assert_eq!(q0.value(), 0_i64);
1006    }
1007
1008    #[test]
1009    fn qtty_integer_nanosecond_overflow() {
1010        // ExactDuration::MAX nanos >> i64::MAX → overflow
1011        assert_eq!(
1012            ExactDuration::MAX.as_nanoseconds_i(),
1013            Err(DurationError::Overflow)
1014        );
1015        assert_eq!(
1016            ExactDuration::MIN.as_nanoseconds_i(),
1017            Err(DurationError::Overflow)
1018        );
1019    }
1020}