1use std::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 Warning {
25 Invalid(String),
27
28 InvalidType,
30
31 Overflow,
33}
34
35impl fmt::Display for Warning {
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 crate::Warning for Warning {
46 fn id(&self) -> crate::SmartString {
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 Warning {
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 Warning = Warning;
110
111 fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
112 let warnings = warning::Set::new();
113 let Some(s) = elem.as_number_str() else {
114 return warnings.bail(Warning::InvalidType, elem);
115 };
116
117 let seconds = match s.parse::<u64>() {
119 Ok(n) => n,
120 Err(err) => {
121 return warnings.bail(Warning::Invalid(err.to_string()), elem);
122 }
123 };
124
125 let Ok(seconds) = i64::try_from(seconds) else {
128 return warnings.bail(
129 Warning::Invalid("The duration value is larger than an i64 can represent.".into()),
130 elem,
131 );
132 };
133 let dt = TimeDelta::seconds(seconds);
134
135 Ok(Seconds(dt).into_caveat(warnings))
136 }
137}
138
139impl Cost for TimeDelta {
141 fn cost(&self, money: Money) -> Money {
142 let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
143 Money::from_decimal(cost)
144 }
145}
146
147impl SaturatingAdd for TimeDelta {
148 fn saturating_add(self, other: TimeDelta) -> TimeDelta {
149 self.checked_add(&other).unwrap_or(TimeDelta::MAX)
150 }
151}
152
153impl SaturatingSub for TimeDelta {
154 fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
155 self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
156 }
157}
158
159#[allow(dead_code, reason = "used during debug sessions")]
161pub(crate) trait AsHms {
162 fn as_hms(&self) -> Hms;
164}
165
166impl AsHms for TimeDelta {
167 fn as_hms(&self) -> Hms {
168 Hms(*self)
169 }
170}
171
172impl AsHms for Decimal {
173 fn as_hms(&self) -> Hms {
175 Hms(self.to_duration())
176 }
177}
178
179pub struct Hms(pub TimeDelta);
181
182impl fmt::Debug for Hms {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 fmt::Display::fmt(self, f)
186 }
187}
188
189impl fmt::Display for Hms {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 let duration = self.0;
192 let seconds = duration.num_seconds();
193
194 if seconds.is_negative() {
196 f.write_str("-")?;
197 }
198
199 let seconds = seconds.abs();
201
202 let seconds = seconds % SECS_IN_MIN;
203 let minutes = (seconds / SECS_IN_MIN) % MINS_IN_HOUR;
204 let hours = seconds / (SECS_IN_MIN * MINS_IN_HOUR);
205
206 write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
207 }
208}
209
210#[cfg(test)]
211mod test {
212 use chrono::TimeDelta;
213
214 use crate::test::ApproxEq;
215
216 impl ApproxEq for TimeDelta {
217 fn approx_eq(&self, other: &Self) -> bool {
218 const TOLERANCE: i64 = 3;
219 approx_eq_time_delta(*self, *other, TOLERANCE)
220 }
221 }
222
223 pub fn approx_eq_time_delta(a: TimeDelta, b: TimeDelta, tolerance_secs: i64) -> bool {
225 let diff = a.num_seconds() - b.num_seconds();
226 diff.abs() <= tolerance_secs
227 }
228}
229
230#[cfg(test)]
231mod hour_decimal_tests {
232 use chrono::TimeDelta;
233 use rust_decimal::Decimal;
234 use rust_decimal_macros::dec;
235
236 use crate::duration::ToHoursDecimal;
237
238 use super::MILLIS_IN_SEC;
239
240 #[test]
241 fn zero_minutes_should_be_zero_hours() {
242 assert_eq!(TimeDelta::minutes(0).to_hours_dec(), dec!(0.0));
243 }
244
245 #[test]
246 fn thirty_minutes_should_be_fraction_of_hour() {
247 assert_eq!(TimeDelta::minutes(30).to_hours_dec(), dec!(0.5));
248 }
249
250 #[test]
251 fn sixty_minutes_should_be_fraction_of_hour() {
252 assert_eq!(TimeDelta::minutes(60).to_hours_dec(), dec!(1.0));
253 }
254
255 #[test]
256 fn ninety_minutes_should_be_fraction_of_hour() {
257 assert_eq!(TimeDelta::minutes(90).to_hours_dec(), dec!(1.5));
258 }
259
260 #[test]
261 fn as_seconds_dec_should_not_overflow() {
262 let number = Decimal::from(i64::MAX).checked_div(Decimal::from(MILLIS_IN_SEC));
263 assert!(number.is_some(), "should not overflow");
264 }
265}