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 Overflow,
33}
34
35impl fmt::Display for WarningKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Self::Invalid(err) => write!(f, "Unable to parse the duration: {err}"),
39 Self::InvalidType => write!(f, "The value should be a string."),
40 Self::Overflow => f.write_str("A numeric overflow occurred while creating a duration"),
41 }
42 }
43}
44
45impl warning::Kind for WarningKind {
46 fn id(&self) -> Cow<'static, str> {
47 match self {
48 Self::Invalid(_) => "invalid".into(),
49 Self::InvalidType => "invalid_type".into(),
50 Self::Overflow => "overflow".into(),
51 }
52 }
53}
54
55impl From<rust_decimal::Error> for WarningKind {
56 fn from(_: rust_decimal::Error) -> Self {
57 Self::Overflow
58 }
59}
60
61into_caveat_all!(TimeDelta, Seconds);
62
63pub trait ToHoursDecimal {
65 fn to_hours_dec(&self) -> Decimal;
67}
68
69pub trait ToDuration {
71 fn to_duration(&self) -> TimeDelta;
73}
74
75impl ToHoursDecimal for TimeDelta {
76 fn to_hours_dec(&self) -> Decimal {
77 let div = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
78 let num = Decimal::from(self.num_milliseconds());
79 num.checked_div(div).unwrap_or(Decimal::MAX)
80 }
81}
82
83impl ToDuration for Decimal {
84 fn to_duration(&self) -> TimeDelta {
85 let factor = Decimal::from(MILLIS_IN_SEC * SECS_IN_MIN * MINS_IN_HOUR);
86 let millis = self.saturating_mul(factor).to_i64().unwrap_or(i64::MAX);
87 TimeDelta::milliseconds(millis)
88 }
89}
90
91pub(crate) struct Seconds(TimeDelta);
94
95impl From<Seconds> for TimeDelta {
97 fn from(value: Seconds) -> Self {
98 value.0
99 }
100}
101
102impl json::FromJson<'_, '_> for Seconds {
109 type WarningKind = WarningKind;
110
111 fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::WarningKind> {
112 let warnings = warning::Set::new();
113 let Some(s) = elem.as_number_str() else {
114 return warnings.bail(WarningKind::InvalidType, elem);
115 };
116
117 let seconds = match s.parse::<u64>() {
119 Ok(n) => n,
120 Err(err) => {
121 return warnings.bail(WarningKind::Invalid(err.to_string()), elem);
122 }
123 };
124
125 let Ok(seconds) = i64::try_from(seconds) else {
128 return warnings.bail(
129 WarningKind::Invalid(
130 "The duration value is larger than an i64 can represent.".into(),
131 ),
132 elem,
133 );
134 };
135 let dt = TimeDelta::seconds(seconds);
136
137 Ok(Seconds(dt).into_caveat(warnings))
138 }
139}
140
141impl Cost for TimeDelta {
143 fn cost(&self, money: Money) -> Money {
144 let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
145 Money::from_decimal(cost)
146 }
147}
148
149impl SaturatingAdd for TimeDelta {
150 fn saturating_add(self, other: TimeDelta) -> TimeDelta {
151 self.checked_add(&other).unwrap_or(TimeDelta::MAX)
152 }
153}
154
155impl SaturatingSub for TimeDelta {
156 fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
157 self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
158 }
159}
160
161#[allow(dead_code, reason = "used during debug sessions")]
163pub(crate) trait AsHms {
164 fn as_hms(&self) -> Hms;
166}
167
168impl AsHms for TimeDelta {
169 fn as_hms(&self) -> Hms {
170 Hms(*self)
171 }
172}
173
174impl AsHms for Decimal {
175 fn as_hms(&self) -> Hms {
177 Hms(self.to_duration())
178 }
179}
180
181pub(crate) struct Hms(pub TimeDelta);
183
184impl fmt::Debug for Hms {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 fmt::Display::fmt(self, f)
188 }
189}
190
191impl fmt::Display for Hms {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 let duration = self.0;
194 let seconds = duration.num_seconds();
195
196 if seconds.is_negative() {
198 f.write_str("-")?;
199 }
200
201 let seconds = seconds.abs();
203
204 let seconds = seconds % SECS_IN_MIN;
205 let minutes = (seconds / SECS_IN_MIN) % MINS_IN_HOUR;
206 let hours = seconds / (SECS_IN_MIN * MINS_IN_HOUR);
207
208 write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
209 }
210}
211
212#[cfg(test)]
213mod test {
214 use chrono::TimeDelta;
215
216 use crate::test::ApproxEq;
217
218 impl ApproxEq for TimeDelta {
219 fn approx_eq(&self, other: &Self) -> bool {
220 const TOLERANCE: i64 = 3;
221 approx_eq_time_delta(*self, *other, TOLERANCE)
222 }
223 }
224
225 pub fn approx_eq_time_delta(a: TimeDelta, b: TimeDelta, tolerance_secs: i64) -> bool {
227 let diff = a.num_seconds() - b.num_seconds();
228 diff.abs() <= tolerance_secs
229 }
230}
231
232#[cfg(test)]
233mod hour_decimal_tests {
234 use chrono::TimeDelta;
235 use rust_decimal::Decimal;
236 use rust_decimal_macros::dec;
237
238 use crate::duration::ToHoursDecimal;
239
240 use super::MILLIS_IN_SEC;
241
242 #[test]
243 fn zero_minutes_should_be_zero_hours() {
244 assert_eq!(TimeDelta::minutes(0).to_hours_dec(), dec!(0.0));
245 }
246
247 #[test]
248 fn thirty_minutes_should_be_fraction_of_hour() {
249 assert_eq!(TimeDelta::minutes(30).to_hours_dec(), dec!(0.5));
250 }
251
252 #[test]
253 fn sixty_minutes_should_be_fraction_of_hour() {
254 assert_eq!(TimeDelta::minutes(60).to_hours_dec(), dec!(1.0));
255 }
256
257 #[test]
258 fn ninety_minutes_should_be_fraction_of_hour() {
259 assert_eq!(TimeDelta::minutes(90).to_hours_dec(), dec!(1.5));
260 }
261
262 #[test]
263 fn as_seconds_dec_should_not_overflow() {
264 let number = Decimal::from(i64::MAX).checked_div(Decimal::from(MILLIS_IN_SEC));
265 assert!(number.is_some(), "should not overflow");
266 }
267}