envelope_cli/services/
period.rs

1//! Budget period service
2//!
3//! Provides period management including navigation, validation, and
4//! period-specific operations.
5
6use crate::config::settings::{BudgetPeriodType, Settings};
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::BudgetPeriod;
9use chrono::{Datelike, Duration, Local, NaiveDate};
10
11/// Service for budget period management
12pub struct PeriodService<'a> {
13    settings: &'a Settings,
14}
15
16impl<'a> PeriodService<'a> {
17    /// Create a new period service
18    pub fn new(settings: &'a Settings) -> Self {
19        Self { settings }
20    }
21
22    /// Get the current period based on user preferences
23    pub fn current_period(&self) -> BudgetPeriod {
24        let today = Local::now().date_naive();
25        self.period_for_date(today)
26    }
27
28    /// Get the period containing a specific date
29    pub fn period_for_date(&self, date: NaiveDate) -> BudgetPeriod {
30        match self.settings.budget_period_type {
31            BudgetPeriodType::Monthly => BudgetPeriod::monthly(date.year(), date.month()),
32            BudgetPeriodType::Weekly => {
33                BudgetPeriod::weekly(date.iso_week().year(), date.iso_week().week())
34            }
35            BudgetPeriodType::BiWeekly => {
36                // For bi-weekly, we need to find the start date
37                // Using first Monday of the year as anchor
38                let anchor = self.get_biweekly_anchor(date.year());
39                let days_since_anchor = (date - anchor).num_days();
40                let periods_since_anchor = days_since_anchor / 14;
41                let period_start = anchor + Duration::days(periods_since_anchor * 14);
42                BudgetPeriod::bi_weekly(period_start)
43            }
44        }
45    }
46
47    /// Get the next period after the given one
48    pub fn next_period(&self, period: &BudgetPeriod) -> BudgetPeriod {
49        period.next()
50    }
51
52    /// Get the previous period before the given one
53    pub fn previous_period(&self, period: &BudgetPeriod) -> BudgetPeriod {
54        period.prev()
55    }
56
57    /// Get the anchor date for bi-weekly calculations (first Monday of the year)
58    fn get_biweekly_anchor(&self, year: i32) -> NaiveDate {
59        let jan_1 = NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
60        let days_until_monday = (7 - jan_1.weekday().num_days_from_monday()) % 7;
61        jan_1 + Duration::days(days_until_monday as i64)
62    }
63
64    /// Parse a period string or get current period
65    pub fn parse_or_current(&self, period_str: Option<&str>) -> EnvelopeResult<BudgetPeriod> {
66        match period_str {
67            Some(s) => self.parse(s),
68            None => Ok(self.current_period()),
69        }
70    }
71
72    /// Parse a period string according to user preferences
73    ///
74    /// Formats supported:
75    /// - Monthly: "2025-01", "January 2025", "Jan", "last", "next"
76    /// - Weekly: "2025-W03", "W3", "last", "next"
77    /// - Date range: "2025-01-01..2025-01-14"
78    pub fn parse(&self, s: &str) -> EnvelopeResult<BudgetPeriod> {
79        let s_lower = s.trim().to_lowercase();
80
81        // Handle relative references
82        if s_lower == "current" || s_lower == "now" || s_lower == "this" {
83            return Ok(self.current_period());
84        }
85
86        if s_lower == "last" || s_lower == "previous" || s_lower == "prev" {
87            return Ok(self.previous_period(&self.current_period()));
88        }
89
90        if s_lower == "next" {
91            return Ok(self.next_period(&self.current_period()));
92        }
93
94        // Handle month names
95        if let Some(period) = self.parse_month_name(&s_lower) {
96            return Ok(period);
97        }
98
99        // Handle standard period format (preserve original case for weekly format)
100        BudgetPeriod::parse(s.trim())
101            .map_err(|_| EnvelopeError::Validation(format!("Invalid period format: {}", s)))
102    }
103
104    /// Parse month names like "January", "Jan", etc.
105    fn parse_month_name(&self, s: &str) -> Option<BudgetPeriod> {
106        let months = [
107            ("january", 1),
108            ("jan", 1),
109            ("february", 2),
110            ("feb", 2),
111            ("march", 3),
112            ("mar", 3),
113            ("april", 4),
114            ("apr", 4),
115            ("may", 5),
116            ("june", 6),
117            ("jun", 6),
118            ("july", 7),
119            ("jul", 7),
120            ("august", 8),
121            ("aug", 8),
122            ("september", 9),
123            ("sep", 9),
124            ("sept", 9),
125            ("october", 10),
126            ("oct", 10),
127            ("november", 11),
128            ("nov", 11),
129            ("december", 12),
130            ("dec", 12),
131        ];
132
133        for (name, month) in months {
134            if let Some(stripped) = s.strip_prefix(name) {
135                // Check if year is specified (e.g., "January 2025" or "Jan 2025")
136                let rest = stripped.trim();
137                let year = if rest.is_empty() {
138                    // Use current year, or previous year if month is in the future
139                    let today = Local::now().date_naive();
140                    if month > today.month() {
141                        today.year() - 1
142                    } else {
143                        today.year()
144                    }
145                } else {
146                    rest.parse().ok()?
147                };
148
149                return Some(BudgetPeriod::monthly(year, month));
150            }
151        }
152
153        None
154    }
155
156    /// Get a list of periods for display (e.g., last 6 months)
157    pub fn recent_periods(&self, count: usize) -> Vec<BudgetPeriod> {
158        let mut periods = Vec::with_capacity(count);
159        let mut current = self.current_period();
160
161        for _ in 0..count {
162            periods.push(current.clone());
163            current = self.previous_period(&current);
164        }
165
166        periods.reverse();
167        periods
168    }
169
170    /// Get a list of upcoming periods (current + future)
171    pub fn upcoming_periods(&self, count: usize) -> Vec<BudgetPeriod> {
172        let mut periods = Vec::with_capacity(count);
173        let mut current = self.current_period();
174
175        for _ in 0..count {
176            periods.push(current.clone());
177            current = self.next_period(&current);
178        }
179
180        periods
181    }
182
183    /// Format a period for display
184    pub fn format_period(&self, period: &BudgetPeriod) -> String {
185        period.to_string()
186    }
187
188    /// Format a period in a human-friendly way
189    pub fn format_period_friendly(&self, period: &BudgetPeriod) -> String {
190        match period {
191            BudgetPeriod::Monthly { year, month } => {
192                let month_names = [
193                    "January",
194                    "February",
195                    "March",
196                    "April",
197                    "May",
198                    "June",
199                    "July",
200                    "August",
201                    "September",
202                    "October",
203                    "November",
204                    "December",
205                ];
206                let month_name = month_names[(*month - 1) as usize];
207                format!("{} {}", month_name, year)
208            }
209            BudgetPeriod::Weekly { year, week } => {
210                format!("Week {} of {}", week, year)
211            }
212            BudgetPeriod::BiWeekly { start_date } => {
213                let end_date = *start_date + Duration::days(13);
214                format!(
215                    "{} - {}",
216                    start_date.format("%b %d"),
217                    end_date.format("%b %d, %Y")
218                )
219            }
220            BudgetPeriod::Custom { start, end } => {
221                format!("{} to {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d"))
222            }
223        }
224    }
225
226    /// Check if a period is the current period
227    pub fn is_current(&self, period: &BudgetPeriod) -> bool {
228        *period == self.current_period()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn default_settings() -> Settings {
237        Settings::default()
238    }
239
240    #[test]
241    fn test_current_period() {
242        let settings = default_settings();
243        let service = PeriodService::new(&settings);
244
245        let period = service.current_period();
246        let today = Local::now().date_naive();
247
248        // Should be a monthly period containing today
249        assert!(period.contains(today));
250    }
251
252    #[test]
253    fn test_period_navigation() {
254        let settings = default_settings();
255        let service = PeriodService::new(&settings);
256
257        let jan = BudgetPeriod::monthly(2025, 1);
258        let feb = service.next_period(&jan);
259        let dec = service.previous_period(&jan);
260
261        assert_eq!(feb, BudgetPeriod::monthly(2025, 2));
262        assert_eq!(dec, BudgetPeriod::monthly(2024, 12));
263    }
264
265    #[test]
266    fn test_parse_relative() {
267        let settings = default_settings();
268        let service = PeriodService::new(&settings);
269
270        let current = service.current_period();
271
272        assert_eq!(service.parse("current").unwrap(), current);
273        assert_eq!(service.parse("now").unwrap(), current);
274        assert_eq!(
275            service.parse("last").unwrap(),
276            service.previous_period(&current)
277        );
278        assert_eq!(
279            service.parse("next").unwrap(),
280            service.next_period(&current)
281        );
282    }
283
284    #[test]
285    fn test_parse_standard() {
286        let settings = default_settings();
287        let service = PeriodService::new(&settings);
288
289        assert_eq!(
290            service.parse("2025-01").unwrap(),
291            BudgetPeriod::monthly(2025, 1)
292        );
293        assert_eq!(
294            service.parse("2025-W03").unwrap(),
295            BudgetPeriod::weekly(2025, 3)
296        );
297    }
298
299    #[test]
300    fn test_parse_month_name() {
301        let settings = default_settings();
302        let service = PeriodService::new(&settings);
303
304        let jan2025 = service.parse("January 2025").unwrap();
305        assert_eq!(jan2025, BudgetPeriod::monthly(2025, 1));
306
307        let mar2025 = service.parse("Mar 2025").unwrap();
308        assert_eq!(mar2025, BudgetPeriod::monthly(2025, 3));
309    }
310
311    #[test]
312    fn test_recent_periods() {
313        let settings = default_settings();
314        let service = PeriodService::new(&settings);
315
316        let recent = service.recent_periods(3);
317        assert_eq!(recent.len(), 3);
318
319        // Should be in chronological order
320        assert!(recent[0] < recent[1]);
321        assert!(recent[1] < recent[2]);
322
323        // Last one should be current
324        assert!(service.is_current(&recent[2]));
325    }
326
327    #[test]
328    fn test_format_period_friendly() {
329        let settings = default_settings();
330        let service = PeriodService::new(&settings);
331
332        let jan = BudgetPeriod::monthly(2025, 1);
333        assert_eq!(service.format_period_friendly(&jan), "January 2025");
334
335        let week3 = BudgetPeriod::weekly(2025, 3);
336        assert_eq!(service.format_period_friendly(&week3), "Week 3 of 2025");
337    }
338}