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//!   [`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 qtty::time::TimeUnit;
46use qtty::unit::Second as SecondUnit;
47use qtty::{Quantity, Second};
48
49/// Nanoseconds per second; convenience constant for boundary code.
50pub const NANOS_PER_SECOND: i128 = 1_000_000_000;
51
52/// Error type for fallible [`ExactDuration`] operations.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DurationError {
55    /// Arithmetic overflowed the `i128`-nanosecond representation.
56    Overflow,
57    /// Input scalar was NaN or infinite.
58    NonFinite,
59    /// `(seconds, nanos)` pair violates the canonical sign invariant:
60    /// `seconds > 0` requires `nanos >= 0`, and `seconds < 0` requires `nanos <= 0`.
61    NonCanonical,
62}
63
64impl core::fmt::Display for DurationError {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        match self {
67            Self::Overflow => f.write_str("ExactDuration arithmetic overflowed i128 nanoseconds"),
68            Self::NonFinite => f.write_str("ExactDuration input was NaN or infinite"),
69            Self::NonCanonical => f.write_str(
70                "ExactDuration (seconds, nanos) pair is non-canonical: \
71                 signs must agree (seconds > 0 ⇒ nanos ≥ 0; seconds < 0 ⇒ nanos ≤ 0)",
72            ),
73        }
74    }
75}
76
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 `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::<SecondUnit>().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 `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: qtty::i64::Nanosecond) -> Self {
304        Self::from_nanos(nanos.value() as i128)
305    }
306
307    /// Build from a typed `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: qtty::i64::Second) -> Self {
315        Self::from_nanos(seconds.value() as i128 * NANOS_PER_SECOND)
316    }
317
318    /// Project to a typed `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<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(qtty::i64::Nanosecond::new(self.nanos as i64))
328        }
329    }
330
331    /// Project back into a `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    use qtty::unit::{Day as DayUnit, Millisecond as MsUnit};
597
598    #[test]
599    fn zero_and_constants() {
600        assert_eq!(ExactDuration::ZERO.as_nanos_i128(), 0);
601        assert_eq!(ExactDuration::NANOSECOND.as_nanos_i128(), 1);
602        assert_eq!(ExactDuration::SECOND.as_nanos_i128(), NANOS_PER_SECOND);
603        assert!(ExactDuration::ZERO.is_zero());
604        assert!(!ExactDuration::SECOND.is_negative());
605        assert!((-ExactDuration::SECOND).is_negative());
606    }
607
608    #[test]
609    fn from_seconds_and_nanos_signs() {
610        let half_neg = ExactDuration::from_seconds_and_nanos(-1, 500_000_000).unwrap();
611        assert_eq!(half_neg.as_nanos_i128(), -NANOS_PER_SECOND + 500_000_000);
612        let half_pos = ExactDuration::from_seconds_and_nanos(0, 500_000_000).unwrap();
613        assert_eq!(half_pos.as_nanos_i128(), 500_000_000);
614    }
615
616    #[test]
617    fn boundary_projection_round_trip() {
618        for nanos in [
619            0_i128,
620            1,
621            -1,
622            NANOS_PER_SECOND,
623            -NANOS_PER_SECOND,
624            1_234_567_890,
625            -9_876_543_210,
626        ] {
627            let d = ExactDuration::from_nanos(nanos);
628            let (s, n) = d.as_seconds_i64_nanos();
629            let recovered = (s as i128) * NANOS_PER_SECOND + n as i128;
630            assert_eq!(recovered, nanos, "round trip failed for {nanos}");
631        }
632    }
633
634    #[test]
635    fn neg_round_trip_and_min_overflow() {
636        let d = ExactDuration::from_nanos(1_500_000_000);
637        assert_eq!((-(-d)), d);
638        assert!(matches!(
639            ExactDuration::MIN.checked_neg(),
640            Err(DurationError::Overflow)
641        ));
642    }
643
644    #[test]
645    fn ordering_matches_i128() {
646        let a = ExactDuration::from_nanos(-5);
647        let b = ExactDuration::from_nanos(0);
648        let c = ExactDuration::from_nanos(5);
649        assert!(a < b && b < c);
650        assert_eq!(a.cmp(&a), Ordering::Equal);
651    }
652
653    #[test]
654    fn checked_add_sub_overflow() {
655        assert_eq!(
656            ExactDuration::MAX.checked_add(ExactDuration::NANOSECOND),
657            Err(DurationError::Overflow)
658        );
659        assert_eq!(
660            ExactDuration::MIN.checked_sub(ExactDuration::NANOSECOND),
661            Err(DurationError::Overflow)
662        );
663        assert_eq!(
664            ExactDuration::ZERO
665                .checked_add(ExactDuration::SECOND)
666                .unwrap(),
667            ExactDuration::SECOND
668        );
669    }
670
671    #[test]
672    fn saturating_add_sub() {
673        assert_eq!(
674            ExactDuration::MAX.saturating_add(ExactDuration::SECOND),
675            ExactDuration::MAX
676        );
677        assert_eq!(
678            ExactDuration::MIN.saturating_sub(ExactDuration::SECOND),
679            ExactDuration::MIN
680        );
681    }
682
683    #[test]
684    fn quantity_round_trip_within_mantissa() {
685        let q = Second::new(123.456_789_012_345);
686        let d = ExactDuration::try_from_quantity(q).unwrap();
687        let back = d.as_quantity::<SecondUnit>();
688        assert!((back.value() - q.value()).abs() < 1e-9);
689    }
690
691    #[test]
692    fn quantity_non_finite_errors() {
693        assert_eq!(
694            ExactDuration::try_from_quantity(Second::new(f64::NAN)),
695            Err(DurationError::NonFinite)
696        );
697        assert_eq!(
698            ExactDuration::try_from_quantity(Second::new(f64::INFINITY)),
699            Err(DurationError::NonFinite)
700        );
701    }
702
703    #[test]
704    fn quantity_overflow_errors() {
705        // 1e25 seconds is far outside i128 ns range (1.7e29 ns max).
706        // Use a value that triggers overflow when multiplied by 1e9.
707        let q = Second::new(1.0e30);
708        assert_eq!(
709            ExactDuration::try_from_quantity(q),
710            Err(DurationError::Overflow)
711        );
712    }
713
714    #[test]
715    fn quantity_unit_conversion() {
716        let ms = Quantity::<MsUnit>::new(1500.0);
717        let d = ExactDuration::try_from_quantity(ms).unwrap();
718        assert_eq!(d.as_nanos_i128(), 1_500_000_000);
719
720        let day = Quantity::<DayUnit>::new(1.0);
721        let d2 = ExactDuration::try_from_quantity(day).unwrap();
722        assert_eq!(d2.as_nanos_i128(), 86_400 * NANOS_PER_SECOND);
723    }
724
725    #[test]
726    fn from_seconds_f64_lossy_handles_edges() {
727        assert!(ExactDuration::from_seconds_f64_lossy(f64::NAN).is_none());
728        assert!(ExactDuration::from_seconds_f64_lossy(f64::INFINITY).is_none());
729        assert_eq!(
730            ExactDuration::from_seconds_f64_lossy(1.5)
731                .unwrap()
732                .as_nanos_i128(),
733            1_500_000_000
734        );
735    }
736
737    #[test]
738    fn display_basic() {
739        assert_eq!(ExactDuration::SECOND.to_string(), "1 s");
740        assert_eq!(ExactDuration::from_nanos(0).to_string(), "0 s");
741        assert_eq!(
742            ExactDuration::from_seconds_and_nanos(3, 250_000_000)
743                .unwrap()
744                .to_string(),
745            "3.250000000 s"
746        );
747    }
748
749    #[test]
750    fn add_sub_neg_operators() {
751        let a = ExactDuration::SECOND;
752        let b = ExactDuration::NANOSECOND;
753        assert_eq!((a + b).as_nanos_i128(), 1_000_000_001);
754        assert_eq!((a - b).as_nanos_i128(), 999_999_999);
755        assert_eq!((-a).as_nanos_i128(), -1_000_000_000);
756
757        let mut c = a;
758        c += b;
759        assert_eq!(c.as_nanos_i128(), 1_000_000_001);
760        c -= b;
761        assert_eq!(c.as_nanos_i128(), 1_000_000_000);
762    }
763
764    #[test]
765    #[should_panic(expected = "overflowed")]
766    fn add_panics_on_overflow() {
767        let _ = ExactDuration::MAX + ExactDuration::NANOSECOND;
768    }
769
770    #[test]
771    fn checked_abs_works() {
772        assert_eq!(
773            ExactDuration::from_nanos(-5)
774                .checked_abs()
775                .unwrap()
776                .as_nanos_i128(),
777            5
778        );
779        assert!(matches!(
780            ExactDuration::MIN.checked_abs(),
781            Err(DurationError::Overflow)
782        ));
783    }
784
785    #[cfg(feature = "serde")]
786    #[test]
787    fn serde_round_trip() {
788        let cases = [0_i128, 1, -1, 1_500_000_000, -2_345_678_901];
789        for n in cases {
790            let d = ExactDuration::from_nanos(n);
791            let s = serde_json::to_string(&d).unwrap();
792            let back: ExactDuration = serde_json::from_str(&s).unwrap();
793            assert_eq!(back, d, "serde round-trip {n}");
794        }
795    }
796
797    #[test]
798    fn floor_ceil_round_basic() {
799        let q = ExactDuration::from_nanos(1_000_000_000); // 1 s
800        assert_eq!(
801            ExactDuration::from_nanos(1_500_000_000)
802                .floor_to(q)
803                .as_nanos_i128(),
804            1_000_000_000
805        );
806        assert_eq!(
807            ExactDuration::from_nanos(1_500_000_000)
808                .ceil_to(q)
809                .as_nanos_i128(),
810            2_000_000_000
811        );
812        // Half-to-even: 1.5 rounds to 2 (even); 2.5 rounds to 2 (even); 0.5 rounds to 0 (even).
813        assert_eq!(
814            ExactDuration::from_nanos(1_500_000_000)
815                .round_to(q)
816                .as_nanos_i128(),
817            2_000_000_000
818        );
819        assert_eq!(
820            ExactDuration::from_nanos(2_500_000_000)
821                .round_to(q)
822                .as_nanos_i128(),
823            2_000_000_000
824        );
825        assert_eq!(
826            ExactDuration::from_nanos(500_000_000)
827                .round_to(q)
828                .as_nanos_i128(),
829            0
830        );
831    }
832
833    #[test]
834    fn floor_ceil_round_negative() {
835        let q = ExactDuration::from_nanos(1_000_000_000);
836        // -1.5 s
837        let n = ExactDuration::from_nanos(-1_500_000_000);
838        assert_eq!(n.floor_to(q).as_nanos_i128(), -2_000_000_000);
839        assert_eq!(n.ceil_to(q).as_nanos_i128(), -1_000_000_000);
840        // half-to-even on -1.5 → -2 (even)
841        assert_eq!(n.round_to(q).as_nanos_i128(), -2_000_000_000);
842    }
843
844    #[test]
845    fn round_with_non_positive_quantum_is_identity() {
846        let n = ExactDuration::from_nanos(123);
847        assert_eq!(n.round_to(ExactDuration::ZERO), n);
848        assert_eq!(n.floor_to(ExactDuration::from_nanos(-1)), n);
849        assert_eq!(n.ceil_to(ExactDuration::ZERO), n);
850    }
851
852    #[test]
853    fn round_floor_ceil_saturate_at_extremes() {
854        let q = ExactDuration::SECOND;
855        // Near i128::MAX: result should not panic, may saturate.
856        let near_max = ExactDuration::MAX;
857        let _ = near_max.round_to(q);
858        let _ = near_max.floor_to(q);
859        let _ = near_max.ceil_to(q);
860        let near_min = ExactDuration::MIN;
861        let _ = near_min.round_to(q);
862        let _ = near_min.floor_to(q);
863        let _ = near_min.ceil_to(q);
864    }
865
866    #[test]
867    #[should_panic(expected = "ExactDuration::from_quantity")]
868    fn from_quantity_panics_on_nan() {
869        let _ = ExactDuration::from_quantity(Second::new(f64::NAN));
870    }
871
872    #[test]
873    #[should_panic(expected = "ExactDuration::from_quantity")]
874    fn from_quantity_panics_on_overflow() {
875        let _ = ExactDuration::from_quantity(Second::new(1.0e40));
876    }
877
878    #[cfg(feature = "serde")]
879    #[test]
880    fn serde_serialize_fails_on_out_of_range() {
881        // Duration of ~300 billion years — exceeds i64 seconds range.
882        let huge = ExactDuration::MAX;
883        let result = serde_json::to_string(&huge);
884        assert!(
885            result.is_err(),
886            "expected serde error for out-of-range duration"
887        );
888    }
889
890    #[test]
891    fn checked_projection_overflow_on_max() {
892        assert_eq!(
893            ExactDuration::MAX.as_seconds_i64_nanos_checked(),
894            Err(DurationError::Overflow)
895        );
896        assert_eq!(
897            ExactDuration::MIN.as_seconds_i64_nanos_checked(),
898            Err(DurationError::Overflow)
899        );
900    }
901
902    #[test]
903    fn checked_projection_small_round_trips() {
904        for nanos in [0_i128, 1, -1, 999_999_999, -999_999_999, 1_500_000_000] {
905            let d = ExactDuration::from_nanos(nanos);
906            let (s, n) = d.as_seconds_i64_nanos_checked().unwrap();
907            let recovered = (s as i128) * NANOS_PER_SECOND + n as i128;
908            assert_eq!(recovered, nanos, "checked round-trip failed for {nanos}");
909        }
910    }
911
912    #[test]
913    fn saturating_projection_extremes() {
914        let (s_max, _) = ExactDuration::MAX.as_seconds_i64_nanos_saturating();
915        assert_eq!(s_max, i64::MAX);
916        let (s_min, _) = ExactDuration::MIN.as_seconds_i64_nanos_saturating();
917        assert_eq!(s_min, i64::MIN);
918    }
919
920    #[test]
921    #[should_panic(expected = "as_seconds_i64_nanos: seconds out of i64 range")]
922    fn panicking_projection_panics_on_max() {
923        let _ = ExactDuration::MAX.as_seconds_i64_nanos();
924    }
925
926    #[test]
927    fn canonical_constructor_validates_nanos() {
928        // Valid canonical forms
929        assert!(ExactDuration::from_canonical_seconds_nanos(5, 0).is_ok());
930        assert!(ExactDuration::from_canonical_seconds_nanos(0, 999_999_999).is_ok());
931        assert!(ExactDuration::from_canonical_seconds_nanos(-1, -999_999_999).is_ok());
932        assert!(ExactDuration::from_canonical_seconds_nanos(0, 0).is_ok());
933        assert!(ExactDuration::from_canonical_seconds_nanos(0, -999_999_999).is_ok());
934        // Invalid: |nanos| >= 1_000_000_000
935        assert_eq!(
936            ExactDuration::from_canonical_seconds_nanos(0, 1_000_000_000),
937            Err(DurationError::Overflow)
938        );
939        assert_eq!(
940            ExactDuration::from_canonical_seconds_nanos(0, -1_000_000_000),
941            Err(DurationError::Overflow)
942        );
943        // Invalid: sign mismatch — returns NonCanonical
944        assert_eq!(
945            ExactDuration::from_canonical_seconds_nanos(1, -1),
946            Err(DurationError::NonCanonical)
947        );
948        assert_eq!(
949            ExactDuration::from_canonical_seconds_nanos(-1, 1),
950            Err(DurationError::NonCanonical)
951        );
952        assert_eq!(
953            ExactDuration::from_canonical_seconds_nanos(100, -500_000_000),
954            Err(DurationError::NonCanonical)
955        );
956        assert_eq!(
957            ExactDuration::from_canonical_seconds_nanos(-100, 500_000_000),
958            Err(DurationError::NonCanonical)
959        );
960    }
961
962    #[test]
963    fn display_extreme_falls_back_to_raw_nanos() {
964        // ExactDuration::MAX seconds > i64 range; display must not panic.
965        let s = ExactDuration::MAX.to_string();
966        assert!(s.contains("ns"), "expected raw-ns fallback, got: {s}");
967    }
968
969    #[cfg(feature = "serde")]
970    #[test]
971    fn serde_rejects_non_canonical_pairs() {
972        // sec=0, ns=1_000_000_000: nanos out of range
973        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":0,"ns":1000000000}"#);
974        assert!(r.is_err(), "expected Err for ns=1e9, got {:?}", r);
975
976        // sec=0, ns=-1_000_000_000: nanos out of range
977        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":0,"ns":-1000000000}"#);
978        assert!(r.is_err(), "expected Err for ns=-1e9, got {:?}", r);
979
980        // sec=1, ns=-1: sign mismatch
981        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":1,"ns":-1}"#);
982        assert!(r.is_err(), "expected Err for sec=1,ns=-1, got {:?}", r);
983
984        // sec=-1, ns=1: sign mismatch
985        let r: Result<ExactDuration, _> = serde_json::from_str(r#"{"sec":-1,"ns":1}"#);
986        assert!(r.is_err(), "expected Err for sec=-1,ns=1, got {:?}", r);
987    }
988
989    #[test]
990    fn qtty_integer_nanosecond_round_trip() {
991        // Small positive
992        let d = ExactDuration::from_nanos(123_456_789);
993        let q = d.as_nanoseconds_i().unwrap();
994        assert_eq!(q.value(), 123_456_789_i64);
995        let back = ExactDuration::from_nanoseconds_i(q);
996        assert_eq!(back, d);
997
998        // Small negative
999        let d2 = ExactDuration::from_nanos(-999_000_000);
1000        let q2 = d2.as_nanoseconds_i().unwrap();
1001        assert_eq!(q2.value(), -999_000_000_i64);
1002        assert_eq!(ExactDuration::from_nanoseconds_i(q2), d2);
1003
1004        // Zero
1005        let q0 = ExactDuration::ZERO.as_nanoseconds_i().unwrap();
1006        assert_eq!(q0.value(), 0_i64);
1007    }
1008
1009    #[test]
1010    fn qtty_integer_nanosecond_overflow() {
1011        // ExactDuration::MAX nanos >> i64::MAX → overflow
1012        assert_eq!(
1013            ExactDuration::MAX.as_nanoseconds_i(),
1014            Err(DurationError::Overflow)
1015        );
1016        assert_eq!(
1017            ExactDuration::MIN.as_nanoseconds_i(),
1018            Err(DurationError::Overflow)
1019        );
1020    }
1021}