envelope_cli/models/
period.rs

1//! Budget period representation
2//!
3//! Supports multiple period types: monthly, weekly, bi-weekly, and custom date ranges.
4
5use chrono::{Datelike, Duration, NaiveDate, Weekday};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Represents a budget period
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(tag = "type", content = "value")]
12pub enum BudgetPeriod {
13    /// Monthly period (e.g., "2025-01")
14    Monthly { year: i32, month: u32 },
15
16    /// ISO week period (e.g., "2025-W03")
17    Weekly { year: i32, week: u32 },
18
19    /// Bi-weekly period (identified by start date)
20    BiWeekly { start_date: NaiveDate },
21
22    /// Custom date range
23    Custom { start: NaiveDate, end: NaiveDate },
24}
25
26impl BudgetPeriod {
27    /// Create a monthly period
28    pub fn monthly(year: i32, month: u32) -> Self {
29        Self::Monthly { year, month }
30    }
31
32    /// Create a weekly period (ISO week)
33    pub fn weekly(year: i32, week: u32) -> Self {
34        Self::Weekly { year, week }
35    }
36
37    /// Create a bi-weekly period starting on the given date
38    pub fn bi_weekly(start_date: NaiveDate) -> Self {
39        Self::BiWeekly { start_date }
40    }
41
42    /// Create a custom period
43    pub fn custom(start: NaiveDate, end: NaiveDate) -> Self {
44        Self::Custom { start, end }
45    }
46
47    /// Get the current monthly period
48    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    /// Get the current weekly period
57    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    /// Get the start date of this period
66    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    /// Get the end date of this period (inclusive)
78    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    /// Check if a date falls within this period
96    pub fn contains(&self, date: NaiveDate) -> bool {
97        date >= self.start_date() && date <= self.end_date()
98    }
99
100    /// Get the next period
101    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                // ISO weeks go from 1-52 or 1-53
118                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    /// Get the previous period
148    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    /// Parse a period string
195    ///
196    /// Formats:
197    /// - Monthly: "2025-01"
198    /// - Weekly: "2025-W03"
199    /// - Custom: "2025-01-01..2025-01-15"
200    pub fn parse(s: &str) -> Result<Self, PeriodParseError> {
201        let s = s.trim();
202
203        // Try weekly format first (contains W)
204        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        // Try custom range format (contains ..)
218        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        // Try monthly format (YYYY-MM)
230        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/// Error type for period parsing
289#[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        // ISO week 1 of 2025 starts on Monday December 30, 2024
338        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}