1use std::fmt;
15
16use crate::{Error, Result};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum Grain {
21 Month,
23 Quarter,
25 Semester,
27 Year,
29}
30
31impl Grain {
32 pub const fn months(self) -> u32 {
34 match self {
35 Self::Month => 1,
36 Self::Quarter => 3,
37 Self::Semester => 6,
38 Self::Year => 12,
39 }
40 }
41
42 pub const fn is_multiple_of(self, finer: Grain) -> bool {
44 self.months() % finer.months() == 0
45 }
46}
47
48impl fmt::Display for Grain {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(match self {
51 Self::Month => "M",
52 Self::Quarter => "Q",
53 Self::Semester => "S",
54 Self::Year => "Y",
55 })
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
69pub struct Month {
70 year: i32,
72 month: u8,
73}
74
75impl Month {
76 pub fn new(year: i32, month: u8) -> Result<Self> {
78 if !(1..=12).contains(&month) {
79 return Err(Error::InvalidMonth(month));
80 }
81 Ok(Self { year, month })
82 }
83
84 pub const fn january(year: i32) -> Self {
86 Self { year, month: 1 }
87 }
88
89 pub const fn year(self) -> i32 {
91 self.year
92 }
93
94 pub const fn month(self) -> u8 {
96 self.month
97 }
98
99 const fn ordinal(self) -> i64 {
102 self.year as i64 * 12 + (self.month as i64 - 1)
103 }
104
105 fn from_ordinal(ordinal: i64) -> Self {
106 Self {
107 year: ordinal.div_euclid(12) as i32,
108 month: (ordinal.rem_euclid(12) + 1) as u8,
109 }
110 }
111
112 pub fn add_months(self, n: i64) -> Self {
114 Self::from_ordinal(self.ordinal() + n)
115 }
116
117 pub const fn months_since(self, earlier: Month) -> i64 {
119 self.ordinal() - earlier.ordinal()
120 }
121
122 pub fn floor(self, grain: Grain) -> Self {
126 let size = grain.months() as u8;
127 Self {
128 year: self.year,
129 month: (self.month - 1) / size * size + 1,
130 }
131 }
132}
133
134impl fmt::Display for Month {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(f, "{}-{:02}", self.year, self.month)
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
154pub struct Period {
155 start: Month,
156 grain: Grain,
157}
158
159impl Period {
160 pub fn containing(month: Month, grain: Grain) -> Self {
162 Self {
163 start: month.floor(grain),
164 grain,
165 }
166 }
167
168 pub const fn year(year: i32) -> Self {
170 Self {
171 start: Month::january(year),
172 grain: Grain::Year,
173 }
174 }
175
176 pub const fn start(self) -> Month {
178 self.start
179 }
180
181 pub fn end(self) -> Month {
183 self.start.add_months(self.grain.months() as i64 - 1)
184 }
185
186 pub const fn grain(self) -> Grain {
188 self.grain
189 }
190}
191
192impl fmt::Display for Period {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 let Month { year, month } = self.start;
195 match self.grain {
196 Grain::Year => write!(f, "{year}"),
197 Grain::Semester => write!(f, "{year}H{}", (month - 1) / 6 + 1),
198 Grain::Quarter => write!(f, "{year}Q{}", (month - 1) / 3 + 1),
199 Grain::Month => write!(f, "{}", self.start),
200 }
201 }
202}
203
204pub type Lag = u32;
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn m(year: i32, month: u8) -> Month {
214 Month::new(year, month).unwrap()
215 }
216
217 #[test]
218 fn month_arithmetic_crosses_years() {
219 assert_eq!(m(2020, 1).add_months(-1), m(2019, 12));
220 assert_eq!(m(2020, 12).add_months(13), m(2022, 1));
221 assert_eq!(m(2022, 1).months_since(m(2020, 12)), 13);
222 assert_eq!(m(-1, 12).add_months(1), m(0, 1));
223 }
224
225 #[test]
226 fn rejects_bad_month() {
227 assert_eq!(Month::new(2020, 0), Err(Error::InvalidMonth(0)));
228 assert_eq!(Month::new(2020, 13), Err(Error::InvalidMonth(13)));
229 }
230
231 #[test]
232 fn floor_aligns_to_calendar() {
233 assert_eq!(m(2021, 12).floor(Grain::Quarter), m(2021, 10));
234 assert_eq!(m(2021, 6).floor(Grain::Semester), m(2021, 1));
235 assert_eq!(m(2021, 7).floor(Grain::Semester), m(2021, 7));
236 assert_eq!(m(2021, 7).floor(Grain::Year), m(2021, 1));
237 assert_eq!(m(2021, 7).floor(Grain::Month), m(2021, 7));
238 }
239
240 #[test]
241 fn period_labels() {
242 assert_eq!(Period::year(1981).to_string(), "1981");
243 assert_eq!(
244 Period::containing(m(2021, 7), Grain::Semester).to_string(),
245 "2021H2"
246 );
247 assert_eq!(
248 Period::containing(m(2021, 3), Grain::Month).to_string(),
249 "2021-03"
250 );
251 assert_eq!(Period::year(1981).end(), m(1981, 12));
252 }
253
254 #[test]
255 fn grain_multiples() {
256 assert!(Grain::Year.is_multiple_of(Grain::Quarter));
257 assert!(Grain::Year.is_multiple_of(Grain::Semester));
258 assert!(!Grain::Quarter.is_multiple_of(Grain::Semester));
259 }
260}