Repository: BurntSushi/jiff (language: rust)

Issue:
Jiff should use minute precision when comparing offsets after parsing zoned datetimes
This Jiff program:

```rust
use jiff::Zoned;

fn main() -> anyhow::Result<()> {
    let zdt: Zoned =
        "1969-12-31T23:15:30-00:44:30[Africa/Monrovia]".parse()?;
    dbg!(&zdt);
    let zdt: Zoned = "1969-12-31T23:15:30-00:45[Africa/Monrovia]".parse()?;
    dbg!(&zdt);

    Ok(())
}
```

Has this output:

```
$ cargo -q r
[main.rs:6:5] &zdt = 1969-12-31T23:15:30-00:45[Africa/Monrovia]
Error: parsing "1969-12-31T23:15:30-00:45[Africa/Monrovia]" failed: datetime 1969-12-31T23:15:30 could not resolve to a timestamp since 'reject' conflict resolution was chosen, and because datetime has offset -00:45, but the time zone Africa/Monrovia for the given datetime unambiguously has offset -00:44:30
```

But it shouldn't error. The reason is that `-00:45` as parsed in the second example doesn't match the actual internal offset of `-00:44:30`. 

I realized Jiff had a bug here because of this Temporal issue: https://github.com/tc39/proposal-temporal/issues/3079
add `jiff::tz::Offset::round`
This is useful for supporting #231, so we might as well make it public. We can reuse the rounding implementation for `SignedDuration`, but being careful to make sure we only support `Unit::Hour`, `Unit::Minute` and `Unit::Second`.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dd8662..dfd5f26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,13 @@ Enhancements:
 Opt-in support for using Serde with `jiff::tz::TimeZone` has been added.
 * [#227](https://github.com/BurntSushi/jiff/issues/227):
 The `civil::ISOWeekDate` API has been beefed up with a few convenience methods.
+* [#233](https://github.com/BurntSushi/jiff/issues/233):
+Add `tz::Offset::round` for rounding time zone offsets.
+
+Bug fixes:
+
+* [#231](https://github.com/BurntSushi/jiff/issues/231):
+Use more flexible offset equality when parsing offsets with fractional minutes.
 
 
 0.1.28 (2025-01-27)
diff --git a/src/fmt/temporal/parser.rs b/src/fmt/temporal/parser.rs
index 7f3bad9..7464ad0 100644
--- a/src/fmt/temporal/parser.rs
+++ b/src/fmt/temporal/parser.rs
@@ -13,7 +13,7 @@ use crate::{
     },
     span::Span,
     tz::{
-        AmbiguousZoned, Disambiguation, OffsetConflict, TimeZone,
+        AmbiguousZoned, Disambiguation, Offset, OffsetConflict, TimeZone,
         TimeZoneDatabase,
     },
     util::{escape, parse, t},
@@ -92,9 +92,27 @@ impl<'i> ParsedDateTime<'i> {
             return Ok(tz.into_ambiguous_zoned(dt));
         };
         let offset = parsed_offset.to_offset()?;
-        offset_conflict.resolve(dt, offset, tz).with_context(|| {
-            err!("parsing {input:?} failed", input = self.input)
-        })
+        let is_equal = |parsed: Offset, candidate: Offset| {
+            // If they're equal down to the second, then no amount of rounding
+            // or whatever should change that.
+            if parsed == candidate {
+                return true;
+            }
+            // If the candidate offset we're considering is a whole minute,
+            // then we never need rounding.
+            if candidate.part_seconds_ranged() == 0 {
+                return parsed == candidate;
+            }
+            let Ok(candidate) = candidate.round(Unit::Minute) else {
+                // This is a degenerate case and this is the only sensible
+                // thing to do.
+                return parsed == candidate;
+            };
+            parsed == candidate
+        };
+        offset_conflict.resolve_with(dt, offset, tz, is_equal).with_context(
+            || err!("parsing {input:?} failed", input = self.input),
+        )
     }
 
     #[inline(always)]
diff --git a/src/signed_duration.rs b/src/signed_duration.rs
index 254a789..1d472cc 100644
--- a/src/signed_duration.rs
+++ b/src/signed_duration.rs
@@ -2086,6 +2086,12 @@ impl TryFrom<SignedDuration> for Duration {
     }
 }
 
+impl From<Offset> for SignedDuration {
+    fn from(offset: Offset) -> SignedDuration {
+        SignedDuration::from_secs(i64::from(offset.seconds()))
+    }
+}
+
 impl core::str::FromStr for SignedDuration {
     type Err = Error;
 
@@ -2386,6 +2392,11 @@ impl SignedDurationRound {
         SignedDurationRound { increment, ..self }
     }
 
+    /// Returns the `smallest` unit configuration.
+    pub(crate) fn get_smallest(&self) -> Unit {
+        self.smallest
+    }
+
     /// Does the actual duration rounding.
     fn round(&self, dur: SignedDuration) -> Result<SignedDuration, Error> {
         if self.smallest > Unit::Hour {
diff --git a/src/tz/mod.rs b/src/tz/mod.rs
index 3b09485..00c95bb 100644
--- a/src/tz/mod.rs
+++ b/src/tz/mod.rs
@@ -97,7 +97,7 @@ use self::posix::ReasonablePosixTimeZone;
 pub use self::db::TimeZoneNameIter;
 pub use self::{
     db::{db, TimeZoneDatabase},
-    offset::{Dst, Offset, OffsetArithmetic, OffsetConflict},
+    offset::{Dst, Offset, OffsetArithmetic, OffsetConflict, OffsetRound},
 };
 
 #[cfg(feature = "tzdb-concatenated")]
diff --git a/src/tz/offset.rs b/src/tz/offset.rs
index b359e10..cb0a34a 100644
--- a/src/tz/offset.rs
+++ b/src/tz/offset.rs
@@ -15,7 +15,7 @@ use crate::{
         rangeint::{RFrom, RInto, TryRFrom},
         t::{self, C},
     },
-    SignedDuration,
+    RoundMode, SignedDuration, SignedDurationRound, Unit,
 };
 
 /// An enum indicating whether a particular datetime  is in DST or not.
@@ -888,10 +888,80 @@ impl Offset {
         SignedDuration::offset_until(other, self)
     }
 
-    /// Returns this offset as a [`SignedDuration`].
+    /// Returns a new offset that is rounded according to the given
+    /// configuration.
+    ///
+    /// Rounding an offset has a number of parameters, all of which are
+    /// optional. When no parameters are given, then no rounding is done, and
+    /// the offset as given is returned. That is, it's a no-op.
+    ///
+    /// As is consistent with `Offset` itself, rounding only supports units of
+    /// hours, minutes or seconds. If any other unit is provided, then an error
+    /// is returned.
+    ///
+    /// The parameters are, in brief:
+    ///
+    /// * [`OffsetRound::smallest`] sets the smallest [`Unit`] that is allowed
+    /// to be non-zero in the offset returned. By default, it is set to
+    /// [`Unit::Second`], i.e., no rounding occurs. When the smallest unit is
+    /// set to something bigger than seconds, then the non-zero units in the
+    /// offset smaller than the smallest unit are used to determine how the
+    /// offset should be rounded. For example, rounding `+01:59` to the nearest
+    /// hour using the default rounding mode would produce `+02:00`.
+    /// * [`OffsetRound::mode`] determines how to handle the remainder
+    /// when rounding. The default is [`RoundMode::HalfExpand`], which
+    /// corresponds to how you were likely taught to round in school.
+    /// Alternative modes, like [`RoundMode::Trunc`], exist too. For example,
+    /// a truncating rounding of `+01:59` to the nearest hour would
+    /// produce `+01:00`.
+    /// * [`OffsetRound::increment`] sets the rounding granularity to
+    /// use for the configured smallest unit. For example, if the smallest unit
+    /// is minutes and the increment is `15`, then the offset returned will
+    /// always have its minute component set to a multiple of `15`.
+    ///
+    /// # Errors
+    ///
+    /// In general, there are two main ways for rounding to fail: an improper
+    /// configuration like trying to round an offset to the nearest unit other
+    /// than hours/minutes/seconds, or when overflow occurs. Overflow can occur
+    /// when the offset would exceed the minimum or maximum `Offset` values.
+    /// Typically, this can only realistically happen if the offset before
+    /// rounding is already close to its minimum or maximum value.
+    ///
+    /// # Example: rounding to the nearest multiple of 15 minutes
+    ///
+    /// Most time zone offsets fall on an hour boundary, but some fall on the
+    /// half-hour or even 15 minute boundary:
+    ///
+    /// ```
+    /// use jiff::{tz::Offset, Unit};
+    ///
+    /// let offset = Offset::from_seconds(-(44 * 60 + 30)).unwrap();
+    /// let rounded = offset.round((Unit::Minute, 15))?;
+    /// assert_eq!(rounded, Offset::from_seconds(-45 * 60).unwrap());
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    ///
+    /// # Example: rounding can fail via overflow
+    ///
+    /// ```
+    /// use jiff::{tz::Offset, Unit};
+    ///
+    /// assert_eq!(Offset::MAX.to_string(), "+25:59:59");
+    /// assert_eq!(
+    ///     Offset::MAX.round(Unit::Minute).unwrap_err().to_string(),
+    ///     "rounding offset `+25:59:59` resulted in a duration of 26h, \
+    ///      which overflows `Offset`",
+    /// );
+    /// ```
     #[inline]
-    pub(crate) fn to_duration(self) -> SignedDuration {
-        SignedDuration::from_secs(i64::from(self.seconds()))
+    pub fn round<R: Into<OffsetRound>>(
+        self,
+        options: R,
+    ) -> Result<Offset, Error> {
+        let options: OffsetRound = options.into();
+        options.round(self)
     }
 }
 
@@ -1182,6 +1252,51 @@ impl Neg for Offset {
     }
 }
 
+/// Converts a `SignedDuration` to a time zone offset.
+///
+/// If the signed duration has fractional seconds, then it is automatically
+/// rounded to the nearest second. (Because an `Offset` has only second
+/// precision.)
+///
+/// # Errors
+///
+/// This returns an error if the duration overflows the limits of an `Offset`.
+///
+/// # Example
+///
+/// ```
+/// use jiff::{tz::{self, Offset}, SignedDuration};
+///
+/// let sdur = SignedDuration::from_secs(-5 * 60 * 60);
+/// let offset = Offset::try_from(sdur)?;
+/// assert_eq!(offset, tz::offset(-5));
+///
+/// // Sub-seconds results in rounded.
+/// let sdur = SignedDuration::new(-5 * 60 * 60, -500_000_000);
+/// let offset = Offset::try_from(sdur)?;
+/// assert_eq!(offset, tz::Offset::from_seconds(-(5 * 60 * 60 + 1)).unwrap());
+///
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+impl TryFrom<SignedDuration> for Offset {
+    type Error = Error;
+
+    fn try_from(sdur: SignedDuration) -> Result<Offset, Error> {
+        let mut seconds = sdur.as_secs();
+        let subsec = sdur.subsec_nanos();
+        if subsec >= 500_000_000 {
+            seconds = seconds.saturating_add(1);
+        } else if subsec <= -500_000_000 {
+            seconds = seconds.saturating_sub(1);
+        }
+        let seconds = i32::try_from(seconds).map_err(|_| {
+            err!("`SignedDuration` of {sdur} overflows `Offset`")
+        })?;
+        Offset::from_seconds(seconds)
+            .map_err(|_| err!("`SignedDuration` of {sdur} overflows `Offset`"))
+    }
+}
+
 /// Options for [`Offset::checked_add`] and [`Offset::checked_sub`].
 ///
 /// This type provides a way to ergonomically add one of a few different
@@ -1276,6 +1391,191 @@ impl<'a> From<&'a UnsignedDuration> for OffsetArithmetic {
     }
 }
 
+/// Options for [`Offset::round`].
+///
+/// This type provides a way to configure the rounding of an offset. This
+/// includes setting the smallest unit (i.e., the unit to round), the rounding
+/// increment and the rounding mode (e.g., "ceil" or "truncate").
+///
+/// [`Offset::round`] accepts anything that implements
+/// `Into<OffsetRound>`. There are a few key trait implementations that
+/// make this convenient:
+///
+/// * `From<Unit> for OffsetRound` will construct a rounding
+/// configuration where the smallest unit is set to the one given.
+/// * `From<(Unit, i64)> for OffsetRound` will construct a rounding
+/// configuration where the smallest unit and the rounding increment are set to
+/// the ones given.
+///
+/// In order to set other options (like the rounding mode), one must explicitly
+/// create a `OffsetRound` and pass it to `Offset::round`.
+///
+/// # Example
+///
+/// This example shows how to always round up to the nearest half-hour:
+///
+/// ```
+/// use jiff::{tz::{Offset, OffsetRound}, RoundMode, Unit};
+///
+/// let offset = Offset::from_seconds(4 * 60 * 60 + 17 * 60).unwrap();
+/// let rounded = offset.round(
+///     OffsetRound::new()
+///         .smallest(Unit::Minute)
+///         .increment(30)
+///         .mode(RoundMode::Expand),
+/// )?;
+/// assert_eq!(rounded, Offset::from_seconds(4 * 60 * 60 + 30 * 60).unwrap());
+///
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+#[derive(Clone, Copy, Debug)]
+pub struct OffsetRound(SignedDurationRound);
+
+impl OffsetRound {
+    /// Create a new default configuration for rounding a time zone offset via
+    /// [`Offset::round`].
+    ///
+    /// The default configuration does no rounding.
+    #[inline]
+    pub fn new() -> OffsetRound {
+        OffsetRound(SignedDurationRound::new().smallest(Unit::Second))
+    }
+
+    /// Set the smallest units allowed in the offset returned. These are the
+    /// units that the offset is rounded to.
+    ///
+    /// # Errors
+    ///
+    /// The unit must be [`Unit::Hour`], [`Unit::Minute`] or [`Unit::Second`].
+    ///
+    /// # Example
+    ///
+    /// A basic example that rounds to the nearest minute:
+    ///
+    /// ```
+    /// use jiff::{tz::Offset, Unit};
+    ///
+    /// let offset = Offset::from_seconds(-(5 * 60 * 60 + 30)).unwrap();
+    /// assert_eq!(offset.round(Unit::Hour)?, Offset::from_hours(-5).unwrap());
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn smallest(self, unit: Unit) -> OffsetRound {
+        OffsetRound(self.0.smallest(unit))
+    }
+
+    /// Set the rounding mode.
+    ///
+    /// This defaults to [`RoundMode::HalfExpand`], which makes rounding work
+    /// like how you were taught in school.
+    ///
+    /// # Example
+    ///
+    /// A basic example that rounds to the nearest hour, but changing its
+    /// rounding mode to truncation:
+    ///
+    /// ```
+    /// use jiff::{tz::{Offset, OffsetRound}, RoundMode, Unit};
+    ///
+    /// let offset = Offset::from_seconds(-(5 * 60 * 60 + 30 * 60)).unwrap();
+    /// assert_eq!(
+    ///     offset.round(OffsetRound::new()
+    ///         .smallest(Unit::Hour)
+    ///         .mode(RoundMode::Trunc),
+    ///     )?,
+    ///     // The default round mode does rounding like
+    ///     // how you probably learned in school, and would
+    ///     // result in rounding to -6 hours. But we
+    ///     // change it to truncation here, which makes it
+    ///     // round -5.
+    ///     Offset::from_hours(-5).unwrap(),
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn mode(self, mode: RoundMode) -> OffsetRound {
+        OffsetRound(self.0.mode(mode))
+    }
+
+    /// Set the rounding increment for the smallest unit.
+    ///
+    /// The default value is `1`. Other values permit rounding the smallest
+    /// unit to the nearest integer increment specified. For example, if the
+    /// smallest unit is set to [`Unit::Minute`], then a rounding increment of
+    /// `30` would result in rounding in increments of a half hour. That is,
+    /// the only minute value that could result would be `0` or `30`.
+    ///
+    /// # Errors
+    ///
+    /// The rounding increment must divide evenly into the next highest unit
+    /// after the smallest unit configured (and must not be equivalent to
+    /// it). For example, if the smallest unit is [`Unit::Second`], then
+    /// *some* of the valid values for the rounding increment are `1`, `2`,
+    /// `4`, `5`, `15` and `30`. Namely, any integer that divides evenly into
+    /// `60` seconds since there are `60` seconds in the next highest unit
+    /// (minutes).
+    ///
+    /// # Example
+    ///
+    /// This shows how to round an offset to the nearest 30 minute increment:
+    ///
+    /// ```
+    /// use jiff::{tz::Offset, Unit};
+    ///
+    /// let offset = Offset::from_seconds(4 * 60 * 60 + 15 * 60).unwrap();
+    /// assert_eq!(
+    ///     offset.round((Unit::Minute, 30))?,
+    ///     Offset::from_seconds(4 * 60 * 60 + 30 * 60).unwrap(),
+    /// );
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    #[inline]
+    pub fn increment(self, increment: i64) -> OffsetRound {
+        OffsetRound(self.0.increment(increment))
+    }
+
+    /// Does the actual offset rounding.
+    fn round(&self, offset: Offset) -> Result<Offset, Error> {
+        let smallest = self.0.get_smallest();
+        if !(Unit::Second <= smallest && smallest <= Unit::Hour) {
+            return Err(err!(
+                "rounding `Offset` failed because \
+                 a unit of {plural} was provided, but offset rounding \
+                 can only use hours, minutes or seconds",
+                plural = smallest.plural(),
+            ));
+        }
+        let rounded_sdur = SignedDuration::from(offset).round(self.0)?;
+        Offset::try_from(rounded_sdur).map_err(|_| {
+            err!(
+                "rounding offset `{offset}` resulted in a duration \
+                 of {rounded_sdur:?}, which overflows `Offset`",
+            )
+        })
+    }
+}
+
+impl Default for OffsetRound {
+    fn default() -> OffsetRound {
+        OffsetRound::new()
+    }
+}
+
+impl From<Unit> for OffsetRound {
+    fn from(unit: Unit) -> OffsetRound {
+        OffsetRound::default().smallest(unit)
+    }
+}
+
+impl From<(Unit, i64)> for OffsetRound {
+    fn from((unit, increment): (Unit, i64)) -> OffsetRound {
+        OffsetRound::default().smallest(unit).increment(increment)
+    }
+}
+
 /// Configuration for resolving disparities between an offset and a time zone.
 ///
 /// A conflict between an offset and a time zone most commonly appears in a
@@ -1473,6 +1773,125 @@ impl OffsetConflict {
         offset: Offset,
         tz: TimeZone,
     ) -> Result<AmbiguousZoned, Error> {
+        self.resolve_with(dt, offset, tz, |off1, off2| off1 == off2)
+    }
+
+    /// Resolve a potential conflict between an [`Offset`] and a [`TimeZone`]
+    /// using the given definition of equality for an `Offset`.
+    ///
+    /// The equality predicate is always given a pair of offsets where the
+    /// first is the offset given to `resolve_with` and the second is the
+    /// offset found in the `TimeZone`.
+    ///
+    /// # Errors
+    ///
+    /// This returns an error if this would have returned a timestamp outside
+    /// of its minimum and maximum values.
+    ///
+    /// This can also return an error when using the [`OffsetConflict::Reject`]
+    /// strategy. Namely, when using the `Reject` strategy, any offset that is
+    /// not compatible with the given datetime and time zone will always result
+    /// in an error.
+    ///
+    /// # Example
+    ///
+    /// Unlike [`OffsetConflict::resolve`], this routine permits overriding
+    /// the definition of equality used for comparing offsets. In
+    /// `OffsetConflict::resolve`, exact equality is used. This can be
+    /// troublesome in some cases when a time zone has an offset with
+    /// fractional minutes, such as `Africa/Monrovia` before 1972.
+    ///
+    /// Because RFC 3339 and RFC 9557 do not support time zone offsets
+    /// with fractional minutes, Jiff will serialize offsets with
+    /// fractional minutes by rounding to the nearest minute. This
+    /// will result in a different offset than what is actually
+    /// used in the time zone. Parsing this _should_ succeed, but
+    /// if exact offset equality is used, it won't. This is why a
+    /// [`fmt::temporal::DateTimeParser`](crate::fmt::temporal::DateTimeParser)
+    /// uses this routine with offset equality that rounds offsets to the
+    /// nearest minute before comparison.
+    ///
+    /// ```
+    /// use jiff::{civil::date, tz::{Offset, OffsetConflict, TimeZone}, Unit};
+    ///
+    /// let dt = date(1968, 2, 1).at(23, 15, 0, 0);
+    /// let offset = Offset::from_seconds(-(44 * 60 + 30)).unwrap();
+    /// let zdt = dt.in_tz("Africa/Monrovia")?;
+    /// assert_eq!(zdt.offset(), offset);
+    /// // Notice that the offset has been rounded!
+    /// assert_eq!(zdt.to_string(), "1968-02-01T23:15:00-00:45[Africa/Monrovia]");
+    ///
+    /// // Now imagine parsing extracts the civil datetime, the offset and
+    /// // the time zone, and then naively does exact offset comparison:
+    /// let tz = TimeZone::get("Africa/Monrovia")?;
+    /// // This is the parsed offset, which won't precisely match the actual
+    /// // offset used by `Africa/Monrovia` at this time.
+    /// let offset = Offset::from_seconds(-45 * 60).unwrap();
+    /// let result = OffsetConflict::Reject.resolve(dt, offset, tz.clone());
+    /// assert_eq!(
+    ///     result.unwrap_err().to_string(),
+    ///     "datetime 1968-02-01T23:15:00 could not resolve to a timestamp \
+    ///      since 'reject' conflict resolution was chosen, and because \
+    ///      datetime has offset -00:45, but the time zone Africa/Monrovia \
+    ///      for the given datetime unambiguously has offset -00:44:30",
+    /// );
+    /// let is_equal = |parsed: Offset, candidate: Offset| {
+    ///     candidate.round(Unit::Minute).map_or(
+    ///         parsed == candidate,
+    ///         |candidate| parsed == candidate,
+    ///     )
+    /// };
+    /// let zdt = OffsetConflict::Reject.resolve_with(
+    ///     dt,
+    ///     offset,
+    ///     tz.clone(),
+    ///     is_equal,
+    /// )?.unambiguous()?;
+    /// // Notice that the offset is the actual offset from the time zone:
+    /// assert_eq!(zdt.offset(), Offset::from_seconds(-(44 * 60 + 30)).unwrap());
+    /// // But when we serialize, the offset gets rounded. If we didn't
+    /// // do this, we'd risk the datetime not being parsable by other
+    /// // implementations since RFC 3339 and RFC 9557 don't support fractional
+    /// // minutes in the offset.
+    /// assert_eq!(zdt.to_string(), "1968-02-01T23:15:00-00:45[Africa/Monrovia]");
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    ///
+    /// And indeed, notice that parsing uses this same kind of offset equality
+    /// to permit zoned datetimes whose offsets would be equivalent after
+    /// rounding:
+    ///
+    /// ```
+    /// use jiff::{tz::Offset, Zoned};
+    ///
+    /// let zdt: Zoned = "1968-02-01T23:15:00-00:45[Africa/Monrovia]".parse()?;
+    /// // As above, notice that even though we parsed `-00:45` as the
+    /// // offset, the actual offset of our zoned datetime is the correct
+    /// // one from the time zone.
+    /// assert_eq!(zdt.offset(), Offset::from_seconds(-(44 * 60 + 30)).unwrap());
+    /// // And similarly, re-serializing it results in rounding the offset
+    /// // again for compatibility with RFC 3339 and RFC 9557.
+    /// assert_eq!(zdt.to_string(), "1968-02-01T23:15:00-00:45[Africa/Monrovia]");
+    ///
+    /// // And we also support parsing the actual fractional minute offset
+    /// // as well:
+    /// let zdt: Zoned = "1968-02-01T23:15:00-00:44:30[Africa/Monrovia]".parse()?;
+    /// assert_eq!(zdt.offset(), Offset::from_seconds(-(44 * 60 + 30)).unwrap());
+    /// assert_eq!(zdt.to_string(), "1968-02-01T23:15:00-00:45[Africa/Monrovia]");
+    ///
+    /// # Ok::<(), Box<dyn std::error::Error>>(())
+    /// ```
+    pub fn resolve_with<F>(
+        self,
+        dt: civil::DateTime,
+        offset: Offset,
+        tz: TimeZone,
+        is_equal: F,
+    ) -> Result<AmbiguousZoned, Error>
+    where
+        F: FnMut(Offset, Offset) -> bool,
+    {
         match self {
             // In this case, we ignore any TZ annotation (although still
             // require that it exists) and always use the provided offset.
@@ -1485,13 +1904,13 @@ impl OffsetConflict {
             OffsetConflict::AlwaysTimeZone => Ok(tz.into_ambiguous_zoned(dt)),
             // In this case, we use the offset if it's correct, but otherwise
             // fall back to the time zone annotation if it's not.
-            OffsetConflict::PreferOffset => {
-                Ok(OffsetConflict::resolve_via_prefer(dt, offset, tz))
-            }
+            OffsetConflict::PreferOffset => Ok(
+                OffsetConflict::resolve_via_prefer(dt, offset, tz, is_equal),
+            ),
             // In this case, if the offset isn't possible for the provided time
             // zone annotation, then we return an error.
             OffsetConflict::Reject => {
-                OffsetConflict::resolve_via_reject(dt, offset, tz)
+                OffsetConflict::resolve_via_reject(dt, offset, tz, is_equal)
             }
         }
     }
@@ -1509,6 +1928,7 @@ impl OffsetConflict {
         dt: civil::DateTime,
         given: Offset,
         tz: TimeZone,
+        mut is_equal: impl FnMut(Offset, Offset) -> bool,
     ) -> AmbiguousZoned {
         use crate::tz::AmbiguousOffset::*;
 
@@ -1547,11 +1967,15 @@ impl OffsetConflict {
             //
             // See also: https://github.com/tc39/proposal-temporal/issues/3078
             // See also: https://github.com/BurntSushi/jiff/issues/211
-            Gap { before, after } if given == before || given == after => {
+            Gap { before, after }
+                if is_equal(given, before) || is_equal(given, after) =>
+            {
                 let kind = Unambiguous { offset: before };
                 AmbiguousTimestamp::new(dt, kind)
             }
-            Fold { before, after } if given == before || given == after => {
+            Fold { before, after }
+                if is_equal(given, before) || is_equal(given, after) =>
+            {
                 let kind = Unambiguous { offset: given };
                 AmbiguousTimestamp::new(dt, kind)
             }
@@ -1576,12 +2000,13 @@ impl OffsetConflict {
         dt: civil::DateTime,
         given: Offset,
         tz: TimeZone,
+        mut is_equal: impl FnMut(Offset, Offset) -> bool,
     ) -> Result<AmbiguousZoned, Error> {
         use crate::tz::AmbiguousOffset::*;
 
         let amb = tz.to_ambiguous_timestamp(dt);
         match amb.offset() {
-            Unambiguous { offset } if given != offset => Err(err!(
+            Unambiguous { offset } if !is_equal(given, offset) => Err(err!(
                 "datetime {dt} could not resolve to a timestamp since \
                  'reject' conflict resolution was chosen, and because \
                  datetime has offset {given}, but the time zone {tzname} for \
@@ -1589,7 +2014,9 @@ impl OffsetConflict {
                 tzname = tz.diagnostic_name(),
             )),
             Unambiguous { .. } => Ok(amb.into_ambiguous_zoned(tz)),
-            Gap { before, after } if given != before && given != after => {
+            Gap { before, after }
+                if !is_equal(given, before) && !is_equal(given, after) =>
+            {
                 // Temporal actually seems to report an error whenever a gap
                 // is found, even if the parsed offset matches one of the two
                 // offsets in the gap. I think the reasoning is because the
@@ -1627,7 +2054,9 @@ impl OffsetConflict {
                     tzname = tz.diagnostic_name(),
                 ))
             }
-            Fold { before, after } if given != before && given != after => {
+            Fold { before, after }
+                if !is_equal(given, before) && !is_equal(given, after) =>
+            {
                 Err(err!(
                     "datetime {dt} could not resolve to timestamp \
                      since 'reject' conflict resolution was chosen, and \
diff --git a/src/tz/posix.rs b/src/tz/posix.rs
index d5f85ae..e817fce 100644
--- a/src/tz/posix.rs
+++ b/src/tz/posix.rs
@@ -801,7 +801,7 @@ impl PosixDateTimeSpec {
         let Some(date) = self.date.to_civil_date(year) else { return mkmax() };
         let mut dt = date.to_datetime(Time::MIN);
         let dur_transition = self.time().to_duration();
-        let dur_offset = offset.to_duration();
+        let dur_offset = SignedDuration::from(offset);
         dt = dt.checked_add(dur_transition).unwrap_or_else(|_| {
             if dur_transition.is_negative() {
                 mkmin()


Test patch: