1use std::{borrow::Cow, fmt};
6
7use chrono::TimeDelta;
8use num_traits::ToPrimitive as _;
9use rust_decimal::Decimal;
10
11use crate::{
12 into_caveat_all, json,
13 number::FromDecimal as _,
14 warning::{self, IntoCaveat as _},
15 Cost, Money, SaturatingAdd, SaturatingSub, Verdict,
16};
17
18pub(crate) const SECS_IN_MIN: i64 = 60;
19pub(crate) const MINS_IN_HOUR: i64 = 60;
20pub(crate) const MILLIS_IN_SEC: i64 = 1000;
21
22#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
24pub enum WarningKind {
25 Invalid(String),
27
28 InvalidType,
30}
31
32impl fmt::Display for WarningKind {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 WarningKind::Invalid(err) => write!(f, "Unable to parse the duration: {err}"),
36 WarningKind::InvalidType => write!(f, "The value should be a string."),
37 }
38 }
39}
40
41impl warning::Kind for WarningKind {
42 fn id(&self) -> Cow<'static, str> {
43 match self {
44 WarningKind::Invalid(_) => "invalid".into(),
45 WarningKind::InvalidType => "invalid_type".into(),
46 }
47 }
48}
49
50#[derive(Debug)]
52pub enum Error {
53 Overflow,
55}
56
57impl From<rust_decimal::Error> for Error {
58 fn from(_: rust_decimal::Error) -> Self {
59 Self::Overflow
60 }
61}
62
63impl std::error::Error for Error {}
64
65impl fmt::Display for Error {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Self::Overflow => f.write_str("A numeric overflow occurred while creating a duration"),
69 }
70 }
71}
72
73into_caveat_all!(TimeDelta, Seconds);
74
75pub trait ToHoursDecimal {
77 fn to_hours_dec(&self) -> Decimal;
79}
80
81pub trait ToDuration {
83 fn to_duration(&self) -> TimeDelta;
85}
86
87impl ToHoursDecimal for TimeDelta {
88 fn to_hours_dec(&self) -> Decimal {
89 let div = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
90 let num = Decimal::from(self.num_milliseconds());
91 num.checked_div(div).unwrap_or(Decimal::MAX)
92 }
93}
94
95impl ToDuration for Decimal {
96 fn to_duration(&self) -> TimeDelta {
97 let factor = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
98 let millis = self.saturating_mul(factor).to_i64().unwrap_or(i64::MAX);
99 TimeDelta::milliseconds(millis)
100 }
101}
102
103pub(crate) struct Seconds(TimeDelta);
106
107impl From<Seconds> for TimeDelta {
109 fn from(value: Seconds) -> Self {
110 value.0
111 }
112}
113
114impl json::FromJson<'_, '_> for Seconds {
121 type WarningKind = WarningKind;
122
123 fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
124 let mut warnings = warning::Set::new();
125 let Some(s) = elem.as_number_str() else {
126 warnings.with_elem(WarningKind::InvalidType, elem);
127 return Err(warnings);
128 };
129
130 let seconds = match s.parse::<u64>() {
132 Ok(n) => n,
133 Err(err) => {
134 warnings.with_elem(WarningKind::Invalid(err.to_string()), elem);
135 return Err(warnings);
136 }
137 };
138
139 let Ok(seconds) = i64::try_from(seconds) else {
142 warnings.with_elem(
143 WarningKind::Invalid(
144 "The duration value is larger than an i64 can represent.".into(),
145 ),
146 elem,
147 );
148 return Err(warnings);
149 };
150 let dt = TimeDelta::seconds(seconds);
151
152 Ok(Seconds(dt).into_caveat(warnings))
153 }
154}
155
156impl Cost for TimeDelta {
158 fn cost(&self, money: Money) -> Money {
159 let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
160 Money::from_decimal(cost)
161 }
162}
163
164impl SaturatingAdd for TimeDelta {
165 fn saturating_add(self, other: TimeDelta) -> TimeDelta {
166 self.checked_add(&other).unwrap_or(TimeDelta::MAX)
167 }
168}
169
170impl SaturatingSub for TimeDelta {
171 fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
172 self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
173 }
174}
175
176#[allow(dead_code, reason = "used during debug sessions")]
178pub(crate) trait AsHms {
179 fn as_hms(&self) -> Hms;
181}
182
183impl AsHms for TimeDelta {
184 fn as_hms(&self) -> Hms {
185 Hms(*self)
186 }
187}
188
189impl AsHms for Decimal {
190 fn as_hms(&self) -> Hms {
192 Hms(self.to_duration())
193 }
194}
195
196pub(crate) struct Hms(pub TimeDelta);
198
199impl fmt::Debug for Hms {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 fmt::Display::fmt(self, f)
203 }
204}
205
206impl fmt::Display for Hms {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 let duration = self.0;
209 let seconds = duration.num_seconds();
210
211 if seconds.is_negative() {
213 f.write_str("-")?;
214 }
215
216 let seconds = seconds.abs();
218
219 let seconds = seconds % SECS_IN_MIN;
220 let minutes = (seconds / SECS_IN_MIN) % MINS_IN_HOUR;
221 let hours = seconds / (SECS_IN_MIN * MINS_IN_HOUR);
222
223 write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
224 }
225}
226
227#[cfg(test)]
228mod test {
229 use chrono::TimeDelta;
230
231 use crate::test::ApproxEq;
232
233 use super::Error;
234
235 #[test]
236 const fn error_should_be_send_and_sync() {
237 const fn f<T: Send + Sync>() {}
238
239 f::<Error>();
240 }
241
242 impl ApproxEq for TimeDelta {
243 fn approx_eq(&self, other: &Self) -> bool {
244 const TOLERANCE: i64 = 3;
245 approx_eq_time_delta(*self, *other, TOLERANCE)
246 }
247 }
248
249 pub fn approx_eq_time_delta(a: TimeDelta, b: TimeDelta, tolerance_secs: i64) -> bool {
251 let diff = a.num_seconds() - b.num_seconds();
252 diff.abs() <= tolerance_secs
253 }
254}
255
256#[cfg(test)]
257mod hour_decimal_tests {
258 use chrono::TimeDelta;
259 use rust_decimal::Decimal;
260 use rust_decimal_macros::dec;
261
262 use crate::duration::ToHoursDecimal;
263
264 use super::MILLIS_IN_SEC;
265
266 #[test]
267 fn zero_minutes_should_be_zero_hours() {
268 assert_eq!(TimeDelta::minutes(0).to_hours_dec(), dec!(0.0));
269 }
270
271 #[test]
272 fn thirty_minutes_should_be_fraction_of_hour() {
273 assert_eq!(TimeDelta::minutes(30).to_hours_dec(), dec!(0.5));
274 }
275
276 #[test]
277 fn sixty_minutes_should_be_fraction_of_hour() {
278 assert_eq!(TimeDelta::minutes(60).to_hours_dec(), dec!(1.0));
279 }
280
281 #[test]
282 fn ninety_minutes_should_be_fraction_of_hour() {
283 assert_eq!(TimeDelta::minutes(90).to_hours_dec(), dec!(1.5));
284 }
285
286 #[test]
287 fn as_seconds_dec_should_not_overflow() {
288 let number = Decimal::from(i64::MAX).checked_div(Decimal::from(MILLIS_IN_SEC));
289 assert!(number.is_some(), "should not overflow");
290 }
291}