1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! `chrono` interop for [`google::protobuf::Duration`](crate::google::protobuf::Duration).
//!
//! Enabled with the `chrono` Cargo feature. `no_std`-compatible — `chrono` is
//! pulled in with `default-features = false`.
use crate::google::protobuf::Duration;
/// Errors that can occur when converting a protobuf [`Duration`] to a
/// [`chrono::TimeDelta`].
///
/// Distinct from [`crate::duration_ext::DurationError`] because `TimeDelta`'s
/// representable range (`±i64::MAX` milliseconds) is narrower than proto
/// `Duration`'s, so this conversion has an `Overflow` failure mode that
/// `std::time::Duration` does not.
///
/// This enum is `#[non_exhaustive]` (unlike the older `DurationError` /
/// `TimestampError`, which predate that convention): `match` arms over it
/// must include a wildcard arm.
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DurationChronoError {
/// The `nanos` field is outside `[-999_999_999, 999_999_999]` or its sign
/// is inconsistent with `seconds`.
#[error("nanos field has invalid value or sign mismatch with seconds")]
InvalidNanos,
/// The duration exceeds `chrono::TimeDelta`'s representable range
/// (`±i64::MAX` milliseconds).
#[error("duration is out of range for chrono::TimeDelta")]
Overflow,
}
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
impl From<chrono::TimeDelta> for Duration {
/// Convert a [`chrono::TimeDelta`] to a protobuf [`Duration`].
///
/// Both sides represent a signed duration as `seconds` + `subsec_nanos`
/// with sign-consistent components, so this is a direct field copy.
///
/// # Warning: proto JSON spec range
///
/// `chrono::TimeDelta` ranges to ±`i64::MAX` milliseconds (~9.2e15
/// seconds), while the proto spec restricts `Duration` to
/// ±315,576,000,000 seconds (~10,000 years). A `TimeDelta` beyond that
/// converts without error here — binary encoding round-trips it — but
/// the resulting `Duration` will fail JSON serialization (`json`
/// feature), which enforces the spec range.
///
/// # Examples
///
/// ```
/// use buffa_types::Duration;
/// use chrono::TimeDelta;
///
/// let proto: Duration = TimeDelta::milliseconds(1_500).into();
/// assert_eq!(proto.seconds, 1);
/// assert_eq!(proto.nanos, 500_000_000);
/// ```
fn from(d: chrono::TimeDelta) -> Self {
Self {
seconds: d.num_seconds(),
// `TimeDelta::subsec_nanos` is signed and shares the duration's
// overall sign, matching the proto Duration convention.
nanos: d.subsec_nanos(),
..Default::default()
}
}
}
#[cfg_attr(docsrs, doc(cfg(feature = "chrono")))]
impl TryFrom<Duration> for chrono::TimeDelta {
type Error = DurationChronoError;
/// Convert a protobuf [`Duration`] to a [`chrono::TimeDelta`].
///
/// # Examples
///
/// ```
/// use buffa_types::{Duration, DurationChronoError};
/// use chrono::TimeDelta;
///
/// let proto = Duration {
/// seconds: 2,
/// nanos: 250_000_000,
/// ..Default::default()
/// };
/// let td: TimeDelta = proto.try_into().unwrap();
/// assert_eq!(td, TimeDelta::milliseconds(2_250));
///
/// let too_big = Duration {
/// seconds: i64::MAX,
/// nanos: 0,
/// ..Default::default()
/// };
/// assert_eq!(
/// TimeDelta::try_from(too_big),
/// Err(DurationChronoError::Overflow)
/// );
/// ```
///
/// # Errors
///
/// Returns [`DurationChronoError::InvalidNanos`] if `nanos` is outside
/// `[-999_999_999, 999_999_999]` or if its sign is inconsistent with
/// `seconds`. Such values never come from the [`From<chrono::TimeDelta>`]
/// impl, but `seconds` and `nanos` are independent wire fields, so a
/// decoded `Duration` can carry any combination — the proto spec declares
/// sign-mismatched ones invalid, and this conversion rejects them rather
/// than silently reinterpreting them arithmetically.
///
/// Returns [`DurationChronoError::Overflow`] if the total duration
/// exceeds `chrono::TimeDelta`'s representable range (`±i64::MAX`
/// milliseconds).
fn try_from(d: Duration) -> Result<Self, Self::Error> {
if d.nanos < -999_999_999 || d.nanos > 999_999_999 {
return Err(DurationChronoError::InvalidNanos);
}
// Decoding doesn't validate the spec's sign-consistency rule, so a
// malformed message can carry e.g. {seconds: 5, nanos: -1}. Reject
// per spec instead of guessing at an arithmetic interpretation.
let sign_mismatch = (d.seconds > 0 && d.nanos < 0) || (d.seconds < 0 && d.nanos > 0);
if sign_mismatch {
return Err(DurationChronoError::InvalidNanos);
}
// `chrono::TimeDelta` is internally `i64` milliseconds; large second
// values that fit in proto Duration can overflow it. Build from the
// two components with checked arithmetic.
//
// `try_seconds` rejects |seconds| > i64::MAX / 1000 ≈ 9.22e15. After
// that, `checked_add` is still needed because a `secs_part` close to
// the i64-millisecond boundary plus a `nanos_part` of up to ±999 ms
// can still push the sum over i64::MAX. `nanoseconds(_)` itself can
// never overflow because |nanos| < 1e9 always fits in `TimeDelta`.
let secs_part = Self::try_seconds(d.seconds).ok_or(DurationChronoError::Overflow)?;
let nanos_part = Self::nanoseconds(i64::from(d.nanos));
secs_part
.checked_add(&nanos_part)
.ok_or(DurationChronoError::Overflow)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive_roundtrip() {
let td = chrono::TimeDelta::new(300, 500_000_000).unwrap();
let proto: Duration = td.into();
assert_eq!(proto.seconds, 300);
assert_eq!(proto.nanos, 500_000_000);
let back: chrono::TimeDelta = proto.try_into().unwrap();
assert_eq!(back, td);
}
#[test]
fn zero_roundtrip() {
let td = chrono::TimeDelta::zero();
let proto: Duration = td.into();
assert_eq!(proto.seconds, 0);
assert_eq!(proto.nanos, 0);
let back: chrono::TimeDelta = proto.try_into().unwrap();
assert_eq!(back, td);
}
#[test]
fn negative_roundtrip() {
// -1.5 seconds. chrono returns num_seconds = -1, subsec_nanos = -500_000_000,
// matching the proto convention.
let td = chrono::TimeDelta::milliseconds(-1_500);
let proto: Duration = td.into();
assert_eq!(proto.seconds, -1);
assert_eq!(proto.nanos, -500_000_000);
let back: chrono::TimeDelta = proto.try_into().unwrap();
assert_eq!(back, td);
}
#[test]
fn sub_second_negative_roundtrip() {
let td = chrono::TimeDelta::nanoseconds(-500_000_000);
let proto: Duration = td.into();
assert_eq!(proto.seconds, 0);
assert_eq!(proto.nanos, -500_000_000);
let back: chrono::TimeDelta = proto.try_into().unwrap();
assert_eq!(back, td);
}
#[test]
fn invalid_nanos_rejected() {
let bad = Duration {
seconds: 1,
nanos: 1_000_000_000,
..Default::default()
};
let result: Result<chrono::TimeDelta, _> = bad.try_into();
assert_eq!(result, Err(DurationChronoError::InvalidNanos));
}
#[test]
fn nanos_i32_min_is_invalid() {
let bad = Duration {
seconds: 0,
nanos: i32::MIN,
..Default::default()
};
let result: Result<chrono::TimeDelta, _> = bad.try_into();
assert_eq!(result, Err(DurationChronoError::InvalidNanos));
}
#[test]
fn sign_mismatch_rejected() {
let bad = Duration {
seconds: 5,
nanos: -1,
..Default::default()
};
let result: Result<chrono::TimeDelta, _> = bad.try_into();
assert_eq!(result, Err(DurationChronoError::InvalidNanos));
let bad2 = Duration {
seconds: -5,
nanos: 1,
..Default::default()
};
let result2: Result<chrono::TimeDelta, _> = bad2.try_into();
assert_eq!(result2, Err(DurationChronoError::InvalidNanos));
}
#[test]
fn timedelta_extremes_roundtrip() {
// `TimeDelta` spans ±i64::MAX milliseconds. Pin that both extremes
// survive the proto roundtrip exactly (constructed via `milliseconds`,
// which is total over i64, rather than the MIN/MAX consts that only
// exist in newer chrono versions).
let max = chrono::TimeDelta::milliseconds(i64::MAX);
let proto: Duration = max.into();
assert_eq!(proto.seconds, max.num_seconds());
assert_eq!(proto.nanos, max.subsec_nanos());
let back: chrono::TimeDelta = proto.try_into().unwrap();
assert_eq!(back, max);
let min = chrono::TimeDelta::milliseconds(-i64::MAX);
let proto_min: Duration = min.into();
let back_min: chrono::TimeDelta = proto_min.try_into().unwrap();
assert_eq!(back_min, min);
}
#[test]
fn nanos_addition_overflow_is_overflow() {
// try_seconds accepts |seconds| up to i64::MAX / 1000. At that boundary
// the resulting TimeDelta is within ~999 ms of i64::MAX milliseconds;
// a positive nanos value tips checked_add over the edge.
let boundary_secs = i64::MAX / 1_000;
let near_max = Duration {
seconds: boundary_secs,
nanos: 999_999_999,
..Default::default()
};
let result: Result<chrono::TimeDelta, _> = near_max.try_into();
assert_eq!(result, Err(DurationChronoError::Overflow));
// Mirror for the negative boundary.
let boundary_neg = -(i64::MAX / 1_000);
let near_min = Duration {
seconds: boundary_neg,
nanos: -999_999_999,
..Default::default()
};
let result_neg: Result<chrono::TimeDelta, _> = near_min.try_into();
assert_eq!(result_neg, Err(DurationChronoError::Overflow));
}
#[test]
fn out_of_range_seconds_is_overflow() {
// `chrono::TimeDelta` caps at `i64::MAX` milliseconds, so `i64::MAX`
// seconds overflows by ~1000×.
let huge = Duration {
seconds: i64::MAX,
nanos: 0,
..Default::default()
};
let result: Result<chrono::TimeDelta, _> = huge.try_into();
assert_eq!(result, Err(DurationChronoError::Overflow));
let tiny = Duration {
seconds: i64::MIN,
nanos: 0,
..Default::default()
};
let result2: Result<chrono::TimeDelta, _> = tiny.try_into();
assert_eq!(result2, Err(DurationChronoError::Overflow));
}
}