1use chrono::{Datelike, Duration, NaiveDate, Weekday};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(tag = "type", content = "value")]
12pub enum BudgetPeriod {
13 Monthly { year: i32, month: u32 },
15
16 Weekly { year: i32, week: u32 },
18
19 BiWeekly { start_date: NaiveDate },
21
22 Custom { start: NaiveDate, end: NaiveDate },
24}
25
26impl BudgetPeriod {
27 pub fn monthly(year: i32, month: u32) -> Self {
29 Self::Monthly { year, month }
30 }
31
32 pub fn weekly(year: i32, week: u32) -> Self {
34 Self::Weekly { year, week }
35 }
36
37 pub fn bi_weekly(start_date: NaiveDate) -> Self {
39 Self::BiWeekly { start_date }
40 }
41
42 pub fn custom(start: NaiveDate, end: NaiveDate) -> Self {
44 Self::Custom { start, end }
45 }
46
47 pub fn current_month() -> Self {
49 let today = chrono::Local::now().date_naive();
50 Self::Monthly {
51 year: today.year(),
52 month: today.month(),
53 }
54 }
55
56 pub fn current_week() -> Self {
58 let today = chrono::Local::now().date_naive();
59 Self::Weekly {
60 year: today.iso_week().year(),
61 week: today.iso_week().week(),
62 }
63 }
64
65 pub fn start_date(&self) -> NaiveDate {
67 match self {
68 Self::Monthly { year, month } => NaiveDate::from_ymd_opt(*year, *month, 1)
69 .unwrap_or_else(|| NaiveDate::from_ymd_opt(*year, 1, 1).unwrap()),
70 Self::Weekly { year, week } => NaiveDate::from_isoywd_opt(*year, *week, Weekday::Mon)
71 .unwrap_or_else(|| NaiveDate::from_ymd_opt(*year, 1, 1).unwrap()),
72 Self::BiWeekly { start_date } => *start_date,
73 Self::Custom { start, .. } => *start,
74 }
75 }
76
77 pub fn end_date(&self) -> NaiveDate {
79 match self {
80 Self::Monthly { year, month } => {
81 let next_month = if *month == 12 {
82 NaiveDate::from_ymd_opt(*year + 1, 1, 1)
83 } else {
84 NaiveDate::from_ymd_opt(*year, *month + 1, 1)
85 };
86 next_month.unwrap() - Duration::days(1)
87 }
88 Self::Weekly { year, week } => NaiveDate::from_isoywd_opt(*year, *week, Weekday::Sun)
89 .unwrap_or_else(|| self.start_date() + Duration::days(6)),
90 Self::BiWeekly { start_date } => *start_date + Duration::days(13),
91 Self::Custom { end, .. } => *end,
92 }
93 }
94
95 pub fn contains(&self, date: NaiveDate) -> bool {
97 date >= self.start_date() && date <= self.end_date()
98 }
99
100 pub fn next(&self) -> Self {
102 match self {
103 Self::Monthly { year, month } => {
104 if *month == 12 {
105 Self::Monthly {
106 year: *year + 1,
107 month: 1,
108 }
109 } else {
110 Self::Monthly {
111 year: *year,
112 month: *month + 1,
113 }
114 }
115 }
116 Self::Weekly { year, week } => {
117 let max_week = NaiveDate::from_ymd_opt(*year, 12, 28)
119 .unwrap()
120 .iso_week()
121 .week();
122 if *week >= max_week {
123 Self::Weekly {
124 year: *year + 1,
125 week: 1,
126 }
127 } else {
128 Self::Weekly {
129 year: *year,
130 week: *week + 1,
131 }
132 }
133 }
134 Self::BiWeekly { start_date } => Self::BiWeekly {
135 start_date: *start_date + Duration::days(14),
136 },
137 Self::Custom { start, end } => {
138 let duration = *end - *start;
139 Self::Custom {
140 start: *end + Duration::days(1),
141 end: *end + duration + Duration::days(1),
142 }
143 }
144 }
145 }
146
147 pub fn prev(&self) -> Self {
149 match self {
150 Self::Monthly { year, month } => {
151 if *month == 1 {
152 Self::Monthly {
153 year: *year - 1,
154 month: 12,
155 }
156 } else {
157 Self::Monthly {
158 year: *year,
159 month: *month - 1,
160 }
161 }
162 }
163 Self::Weekly { year, week } => {
164 if *week == 1 {
165 let prev_year = *year - 1;
166 let max_week = NaiveDate::from_ymd_opt(prev_year, 12, 28)
167 .unwrap()
168 .iso_week()
169 .week();
170 Self::Weekly {
171 year: prev_year,
172 week: max_week,
173 }
174 } else {
175 Self::Weekly {
176 year: *year,
177 week: *week - 1,
178 }
179 }
180 }
181 Self::BiWeekly { start_date } => Self::BiWeekly {
182 start_date: *start_date - Duration::days(14),
183 },
184 Self::Custom { start, end } => {
185 let duration = *end - *start;
186 Self::Custom {
187 start: *start - duration - Duration::days(1),
188 end: *start - Duration::days(1),
189 }
190 }
191 }
192 }
193
194 pub fn parse(s: &str) -> Result<Self, PeriodParseError> {
201 let s = s.trim();
202
203 if s.contains('W') {
205 let parts: Vec<&str> = s.split("-W").collect();
206 if parts.len() == 2 {
207 let year: i32 = parts[0]
208 .parse()
209 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
210 let week: u32 = parts[1]
211 .parse()
212 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
213 return Ok(Self::Weekly { year, week });
214 }
215 }
216
217 if s.contains("..") {
219 let parts: Vec<&str> = s.split("..").collect();
220 if parts.len() == 2 {
221 let start = NaiveDate::parse_from_str(parts[0], "%Y-%m-%d")
222 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
223 let end = NaiveDate::parse_from_str(parts[1], "%Y-%m-%d")
224 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
225 return Ok(Self::Custom { start, end });
226 }
227 }
228
229 let parts: Vec<&str> = s.split('-').collect();
231 if parts.len() == 2 {
232 let year: i32 = parts[0]
233 .parse()
234 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
235 let month: u32 = parts[1]
236 .parse()
237 .map_err(|_| PeriodParseError::InvalidFormat(s.to_string()))?;
238
239 if !(1..=12).contains(&month) {
240 return Err(PeriodParseError::InvalidMonth(month));
241 }
242
243 return Ok(Self::Monthly { year, month });
244 }
245
246 Err(PeriodParseError::InvalidFormat(s.to_string()))
247 }
248}
249
250impl fmt::Display for BudgetPeriod {
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 match self {
253 Self::Monthly { year, month } => write!(f, "{:04}-{:02}", year, month),
254 Self::Weekly { year, week } => write!(f, "{:04}-W{:02}", year, week),
255 Self::BiWeekly { start_date } => {
256 let end = *start_date + Duration::days(13);
257 write!(
258 f,
259 "{} - {}",
260 start_date.format("%Y-%m-%d"),
261 end.format("%Y-%m-%d")
262 )
263 }
264 Self::Custom { start, end } => {
265 write!(
266 f,
267 "{}..{}",
268 start.format("%Y-%m-%d"),
269 end.format("%Y-%m-%d")
270 )
271 }
272 }
273 }
274}
275
276impl Ord for BudgetPeriod {
277 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
278 self.start_date().cmp(&other.start_date())
279 }
280}
281
282impl PartialOrd for BudgetPeriod {
283 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
284 Some(self.cmp(other))
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum PeriodParseError {
291 InvalidFormat(String),
292 InvalidMonth(u32),
293}
294
295impl fmt::Display for PeriodParseError {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 match self {
298 PeriodParseError::InvalidFormat(s) => write!(f, "Invalid period format: {}", s),
299 PeriodParseError::InvalidMonth(m) => write!(f, "Invalid month: {}", m),
300 }
301 }
302}
303
304impl std::error::Error for PeriodParseError {}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_monthly_period() {
312 let period = BudgetPeriod::monthly(2025, 1);
313 assert_eq!(
314 period.start_date(),
315 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()
316 );
317 assert_eq!(
318 period.end_date(),
319 NaiveDate::from_ymd_opt(2025, 1, 31).unwrap()
320 );
321 }
322
323 #[test]
324 fn test_monthly_navigation() {
325 let jan = BudgetPeriod::monthly(2025, 1);
326 let feb = jan.next();
327 assert_eq!(feb, BudgetPeriod::monthly(2025, 2));
328
329 let dec = BudgetPeriod::monthly(2024, 12);
330 let jan2025 = dec.next();
331 assert_eq!(jan2025, BudgetPeriod::monthly(2025, 1));
332 }
333
334 #[test]
335 fn test_weekly_period() {
336 let period = BudgetPeriod::weekly(2025, 1);
337 assert!(period.start_date() <= NaiveDate::from_ymd_opt(2025, 1, 5).unwrap());
339 }
340
341 #[test]
342 fn test_contains() {
343 let jan = BudgetPeriod::monthly(2025, 1);
344 assert!(jan.contains(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()));
345 assert!(!jan.contains(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()));
346 }
347
348 #[test]
349 fn test_parse_monthly() {
350 let period = BudgetPeriod::parse("2025-01").unwrap();
351 assert_eq!(period, BudgetPeriod::monthly(2025, 1));
352 }
353
354 #[test]
355 fn test_parse_weekly() {
356 let period = BudgetPeriod::parse("2025-W03").unwrap();
357 assert_eq!(period, BudgetPeriod::weekly(2025, 3));
358 }
359
360 #[test]
361 fn test_display() {
362 assert_eq!(format!("{}", BudgetPeriod::monthly(2025, 1)), "2025-01");
363 assert_eq!(format!("{}", BudgetPeriod::weekly(2025, 3)), "2025-W03");
364 }
365
366 #[test]
367 fn test_serialization() {
368 let period = BudgetPeriod::monthly(2025, 1);
369 let json = serde_json::to_string(&period).unwrap();
370 let deserialized: BudgetPeriod = serde_json::from_str(&json).unwrap();
371 assert_eq!(period, deserialized);
372 }
373}