Repository: BurntSushi/jiff (language: rust)

Issue:
Zoned + 1.second() loses subsec nanoseconds
Example with jiff 0.1.26:

```rust
use std::str::FromStr;
use jiff::{ToSpan, Zoned};

fn main() {
    let t0 = Zoned::from_str("2025-01-25T19:32:21.783444592+01:00[Europe/Paris]").unwrap();
    let span = 1.second();
    let t1 = &t0 + span;
    println!("{t0} + {span:#} = {t1}");
    assert_eq!(t0, &t1 - span, "should be reversible");
}
```

Output:

```
2025-01-25T19:32:21.783444592+01:00[Europe/Paris] + 1s = 2025-01-25T19:32:22+01:00[Europe/Paris]
thread 'main' panicked at scratchpad/src/main.rs:10:5:
assertion `left == right` failed: should be reversible
  left: 2025-01-25T19:32:21.783444592+01:00[Europe/Paris]
 right: 2025-01-25T19:32:21+01:00[Europe/Paris]
````

Note that the subsec nanoseconds were zeroed out during the first addition (and not magically restored in the inverse subtraction). I find this surprising since both types have nanosecond precision. It also appears to contradict statements such as `Zoned::checked_add`'s:

> If spans of time are limited to units of hours (or less), then this routine is reversible. This also implies that all operations with a [SignedDuration](https://docs.rs/jiff/latest/jiff/struct.SignedDuration.html) or a [std::time::Duration](https://doc.rust-lang.org/nightly/core/time/struct.Duration.html) are reversible.

The precise datetime or time zone doesn't seem to matter, except that it (unsurprisingly) needs to have non-zero subsec nanoseconds. Some variations I've tried that *do not* exhibit this problem:

- A `Span` storing nanoseconds, either constructed directly or via `1.second().round(SpanRound::new().largest(Unit::Nanosecond))`.
- Using `civil::DateTime` in place of `Zoned`.
- Using an ordinary `std::time::Duration` instead of a `Span`.



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ce79d5..bb36130 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
 # CHANGELOG
 
+0.1.27 (2025-01-25)
+===================
+This is a small release with a bug fix for precision loss in some cases when
+doing arithmetic on `Timestamp` or `Zoned`.
+
+Bug fixes:
+
+* [#223](https://github.com/BurntSushi/jiff/issues/223):
+Fix the check for fractional seconds before taking the fast path.
+
+
 0.1.26 (2025-01-23)
 ===================
 This is a small release with another deprecation and a new API for doing
diff --git a/src/timestamp.rs b/src/timestamp.rs
index 10aa7a1..99ee0dd 100644
--- a/src/timestamp.rs
+++ b/src/timestamp.rs
@@ -1257,12 +1257,19 @@ impl Timestamp {
         }
         // The common case is probably a span without fractional seconds, so
         // we specialize for that since it requires a fair bit less math.
-        if let Some(span_seconds) = span.to_invariant_seconds() {
-            let time_seconds = self.as_second_ranged();
-            let sum = time_seconds
-                .try_checked_add("span", span_seconds)
-                .with_context(|| err!("adding {span} to {self} overflowed"))?;
-            return Ok(Timestamp::from_second_ranged(sum));
+        //
+        // Note that this only works when *both* the span and timestamp lack
+        // fractional seconds.
+        if self.subsec_nanosecond_ranged() == 0 {
+            if let Some(span_seconds) = span.to_invariant_seconds() {
+                let time_seconds = self.as_second_ranged();
+                let sum = time_seconds
+                    .try_checked_add("span", span_seconds)
+                    .with_context(|| {
+                    err!("adding {span} to {self} overflowed")
+                })?;
+                return Ok(Timestamp::from_second_ranged(sum));
+            }
         }
         let time_nanos = self.as_nanosecond_ranged();
         let span_nanos = span.to_invariant_nanoseconds();
@@ -3544,11 +3551,14 @@ impl From<(Unit, i64)> for TimestampRound {
 
 #[cfg(test)]
 mod tests {
+    use alloc::string::ToString;
+
     use std::io::Cursor;
 
     use crate::{
         civil::{self, datetime},
         tz::Offset,
+        ToSpan,
     };
 
     use super::*;
@@ -3756,4 +3766,14 @@ mod tests {
 
         assert_eq!(deserialized, expected);
     }
+
+    #[test]
+    fn timestamp_precision_loss() {
+        let ts1: Timestamp =
+            "2025-01-25T19:32:21.783444592+01:00".parse().unwrap();
+        let span = 1.second();
+        let ts2 = ts1 + span;
+        assert_eq!(ts2.to_string(), "2025-01-25T18:32:22.783444592Z");
+        assert_eq!(ts1, ts2 - span, "should be reversible");
+    }
 }
diff --git a/src/zoned.rs b/src/zoned.rs
index 85a81cf..b67771f 100644
--- a/src/zoned.rs
+++ b/src/zoned.rs
@@ -5483,4 +5483,22 @@ mod tests {
             .unwrap();
         assert_eq!(zdt2.to_string(), "2024-03-10T01:30:00-05:00[US/Eastern]");
     }
+
+    #[test]
+    fn zoned_precision_loss() {
+        if crate::tz::db().is_definitively_empty() {
+            return;
+        }
+
+        let zdt1: Zoned = "2025-01-25T19:32:21.783444592+01:00[Europe/Paris]"
+            .parse()
+            .unwrap();
+        let span = 1.second();
+        let zdt2 = &zdt1 + span;
+        assert_eq!(
+            zdt2.to_string(),
+            "2025-01-25T19:32:22.783444592+01:00[Europe/Paris]"
+        );
+        assert_eq!(zdt1, &zdt2 - span, "should be reversible");
+    }
 }


Test patch: