diff --git a/README.md b/README.md index 490deea..944f0c9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ use chrono::NaiveDate; use rustframe::{ frame::{Frame, RowIndex}, matrix::{BoolOps, Matrix, SeriesOps}, - utils::{BDateFreq, BDatesList}, + utils::{DateFreq, BDatesList}, }; let n_periods = 4; @@ -53,8 +53,7 @@ let n_periods = 4; let dates: Vec = BDatesList::from_n_periods("2024-01-02".to_string(), BDateFreq::Daily, n_periods) .unwrap() - .list() - .unwrap(); + .list().unwrap(); let col_names: Vec = vec!["a".to_string(), "b".to_string()]; diff --git a/src/utils/bdates.rs b/src/utils/bdates.rs deleted file mode 100644 index c3818ba..0000000 --- a/src/utils/bdates.rs +++ /dev/null @@ -1,2410 +0,0 @@ -use chrono::{Datelike, Duration, NaiveDate, Weekday}; -use std::collections::HashMap; -use std::error::Error; -use std::hash::Hash; -use std::result::Result; -use std::str::FromStr; - -/// Represents the frequency at which business dates should be generated. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum BDateFreq { - Daily, - WeeklyMonday, - MonthStart, - QuarterStart, - YearStart, - MonthEnd, - QuarterEnd, - WeeklyFriday, - YearEnd, -} - -/// Indicates whether the first or last date in a periodic group (like month, quarter) -/// is selected for the frequency. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AggregationType { - Start, // Select the first valid business day in the period - End, // Select the last valid business day in the period -} - -impl BDateFreq { - /// Attempts to parse a frequency string into a `BDateFreq` enum. - /// - /// This is a convenience wrapper around `from_str`. - /// - /// # Arguments - /// - /// * `freq` - The frequency string (e.g., "D", "W", "ME"). - /// - /// # Errors - /// - /// Returns an error if the string does not match any known frequency. - pub fn from_string(freq: String) -> Result> { - // Delegate parsing to the FromStr implementation - freq.parse() - } - - /// Returns the canonical string representation of the frequency. - /// - /// This returns the primary code (e.g., "D", "W", "Y", "YE"), not the aliases. - pub fn to_string(&self) -> String { - let r = match self { - BDateFreq::Daily => "D", - BDateFreq::WeeklyMonday => "W", - BDateFreq::MonthStart => "M", - BDateFreq::QuarterStart => "Q", - BDateFreq::YearStart => "Y", - BDateFreq::MonthEnd => "ME", - BDateFreq::QuarterEnd => "QE", - BDateFreq::WeeklyFriday => "WF", - BDateFreq::YearEnd => "YE", - }; - r.to_string() - } - - /// Determines whether the frequency represents a start-of-period or end-of-period aggregation. - pub fn agg_type(&self) -> AggregationType { - match self { - BDateFreq::Daily - | BDateFreq::WeeklyMonday - | BDateFreq::MonthStart - | BDateFreq::QuarterStart - | BDateFreq::YearStart => AggregationType::Start, - - BDateFreq::WeeklyFriday - | BDateFreq::MonthEnd - | BDateFreq::QuarterEnd - | BDateFreq::YearEnd => AggregationType::End, - } - } -} - -// Implement FromStr for BDateFreq to allow parsing directly using `parse()` -impl FromStr for BDateFreq { - type Err = Box; - - /// Attempts to parse a frequency string slice into a `BDateFreq` enum. - /// - /// Supports various frequency codes and common aliases. - /// - /// | Code | Alias | Description | - /// |------|---------|---------------------| - /// | D | | Daily | - /// | W | WS | Weekly Monday | - /// | M | MS | Month Start | - /// | Q | QS | Quarter Start | - /// | Y | A, AS, YS | Year Start | - /// | ME | | Month End | - /// | QE | | Quarter End | - /// | WF | | Weekly Friday | - /// | YE | AE | Year End (Annual) | - /// - /// # Arguments - /// - /// * `freq` - The frequency string slice (e.g., "D", "W", "ME"). - /// - /// # Errors - /// - /// Returns an error if the string does not match any known frequency. - fn from_str(freq: &str) -> Result { - let r = match freq { - "D" => BDateFreq::Daily, - "W" | "WS" => BDateFreq::WeeklyMonday, - "M" | "MS" => BDateFreq::MonthStart, - "Q" | "QS" => BDateFreq::QuarterStart, - "Y" | "A" | "AS" | "YS" => BDateFreq::YearStart, // Support standard aliases for year start - "ME" => BDateFreq::MonthEnd, - "QE" => BDateFreq::QuarterEnd, - "WF" => BDateFreq::WeeklyFriday, - "YE" | "AE" => BDateFreq::YearEnd, // Include 'AE' alias for year end - _ => return Err(format!("Invalid frequency specified: {}", freq).into()), - }; - Ok(r) - } -} - -/// Represents a list of business dates generated between a start and end date -/// at a specified frequency. Provides methods to retrieve the full list, -/// count, or dates grouped by period. -#[derive(Debug, Clone)] -pub struct BDatesList { - start_date_str: String, - end_date_str: String, - freq: BDateFreq, - // TODO: cache the generated date list to reduce repeated computation. - // Currently, list(), count(), and groups() regenerate the list on every invocation. - // cached_list: Option>, -} - -// Enumeration of period keys used for grouping dates. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -enum GroupKey { - Daily(NaiveDate), // Daily grouping: use the exact date - Weekly(i32, u32), // Weekly grouping: use year and ISO week number - Monthly(i32, u32), // Monthly grouping: use year and month (1-12) - Quarterly(i32, u32), // Quarterly grouping: use year and quarter (1-4) - Yearly(i32), // Yearly grouping: use year -} - -/// Represents a collection of business dates generated according to specific rules. -/// -/// It can be defined either by a start and end date range or by a start date -/// and a fixed number of periods. It provides methods to retrieve the dates -/// as a flat list, count them, or group them by their natural period -/// (e.g., month, quarter). -/// -/// Business days are typically Monday to Friday. Weekend dates are skipped or -/// adjusted depending on the frequency rules. -/// -/// # Examples -/// -/// **1. Using `new` (Start and End Date):** -/// -/// ```rust -/// use chrono::NaiveDate; -/// use std::error::Error; -/// use rustframe::utils::{BDatesList, BDateFreq}; // Replace bdates with your actual crate/module name -/// -/// fn main() -> Result<(), Box> { -/// let start_date = "2023-11-01".to_string(); // Wednesday -/// let end_date = "2023-11-07".to_string(); // Tuesday -/// let freq = BDateFreq::Daily; -/// -/// let bdates = BDatesList::new(start_date, end_date, freq); -/// -/// let expected_dates = vec![ -/// NaiveDate::from_ymd_opt(2023, 11, 1).unwrap(), // Wed -/// NaiveDate::from_ymd_opt(2023, 11, 2).unwrap(), // Thu -/// NaiveDate::from_ymd_opt(2023, 11, 3).unwrap(), // Fri -/// NaiveDate::from_ymd_opt(2023, 11, 6).unwrap(), // Mon -/// NaiveDate::from_ymd_opt(2023, 11, 7).unwrap(), // Tue -/// ]; -/// -/// assert_eq!(bdates.list()?, expected_dates); -/// assert_eq!(bdates.count()?, 5); -/// Ok(()) -/// } -/// ``` -/// -/// **2. Using `from_n_periods` (Start Date and Count):** -/// -/// ```rust -/// use chrono::NaiveDate; -/// use std::error::Error; -/// use rustframe::utils::{BDatesList, BDateFreq}; // Replace bdates with your actual crate/module name -/// -/// fn main() -> Result<(), Box> { -/// let start_date = "2024-02-28".to_string(); // Wednesday -/// let freq = BDateFreq::WeeklyFriday; -/// let n_periods = 3; -/// -/// let bdates = BDatesList::from_n_periods(start_date, freq, n_periods)?; -/// -/// // The first Friday on or after 2024-02-28 is Mar 1. -/// // The next two Fridays are Mar 8 and Mar 15. -/// let expected_dates = vec![ -/// NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(), -/// NaiveDate::from_ymd_opt(2024, 3, 8).unwrap(), -/// NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(), -/// ]; -/// -/// assert_eq!(bdates.list()?, expected_dates); -/// assert_eq!(bdates.count()?, 3); -/// assert_eq!(bdates.start_date_str(), "2024-02-28"); // Keeps original start string -/// assert_eq!(bdates.end_date_str(), "2024-03-15"); // End date is the last generated date -/// Ok(()) -/// } -/// ``` -/// -/// **3. Using `groups()`:** -/// -/// ```rust -/// use chrono::NaiveDate; -/// use std::error::Error; -/// use rustframe::utils::{BDatesList, BDateFreq}; // Replace bdates with your actual crate/module name -/// -/// fn main() -> Result<(), Box> { -/// let start_date = "2023-11-20".to_string(); // Mon, Week 47 -/// let end_date = "2023-12-08".to_string(); // Fri, Week 49 -/// let freq = BDateFreq::WeeklyMonday; -/// -/// let bdates = BDatesList::new(start_date, end_date, freq); -/// -/// // Mondays in range: Nov 20, Nov 27, Dec 4 -/// let groups = bdates.groups()?; -/// -/// assert_eq!(groups.len(), 3); // One group per week containing a Monday -/// assert_eq!(groups[0], vec![NaiveDate::from_ymd_opt(2023, 11, 20).unwrap()]); // Week 47 -/// assert_eq!(groups[1], vec![NaiveDate::from_ymd_opt(2023, 11, 27).unwrap()]); // Week 48 -/// assert_eq!(groups[2], vec![NaiveDate::from_ymd_opt(2023, 12, 4).unwrap()]); // Week 49 -/// Ok(()) -/// } -/// ``` -impl BDatesList { - /// Creates a new `BDatesList` instance defined by a start and end date. - /// - /// # Arguments - /// - /// * `start_date_str` - The inclusive start date as a string (e.g., "YYYY-MM-DD"). - /// * `end_date_str` - The inclusive end date as a string (e.g., "YYYY-MM-DD"). - /// * `freq` - The frequency for generating dates. - pub fn new(start_date_str: String, end_date_str: String, freq: BDateFreq) -> Self { - BDatesList { - start_date_str, - end_date_str, - freq, - } - } - - /// Creates a new `BDatesList` instance defined by a start date, frequency, - /// and the number of periods (dates) to generate. - /// - /// This calculates the required dates using a `BDatesGenerator` and determines - /// the effective end date based on the last generated date. - /// - /// # Arguments - /// - /// * `start_date_str` - The start date as a string (e.g., "YYYY-MM-DD"). The first generated date will be on or after this date. - /// * `freq` - The frequency for generating dates. - /// * `n_periods` - The exact number of business dates to generate according to the frequency. - /// - /// # Errors - /// - /// Returns an error if: - /// * `start_date_str` cannot be parsed. - /// * `n_periods` is 0 (as this would result in an empty list and no defined end date). - pub fn from_n_periods( - start_date_str: String, - freq: BDateFreq, - n_periods: usize, - ) -> Result> { - if n_periods == 0 { - return Err("n_periods must be greater than 0".into()); - } - - let start_date = NaiveDate::parse_from_str(&start_date_str, "%Y-%m-%d")?; - - // Instantiate the date generator to compute the sequence of business dates. - let generator = BDatesGenerator::new(start_date, freq, n_periods)?; - let dates: Vec = generator.collect(); - - // Confirm that the generator returned at least one date when n_periods > 0. - let last_date = dates - .last() - .ok_or("Generator failed to produce dates for the specified periods")?; - - let end_date_str = last_date.format("%Y-%m-%d").to_string(); - - Ok(BDatesList { - start_date_str, - end_date_str, - freq, - }) - } - - /// Returns the flat list of business dates within the specified range and frequency. - /// - /// The list is guaranteed to be sorted chronologically. - /// - /// # Errors - /// - /// Returns an error if the start or end date strings cannot be parsed. - pub fn list(&self) -> Result, Box> { - // Retrieve the list of business dates via the shared helper function. - get_bdates_list_with_freq(&self.start_date_str, &self.end_date_str, self.freq) - } - - /// Returns the count of business dates within the specified range and frequency. - /// - /// # Errors - /// - /// Returns an error if the start or end date strings cannot be parsed. - pub fn count(&self) -> Result> { - // Compute the total number of business dates by invoking `list()` and returning its length. - self.list().map(|list| list.len()) - } - - /// Returns a list of date lists, where each inner list contains dates - /// belonging to the same period (determined by frequency). - /// - /// The outer list (groups) is sorted chronologically by period, and the - /// inner lists (dates within each period) are also sorted. - /// - /// # Errors - /// - /// Returns an error if the start or end date strings cannot be parsed. - pub fn groups(&self) -> Result>, Box> { - // Retrieve all business dates in chronological order. - let dates = self.list()?; - - // Aggregate dates into buckets keyed by period. - let mut groups: HashMap> = HashMap::new(); - - for date in dates { - // Derive the appropriate GroupKey for the current date based on the configured frequency. - let key = match self.freq { - BDateFreq::Daily => GroupKey::Daily(date), - BDateFreq::WeeklyMonday | BDateFreq::WeeklyFriday => { - let iso_week = date.iso_week(); - GroupKey::Weekly(iso_week.year(), iso_week.week()) - } - BDateFreq::MonthStart | BDateFreq::MonthEnd => { - GroupKey::Monthly(date.year(), date.month()) - } - BDateFreq::QuarterStart | BDateFreq::QuarterEnd => { - GroupKey::Quarterly(date.year(), month_to_quarter(date.month())) - } - BDateFreq::YearStart | BDateFreq::YearEnd => GroupKey::Yearly(date.year()), - }; - - // Append the date to its period group. - groups.entry(key).or_insert_with(Vec::new).push(date); - } - - // Transform the group map into a vector of (GroupKey, Vec) tuples. - let mut sorted_groups: Vec<(GroupKey, Vec)> = groups.into_iter().collect(); - - // Sort groups chronologically using the derived `Ord` implementation on `GroupKey`. - sorted_groups.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); - - // Note: Dates within each group remain sorted due to initial ordered input. - - // Discard group keys to return only the list of date vectors. - let result_groups = sorted_groups.into_iter().map(|(_, dates)| dates).collect(); - - Ok(result_groups) - } - - /// Returns the start date parsed as a `NaiveDate`. - /// - /// # Errors - /// - /// Returns an error if the start date string is not in "YYYY-MM-DD" format. - pub fn start_date(&self) -> Result> { - NaiveDate::parse_from_str(&self.start_date_str, "%Y-%m-%d").map_err(|e| e.into()) - } - - /// Returns the start date string. - pub fn start_date_str(&self) -> &str { - &self.start_date_str - } - - /// Returns the end date parsed as a `NaiveDate`. - /// - /// # Errors - /// - /// Returns an error if the end date string is not in "YYYY-MM-DD" format. - pub fn end_date(&self) -> Result> { - NaiveDate::parse_from_str(&self.end_date_str, "%Y-%m-%d").map_err(|e| e.into()) - } - - /// Returns the end date string. - pub fn end_date_str(&self) -> &str { - &self.end_date_str - } - - /// Returns the frequency enum. - pub fn freq(&self) -> BDateFreq { - self.freq - } - - /// Returns the canonical string representation of the frequency. - pub fn freq_str(&self) -> String { - self.freq.to_string() - } -} - -// Business date iterator: generates a sequence of business dates for a given frequency and period count. - -/// An iterator that generates a sequence of business dates based on a start date, -/// frequency, and a specified number of periods. -/// -/// This implements the `Iterator` trait, allowing generation of dates one by one. -/// It's useful when you need to process dates lazily or only need a fixed number -/// starting from a specific point, without necessarily defining an end date beforehand. -/// -/// # Examples -/// -/// **1. Basic Iteration:** -/// -/// ```rust -/// use chrono::NaiveDate; -/// use std::error::Error; -/// use rustframe::utils::{BDatesGenerator, BDateFreq}; -/// -/// fn main() -> Result<(), Box> { -/// let start = NaiveDate::from_ymd_opt(2023, 12, 28).unwrap(); // Thursday -/// let freq = BDateFreq::MonthEnd; -/// let n_periods = 4; // Dec '23, Jan '24, Feb '24, Mar '24 -/// -/// let mut generator = BDatesGenerator::new(start, freq, n_periods)?; -/// -/// // First month-end on or after 2023-12-28 is 2023-12-29 -/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2023, 12, 29).unwrap())); -/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap())); -/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 2, 29).unwrap())); // Leap year -/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 3, 29).unwrap())); // Mar 31 is Sun -/// assert_eq!(generator.next(), None); // Exhausted -/// Ok(()) -/// } -/// ``` -/// -/// **2. Collecting into a Vec:** -/// -/// ```rust -/// use chrono::NaiveDate; -/// use std::error::Error; -/// use rustframe::utils::{BDatesGenerator, BDateFreq}; // Replace bdates with your actual crate/module name -/// -/// fn main() -> Result<(), Box> { -/// let start = NaiveDate::from_ymd_opt(2024, 4, 29).unwrap(); // Monday -/// let freq = BDateFreq::Daily; -/// let n_periods = 5; -/// -/// let generator = BDatesGenerator::new(start, freq, n_periods)?; -/// let dates: Vec = generator.collect(); -/// -/// let expected_dates = vec![ -/// NaiveDate::from_ymd_opt(2024, 4, 29).unwrap(), // Mon -/// NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(), // Tue -/// NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(), // Wed -/// NaiveDate::from_ymd_opt(2024, 5, 2).unwrap(), // Thu -/// NaiveDate::from_ymd_opt(2024, 5, 3).unwrap(), // Fri -/// ]; -/// -/// assert_eq!(dates, expected_dates); -/// Ok(()) -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct BDatesGenerator { - freq: BDateFreq, - periods_remaining: usize, - // Next business date candidate to yield; None when iteration is complete. - next_date_candidate: Option, -} - -impl BDatesGenerator { - /// Creates a new `BDatesGenerator`. - /// - /// It calculates the first valid business date based on the `start_date` and `freq`, - /// which will be the first item yielded by the iterator. - /// - /// # Arguments - /// - /// * `start_date` - The date from which to start searching for the first valid business date. - /// * `freq` - The frequency for generating dates. - /// * `n_periods` - The total number of business dates to generate. - /// - /// # Errors - /// - /// Can potentially return an error if date calculations lead to overflows, - /// though this is highly unlikely with realistic date ranges. (Currently returns Ok). - /// Note: The internal `find_first_bdate_on_or_after` might panic on extreme date overflows, - /// but practical usage should be safe. - pub fn new( - start_date: NaiveDate, - freq: BDateFreq, - n_periods: usize, - ) -> Result> { - let first_date = if n_periods > 0 { - Some(find_first_bdate_on_or_after(start_date, freq)) - } else { - // No dates when period count is zero. - None - }; - - Ok(BDatesGenerator { - freq, - periods_remaining: n_periods, - next_date_candidate: first_date, - }) - } -} - -impl Iterator for BDatesGenerator { - type Item = NaiveDate; - - /// Returns the next business date in the sequence, or `None` if the specified - /// number of periods has been generated. - fn next(&mut self) -> Option { - // Terminate if no periods remain or no initial date is set. - if self.periods_remaining == 0 || self.next_date_candidate.is_none() { - return None; - } - - // Retrieve and store the current date for output. - let current_date = self.next_date_candidate.unwrap(); - - // Compute and queue the subsequent date for the next call. - self.next_date_candidate = Some(find_next_bdate(current_date, self.freq)); - - // Decrement the remaining period count. - self.periods_remaining -= 1; - - // Yield the current business date. - Some(current_date) - } -} - -// Internal helper functions (private implementation) - -/// Generates the flat list of business dates for the given range and frequency. -/// -/// Filters out weekends and ensures the final list is sorted. This is the core -/// generation logic used by `BDatesList::list` and `BDatesList::groups`. -/// -/// # Arguments (Internal) -/// -/// * `start_date_str` - Inclusive start date string. -/// * `end_date_str` - Inclusive end date string. -/// * `freq` - The frequency. -/// -/// # Errors (Internal) -/// -/// Returns an error if date strings are invalid. -fn get_bdates_list_with_freq( - start_date_str: &str, - end_date_str: &str, - freq: BDateFreq, -) -> Result, Box> { - // Parse input date strings; propagate parsing errors. - let start_date = NaiveDate::parse_from_str(start_date_str, "%Y-%m-%d")?; - let end_date = NaiveDate::parse_from_str(end_date_str, "%Y-%m-%d")?; - - // Return empty list immediately if the date range is invalid. - if start_date > end_date { - return Ok(Vec::new()); - } - - // Generate dates according to the requested frequency. - let mut dates = match freq { - BDateFreq::Daily => collect_daily(start_date, end_date), - BDateFreq::WeeklyMonday => collect_weekly(start_date, end_date, Weekday::Mon), - BDateFreq::WeeklyFriday => collect_weekly(start_date, end_date, Weekday::Fri), - BDateFreq::MonthStart => collect_monthly(start_date, end_date, true), - BDateFreq::MonthEnd => collect_monthly(start_date, end_date, false), - BDateFreq::QuarterStart => collect_quarterly(start_date, end_date, true), - BDateFreq::QuarterEnd => collect_quarterly(start_date, end_date, false), - BDateFreq::YearStart => collect_yearly(start_date, end_date, true), - BDateFreq::YearEnd => collect_yearly(start_date, end_date, false), - }; - - // Exclude weekends to ensure only business days remain. - dates.retain(|d| is_weekday(*d)); - - // Guarantee chronological order of the result. - dates.sort(); - - Ok(dates) -} - -/* Low-level date collection routines (private) */ - -/// Returns all weekdays from `start_date` through `end_date`, inclusive. -fn collect_daily(start_date: NaiveDate, end_date: NaiveDate) -> Vec { - let mut result = Vec::new(); - let mut current = start_date; - - // Iterate one day at a time. - while current <= end_date { - if is_weekday(current) { - result.push(current); - } - current = current - .succ_opt() - .expect("Date overflow near end of supported range"); - } - - result -} - -/// Returns each occurrence of `target_weekday` within the date range. -fn collect_weekly( - start_date: NaiveDate, - end_date: NaiveDate, - target_weekday: Weekday, -) -> Vec { - let mut result = Vec::new(); - - // Find the first matching weekday on or after the start date. - let mut current = move_to_weekday_on_or_after(start_date, target_weekday); - - // Step through each week until exceeding the end date. - while current <= end_date { - // Only include if still a weekday. - if is_weekday(current) { - result.push(current); - } - current = current - .checked_add_signed(Duration::days(7)) - .expect("Date overflow when advancing by one week"); - } - - result -} - -/// Returns either the first or last business day of each month in the range. -fn collect_monthly( - start_date: NaiveDate, - end_date: NaiveDate, - want_first_day: bool, -) -> Vec { - let mut result = Vec::new(); - let mut year = start_date.year(); - let mut month = start_date.month(); - - // Advance (year, month) by one month. - let next_month = |(yr, mo): (i32, u32)| { - if mo == 12 { (yr + 1, 1) } else { (yr, mo + 1) } - }; - - // Iterate months from the start date until past the end date. - loop { - // Determine the candidate business date for this month. - let candidate = if want_first_day { - first_business_day_of_month(year, month) - } else { - last_business_day_of_month(year, month) - }; - - // Stop if the candidate is beyond the allowed range. - if candidate > end_date { - break; - } - - // Include candidate if it falls within [start_date, end_date]. - if candidate >= start_date && is_weekday(candidate) { - result.push(candidate); - } - - // If we've processed the end date's month, terminate. - if year > end_date.year() || (year == end_date.year() && month >= end_date.month()) { - break; - } - - // Move to the next month. - let (ny, nm) = next_month((year, month)); - year = ny; - month = nm; - - // Safety guard against unexpected infinite loops. - if year > end_date.year() + 1 { - break; - } - } - - result -} - -/// Return either the first or last business day in each quarter of the range. -fn collect_quarterly( - start_date: NaiveDate, - end_date: NaiveDate, - want_first_day: bool, -) -> Vec { - let mut result = Vec::new(); - - let mut year = start_date.year(); - // Start from the quarter containing the start date. - let mut q = month_to_quarter(start_date.month()); - - // Iterate quarter by quarter until we pass the end date. - loop { - // Compute the candidate date (first or last business day) for the current quarter. - // Use _opt and expect(), expecting valid quarter/year combinations. - let candidate = if want_first_day { - first_business_day_of_quarter(year, q) - } else { - last_business_day_of_quarter(year, q) - }; - - // If the candidate is after the end date, we've gone past the range, so stop. - if candidate > end_date { - break; - } - - // If the candidate is within the specified range [start_date, end_date], add it. - if candidate >= start_date { - // Ensure it's actually a weekday (should be, but adds safety) - if is_weekday(candidate) { - result.push(candidate); - } - } - // Note: We don't break if candidate < start_date because a later quarter - // might be within the range. - - // Check if the current quarter is the last one we should process - let end_q = month_to_quarter(end_date.month()); - if year > end_date.year() || (year == end_date.year() && q >= end_q) { - break; // Stop after processing the end_date's quarter - } - - // Advance to the next quarter. - if q == 4 { - year += 1; - q = 1; - } else { - q += 1; - } - - // Safety break - if year > end_date.year() + 1 { - break; - } - } - - result -} - -/// Return either the first or last business day in each year of the range. -fn collect_yearly( - start_date: NaiveDate, - end_date: NaiveDate, - want_first_day: bool, -) -> Vec { - let mut result = Vec::new(); - // Start from the year of the start date. - let mut year = start_date.year(); - - // Iterate year by year until we pass the end date's year. - while year <= end_date.year() { - // Compute the candidate date (first or last business day) for the current year. - // Use _opt and expect(), expecting valid year. - let candidate = if want_first_day { - first_business_day_of_year(year) - } else { - last_business_day_of_year(year) - }; - - // If the candidate is within the specified range [start_date, end_date], add it. - if candidate >= start_date && candidate <= end_date { - // Ensure it's actually a weekday (should be, but adds safety) - if is_weekday(candidate) { - result.push(candidate); - } - } else if want_first_day && candidate > end_date { - // Optimization: If the *first* bday of the year is already past end_date, - // no subsequent year's first bday will be in range. - // Similar logic applies for last bday if candidate > end_date, but it's less likely to trigger early. - break; - } - // Note: We don't break if candidate < start_date because a later year's candidate - // might be within the range (e.g. start_date 2023-12-15, YE freq, candidate for 2023 is 2023-12-29 (ok), - // candidate for 2024 is 2024-12-31 (could be ok)). - - year += 1; - } - result -} - -/* ---------------------- Core Date Utility Functions (Internal) ---------------------- */ - -/// Checks if a given date is a weekday (Monday-Friday). -fn is_weekday(date: NaiveDate) -> bool { - !matches!(date.weekday(), Weekday::Sat | Weekday::Sun) -} - -/// Given a date and a `target_weekday`, returns the date that is the first -/// `target_weekday` on or after the given date. -fn move_to_weekday_on_or_after(date: NaiveDate, target: Weekday) -> NaiveDate { - let mut current = date; - while current.weekday() != target { - // Use succ_opt() and expect(), assuming valid date and no overflow - current = current - .succ_opt() - .expect("date overflow moving to next weekday"); - } - current -} - -/// Return the earliest business day of the given (year, month). -fn first_business_day_of_month(year: i32, month: u32) -> NaiveDate { - // Start with the 1st of the month. Use _opt and expect(), assuming valid Y/M. - let mut d = NaiveDate::from_ymd_opt(year, month, 1).expect("invalid year-month combination"); - // If it's Sat/Sun, move forward until we find a weekday. - while !is_weekday(d) { - // Use succ_opt() and expect(), assuming valid date and no overflow. - d = d.succ_opt().expect("date overflow finding first bday"); - } - d -} - -/// Returns the last business day (Monday-Friday) of the specified month. -/// -/// Calculates the number of days in the month, then steps backward from the -/// month's last day until a weekday is found. -/// -/// # Panics -/// Panics if date construction or predecessor operations underflow. -fn last_business_day_of_month(year: i32, month: u32) -> NaiveDate { - let last_dom = days_in_month(year, month); - let mut d = - NaiveDate::from_ymd_opt(year, month, last_dom).expect("Invalid year-month-day combination"); - while !is_weekday(d) { - d = d - .pred_opt() - .expect("Date underflow finding last business day"); - } - d -} - -/// Returns the number of days in the specified month, correctly handling leap years. -/// -/// Determines the first day of the next month and subtracts one day. -/// -/// # Panics -/// Panics if date construction or predecessor operations underflow. -fn days_in_month(year: i32, month: u32) -> u32 { - let (ny, nm) = if month == 12 { - (year + 1, 1) - } else { - (year, month + 1) - }; - let first_of_next = - NaiveDate::from_ymd_opt(ny, nm, 1).expect("Invalid next year-month combination"); - let last_of_this = first_of_next - .pred_opt() - .expect("Date underflow computing last day of month"); - last_of_this.day() -} - -/// Maps a month (1-12) to its corresponding quarter (1-4). -/// -/// # Panics -/// Panics if `m` is outside the range 1-12. -fn month_to_quarter(m: u32) -> u32 { - match m { - 1..=3 => 1, - 4..=6 => 2, - 7..=9 => 3, - 10..=12 => 4, - _ => panic!("Invalid month: {}", m), - } -} - -/// Returns the starting month (1, 4, 7, or 10) for the given quarter (1-4). -/// -/// # Panics -/// Panics if `quarter` is not in 1-4. -fn quarter_start_month(quarter: u32) -> u32 { - match quarter { - 1 => 1, - 2 => 4, - 3 => 7, - 4 => 10, - _ => panic!("Invalid quarter: {}", quarter), - } -} - -/// Returns the first business day (Monday-Friday) of the specified quarter. -/// -/// Delegates to `first_business_day_of_month` using the quarter's start month. -fn first_business_day_of_quarter(year: i32, quarter: u32) -> NaiveDate { - let month = quarter_start_month(quarter); - first_business_day_of_month(year, month) -} - -/// Returns the last business day (Monday-Friday) of the specified quarter. -/// -/// Determines the quarter's final month and delegates to -/// `last_business_day_of_month`. -fn last_business_day_of_quarter(year: i32, quarter: u32) -> NaiveDate { - let last_month = quarter * 3; - last_business_day_of_month(year, last_month) -} - -/// Returns the first business day (Monday-Friday) of the specified year. -/// -/// Starts at January 1st and advances to the next weekday if needed. -/// -/// # Panics -/// Panics if date construction or successor operations overflow. -fn first_business_day_of_year(year: i32) -> NaiveDate { - let mut d = NaiveDate::from_ymd_opt(year, 1, 1).expect("Invalid year for January 1st"); - while !is_weekday(d) { - d = d - .succ_opt() - .expect("Date overflow finding first business day of year"); - } - d -} - -/// Returns the last business day (Monday-Friday) of the specified year. -/// -/// Starts at December 31st and moves backward to the previous weekday if needed. -/// -/// # Panics -/// Panics if date construction or predecessor operations underflow. -fn last_business_day_of_year(year: i32) -> NaiveDate { - let mut d = NaiveDate::from_ymd_opt(year, 12, 31).expect("Invalid year for December 31st"); - while !is_weekday(d) { - d = d - .pred_opt() - .expect("Date underflow finding last business day of year"); - } - d -} - -/// Finds the first valid business date on or after `start_date` according to `freq`. -/// -/// This may advance across days, weeks, months, quarters, or years depending on `freq`. -/// -/// # Panics -/// Panics on extreme date overflows or underflows. -fn find_first_bdate_on_or_after(start_date: NaiveDate, freq: BDateFreq) -> NaiveDate { - match freq { - BDateFreq::Daily => { - let mut d = start_date; - while !is_weekday(d) { - d = d - .succ_opt() - .expect("Date overflow finding first daily date"); - } - d - } - BDateFreq::WeeklyMonday => move_to_weekday_on_or_after(start_date, Weekday::Mon), - BDateFreq::WeeklyFriday => move_to_weekday_on_or_after(start_date, Weekday::Fri), - BDateFreq::MonthStart => { - let mut candidate = first_business_day_of_month(start_date.year(), start_date.month()); - if candidate < start_date { - let (ny, nm) = if start_date.month() == 12 { - (start_date.year() + 1, 1) - } else { - (start_date.year(), start_date.month() + 1) - }; - candidate = first_business_day_of_month(ny, nm); - } - candidate - } - BDateFreq::MonthEnd => { - let mut candidate = last_business_day_of_month(start_date.year(), start_date.month()); - if candidate < start_date { - let (ny, nm) = if start_date.month() == 12 { - (start_date.year() + 1, 1) - } else { - (start_date.year(), start_date.month() + 1) - }; - candidate = last_business_day_of_month(ny, nm); - } - candidate - } - BDateFreq::QuarterStart => { - let current_q = month_to_quarter(start_date.month()); - let mut candidate = first_business_day_of_quarter(start_date.year(), current_q); - if candidate < start_date { - let (ny, nq) = if current_q == 4 { - (start_date.year() + 1, 1) - } else { - (start_date.year(), current_q + 1) - }; - candidate = first_business_day_of_quarter(ny, nq); - } - candidate - } - BDateFreq::QuarterEnd => { - let current_q = month_to_quarter(start_date.month()); - let mut candidate = last_business_day_of_quarter(start_date.year(), current_q); - if candidate < start_date { - let (ny, nq) = if current_q == 4 { - (start_date.year() + 1, 1) - } else { - (start_date.year(), current_q + 1) - }; - candidate = last_business_day_of_quarter(ny, nq); - } - candidate - } - BDateFreq::YearStart => { - let mut candidate = first_business_day_of_year(start_date.year()); - if candidate < start_date { - candidate = first_business_day_of_year(start_date.year() + 1); - } - candidate - } - BDateFreq::YearEnd => { - let mut candidate = last_business_day_of_year(start_date.year()); - if candidate < start_date { - candidate = last_business_day_of_year(start_date.year() + 1); - } - candidate - } - } -} - -/// Finds the next business date after `current_date` according to `freq`. -/// -/// Assumes `current_date` was previously generated. Advances by days, weeks, -/// months, quarters, or years as specified. -/// -/// # Panics -/// Panics on extreme date overflows or underflows. -fn find_next_bdate(current_date: NaiveDate, freq: BDateFreq) -> NaiveDate { - match freq { - BDateFreq::Daily => { - let mut next_day = current_date - .succ_opt() - .expect("Date overflow finding next daily date"); - while !is_weekday(next_day) { - next_day = next_day - .succ_opt() - .expect("Date overflow finding next daily weekday"); - } - next_day - } - BDateFreq::WeeklyMonday | BDateFreq::WeeklyFriday => current_date - .checked_add_signed(Duration::days(7)) - .expect("Date overflow adding one week"), - BDateFreq::MonthStart => { - let (ny, nm) = if current_date.month() == 12 { - (current_date.year() + 1, 1) - } else { - (current_date.year(), current_date.month() + 1) - }; - first_business_day_of_month(ny, nm) - } - BDateFreq::MonthEnd => { - let (ny, nm) = if current_date.month() == 12 { - (current_date.year() + 1, 1) - } else { - (current_date.year(), current_date.month() + 1) - }; - last_business_day_of_month(ny, nm) - } - BDateFreq::QuarterStart => { - let current_q = month_to_quarter(current_date.month()); - let (ny, nq) = if current_q == 4 { - (current_date.year() + 1, 1) - } else { - (current_date.year(), current_q + 1) - }; - first_business_day_of_quarter(ny, nq) - } - BDateFreq::QuarterEnd => { - let current_q = month_to_quarter(current_date.month()); - let (ny, nq) = if current_q == 4 { - (current_date.year() + 1, 1) - } else { - (current_date.year(), current_q + 1) - }; - last_business_day_of_quarter(ny, nq) - } - BDateFreq::YearStart => first_business_day_of_year(current_date.year() + 1), - BDateFreq::YearEnd => last_business_day_of_year(current_date.year() + 1), - } -} - -// --- Example Usage and Tests --- - -#[cfg(test)] -mod tests { - use super::*; - use chrono::NaiveDate; - - // Helper to create a NaiveDate for tests, handling the expect for fixed dates. - fn date(year: i32, month: u32, day: u32) -> NaiveDate { - NaiveDate::from_ymd_opt(year, month, day).expect("Invalid date in test setup") - } - - // --- BDateFreq Tests --- - - #[test] - fn test_bdatefreq_from_str() -> Result<(), Box> { - assert_eq!(BDateFreq::from_str("D")?, BDateFreq::Daily); - assert_eq!("D".parse::()?, BDateFreq::Daily); // Test FromStr impl - assert_eq!(BDateFreq::from_str("W")?, BDateFreq::WeeklyMonday); - assert_eq!(BDateFreq::from_str("M")?, BDateFreq::MonthStart); - assert_eq!(BDateFreq::from_str("Q")?, BDateFreq::QuarterStart); - - // Test YearStart codes and aliases (Y, A, AS, YS) - assert_eq!(BDateFreq::from_str("Y")?, BDateFreq::YearStart); - assert_eq!(BDateFreq::from_str("A")?, BDateFreq::YearStart); - assert_eq!(BDateFreq::from_str("AS")?, BDateFreq::YearStart); - assert_eq!(BDateFreq::from_str("YS")?, BDateFreq::YearStart); - assert_eq!("Y".parse::()?, BDateFreq::YearStart); // Test FromStr impl - - assert_eq!(BDateFreq::from_str("ME")?, BDateFreq::MonthEnd); - assert_eq!(BDateFreq::from_str("QE")?, BDateFreq::QuarterEnd); - assert_eq!(BDateFreq::from_str("WF")?, BDateFreq::WeeklyFriday); - assert_eq!("WF".parse::()?, BDateFreq::WeeklyFriday); // Test FromStr impl - - // Test YearEnd codes and aliases (YE, AE) - assert_eq!(BDateFreq::from_str("YE")?, BDateFreq::YearEnd); - assert_eq!(BDateFreq::from_str("AE")?, BDateFreq::YearEnd); - - // Test aliases for other frequencies - assert_eq!(BDateFreq::from_str("WS")?, BDateFreq::WeeklyMonday); - assert_eq!(BDateFreq::from_str("MS")?, BDateFreq::MonthStart); - assert_eq!(BDateFreq::from_str("QS")?, BDateFreq::QuarterStart); - - // Test invalid string - assert!(BDateFreq::from_str("INVALID").is_err()); - assert!("INVALID".parse::().is_err()); // Test FromStr impl - let err = BDateFreq::from_str("INVALID").unwrap_err(); - assert_eq!(err.to_string(), "Invalid frequency specified: INVALID"); - - Ok(()) - } - - #[test] - fn test_bdatefreq_to_string() { - assert_eq!(BDateFreq::Daily.to_string(), "D"); - assert_eq!(BDateFreq::WeeklyMonday.to_string(), "W"); - assert_eq!(BDateFreq::MonthStart.to_string(), "M"); - assert_eq!(BDateFreq::QuarterStart.to_string(), "Q"); - assert_eq!(BDateFreq::YearStart.to_string(), "Y"); // Assert "Y" - assert_eq!(BDateFreq::MonthEnd.to_string(), "ME"); - assert_eq!(BDateFreq::QuarterEnd.to_string(), "QE"); - assert_eq!(BDateFreq::WeeklyFriday.to_string(), "WF"); - assert_eq!(BDateFreq::YearEnd.to_string(), "YE"); - } - - #[test] - fn test_bdatefreq_from_string() -> Result<(), Box> { - assert_eq!(BDateFreq::from_string("D".to_string())?, BDateFreq::Daily); - assert!(BDateFreq::from_string("INVALID".to_string()).is_err()); - Ok(()) - } - - #[test] - fn test_bdatefreq_agg_type() { - assert_eq!(BDateFreq::Daily.agg_type(), AggregationType::Start); - assert_eq!(BDateFreq::WeeklyMonday.agg_type(), AggregationType::Start); - assert_eq!(BDateFreq::MonthStart.agg_type(), AggregationType::Start); - assert_eq!(BDateFreq::QuarterStart.agg_type(), AggregationType::Start); - assert_eq!(BDateFreq::YearStart.agg_type(), AggregationType::Start); - - assert_eq!(BDateFreq::WeeklyFriday.agg_type(), AggregationType::End); - assert_eq!(BDateFreq::MonthEnd.agg_type(), AggregationType::End); - assert_eq!(BDateFreq::QuarterEnd.agg_type(), AggregationType::End); - assert_eq!(BDateFreq::YearEnd.agg_type(), AggregationType::End); - } - - // --- BDatesList Property Tests --- - - #[test] - fn test_bdates_list_properties_new() -> Result<(), Box> { - let start_str = "2023-01-01".to_string(); - let end_str = "2023-12-31".to_string(); - let freq = BDateFreq::QuarterEnd; - let dates_list = BDatesList::new(start_str.clone(), end_str.clone(), freq); - - // check start_date_str - assert_eq!(dates_list.start_date_str(), start_str); - // check end_date_str - assert_eq!(dates_list.end_date_str(), end_str); - // check frequency enum - assert_eq!(dates_list.freq(), freq); - // check frequency string - assert_eq!(dates_list.freq_str(), "QE"); - - // Check parsed dates - assert_eq!(dates_list.start_date()?, date(2023, 1, 1)); - assert_eq!(dates_list.end_date()?, date(2023, 12, 31)); - - Ok(()) - } - - #[test] - fn test_bdates_list_properties_from_n_periods() -> Result<(), Box> { - let start_str = "2023-01-01".to_string(); // Sunday - let freq = BDateFreq::Daily; - let n_periods = 5; // Expect: Jan 2, 3, 4, 5, 6 - let dates_list = BDatesList::from_n_periods(start_str.clone(), freq, n_periods)?; - - // check start_date_str (should be original) - assert_eq!(dates_list.start_date_str(), start_str); - // check end_date_str (should be the last generated date) - assert_eq!(dates_list.end_date_str(), "2023-01-06"); - // check frequency enum - assert_eq!(dates_list.freq(), freq); - // check frequency string - assert_eq!(dates_list.freq_str(), "D"); - - // Check parsed dates - assert_eq!(dates_list.start_date()?, date(2023, 1, 1)); - assert_eq!(dates_list.end_date()?, date(2023, 1, 6)); - - // Check the actual list matches - assert_eq!( - dates_list.list()?, - vec![ - date(2023, 1, 2), - date(2023, 1, 3), - date(2023, 1, 4), - date(2023, 1, 5), - date(2023, 1, 6) - ] - ); - assert_eq!(dates_list.count()?, 5); - - Ok(()) - } - - #[test] - fn test_bdates_list_from_n_periods_zero_periods() { - let start_str = "2023-01-01".to_string(); - let freq = BDateFreq::Daily; - let n_periods = 0; - let result = BDatesList::from_n_periods(start_str.clone(), freq, n_periods); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err().to_string(), - "n_periods must be greater than 0" - ); - } - - #[test] - fn test_bdates_list_from_n_periods_invalid_start_date() { - let start_str = "invalid-date".to_string(); - let freq = BDateFreq::Daily; - let n_periods = 5; - let result = BDatesList::from_n_periods(start_str.clone(), freq, n_periods); - assert!(result.is_err()); - // Error comes from NaiveDate::parse_from_str - assert!( - result - .unwrap_err() - .to_string() - .contains("input contains invalid characters") - ); - } - - #[test] - fn test_bdates_list_invalid_date_string_new() { - let dates_list_start_invalid = BDatesList::new( - "invalid-date".to_string(), - "2023-12-31".to_string(), - BDateFreq::Daily, - ); - assert!(dates_list_start_invalid.list().is_err()); - assert!(dates_list_start_invalid.count().is_err()); - assert!(dates_list_start_invalid.groups().is_err()); - assert!(dates_list_start_invalid.start_date().is_err()); - assert!(dates_list_start_invalid.end_date().is_ok()); // End date is valid - - let dates_list_end_invalid = BDatesList::new( - "2023-01-01".to_string(), - "invalid-date".to_string(), - BDateFreq::Daily, - ); - assert!(dates_list_end_invalid.list().is_err()); - assert!(dates_list_end_invalid.count().is_err()); - assert!(dates_list_end_invalid.groups().is_err()); - assert!(dates_list_end_invalid.start_date().is_ok()); // Start date is valid - assert!(dates_list_end_invalid.end_date().is_err()); - } - - // --- BDatesList Core Logic Tests (via list and count) --- - - #[test] - /// Tests the `list()` method for QuarterEnd frequency over a full year. - fn test_bdates_list_quarterly_end_list() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-01-01".to_string(), - "2023-12-31".to_string(), - BDateFreq::QuarterEnd, - ); - - let list = dates_list.list()?; - assert_eq!(list.len(), 4); - assert_eq!( - list, - vec![ - date(2023, 3, 31), - date(2023, 6, 30), - date(2023, 9, 29), - date(2023, 12, 29) - ] - ); // Fri, Fri, Fri, Fri - - Ok(()) - } - - #[test] - /// Tests the `list()` method for WeeklyMonday frequency. - fn test_bdates_list_weekly_monday_list() -> Result<(), Box> { - // Range includes start date that is Monday, end date that is Sunday - let dates_list = BDatesList::new( - "2023-10-30".to_string(), // Monday (Week 44) - "2023-11-12".to_string(), // Sunday (Week 45 ends, Week 46 starts) - BDateFreq::WeeklyMonday, - ); - - let list = dates_list.list()?; - // Mondays >= 2023-10-30 and <= 2023-11-12: - // 2023-10-30 (Included) - // 2023-11-06 (Included) - // 2023-11-13 (Excluded) - assert_eq!(list.len(), 2); - assert_eq!(list, vec![date(2023, 10, 30), date(2023, 11, 6)]); - - Ok(()) - } - - #[test] - /// Tests the `list()` method for Daily frequency over a short range including weekends. - fn test_bdates_list_daily_list() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-11-01".to_string(), // Wednesday - "2023-11-05".to_string(), // Sunday - BDateFreq::Daily, - ); - - let list = dates_list.list()?; - // Business days in range: Wed, Thu, Fri - assert_eq!(list.len(), 3); - assert_eq!( - list, - vec![date(2023, 11, 1), date(2023, 11, 2), date(2023, 11, 3)] - ); - - Ok(()) - } - - #[test] - /// Tests the `list()` method with an empty date range (end before start). - fn test_bdates_list_empty_range_list() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-12-31".to_string(), - "2023-01-01".to_string(), // End date before start date - BDateFreq::Daily, - ); - let list = dates_list.list()?; - assert!(list.is_empty()); - assert_eq!(dates_list.count()?, 0); // Also test count here - - Ok(()) - } - - #[test] - /// Tests the `count()` method for various frequencies. - fn test_bdates_list_count() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-01-01".to_string(), - "2023-12-31".to_string(), - BDateFreq::MonthEnd, - ); - assert_eq!(dates_list.count()?, 12); // 12 month ends in 2023 - - let dates_list_weekly = BDatesList::new( - "2023-11-01".to_string(), // Wed - "2023-11-30".to_string(), // Thu - BDateFreq::WeeklyFriday, - ); - // Fridays in range: 2023-11-03, 2023-11-10, 2023-11-17, 2023-11-24 - assert_eq!(dates_list_weekly.count()?, 4); - - Ok(()) - } - - #[test] - /// Tests `list()` and `count()` for YearlyStart frequency. - fn test_bdates_list_yearly_start() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-06-01".to_string(), - "2025-06-01".to_string(), - BDateFreq::YearStart, - ); - // Year starts >= 2023-06-01 and <= 2025-06-01: - // 2023-01-02 (Mon, Jan 1st is Sun) -> Excluded (< 2023-06-01) - // 2024-01-01 (Mon) -> Included - // 2025-01-01 (Wed) -> Included - assert_eq!(dates_list.list()?, vec![date(2024, 1, 1), date(2025, 1, 1)]); - assert_eq!(dates_list.count()?, 2); - - Ok(()) - } - - #[test] - /// Tests `list()` and `count()` for MonthlyStart frequency. - fn test_bdates_list_monthly_start() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-11-15".to_string(), // Mid-Nov - "2024-02-15".to_string(), // Mid-Feb - BDateFreq::MonthStart, - ); - // Month starts >= 2023-11-15 and <= 2024-02-15: - // 2023-11-01 (Wed) -> Excluded (< 2023-11-15) - // 2023-12-01 (Fri) -> Included - // 2024-01-01 (Mon) -> Included - // 2024-02-01 (Thu) -> Included - // 2024-03-01 (Fri) -> Excluded (> 2024-02-15) - assert_eq!( - dates_list.list()?, - vec![date(2023, 12, 1), date(2024, 1, 1), date(2024, 2, 1)] - ); - assert_eq!(dates_list.count()?, 3); - - Ok(()) - } - - #[test] - /// Tests `list()` and `count()` for WeeklyFriday with a range ending mid-week. - fn test_bdates_list_weekly_friday_midweek_end() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-11-01".to_string(), // Wed (Week 44) - "2023-11-14".to_string(), // Tue (Week 46 starts on Mon 13th) - BDateFreq::WeeklyFriday, - ); - // Fridays >= 2023-11-01 and <= 2023-11-14: - // 2023-11-03 (Week 44) -> Included - // 2023-11-10 (Week 45) -> Included - // 2023-11-17 (Week 46) -> Excluded (> 2023-11-14) - assert_eq!( - dates_list.list()?, - vec![date(2023, 11, 3), date(2023, 11, 10)] - ); - assert_eq!(dates_list.count()?, 2); - - Ok(()) - } - - // --- Tests for groups() method --- - - #[test] - /// Tests the `groups()` method for MonthlyEnd frequency across year boundary. - fn test_bdates_list_groups_monthly_end() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-10-15".to_string(), // Mid-October - "2024-01-15".to_string(), // Mid-January next year - BDateFreq::MonthEnd, - ); - - let groups = dates_list.groups()?; - // Expected Month Ends within range ["2023-10-15", "2024-01-15"]: - // 2023-10-31 (>= 2023-10-15) -> Included - // 2023-11-30 (>= 2023-10-15) -> Included - // 2023-12-29 (>= 2023-10-15) -> Included - // 2024-01-31 (> 2024-01-15) -> Excluded - assert_eq!(groups.len(), 3); - - // Check groups and dates within them (should be sorted by key, then by date). - // Keys: Monthly(2023, 10), Monthly(2023, 11), Monthly(2023, 12) - assert_eq!(groups[0], vec![date(2023, 10, 31)]); // Oct 2023 end - assert_eq!(groups[1], vec![date(2023, 11, 30)]); // Nov 2023 end - assert_eq!(groups[2], vec![date(2023, 12, 29)]); // Dec 2023 end (31st is Sunday) - - Ok(()) - } - - #[test] - /// Tests the `groups()` method for Daily frequency over a short range. - fn test_bdates_list_groups_daily() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-11-01".to_string(), // Wed - "2023-11-05".to_string(), // Sun - BDateFreq::Daily, - ); - - let groups = dates_list.groups()?; - // Business days in range: Wed, Thu, Fri. Each is its own group. - assert_eq!(groups.len(), 3); - - // Keys: Daily(2023-11-01), Daily(2023-11-02), Daily(2023-11-03) - assert_eq!(groups[0], vec![date(2023, 11, 1)]); - assert_eq!(groups[1], vec![date(2023, 11, 2)]); - assert_eq!(groups[2], vec![date(2023, 11, 3)]); - - Ok(()) - } - - #[test] - /// Tests the `groups()` method for WeeklyFriday frequency. - fn test_bdates_list_groups_weekly_friday() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-11-01".to_string(), // Wed (ISO Week 44) - "2023-11-15".to_string(), // Wed (ISO Week 46) - BDateFreq::WeeklyFriday, - ); - - let groups = dates_list.groups()?; - // Fridays in range ["2023-11-01", "2023-11-15"]: - // 2023-11-03 (ISO Week 44) -> Included - // 2023-11-10 (ISO Week 45) -> Included - // 2023-11-17 (ISO Week 46) -> Excluded (> 2023-11-15) - assert_eq!(groups.len(), 2); // Groups for Week 44, Week 45 - - // Check grouping by ISO week - // Keys: Weekly(2023, 44), Weekly(2023, 45) - assert_eq!(groups[0], vec![date(2023, 11, 3)]); // ISO Week 44 group - assert_eq!(groups[1], vec![date(2023, 11, 10)]); // ISO Week 45 group - - Ok(()) - } - - #[test] - /// Tests the `groups()` method for QuarterlyStart frequency spanning years. - fn test_bdates_list_groups_quarterly_start_spanning_years() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-08-01".to_string(), // Start date after Q3 2023 start business day - "2024-05-01".to_string(), // End date after Q2 2024 start business day - BDateFreq::QuarterStart, - ); - - let groups = dates_list.groups()?; - // Quarterly starting business days *within the date range* ["2023-08-01", "2024-05-01"]: - // 2023-07-03 (Q3 2023 start) -> Excluded by start_date 2023-08-01 - // 2023-10-02 (Q4 2023 start - Oct 1st is Sunday) -> Included - // 2024-01-01 (Q1 2024 start - Jan 1st is Monday) -> Included - // 2024-04-01 (Q2 2024 start) -> Included - // 2024-07-01 (Q3 2024 start) -> Excluded by end_date 2024-05-01 - - // Expected groups: Q4 2023, Q1 2024, Q2 2024 - assert_eq!(groups.len(), 3); - - // Check groups and dates within them (should be sorted by key, then by date) - // Key order: Quarterly(2023, 4), Quarterly(2024, 1), Quarterly(2024, 2) - assert_eq!(groups[0], vec![date(2023, 10, 2)]); // Q4 2023 group - assert_eq!(groups[1], vec![date(2024, 1, 1)]); // Q1 2024 group (Jan 1st 2024 was a Mon) - assert_eq!(groups[2], vec![date(2024, 4, 1)]); // Q2 2024 group - - Ok(()) - } - - #[test] - /// Tests the `groups()` method for YearlyEnd frequency across year boundary. - fn test_bdates_list_groups_yearly_end() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2022-01-01".to_string(), - "2024-03-31".to_string(), // End date is Q1 2024 - BDateFreq::YearEnd, - ); - - let groups = dates_list.groups()?; - // Yearly ending business days *within the date range* ["2022-01-01", "2024-03-31"]: - // 2022-12-30 (Year 2022 end - 31st Sat) -> Included (>= 2022-01-01) - // 2023-12-29 (Year 2023 end - 31st Sun) -> Included (>= 2022-01-01) - // 2024-12-31 (Year 2024 end) -> Excluded because it's after 2024-03-31 - - // Expected groups: 2022, 2023 - assert_eq!(groups.len(), 2); - - // Check groups and dates within them (should be sorted by key, then by date) - // Key order: Yearly(2022), Yearly(2023) - assert_eq!(groups[0], vec![date(2022, 12, 30)]); // 2022 YE group - assert_eq!(groups[1], vec![date(2023, 12, 29)]); // 2023 YE group - - Ok(()) - } - - #[test] - /// Tests the `groups()` method with an empty date range (end before start). - fn test_bdates_list_groups_empty_range() -> Result<(), Box> { - let dates_list = BDatesList::new( - "2023-12-31".to_string(), - "2023-01-01".to_string(), // End date before start date - BDateFreq::Daily, - ); - let groups = dates_list.groups()?; - assert!(groups.is_empty()); - - Ok(()) - } - - // --- Tests for internal helper functions --- - - #[test] - /// Tests the `is_weekday` function for all days of the week. - fn test_is_weekday() { - assert!(is_weekday(date(2023, 11, 6))); // Mon - assert!(is_weekday(date(2023, 11, 7))); // Tue - assert!(is_weekday(date(2023, 11, 8))); // Wed - assert!(is_weekday(date(2023, 11, 9))); // Thu - assert!(is_weekday(date(2023, 11, 10))); // Fri - assert!(!is_weekday(date(2023, 11, 11))); // Sat - assert!(!is_weekday(date(2023, 11, 12))); // Sun - } - - #[test] - /// Tests the `move_to_weekday_on_or_after` function. - fn test_move_to_weekday_on_or_after() { - // Already the target weekday - assert_eq!( - move_to_weekday_on_or_after(date(2023, 11, 6), Weekday::Mon), - date(2023, 11, 6) - ); - // Target weekday is later in the week - assert_eq!( - move_to_weekday_on_or_after(date(2023, 11, 8), Weekday::Fri), - date(2023, 11, 10) - ); - // Target weekday is next week - assert_eq!( - move_to_weekday_on_or_after(date(2023, 11, 11), Weekday::Mon), - date(2023, 11, 13) - ); // Sat to next Mon - assert_eq!( - move_to_weekday_on_or_after(date(2023, 11, 10), Weekday::Mon), - date(2023, 11, 13) - ); // Fri to next Mon - } - - #[test] - /// Tests `first_business_day_of_month` including weekend starts. - fn test_first_business_day_of_month() { - // Month starts on a weekday - assert_eq!(first_business_day_of_month(2023, 11), date(2023, 11, 1)); // Nov 1st 2023 is Wed - // Month starts on a Sunday, 1st business day is Monday - assert_eq!(first_business_day_of_month(2023, 10), date(2023, 10, 2)); // Oct 1st 2023 is Sun - // Month starts on a Saturday, 1st business day is Monday - assert_eq!(first_business_day_of_month(2022, 10), date(2022, 10, 3)); // Oct 1st 2022 is Sat - } - - #[test] - /// Tests `last_business_day_of_month` including weekend ends. - fn test_last_business_day_of_month() { - // Month ends on a weekday - assert_eq!(last_business_day_of_month(2023, 11), date(2023, 11, 30)); // Nov 30th 2023 is Thu - // Month ends on a Sunday, last business day is Friday - assert_eq!(last_business_day_of_month(2023, 12), date(2023, 12, 29)); // Dec 31st 2023 is Sun - // Month ends on a Saturday, last business day is Friday - assert_eq!(last_business_day_of_month(2022, 12), date(2022, 12, 30)); // Dec 31st 2022 is Sat - // Month ends on Friday - assert_eq!(last_business_day_of_month(2023, 3), date(2023, 3, 31)); // Mar 31st 2023 is Fri - } - - #[test] - /// Tests `days_in_month` including leap years and different month lengths. - fn test_days_in_month() { - assert_eq!(days_in_month(2023, 1), 31); // Jan (31) - assert_eq!(days_in_month(2023, 2), 28); // Feb (28, non-leap) - assert_eq!(days_in_month(2024, 2), 29); // Feb (29, leap) - assert_eq!(days_in_month(2023, 4), 30); // Apr (30) - assert_eq!(days_in_month(2023, 12), 31); // Dec (31) - } - - #[test] - /// Tests the `month_to_quarter` mapping. - fn test_month_to_quarter() { - assert_eq!(month_to_quarter(1), 1); - assert_eq!(month_to_quarter(2), 1); - assert_eq!(month_to_quarter(3), 1); - assert_eq!(month_to_quarter(4), 2); - assert_eq!(month_to_quarter(5), 2); - assert_eq!(month_to_quarter(6), 2); - assert_eq!(month_to_quarter(7), 3); - assert_eq!(month_to_quarter(8), 3); - assert_eq!(month_to_quarter(9), 3); - assert_eq!(month_to_quarter(10), 4); - assert_eq!(month_to_quarter(11), 4); - assert_eq!(month_to_quarter(12), 4); - } - - #[test] - #[should_panic(expected = "Invalid month: 0")] - fn test_month_to_quarter_invalid_low() { - month_to_quarter(0); - } - - #[test] - #[should_panic(expected = "Invalid month: 13")] - fn test_month_to_quarter_invalid_high() { - month_to_quarter(13); - } - - #[test] - /// Tests `first_business_day_of_quarter` including weekend starts. - fn test_first_business_day_of_quarter() { - // Q1 2023: Jan 1st 2023 is Sun, 1st bday is Mon Jan 2nd - assert_eq!(first_business_day_of_quarter(2023, 1), date(2023, 1, 2)); - // Q2 2023: Apr 1st 2023 is Sat, 1st bday is Mon Apr 3rd - assert_eq!(first_business_day_of_quarter(2023, 2), date(2023, 4, 3)); - // Q3 2023: Jul 1st 2023 is Sat, 1st bday is Mon Jul 3rd - assert_eq!(first_business_day_of_quarter(2023, 3), date(2023, 7, 3)); - // Q4 2023: Oct 1st 2023 is Sun, 1st bday is Mon Oct 2nd - assert_eq!(first_business_day_of_quarter(2023, 4), date(2023, 10, 2)); - // Q1 2024: Jan 1st 2024 is Mon, 1st bday is Mon Jan 1st - assert_eq!(first_business_day_of_quarter(2024, 1), date(2024, 1, 1)); - } - - #[test] - /// Tests `last_business_day_of_quarter` including weekend ends. - fn test_last_business_day_of_quarter() { - // Q1 2023: Ends Mar 31st (Fri), last bday is Mar 31st - assert_eq!(last_business_day_of_quarter(2023, 1), date(2023, 3, 31)); - // Q2 2023: Ends Jun 30th (Fri), last bday is Jun 30th - assert_eq!(last_business_day_of_quarter(2023, 2), date(2023, 6, 30)); - // Q3 2023: Ends Sep 30th (Sat), last bday is Sep 29th (Fri) - assert_eq!(last_business_day_of_quarter(2023, 3), date(2023, 9, 29)); - // Q4 2023: Ends Dec 31st (Sun), last bday is Dec 29th (Fri) - assert_eq!(last_business_day_of_quarter(2023, 4), date(2023, 12, 29)); - } - - #[test] - /// Tests `first_business_day_of_year` including weekend starts. - fn test_first_business_day_of_year() { - // 2023: Jan 1st is Sun, 1st bday is Jan 2nd (Mon) - assert_eq!(first_business_day_of_year(2023), date(2023, 1, 2)); - // 2024: Jan 1st is Mon, 1st bday is Jan 1st (Mon) - assert_eq!(first_business_day_of_year(2024), date(2024, 1, 1)); - // 2022: Jan 1st is Sat, 1st bday is Jan 3rd (Mon) - assert_eq!(first_business_day_of_year(2022), date(2022, 1, 3)); - } - - #[test] - /// Tests `last_business_day_of_year` including weekend ends. - fn test_last_business_day_of_year() { - // 2023: Dec 31st is Sun, last bday is Dec 29th (Fri) - assert_eq!(last_business_day_of_year(2023), date(2023, 12, 29)); - // 2024: Dec 31st is Tue, last bday is Dec 31st (Tue) - assert_eq!(last_business_day_of_year(2024), date(2024, 12, 31)); - // 2022: Dec 31st is Sat, last bday is Dec 30th (Fri) - assert_eq!(last_business_day_of_year(2022), date(2022, 12, 30)); - } - - // Test `collect_daily` edge cases - #[test] - fn test_collect_daily_single_day_range() { - // Single weekday - let start = date(2023, 11, 8); // Wed - assert_eq!(collect_daily(start, start), vec![start]); - // Single weekend day - should be empty - let start = date(2023, 11, 11); // Sat - assert_eq!(collect_daily(start, start), vec![]); - } - - #[test] - fn test_collect_daily_range_spanning_weekend() { - let start = date(2023, 11, 10); // Fri - let end = date(2023, 11, 13); // Mon - // Fri, Sat(skipped), Sun(skipped), Mon - assert_eq!( - collect_daily(start, end), - vec![date(2023, 11, 10), date(2023, 11, 13)] - ); - } - - // Test `collect_weekly` edge cases - #[test] - fn test_collect_weekly_start_is_target() { - let start = date(2023, 11, 13); // Mon - let end = date(2023, 11, 20); // Mon - // Start date is already the target weekday - assert_eq!( - collect_weekly(start, end, Weekday::Mon), - vec![date(2023, 11, 13), date(2023, 11, 20)] - ); - } - - #[test] - fn test_collect_weekly_end_before_target() { - let start = date(2023, 11, 13); // Mon - let end = date(2023, 11, 16); // Thu - // Target Friday is after the end date - assert_eq!(collect_weekly(start, end, Weekday::Fri), vec![]); - } - - #[test] - fn test_collect_weekly_single_week() { - let start = date(2023, 11, 8); // Wed - let end = date(2023, 11, 14); // Tue - // Only one Monday (Nov 13) and one Friday (Nov 10) in this range - assert_eq!( - collect_weekly(start, end, Weekday::Mon), - vec![date(2023, 11, 13)] - ); - assert_eq!( - collect_weekly(start, end, Weekday::Fri), - vec![date(2023, 11, 10)] - ); - } - - // Test `collect_monthly` edge cases - #[test] - fn test_collect_monthly_range_starts_mid_month_ends_mid_month() { - let start = date(2023, 10, 15); // Mid Oct - let end = date(2024, 1, 15); // Mid Jan - // Month starts >= start_date AND <= end_date: Nov 2023, Dec 2023, Jan 2024 - assert_eq!( - collect_monthly(start, end, true), - vec![date(2023, 11, 1), date(2023, 12, 1), date(2024, 1, 1)] - ); - // Month ends >= start_date AND <= end_date: Oct 2023, Nov 2023, Dec 2023 - // Last business day of Oct 2023 is Oct 31st, which is after Oct 15th start. - // Last business day of Jan 2024 is Jan 31st, which is after Jan 15th end. - assert_eq!( - collect_monthly(start, end, false), - vec![date(2023, 10, 31), date(2023, 11, 30), date(2023, 12, 29)] - ); - } - - #[test] - fn test_collect_monthly_single_month() { - let start = date(2023, 11, 1); // Nov 1st (Wed) - let end = date(2023, 11, 30); // Nov 30th (Thu) - // Range covers exactly one month, start and end dates are the start/end business days - assert_eq!(collect_monthly(start, end, true), vec![date(2023, 11, 1)]); - assert_eq!(collect_monthly(start, end, false), vec![date(2023, 11, 30)]); - } - - #[test] - fn test_collect_monthly_range_short() { - let start = date(2023, 11, 15); // Mid Nov - let end = date(2023, 11, 20); // Mid Nov - // No month starts or ends are within this short range. - assert_eq!(collect_monthly(start, end, true), vec![]); - assert_eq!(collect_monthly(start, end, false), vec![]); - } - - #[test] - fn test_collect_monthly_full_year_start() { - let start = date(2023, 1, 1); - let end = date(2023, 12, 31); - let expected: Vec = (1..=12) - .map(|m| first_business_day_of_month(2023, m)) - .collect(); - assert_eq!(collect_monthly(start, end, true), expected); - } - - #[test] - fn test_collect_monthly_full_year_end() { - let start = date(2023, 1, 1); - let end = date(2023, 12, 31); - let expected: Vec = (1..=12) - .map(|m| last_business_day_of_month(2023, m)) - .collect(); - assert_eq!(collect_monthly(start, end, false), expected); - } - - // Test `collect_quarterly` edge cases - #[test] - fn test_collect_quarterly_range_starts_mid_quarter_ends_mid_quarter() { - let start = date(2023, 8, 15); // Mid Q3 2023 - let end = date(2024, 2, 15); // Mid Q1 2024 - // Q starts >= start_date AND <= end_date: Q4 2023, Q1 2024 - // Q3 2023 start bday (Jul 3rd) < start_date (Aug 15th) -> Excluded - // Q4 2023 start bday (Oct 2nd) >= start_date (Aug 15th) -> Included - // Q1 2024 start bday (Jan 1st) >= start_date (Aug 15th) AND <= end_date -> Included - // Q2 2024 start bday (Apr 1st) > end_date (Feb 15th) -> Excluded - assert_eq!( - collect_quarterly(start, end, true), - vec![date(2023, 10, 2), date(2024, 1, 1)] - ); - // Q ends >= start_date AND <= end_date: Q3 2023, Q4 2023 - // Q3 2023 end bday (Sep 29th) >= start_date (Aug 15th) -> Included - // Q4 2023 end bday (Dec 29th) >= start_date (Aug 15th) -> Included - // Q1 2024 end bday (Mar 29th) > end_date (Feb 15th) -> Excluded - assert_eq!( - collect_quarterly(start, end, false), - vec![date(2023, 9, 29), date(2023, 12, 29)] - ); - } - - #[test] - fn test_collect_quarterly_single_quarter() { - let start = date(2023, 4, 3); // Apr 3rd (Q2 start bday) - let end = date(2023, 6, 30); // Jun 30th (Q2 end bday) - // Range covers exactly one quarter - assert_eq!(collect_quarterly(start, end, true), vec![date(2023, 4, 3)]); - assert_eq!( - collect_quarterly(start, end, false), - vec![date(2023, 6, 30)] - ); - } - - #[test] - fn test_collect_quarterly_range_short() { - let start = date(2023, 5, 15); // Mid Q2 - let end = date(2023, 6, 15); // Mid Q2 - // No quarter starts or ends are within this short range. - assert_eq!(collect_quarterly(start, end, true), vec![]); - assert_eq!(collect_quarterly(start, end, false), vec![]); - } - - #[test] - fn test_collect_quarterly_full_year_start() { - let start = date(2023, 1, 1); - let end = date(2023, 12, 31); - // Q1: Jan 2, Q2: Apr 3, Q3: Jul 3, Q4: Oct 2 - assert_eq!( - collect_quarterly(start, end, true), - vec![ - date(2023, 1, 2), - date(2023, 4, 3), - date(2023, 7, 3), - date(2023, 10, 2) - ] - ); - } - - #[test] - fn test_collect_quarterly_full_year_end() { - let start = date(2023, 1, 1); - let end = date(2023, 12, 31); - // Q1: Mar 31, Q2: Jun 30, Q3: Sep 29, Q4: Dec 29 - assert_eq!( - collect_quarterly(start, end, false), - vec![ - date(2023, 3, 31), - date(2023, 6, 30), - date(2023, 9, 29), - date(2023, 12, 29) - ] - ); - } - - // Test `collect_yearly` edge cases - #[test] - fn test_collect_yearly_range_starts_mid_year_ends_mid_year() -> Result<(), Box> { - let start = date(2023, 6, 1); // Mid 2023 - let end = date(2024, 6, 1); // Mid 2024 - // Year starts >= start_date AND <= end_date: 2024 - // 2023 start bday (Jan 2nd) < start_date (Jun 1st) -> Excluded - // 2024 start bday (Jan 1st) >= start_date (Jun 1st) AND <= end_date -> Included - // 2025 start bday (Jan 1st) > end_date (Jun 1st) -> Excluded - assert_eq!(collect_yearly(start, end, true), vec![date(2024, 1, 1)]); - // Year ends >= start_date AND <= end_date: 2023 - // 2023 end bday (Dec 29th) >= start_date (Jun 1st) -> Included - // 2024 end bday (Dec 31st) > end_date (Jun 1st) -> Excluded <-- Correction: Original thought was wrong - assert_eq!(collect_yearly(start, end, false), vec![date(2023, 12, 29)]); - Ok(()) - } - - #[test] - fn test_collect_yearly_single_year() { - let start = date(2024, 1, 1); // 2024 start bday - let end = date(2024, 12, 31); // 2024 end bday - // Range covers exactly one year - assert_eq!(collect_yearly(start, end, true), vec![date(2024, 1, 1)]); - assert_eq!(collect_yearly(start, end, false), vec![date(2024, 12, 31)]); - } - - #[test] - fn test_collect_yearly_range_short() { - let start = date(2023, 5, 15); // Mid 2023 - let end = date(2023, 6, 15); // Mid 2023 - // No year starts or ends are within this short range. - assert_eq!(collect_yearly(start, end, true), vec![]); - assert_eq!(collect_yearly(start, end, false), vec![]); - } - - #[test] - fn test_collect_yearly_full_years() { - let start = date(2022, 1, 1); - let end = date(2024, 12, 31); - // Year starts - assert_eq!( - collect_yearly(start, end, true), - vec![date(2022, 1, 3), date(2023, 1, 2), date(2024, 1, 1)] - ); - // Year ends - assert_eq!( - collect_yearly(start, end, false), - vec![date(2022, 12, 30), date(2023, 12, 29), date(2024, 12, 31)] - ); - } - - // --- Tests for Generator Helper Functions --- - - #[test] - fn test_find_first_bdate_on_or_after() { - // Daily - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 8), BDateFreq::Daily), - date(2023, 11, 8) - ); // Wed -> Wed - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 11), BDateFreq::Daily), - date(2023, 11, 13) - ); // Sat -> Mon - - // Weekly Mon - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 8), BDateFreq::WeeklyMonday), - date(2023, 11, 13) - ); // Wed -> Next Mon - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 13), BDateFreq::WeeklyMonday), - date(2023, 11, 13) - ); // Mon -> Mon - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 12), BDateFreq::WeeklyMonday), - date(2023, 11, 13) - ); // Sun -> Mon - - // Weekly Fri - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 8), BDateFreq::WeeklyFriday), - date(2023, 11, 10) - ); // Wed -> Fri - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 10), BDateFreq::WeeklyFriday), - date(2023, 11, 10) - ); // Fri -> Fri - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 11), BDateFreq::WeeklyFriday), - date(2023, 11, 17) - ); // Sat -> Next Fri - - // Month Start - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 1), BDateFreq::MonthStart), - date(2023, 11, 1) - ); // Nov 1 (Wed) -> Nov 1 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 10, 15), BDateFreq::MonthStart), - date(2023, 11, 1) - ); // Mid Oct -> Nov 1 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 15), BDateFreq::MonthStart), - date(2024, 1, 1) - ); // Mid Dec -> Jan 1 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 10, 1), BDateFreq::MonthStart), - date(2023, 10, 2) - ); // Oct 1 (Sun) -> Oct 2 - - // Month End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 30), BDateFreq::MonthEnd), - date(2023, 11, 30) - ); // Nov 30 (Thu) -> Nov 30 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 15), BDateFreq::MonthEnd), - date(2023, 11, 30) - ); // Mid Nov -> Nov 30 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 30), BDateFreq::MonthEnd), - date(2024, 1, 31) - ); // Dec 30 (Sat) -> Jan 31 (Dec end was 29th, which is < 30th) - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 29), BDateFreq::MonthEnd), - date(2023, 12, 29) - ); // Dec 29 (Fri) -> Dec 29 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 9, 30), BDateFreq::MonthEnd), - date(2023, 10, 31) - ); // Sep 30 (Sat) -> Oct 31 (Sep end was 29th, < 30th) - - // Quarter Start - assert_eq!( - find_first_bdate_on_or_after(date(2023, 10, 2), BDateFreq::QuarterStart), - date(2023, 10, 2) - ); // Q4 Start (Mon) -> Q4 Start - assert_eq!( - find_first_bdate_on_or_after(date(2023, 8, 15), BDateFreq::QuarterStart), - date(2023, 10, 2) - ); // Mid Q3 -> Q4 Start - assert_eq!( - find_first_bdate_on_or_after(date(2023, 11, 15), BDateFreq::QuarterStart), - date(2024, 1, 1) - ); // Mid Q4 -> Q1 Start - assert_eq!( - find_first_bdate_on_or_after(date(2023, 1, 1), BDateFreq::QuarterStart), - date(2023, 1, 2) - ); // Jan 1 (Sun) -> Jan 2 (Mon) - - // Quarter End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 9, 29), BDateFreq::QuarterEnd), - date(2023, 9, 29) - ); // Q3 End (Fri) -> Q3 End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 8, 15), BDateFreq::QuarterEnd), - date(2023, 9, 29) - ); // Mid Q3 -> Q3 End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 10, 15), BDateFreq::QuarterEnd), - date(2023, 12, 29) - ); // Mid Q4 -> Q4 End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 30), BDateFreq::QuarterEnd), - date(2024, 3, 29) - ); // Dec 30 (Sat) -> Q1 End (Q4 end was 29th, < 30th) - - // Year Start - assert_eq!( - find_first_bdate_on_or_after(date(2024, 1, 1), BDateFreq::YearStart), - date(2024, 1, 1) - ); // Jan 1 (Mon) -> Jan 1 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 6, 15), BDateFreq::YearStart), - date(2024, 1, 1) - ); // Mid 2023 -> Jan 1 2024 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 1, 1), BDateFreq::YearStart), - date(2023, 1, 2) - ); // Jan 1 (Sun) -> Jan 2 - - // Year End - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 29), BDateFreq::YearEnd), - date(2023, 12, 29) - ); // Dec 29 (Fri) -> Dec 29 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 6, 15), BDateFreq::YearEnd), - date(2023, 12, 29) - ); // Mid 2023 -> Dec 29 2023 - assert_eq!( - find_first_bdate_on_or_after(date(2023, 12, 30), BDateFreq::YearEnd), - date(2024, 12, 31) - ); // Dec 30 (Sat) -> Dec 31 2024 (2023 end was 29th, < 30th) - } - - #[test] - fn test_find_next_bdate() { - // Daily - assert_eq!( - find_next_bdate(date(2023, 11, 8), BDateFreq::Daily), - date(2023, 11, 9) - ); // Wed -> Thu - assert_eq!( - find_next_bdate(date(2023, 11, 10), BDateFreq::Daily), - date(2023, 11, 13) - ); // Fri -> Mon - - // Weekly Mon - assert_eq!( - find_next_bdate(date(2023, 11, 13), BDateFreq::WeeklyMonday), - date(2023, 11, 20) - ); // Mon -> Next Mon - - // Weekly Fri - assert_eq!( - find_next_bdate(date(2023, 11, 10), BDateFreq::WeeklyFriday), - date(2023, 11, 17) - ); // Fri -> Next Fri - - // Month Start - assert_eq!( - find_next_bdate(date(2023, 11, 1), BDateFreq::MonthStart), - date(2023, 12, 1) - ); // Nov 1 -> Dec 1 - assert_eq!( - find_next_bdate(date(2023, 12, 1), BDateFreq::MonthStart), - date(2024, 1, 1) - ); // Dec 1 -> Jan 1 - - // Month End - assert_eq!( - find_next_bdate(date(2023, 10, 31), BDateFreq::MonthEnd), - date(2023, 11, 30) - ); // Oct 31 -> Nov 30 - assert_eq!( - find_next_bdate(date(2023, 11, 30), BDateFreq::MonthEnd), - date(2023, 12, 29) - ); // Nov 30 -> Dec 29 - assert_eq!( - find_next_bdate(date(2023, 12, 29), BDateFreq::MonthEnd), - date(2024, 1, 31) - ); // Dec 29 -> Jan 31 - - // Quarter Start - assert_eq!( - find_next_bdate(date(2023, 10, 2), BDateFreq::QuarterStart), - date(2024, 1, 1) - ); // Q4 Start -> Q1 Start - assert_eq!( - find_next_bdate(date(2024, 1, 1), BDateFreq::QuarterStart), - date(2024, 4, 1) - ); // Q1 Start -> Q2 Start - - // Quarter End - assert_eq!( - find_next_bdate(date(2023, 9, 29), BDateFreq::QuarterEnd), - date(2023, 12, 29) - ); // Q3 End -> Q4 End - assert_eq!( - find_next_bdate(date(2023, 12, 29), BDateFreq::QuarterEnd), - date(2024, 3, 29) - ); // Q4 End -> Q1 End (Mar 31 2024 is Sun) - - // Year Start - assert_eq!( - find_next_bdate(date(2023, 1, 2), BDateFreq::YearStart), - date(2024, 1, 1) - ); // 2023 Start -> 2024 Start - assert_eq!( - find_next_bdate(date(2024, 1, 1), BDateFreq::YearStart), - date(2025, 1, 1) - ); // 2024 Start -> 2025 Start - - // Year End - assert_eq!( - find_next_bdate(date(2022, 12, 30), BDateFreq::YearEnd), - date(2023, 12, 29) - ); // 2022 End -> 2023 End - assert_eq!( - find_next_bdate(date(2023, 12, 29), BDateFreq::YearEnd), - date(2024, 12, 31) - ); // 2023 End -> 2024 End - } - - // --- Tests for BDatesGenerator --- - - #[test] - fn test_generator_new_zero_periods() -> Result<(), Box> { - let start_date = date(2023, 1, 1); - let freq = BDateFreq::Daily; - let n_periods = 0; - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - assert_eq!(generator.next(), None); // Should be immediately exhausted - Ok(()) - } - - #[test] - fn test_generator_daily() -> Result<(), Box> { - let start_date = date(2023, 11, 10); // Friday - let freq = BDateFreq::Daily; - let n_periods = 4; - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 11, 10))); // Fri - assert_eq!(generator.next(), Some(date(2023, 11, 13))); // Mon - assert_eq!(generator.next(), Some(date(2023, 11, 14))); // Tue - assert_eq!(generator.next(), Some(date(2023, 11, 15))); // Wed - assert_eq!(generator.next(), None); // Exhausted - - // Test starting on weekend - let start_date_sat = date(2023, 11, 11); // Saturday - let mut generator_sat = BDatesGenerator::new(start_date_sat, freq, 2)?; - assert_eq!(generator_sat.next(), Some(date(2023, 11, 13))); // Mon - assert_eq!(generator_sat.next(), Some(date(2023, 11, 14))); // Tue - assert_eq!(generator_sat.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_weekly_monday() -> Result<(), Box> { - let start_date = date(2023, 11, 8); // Wednesday - let freq = BDateFreq::WeeklyMonday; - let n_periods = 3; - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 11, 13))); - assert_eq!(generator.next(), Some(date(2023, 11, 20))); - assert_eq!(generator.next(), Some(date(2023, 11, 27))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_weekly_friday() -> Result<(), Box> { - let start_date = date(2023, 11, 11); // Saturday - let freq = BDateFreq::WeeklyFriday; - let n_periods = 3; - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 11, 17))); - assert_eq!(generator.next(), Some(date(2023, 11, 24))); - assert_eq!(generator.next(), Some(date(2023, 12, 1))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_month_start() -> Result<(), Box> { - let start_date = date(2023, 10, 15); // Mid-Oct - let freq = BDateFreq::MonthStart; - let n_periods = 4; // Nov, Dec, Jan, Feb - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 11, 1))); - assert_eq!(generator.next(), Some(date(2023, 12, 1))); - assert_eq!(generator.next(), Some(date(2024, 1, 1))); - assert_eq!(generator.next(), Some(date(2024, 2, 1))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_month_end() -> Result<(), Box> { - let start_date = date(2023, 9, 30); // Sep 30 (Sat) - let freq = BDateFreq::MonthEnd; - let n_periods = 4; // Oct, Nov, Dec, Jan - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 10, 31))); // Sep end was 29th < 30th, so start with Oct end - assert_eq!(generator.next(), Some(date(2023, 11, 30))); - assert_eq!(generator.next(), Some(date(2023, 12, 29))); - assert_eq!(generator.next(), Some(date(2024, 1, 31))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_quarter_start() -> Result<(), Box> { - let start_date = date(2023, 8, 1); // Mid-Q3 - let freq = BDateFreq::QuarterStart; - let n_periods = 3; // Q4'23, Q1'24, Q2'24 - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 10, 2))); // Q3 start was Jul 3, < Aug 1. Next is Q4 start. - assert_eq!(generator.next(), Some(date(2024, 1, 1))); - assert_eq!(generator.next(), Some(date(2024, 4, 1))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_quarter_end() -> Result<(), Box> { - let start_date = date(2023, 11, 1); // Mid-Q4 - let freq = BDateFreq::QuarterEnd; - let n_periods = 3; // Q4'23, Q1'24, Q2'24 - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 12, 29))); // Q4 end is Dec 29 >= Nov 1 - assert_eq!(generator.next(), Some(date(2024, 3, 29))); // Q1 end (Mar 31 is Sun) - assert_eq!(generator.next(), Some(date(2024, 6, 28))); // Q2 end (Jun 30 is Sun) - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_year_start() -> Result<(), Box> { - let start_date = date(2023, 1, 1); // Jan 1 (Sun) - let freq = BDateFreq::YearStart; - let n_periods = 3; // 2023, 2024, 2025 - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 1, 2))); // 2023 start bday >= Jan 1 - assert_eq!(generator.next(), Some(date(2024, 1, 1))); - assert_eq!(generator.next(), Some(date(2025, 1, 1))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_year_end() -> Result<(), Box> { - let start_date = date(2022, 12, 31); // Dec 31 (Sat) - let freq = BDateFreq::YearEnd; - let n_periods = 3; // 2023, 2024, 2025 - let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; - - assert_eq!(generator.next(), Some(date(2023, 12, 29))); // 2022 end was Dec 30 < Dec 31. Next is 2023 end. - assert_eq!(generator.next(), Some(date(2024, 12, 31))); - assert_eq!(generator.next(), Some(date(2025, 12, 31))); - assert_eq!(generator.next(), None); - - Ok(()) - } - - #[test] - fn test_generator_collect() -> Result<(), Box> { - let start_date = date(2023, 11, 10); // Friday - let freq = BDateFreq::Daily; - let n_periods = 4; - let generator = BDatesGenerator::new(start_date, freq, n_periods)?; // Use non-mut binding for collect - let dates: Vec = generator.collect(); - - assert_eq!( - dates, - vec![ - date(2023, 11, 10), // Fri - date(2023, 11, 13), // Mon - date(2023, 11, 14), // Tue - date(2023, 11, 15) // Wed - ] - ); - Ok(()) - } -} // end mod tests diff --git a/src/utils/dateutils/bdates.rs b/src/utils/dateutils/bdates.rs new file mode 100644 index 0000000..8436c21 --- /dev/null +++ b/src/utils/dateutils/bdates.rs @@ -0,0 +1,1233 @@ +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use std::collections::HashMap; +use std::error::Error; +use std::hash::Hash; +use std::result::Result; +use std::str::FromStr; + +use crate::utils::dateutils::dates::{ + find_next_date, AggregationType, DateFreq, DatesGenerator, DatesList, +}; + +use crate::utils::dateutils::dates; + +/// Represents a list of business dates generated between a start and end date +/// at a specified frequency. Provides methods to retrieve the full list, +/// count, or dates grouped by period. +#[derive(Debug, Clone)] +pub struct BDatesList { + start_date_str: String, + end_date_str: String, + freq: DateFreq, + // TODO: cache the generated date list to reduce repeated computation. + // Currently, list(), count(), and groups() regenerate the list on every invocation. + // cached_list: Option>, +} + +// Enumeration of period keys used for grouping dates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum GroupKey { + Daily(NaiveDate), // Daily grouping: use the exact date + Weekly(i32, u32), // Weekly grouping: use year and ISO week number + Monthly(i32, u32), // Monthly grouping: use year and month (1-12) + Quarterly(i32, u32), // Quarterly grouping: use year and quarter (1-4) + Yearly(i32), // Yearly grouping: use year +} + +/// Represents a collection of business dates generated according to specific rules. +/// +/// It can be defined either by a start and end date range or by a start date +/// and a fixed number of periods. It provides methods to retrieve the dates +/// as a flat list, count them, or group them by their natural period +/// (e.g., month, quarter). +/// +/// Business days are typically Monday to Friday. Weekend dates are skipped or +/// adjusted depending on the frequency rules. +/// +/// # Examples +/// +/// **1. Using `new` (Start and End Date):** +/// +/// ```rust +/// use chrono::NaiveDate; +/// use std::error::Error; +/// use rustframe::utils::{BDatesList, DateFreq}; // Replace bdates with your actual crate/module name +/// +/// fn main() -> Result<(), Box> { +/// let start_date = "2023-11-01".to_string(); // Wednesday +/// let end_date = "2023-11-07".to_string(); // Tuesday +/// let freq = DateFreq::Daily; +/// +/// let bdates = BDatesList::new(start_date, end_date, freq); +/// +/// let expected_dates = vec![ +/// NaiveDate::from_ymd_opt(2023, 11, 1).unwrap(), // Wed +/// NaiveDate::from_ymd_opt(2023, 11, 2).unwrap(), // Thu +/// NaiveDate::from_ymd_opt(2023, 11, 3).unwrap(), // Fri +/// NaiveDate::from_ymd_opt(2023, 11, 6).unwrap(), // Mon +/// NaiveDate::from_ymd_opt(2023, 11, 7).unwrap(), // Tue +/// ]; +/// +/// assert_eq!(bdates.list()?, expected_dates); +/// assert_eq!(bdates.count()?, 5); +/// Ok(()) +/// } +/// ``` +/// +/// **2. Using `from_n_periods` (Start Date and Count):** +/// +/// ```rust +/// use chrono::NaiveDate; +/// use std::error::Error; +/// use rustframe::utils::{BDatesList, DateFreq}; // Replace bdates with your actual crate/module name +/// +/// fn main() -> Result<(), Box> { +/// let start_date = "2024-02-28".to_string(); // Wednesday +/// let freq = DateFreq::WeeklyFriday; +/// let n_periods = 3; +/// +/// let bdates = BDatesList::from_n_periods(start_date, freq, n_periods)?; +/// +/// // The first Friday on or after 2024-02-28 is Mar 1. +/// // The next two Fridays are Mar 8 and Mar 15. +/// let expected_dates = vec![ +/// NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(), +/// NaiveDate::from_ymd_opt(2024, 3, 8).unwrap(), +/// NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(), +/// ]; +/// +/// assert_eq!(bdates.list()?, expected_dates); +/// assert_eq!(bdates.count()?, 3); +/// assert_eq!(bdates.start_date_str(), "2024-02-28"); // Keeps original start string +/// assert_eq!(bdates.end_date_str(), "2024-03-15"); // End date is the last generated date +/// Ok(()) +/// } +/// ``` +/// +/// **3. Using `groups()`:** +/// +/// ```rust +/// use chrono::NaiveDate; +/// use std::error::Error; +/// use rustframe::utils::{BDatesList, DateFreq}; // Replace bdates with your actual crate/module name +/// +/// fn main() -> Result<(), Box> { +/// let start_date = "2023-11-20".to_string(); // Mon, Week 47 +/// let end_date = "2023-12-08".to_string(); // Fri, Week 49 +/// let freq = DateFreq::WeeklyMonday; +/// +/// let bdates = BDatesList::new(start_date, end_date, freq); +/// +/// // Mondays in range: Nov 20, Nov 27, Dec 4 +/// let groups = bdates.groups()?; +/// +/// assert_eq!(groups.len(), 3); // One group per week containing a Monday +/// assert_eq!(groups[0], vec![NaiveDate::from_ymd_opt(2023, 11, 20).unwrap()]); // Week 47 +/// assert_eq!(groups[1], vec![NaiveDate::from_ymd_opt(2023, 11, 27).unwrap()]); // Week 48 +/// assert_eq!(groups[2], vec![NaiveDate::from_ymd_opt(2023, 12, 4).unwrap()]); // Week 49 +/// Ok(()) +/// } +/// ``` +impl BDatesList { + /// Creates a new `BDatesList` instance defined by a start and end date. + /// + /// # Arguments + /// + /// * `start_date_str` - The inclusive start date as a string (e.g., "YYYY-MM-DD"). + /// * `end_date_str` - The inclusive end date as a string (e.g., "YYYY-MM-DD"). + /// * `freq` - The frequency for generating dates. + pub fn new(start_date_str: String, end_date_str: String, freq: DateFreq) -> Self { + BDatesList { + start_date_str, + end_date_str, + freq, + } + } + + /// Creates a new `BDatesList` instance defined by a start date, frequency, + /// and the number of periods (dates) to generate. + /// + /// This calculates the required dates using a `BDatesGenerator` and determines + /// the effective end date based on the last generated date. + /// + /// # Arguments + /// + /// * `start_date_str` - The start date as a string (e.g., "YYYY-MM-DD"). The first generated date will be on or after this date. + /// * `freq` - The frequency for generating dates. + /// * `n_periods` - The exact number of business dates to generate according to the frequency. + /// + /// # Errors + /// + /// Returns an error if: + /// * `start_date_str` cannot be parsed. + /// * `n_periods` is 0 (as this would result in an empty list and no defined end date). + pub fn from_n_periods( + start_date_str: String, + freq: DateFreq, + n_periods: usize, + ) -> Result> { + if n_periods == 0 { + return Err("n_periods must be greater than 0".into()); + } + + let start_date = NaiveDate::parse_from_str(&start_date_str, "%Y-%m-%d")?; + + // Instantiate the date generator to compute the sequence of business dates. + let generator = BDatesGenerator::new(start_date, freq, n_periods)?; + let dates: Vec = generator.collect(); + + // Confirm that the generator returned at least one date when n_periods > 0. + let last_date = dates + .last() + .ok_or("Generator failed to produce dates for the specified periods")?; + + let end_date_str = last_date.format("%Y-%m-%d").to_string(); + + Ok(BDatesList { + start_date_str, + end_date_str, + freq, + }) + } + + /// Returns the flat list of business dates within the specified range and frequency. + /// + /// The list is guaranteed to be sorted chronologically. + /// + /// # Errors + /// + /// Returns an error if the start or end date strings cannot be parsed. + pub fn list(&self) -> Result, Box> { + // Retrieve the list of business dates via the shared helper function. + get_bdates_list_with_freq(&self.start_date_str, &self.end_date_str, self.freq) + } + + /// Returns the count of business dates within the specified range and frequency. + /// + /// # Errors + /// + /// Returns an error if the start or end date strings cannot be parsed. + pub fn count(&self) -> Result> { + // Compute the total number of business dates by invoking `list()` and returning its length. + self.list().map(|list| list.len()) + } + + /// Returns a list of date lists, where each inner list contains dates + /// belonging to the same period (determined by frequency). + /// + /// The outer list (groups) is sorted chronologically by period, and the + /// inner lists (dates within each period) are also sorted. + /// + /// # Errors + /// + /// Returns an error if the start or end date strings cannot be parsed. + pub fn groups(&self) -> Result>, Box> { + // Retrieve all business dates in chronological order. + let dates = self.list()?; + + // Aggregate dates into buckets keyed by period. + let mut groups: HashMap> = HashMap::new(); + + for date in dates { + // Derive the appropriate GroupKey for the current date based on the configured frequency. + let key = match self.freq { + DateFreq::Daily => GroupKey::Daily(date), + DateFreq::WeeklyMonday | DateFreq::WeeklyFriday => { + let iso_week = date.iso_week(); + GroupKey::Weekly(iso_week.year(), iso_week.week()) + } + DateFreq::MonthStart | DateFreq::MonthEnd => { + GroupKey::Monthly(date.year(), date.month()) + } + DateFreq::QuarterStart | DateFreq::QuarterEnd => { + GroupKey::Quarterly(date.year(), dates::month_to_quarter(date.month())) + } + DateFreq::YearStart | DateFreq::YearEnd => GroupKey::Yearly(date.year()), + }; + + // Append the date to its period group. + groups.entry(key).or_insert_with(Vec::new).push(date); + } + + // Transform the group map into a vector of (GroupKey, Vec) tuples. + let mut sorted_groups: Vec<(GroupKey, Vec)> = groups.into_iter().collect(); + + // Sort groups chronologically using the derived `Ord` implementation on `GroupKey`. + sorted_groups.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); + + // Note: Dates within each group remain sorted due to initial ordered input. + + // Discard group keys to return only the list of date vectors. + let result_groups = sorted_groups.into_iter().map(|(_, dates)| dates).collect(); + + Ok(result_groups) + } + + /// Returns the start date parsed as a `NaiveDate`. + /// + /// # Errors + /// + /// Returns an error if the start date string is not in "YYYY-MM-DD" format. + pub fn start_date(&self) -> Result> { + NaiveDate::parse_from_str(&self.start_date_str, "%Y-%m-%d").map_err(|e| e.into()) + } + + /// Returns the start date string. + pub fn start_date_str(&self) -> &str { + &self.start_date_str + } + + /// Returns the end date parsed as a `NaiveDate`. + /// + /// # Errors + /// + /// Returns an error if the end date string is not in "YYYY-MM-DD" format. + pub fn end_date(&self) -> Result> { + NaiveDate::parse_from_str(&self.end_date_str, "%Y-%m-%d").map_err(|e| e.into()) + } + + /// Returns the end date string. + pub fn end_date_str(&self) -> &str { + &self.end_date_str + } + + /// Returns the frequency enum. + pub fn freq(&self) -> DateFreq { + self.freq + } + + /// Returns the canonical string representation of the frequency. + pub fn freq_str(&self) -> String { + self.freq.to_string() + } +} + +// Business date iterator: generates a sequence of business dates for a given frequency and period count. + +/// An iterator that generates a sequence of business dates based on a start date, +/// frequency, and a specified number of periods. +/// +/// This implements the `Iterator` trait, allowing generation of dates one by one. +/// It's useful when you need to process dates lazily or only need a fixed number +/// starting from a specific point, without necessarily defining an end date beforehand. +/// +/// # Examples +/// +/// **1. Basic Iteration:** +/// +/// ```rust +/// use chrono::NaiveDate; +/// use std::error::Error; +/// use rustframe::utils::{BDatesGenerator, DateFreq}; +/// +/// fn main() -> Result<(), Box> { +/// let start = NaiveDate::from_ymd_opt(2023, 12, 28).unwrap(); // Thursday +/// let freq = DateFreq::MonthEnd; +/// let n_periods = 4; // Dec '23, Jan '24, Feb '24, Mar '24 +/// +/// let mut generator = BDatesGenerator::new(start, freq, n_periods)?; +/// +/// // First month-end on or after 2023-12-28 is 2023-12-29 +/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2023, 12, 29).unwrap())); +/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap())); +/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 2, 29).unwrap())); // Leap year +/// assert_eq!(generator.next(), Some(NaiveDate::from_ymd_opt(2024, 3, 29).unwrap())); // Mar 31 is Sun +/// assert_eq!(generator.next(), None); // Exhausted +/// Ok(()) +/// } +/// ``` +/// +/// **2. Collecting into a Vec:** +/// +/// ```rust +/// use chrono::NaiveDate; +/// use std::error::Error; +/// use rustframe::utils::{BDatesGenerator, DateFreq}; // Replace bdates with your actual crate/module name +/// +/// fn main() -> Result<(), Box> { +/// let start = NaiveDate::from_ymd_opt(2024, 4, 29).unwrap(); // Monday +/// let freq = DateFreq::Daily; +/// let n_periods = 5; +/// +/// let generator = BDatesGenerator::new(start, freq, n_periods)?; +/// let dates: Vec = generator.collect(); +/// +/// let expected_dates = vec![ +/// NaiveDate::from_ymd_opt(2024, 4, 29).unwrap(), // Mon +/// NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(), // Tue +/// NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(), // Wed +/// NaiveDate::from_ymd_opt(2024, 5, 2).unwrap(), // Thu +/// NaiveDate::from_ymd_opt(2024, 5, 3).unwrap(), // Fri +/// ]; +/// +/// assert_eq!(dates, expected_dates); +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct BDatesGenerator { + freq: DateFreq, + periods_remaining: usize, + // Next business date candidate to yield; None when iteration is complete. + next_date_candidate: Option, +} + +impl BDatesGenerator { + /// Creates a new `BDatesGenerator`. + /// + /// It calculates the first valid business date based on the `start_date` and `freq`, + /// which will be the first item yielded by the iterator. + /// + /// # Arguments + /// + /// * `start_date` - The date from which to start searching for the first valid business date. + /// * `freq` - The frequency for generating dates. + /// * `n_periods` - The total number of business dates to generate. + /// + /// # Errors + /// + /// Can potentially return an error if date calculations lead to overflows, + /// though this is highly unlikely with realistic date ranges. (Currently returns Ok). + /// Note: The internal `find_first_bdate_on_or_after` might panic on extreme date overflows, + /// but practical usage should be safe. + pub fn new( + start_date: NaiveDate, + freq: DateFreq, + n_periods: usize, + ) -> Result> { + let start_date = iter_till_bdate(start_date); + let first_date = if n_periods > 0 { + Some(find_first_bdate_on_or_after(start_date, freq)) + } else { + // No dates when period count is zero. + None + }; + + Ok(BDatesGenerator { + freq, + periods_remaining: n_periods, + next_date_candidate: first_date, + }) + } +} + +impl Iterator for BDatesGenerator { + type Item = NaiveDate; + + /// Returns the next business date in the sequence, or `None` if the specified + /// number of periods has been generated. + fn next(&mut self) -> Option { + // Terminate if no periods remain or no initial date is set. + if self.periods_remaining == 0 || self.next_date_candidate.is_none() { + return None; + } + + // Retrieve and store the current date for output. + let current_date = self.next_date_candidate.unwrap(); + + // Compute and queue the subsequent date for the next call. + self.next_date_candidate = Some(find_next_bdate(current_date, self.freq)); + + // Decrement the remaining period count. + self.periods_remaining -= 1; + + // Yield the current business date. + Some(current_date) + } +} + +/// Check if the date is a weekend (Saturday or Sunday). +pub fn is_business_date(date: NaiveDate) -> bool { + match date.weekday() { + Weekday::Sat | Weekday::Sun => false, + _ => true, + } +} + +pub fn find_next_bdate(date: NaiveDate, freq: DateFreq) -> NaiveDate { + let next_date: NaiveDate = find_next_date(date, freq).unwrap(); + let next_date = iter_till_bdate(next_date); + next_date +} + +pub fn find_first_bdate_on_or_after(date: NaiveDate, freq: DateFreq) -> NaiveDate { + // Find the first business date on or after the given date. + let first_date = dates::find_first_date_on_or_after(date, freq).unwrap(); + let first_date = iter_till_bdate_by_freq(first_date, freq); + // let first_date = iter_till_bdate(first_date); + + first_date +} + +/// Iterate forwards or backwards (depending on the frequency) +/// until a business date is found. +fn iter_till_bdate_by_freq(date: NaiveDate, freq: DateFreq) -> NaiveDate { + let agg_type = freq.agg_type(); + let dur = match agg_type { + AggregationType::Start => Duration::days(1), + AggregationType::End => Duration::days(-1), + }; + let mut current_date = date; + while !is_business_date(current_date) { + current_date = current_date + dur; + } + current_date +} + +/// Increment day-by-day until a business date is found. +fn iter_till_bdate(date: NaiveDate) -> NaiveDate { + let mut current_date = date; + while !is_business_date(current_date) { + current_date = current_date + Duration::days(1); + } + current_date +} + +/// Increment day-by-day until a business date is found. +fn iter_reverse_till_bdate(date: NaiveDate) -> NaiveDate { + let mut current_date = date; + while !is_business_date(current_date) { + current_date = current_date - Duration::days(1); + } + current_date +} + +fn crop_to_first_and_last_bdate(dates: &mut Vec) { + if dates.is_empty() { + return; + } + let start_date = dates[0].clone(); + let end_date = dates[dates.len() - 1].clone(); + let start_date = iter_till_bdate(start_date); + let end_date = iter_reverse_till_bdate(end_date); + while dates.first().unwrap() < &start_date { + dates.remove(0); + } + while dates.last().unwrap() > &end_date { + dates.pop(); + } + + if dates.is_empty() { + dates.push(start_date); + } + +} + +/// Helper function to get a list of business dates based on the frequency. +pub fn get_bdates_list_with_freq( + start_date_str: &str, + end_date_str: &str, + freq: DateFreq, +) -> Result, Box> { + // Generate the list of business dates using the shared logic. + + let start_date = NaiveDate::parse_from_str(start_date_str, "%Y-%m-%d")?; + let end_date = NaiveDate::parse_from_str(end_date_str, "%Y-%m-%d")?; + let start_date = iter_till_bdate(start_date); + let end_date = iter_reverse_till_bdate(end_date); + + let mut dates = dates::get_dates_list_with_freq_from_naive_date(start_date, end_date, freq)?; + + // crop to first and last business date + crop_to_first_and_last_bdate(&mut dates); + + match freq { + DateFreq::Daily => { + dates.retain(|date| is_business_date(*date)); + } + DateFreq::WeeklyMonday | DateFreq::WeeklyFriday => { + // No logic needed (or possible?) + } + _ => { + dates.iter_mut().for_each(|date| { + *date = iter_till_bdate_by_freq(*date, freq); + }); + } + } + + Ok(dates) +} + +// --- Example Usage and Tests --- + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + // Helper to create a NaiveDate for tests, handling the expect for fixed dates. + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("Invalid date in test setup") + } + + // --- DateFreq Tests --- + + #[test] + fn test_date_freq_from_str() -> Result<(), Box> { + assert_eq!(DateFreq::from_str("D")?, DateFreq::Daily); + assert_eq!("D".parse::()?, DateFreq::Daily); // Test FromStr impl + assert_eq!(DateFreq::from_str("W")?, DateFreq::WeeklyMonday); + assert_eq!(DateFreq::from_str("M")?, DateFreq::MonthStart); + assert_eq!(DateFreq::from_str("Q")?, DateFreq::QuarterStart); + + // Test YearStart codes and aliases (Y, A, AS, YS) + assert_eq!(DateFreq::from_str("Y")?, DateFreq::YearStart); + assert_eq!(DateFreq::from_str("A")?, DateFreq::YearStart); + assert_eq!(DateFreq::from_str("AS")?, DateFreq::YearStart); + assert_eq!(DateFreq::from_str("YS")?, DateFreq::YearStart); + assert_eq!("Y".parse::()?, DateFreq::YearStart); // Test FromStr impl + + assert_eq!(DateFreq::from_str("ME")?, DateFreq::MonthEnd); + assert_eq!(DateFreq::from_str("QE")?, DateFreq::QuarterEnd); + assert_eq!(DateFreq::from_str("WF")?, DateFreq::WeeklyFriday); + assert_eq!("WF".parse::()?, DateFreq::WeeklyFriday); // Test FromStr impl + + // Test YearEnd codes and aliases (YE, AE) + assert_eq!(DateFreq::from_str("YE")?, DateFreq::YearEnd); + assert_eq!(DateFreq::from_str("AE")?, DateFreq::YearEnd); + + // Test aliases for other frequencies + assert_eq!(DateFreq::from_str("WS")?, DateFreq::WeeklyMonday); + assert_eq!(DateFreq::from_str("MS")?, DateFreq::MonthStart); + assert_eq!(DateFreq::from_str("QS")?, DateFreq::QuarterStart); + + // Test invalid string + assert!(DateFreq::from_str("INVALID").is_err()); + assert!("INVALID".parse::().is_err()); // Test FromStr impl + let err = DateFreq::from_str("INVALID").unwrap_err(); + assert_eq!(err.to_string(), "Invalid frequency specified: INVALID"); + + Ok(()) + } + + #[test] + fn test_date_freq_to_string() { + assert_eq!(DateFreq::Daily.to_string(), "D"); + assert_eq!(DateFreq::WeeklyMonday.to_string(), "W"); + assert_eq!(DateFreq::MonthStart.to_string(), "M"); + assert_eq!(DateFreq::QuarterStart.to_string(), "Q"); + assert_eq!(DateFreq::YearStart.to_string(), "Y"); // Assert "Y" + assert_eq!(DateFreq::MonthEnd.to_string(), "ME"); + assert_eq!(DateFreq::QuarterEnd.to_string(), "QE"); + assert_eq!(DateFreq::WeeklyFriday.to_string(), "WF"); + assert_eq!(DateFreq::YearEnd.to_string(), "YE"); + } + + #[test] + fn test_date_freq_from_string() -> Result<(), Box> { + assert_eq!(DateFreq::from_string("D".to_string())?, DateFreq::Daily); + assert!(DateFreq::from_string("INVALID".to_string()).is_err()); + Ok(()) + } + + #[test] + fn test_date_freq_agg_type() { + assert_eq!(DateFreq::Daily.agg_type(), AggregationType::Start); + assert_eq!(DateFreq::WeeklyMonday.agg_type(), AggregationType::Start); + assert_eq!(DateFreq::MonthStart.agg_type(), AggregationType::Start); + assert_eq!(DateFreq::QuarterStart.agg_type(), AggregationType::Start); + assert_eq!(DateFreq::YearStart.agg_type(), AggregationType::Start); + + assert_eq!(DateFreq::WeeklyFriday.agg_type(), AggregationType::End); + assert_eq!(DateFreq::MonthEnd.agg_type(), AggregationType::End); + assert_eq!(DateFreq::QuarterEnd.agg_type(), AggregationType::End); + assert_eq!(DateFreq::YearEnd.agg_type(), AggregationType::End); + } + + // --- BDatesList Property Tests --- + + #[test] + fn test_bdates_list_properties_new() -> Result<(), Box> { + let start_str = "2023-01-01".to_string(); + let end_str = "2023-12-31".to_string(); + let freq = DateFreq::QuarterEnd; + let dates_list = BDatesList::new(start_str.clone(), end_str.clone(), freq); + + // check start_date_str + assert_eq!(dates_list.start_date_str(), start_str); + // check end_date_str + assert_eq!(dates_list.end_date_str(), end_str); + // check frequency enum + assert_eq!(dates_list.freq(), freq); + // check frequency string + assert_eq!(dates_list.freq_str(), "QE"); + + // Check parsed dates + assert_eq!(dates_list.start_date()?, date(2023, 1, 1)); + assert_eq!(dates_list.end_date()?, date(2023, 12, 31)); + + Ok(()) + } + + #[test] + fn test_bdates_list_properties_from_n_periods() -> Result<(), Box> { + let start_str = "2023-01-01".to_string(); // Sunday + let freq = DateFreq::Daily; + let n_periods = 5; // Expect: Jan 2, 3, 4, 5, 6 + let dates_list = BDatesList::from_n_periods(start_str.clone(), freq, n_periods)?; + + // check start_date_str (should be original) + assert_eq!(dates_list.start_date_str(), start_str); + // check end_date_str (should be the last generated date) + assert_eq!(dates_list.end_date_str(), "2023-01-06"); + // check frequency enum + assert_eq!(dates_list.freq(), freq); + // check frequency string + assert_eq!(dates_list.freq_str(), "D"); + + // Check parsed dates + assert_eq!(dates_list.start_date()?, date(2023, 1, 1)); + assert_eq!(dates_list.end_date()?, date(2023, 1, 6)); + + // Check the actual list matches + assert_eq!( + dates_list.list()?, + vec![ + date(2023, 1, 2), + date(2023, 1, 3), + date(2023, 1, 4), + date(2023, 1, 5), + date(2023, 1, 6) + ] + ); + assert_eq!(dates_list.count()?, 5); + + Ok(()) + } + + #[test] + fn test_bdates_list_from_n_periods_zero_periods() { + let start_str = "2023-01-01".to_string(); + let freq = DateFreq::Daily; + let n_periods = 0; + let result = BDatesList::from_n_periods(start_str.clone(), freq, n_periods); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "n_periods must be greater than 0" + ); + } + + #[test] + fn test_bdates_list_from_n_periods_invalid_start_date() { + let start_str = "invalid-date".to_string(); + let freq = DateFreq::Daily; + let n_periods = 5; + let result = BDatesList::from_n_periods(start_str.clone(), freq, n_periods); + assert!(result.is_err()); + // Error comes from NaiveDate::parse_from_str + assert!(result + .unwrap_err() + .to_string() + .contains("input contains invalid characters")); + } + + #[test] + fn test_bdates_list_invalid_date_string_new() { + let dates_list_start_invalid = BDatesList::new( + "invalid-date".to_string(), + "2023-12-31".to_string(), + DateFreq::Daily, + ); + assert!(dates_list_start_invalid.list().is_err()); + assert!(dates_list_start_invalid.count().is_err()); + assert!(dates_list_start_invalid.groups().is_err()); + assert!(dates_list_start_invalid.start_date().is_err()); + assert!(dates_list_start_invalid.end_date().is_ok()); // End date is valid + + let dates_list_end_invalid = BDatesList::new( + "2023-01-01".to_string(), + "invalid-date".to_string(), + DateFreq::Daily, + ); + assert!(dates_list_end_invalid.list().is_err()); + assert!(dates_list_end_invalid.count().is_err()); + assert!(dates_list_end_invalid.groups().is_err()); + assert!(dates_list_end_invalid.start_date().is_ok()); // Start date is valid + assert!(dates_list_end_invalid.end_date().is_err()); + } + + // --- BDatesList Core Logic Tests (via list and count) --- + + #[test] + /// Tests the `list()` method for QuarterEnd frequency over a full year. + fn test_bdates_list_quarterly_end_list() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-01-01".to_string(), + "2023-12-31".to_string(), + DateFreq::QuarterEnd, + ); + + let list = dates_list.list()?; + assert_eq!(list.len(), 4); + assert_eq!( + list, + vec![ + date(2023, 3, 31), + date(2023, 6, 30), + date(2023, 9, 29), + date(2023, 12, 29) + ] + ); // Fri, Fri, Fri, Fri + + Ok(()) + } + + #[test] + /// Tests the `list()` method for WeeklyMonday frequency. + fn test_bdates_list_weekly_monday_list() -> Result<(), Box> { + // Range includes start date that is Monday, end date that is Sunday + let dates_list = BDatesList::new( + "2023-10-30".to_string(), // Monday (Week 44) + "2023-11-12".to_string(), // Sunday (Week 45 ends, Week 46 starts) + DateFreq::WeeklyMonday, + ); + + let list = dates_list.list()?; + // Mondays >= 2023-10-30 and <= 2023-11-12: + // 2023-10-30 (Included) + // 2023-11-06 (Included) + // 2023-11-13 (Excluded) + assert_eq!(list.len(), 2); + assert_eq!(list, vec![date(2023, 10, 30), date(2023, 11, 6)]); + + Ok(()) + } + + #[test] + /// Tests the `list()` method for Daily frequency over a short range including weekends. + fn test_bdates_list_daily_list() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-11-01".to_string(), // Wednesday + "2023-11-05".to_string(), // Sunday + DateFreq::Daily, + ); + + let list = dates_list.list()?; + // Business days in range: Wed, Thu, Fri + assert_eq!(list.len(), 3); + assert_eq!( + list, + vec![date(2023, 11, 1), date(2023, 11, 2), date(2023, 11, 3)] + ); + + Ok(()) + } + + #[test] + /// Tests the `list()` method with an empty date range (end before start). + fn test_bdates_list_empty_range_list() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-12-31".to_string(), + "2023-01-01".to_string(), // End date before start date + DateFreq::Daily, + ); + let list = dates_list.list()?; + assert!(list.is_empty()); + assert_eq!(dates_list.count()?, 0); // Also test count here + + Ok(()) + } + + #[test] + /// Tests the `count()` method for various frequencies. + fn test_bdates_list_count() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-01-01".to_string(), + "2023-12-31".to_string(), + DateFreq::MonthEnd, + ); + assert_eq!(dates_list.count()?, 12); // 12 month ends in 2023 + + let dates_list_weekly = BDatesList::new( + "2023-11-01".to_string(), // Wed + "2023-11-30".to_string(), // Thu + DateFreq::WeeklyFriday, + ); + // Fridays in range: 2023-11-03, 2023-11-10, 2023-11-17, 2023-11-24 + assert_eq!(dates_list_weekly.count()?, 4); + + Ok(()) + } + + #[test] + /// Tests `list()` and `count()` for YearlyStart frequency. + fn test_bdates_list_yearly_start() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-06-01".to_string(), + "2025-06-01".to_string(), + DateFreq::YearStart, + ); + // Year starts >= 2023-06-01 and <= 2025-06-01: + // 2023-01-02 (Mon, Jan 1st is Sun) -> Excluded (< 2023-06-01) + // 2024-01-01 (Mon) -> Included + // 2025-01-01 (Wed) -> Included + assert_eq!(dates_list.list()?, vec![date(2024, 1, 1), date(2025, 1, 1)]); + assert_eq!(dates_list.count()?, 2); + + Ok(()) + } + + #[test] + /// Tests `list()` and `count()` for MonthlyStart frequency. + fn test_bdates_list_monthly_start() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-11-15".to_string(), // Mid-Nov + "2024-02-15".to_string(), // Mid-Feb + DateFreq::MonthStart, + ); + // Month starts >= 2023-11-15 and <= 2024-02-15: + // 2023-11-01 (Wed) -> Excluded (< 2023-11-15) + // 2023-12-01 (Fri) -> Included + // 2024-01-01 (Mon) -> Included + // 2024-02-01 (Thu) -> Included + // 2024-03-01 (Fri) -> Excluded (> 2024-02-15) + assert_eq!( + dates_list.list()?, + vec![date(2023, 12, 1), date(2024, 1, 1), date(2024, 2, 1)] + ); + assert_eq!(dates_list.count()?, 3); + + Ok(()) + } + + #[test] + /// Tests `list()` and `count()` for WeeklyFriday with a range ending mid-week. + fn test_bdates_list_weekly_friday_midweek_end() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-11-01".to_string(), // Wed (Week 44) + "2023-11-14".to_string(), // Tue (Week 46 starts on Mon 13th) + DateFreq::WeeklyFriday, + ); + // Fridays >= 2023-11-01 and <= 2023-11-14: + // 2023-11-03 (Week 44) -> Included + // 2023-11-10 (Week 45) -> Included + // 2023-11-17 (Week 46) -> Excluded (> 2023-11-14) + assert_eq!( + dates_list.list()?, + vec![date(2023, 11, 3), date(2023, 11, 10)] + ); + assert_eq!(dates_list.count()?, 2); + + Ok(()) + } + + // --- Tests for groups() method --- + + #[test] + /// Tests the `groups()` method for MonthlyEnd frequency across year boundary. + fn test_bdates_list_groups_monthly_end() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-10-15".to_string(), // Mid-October + "2024-01-15".to_string(), // Mid-January next year + DateFreq::MonthEnd, + ); + + let groups = dates_list.groups()?; + // Expected Month Ends within range ["2023-10-15", "2024-01-15"]: + // 2023-10-31 (>= 2023-10-15) -> Included + // 2023-11-30 (>= 2023-10-15) -> Included + // 2023-12-29 (>= 2023-10-15) -> Included + // 2024-01-31 (> 2024-01-15) -> Excluded + assert_eq!(groups.len(), 3); + + // Check groups and dates within them (should be sorted by key, then by date). + // Keys: Monthly(2023, 10), Monthly(2023, 11), Monthly(2023, 12) + assert_eq!(groups[0], vec![date(2023, 10, 31)]); // Oct 2023 end + assert_eq!(groups[1], vec![date(2023, 11, 30)]); // Nov 2023 end + assert_eq!(groups[2], vec![date(2023, 12, 29)]); // Dec 2023 end (31st is Sunday) + + Ok(()) + } + + #[test] + /// Tests the `groups()` method for Daily frequency over a short range. + fn test_bdates_list_groups_daily() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-11-01".to_string(), // Wed + "2023-11-05".to_string(), // Sun + DateFreq::Daily, + ); + + let groups = dates_list.groups()?; + // Business days in range: Wed, Thu, Fri. Each is its own group. + assert_eq!(groups.len(), 3); + + // Keys: Daily(2023-11-01), Daily(2023-11-02), Daily(2023-11-03) + assert_eq!(groups[0], vec![date(2023, 11, 1)]); + assert_eq!(groups[1], vec![date(2023, 11, 2)]); + assert_eq!(groups[2], vec![date(2023, 11, 3)]); + + Ok(()) + } + + #[test] + /// Tests the `groups()` method for WeeklyFriday frequency. + fn test_bdates_list_groups_weekly_friday() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-11-01".to_string(), // Wed (ISO Week 44) + "2023-11-15".to_string(), // Wed (ISO Week 46) + DateFreq::WeeklyFriday, + ); + + let groups = dates_list.groups()?; + // Fridays in range ["2023-11-01", "2023-11-15"]: + // 2023-11-03 (ISO Week 44) -> Included + // 2023-11-10 (ISO Week 45) -> Included + // 2023-11-17 (ISO Week 46) -> Excluded (> 2023-11-15) + assert_eq!(groups.len(), 2); // Groups for Week 44, Week 45 + + // Check grouping by ISO week + // Keys: Weekly(2023, 44), Weekly(2023, 45) + assert_eq!(groups[0], vec![date(2023, 11, 3)]); // ISO Week 44 group + assert_eq!(groups[1], vec![date(2023, 11, 10)]); // ISO Week 45 group + + Ok(()) + } + + #[test] + /// Tests the `groups()` method for QuarterlyStart frequency spanning years. + fn test_bdates_list_groups_quarterly_start_spanning_years() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-08-01".to_string(), // Start date after Q3 2023 start business day + "2024-05-01".to_string(), // End date after Q2 2024 start business day + DateFreq::QuarterStart, + ); + + let groups = dates_list.groups()?; + // Quarterly starting business days *within the date range* ["2023-08-01", "2024-05-01"]: + // 2023-07-03 (Q3 2023 start) -> Excluded by start_date 2023-08-01 + // 2023-10-02 (Q4 2023 start - Oct 1st is Sunday) -> Included + // 2024-01-01 (Q1 2024 start - Jan 1st is Monday) -> Included + // 2024-04-01 (Q2 2024 start) -> Included + // 2024-07-01 (Q3 2024 start) -> Excluded by end_date 2024-05-01 + + // Expected groups: Q4 2023, Q1 2024, Q2 2024 + assert_eq!(groups.len(), 3); + + // Check groups and dates within them (should be sorted by key, then by date) + // Key order: Quarterly(2023, 4), Quarterly(2024, 1), Quarterly(2024, 2) + assert_eq!(groups[0], vec![date(2023, 10, 2)]); // Q4 2023 group + assert_eq!(groups[1], vec![date(2024, 1, 1)]); // Q1 2024 group (Jan 1st 2024 was a Mon) + assert_eq!(groups[2], vec![date(2024, 4, 1)]); // Q2 2024 group + + Ok(()) + } + + #[test] + /// Tests the `groups()` method for YearlyEnd frequency across year boundary. + fn test_bdates_list_groups_yearly_end() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2022-01-01".to_string(), + "2024-03-31".to_string(), // End date is Q1 2024 + DateFreq::YearEnd, + ); + + let groups = dates_list.groups()?; + // Yearly ending business days *within the date range* ["2022-01-01", "2024-03-31"]: + // 2022-12-30 (Year 2022 end - 31st Sat) -> Included (>= 2022-01-01) + // 2023-12-29 (Year 2023 end - 31st Sun) -> Included (>= 2022-01-01) + // 2024-12-31 (Year 2024 end) -> Excluded because it's after 2024-03-31 + + // Expected groups: 2022, 2023 + assert_eq!(groups.len(), 2); + + // Check groups and dates within them (should be sorted by key, then by date) + // Key order: Yearly(2022), Yearly(2023) + assert_eq!(groups[0], vec![date(2022, 12, 30)]); // 2022 YE group + assert_eq!(groups[1], vec![date(2023, 12, 29)]); // 2023 YE group + + Ok(()) + } + + #[test] + /// Tests the `groups()` method with an empty date range (end before start). + fn test_bdates_list_groups_empty_range() -> Result<(), Box> { + let dates_list = BDatesList::new( + "2023-12-31".to_string(), + "2023-01-01".to_string(), // End date before start date + DateFreq::Daily, + ); + let groups = dates_list.groups()?; + assert!(groups.is_empty()); + + Ok(()) + } + + // --- Tests for BDatesGenerator --- + + #[test] + fn test_generator_new_zero_periods() -> Result<(), Box> { + let start_date = date(2023, 1, 1); + let freq = DateFreq::Daily; + let n_periods = 0; + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + assert_eq!(generator.next(), None); // Should be immediately exhausted + Ok(()) + } + + #[test] + fn test_generator_daily() -> Result<(), Box> { + let start_date = date(2023, 11, 10); // Friday + let freq = DateFreq::Daily; + let n_periods = 4; + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 11, 10))); // Fri + assert_eq!(generator.next(), Some(date(2023, 11, 13))); // Mon + assert_eq!(generator.next(), Some(date(2023, 11, 14))); // Tue + assert_eq!(generator.next(), Some(date(2023, 11, 15))); // Wed + assert_eq!(generator.next(), None); // Exhausted + + // Test starting on weekend + let start_date_sat = date(2023, 11, 11); // Saturday + let mut generator_sat = BDatesGenerator::new(start_date_sat, freq, 2)?; + assert_eq!(generator_sat.next(), Some(date(2023, 11, 13))); // Mon + assert_eq!(generator_sat.next(), Some(date(2023, 11, 14))); // Tue + assert_eq!(generator_sat.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_weekly_monday() -> Result<(), Box> { + let start_date = date(2023, 11, 8); // Wednesday + let freq = DateFreq::WeeklyMonday; + let n_periods = 3; + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 11, 13))); + assert_eq!(generator.next(), Some(date(2023, 11, 20))); + assert_eq!(generator.next(), Some(date(2023, 11, 27))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_weekly_friday() -> Result<(), Box> { + let start_date = date(2023, 11, 11); // Saturday + let freq = DateFreq::WeeklyFriday; + let n_periods = 3; + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 11, 17))); + assert_eq!(generator.next(), Some(date(2023, 11, 24))); + assert_eq!(generator.next(), Some(date(2023, 12, 1))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_month_start() -> Result<(), Box> { + let start_date = date(2023, 10, 15); // Mid-Oct + let freq = DateFreq::MonthStart; + let n_periods = 4; // Nov, Dec, Jan, Feb + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 11, 1))); + assert_eq!(generator.next(), Some(date(2023, 12, 1))); + assert_eq!(generator.next(), Some(date(2024, 1, 1))); + assert_eq!(generator.next(), Some(date(2024, 2, 1))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_month_end() -> Result<(), Box> { + let start_date = date(2023, 9, 30); // Sep 30 (Sat) + let freq = DateFreq::MonthEnd; + let n_periods = 4; // Oct, Nov, Dec, Jan + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 10, 31))); // Sep end was 29th < 30th, so start with Oct end + assert_eq!(generator.next(), Some(date(2023, 11, 30))); + assert_eq!(generator.next(), Some(date(2023, 12, 29))); + assert_eq!(generator.next(), Some(date(2024, 1, 31))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_quarter_start() -> Result<(), Box> { + let start_date = date(2023, 8, 1); // Mid-Q3 + let freq = DateFreq::QuarterStart; + let n_periods = 3; // Q4'23, Q1'24, Q2'24 + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 10, 2))); // Q3 start was Jul 3, < Aug 1. Next is Q4 start. + assert_eq!(generator.next(), Some(date(2024, 1, 1))); + assert_eq!(generator.next(), Some(date(2024, 4, 1))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_quarter_end() -> Result<(), Box> { + let start_date = date(2023, 11, 1); // Mid-Q4 + let freq = DateFreq::QuarterEnd; + let n_periods = 3; // Q4'23, Q1'24, Q2'24 + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 12, 29))); // Q4 end is Dec 29 >= Nov 1 + assert_eq!(generator.next(), Some(date(2024, 3, 29))); // Q1 end (Mar 31 is Sun) + assert_eq!(generator.next(), Some(date(2024, 6, 28))); // Q2 end (Jun 30 is Sun) + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_year_start() -> Result<(), Box> { + let start_date = date(2023, 1, 1); // Jan 1 (Sun) + let freq = DateFreq::YearStart; + let n_periods = 3; // 2023, 2024, 2025 + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 1, 2))); // 2023 start bday >= Jan 1 + assert_eq!(generator.next(), Some(date(2024, 1, 1))); + assert_eq!(generator.next(), Some(date(2025, 1, 1))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_year_end() -> Result<(), Box> { + let start_date = date(2022, 12, 31); // Dec 31 (Sat) + let freq = DateFreq::YearEnd; + let n_periods = 3; // 2023, 2024, 2025 + let mut generator = BDatesGenerator::new(start_date, freq, n_periods)?; + + assert_eq!(generator.next(), Some(date(2023, 12, 29))); // 2022 end was Dec 30 < Dec 31. Next is 2023 end. + assert_eq!(generator.next(), Some(date(2024, 12, 31))); + assert_eq!(generator.next(), Some(date(2025, 12, 31))); + assert_eq!(generator.next(), None); + + Ok(()) + } + + #[test] + fn test_generator_collect() -> Result<(), Box> { + let start_date = date(2023, 11, 10); // Friday + let freq = DateFreq::Daily; + let n_periods = 4; + let generator = BDatesGenerator::new(start_date, freq, n_periods)?; // Use non-mut binding for collect + let dates: Vec = generator.collect(); + + assert_eq!( + dates, + vec![ + date(2023, 11, 10), // Fri + date(2023, 11, 13), // Mon + date(2023, 11, 14), // Tue + date(2023, 11, 15) // Wed + ] + ); + Ok(()) + } +} // end mod tests diff --git a/src/utils/dates.rs b/src/utils/dateutils/dates.rs similarity index 95% rename from src/utils/dates.rs rename to src/utils/dateutils/dates.rs index e2bcf3c..0adb277 100644 --- a/src/utils/dates.rs +++ b/src/utils/dateutils/dates.rs @@ -565,7 +565,7 @@ impl Iterator for DatesGenerator { /// Generates the flat list of dates for the given range and frequency. /// Assumes the `collect_*` functions return sorted dates. -fn get_dates_list_with_freq( +pub fn get_dates_list_with_freq( start_date_str: &str, end_date_str: &str, freq: DateFreq, @@ -648,8 +648,13 @@ fn collect_monthly( let mut year = start_date.year(); let mut month = start_date.month(); - let next_month = - |(yr, mo): (i32, u32)| -> (i32, u32) { if mo == 12 { (yr + 1, 1) } else { (yr, mo + 1) } }; + let next_month = |(yr, mo): (i32, u32)| -> (i32, u32) { + if mo == 12 { + (yr + 1, 1) + } else { + (yr, mo + 1) + } + }; loop { let candidate = if want_first_day { @@ -728,6 +733,19 @@ fn collect_quarterly( Ok(result) } +pub fn get_dates_list_with_freq_from_naive_date( + start_date: NaiveDate, + end_date: NaiveDate, + freq: DateFreq, +) -> Result, Box> { + get_dates_list_with_freq( + &start_date.format("%Y-%m-%d").to_string(), + &end_date.format("%Y-%m-%d").to_string(), + freq, + ) +} + + /// Return either the first or last calendar day in each year of the range. fn collect_yearly( start_date: NaiveDate, @@ -814,7 +832,7 @@ fn last_day_of_month(year: i32, month: u32) -> Result> /// Converts a month number (1-12) to a quarter number (1-4). /// Panics if month is invalid (should not happen with valid NaiveDate). -fn month_to_quarter(m: u32) -> u32 { +pub fn month_to_quarter(m: u32) -> u32 { match m { 1..=3 => 1, 4..=6 => 2, @@ -873,9 +891,28 @@ fn last_day_of_year(year: i32) -> Result> { // --- Generator Helper Functions --- +fn get_first_date_helper(freq: DateFreq) -> fn(i32, u32) -> Result> { + if matches!( + freq, + DateFreq::Daily | DateFreq::WeeklyMonday | DateFreq::WeeklyFriday + ) { + panic!("Daily, WeeklyMonday, and WeeklyFriday frequencies are not supported here"); + } + + match freq { + DateFreq::MonthStart => first_day_of_month, + DateFreq::MonthEnd => last_day_of_month, + DateFreq::QuarterStart => first_day_of_quarter, + DateFreq::QuarterEnd => last_day_of_quarter, + DateFreq::YearStart => |year, _| first_day_of_year(year), + DateFreq::YearEnd => |year, _| last_day_of_year(year), + _ => unreachable!(), + } +} + /// Finds the *first* valid date according to the frequency, /// starting the search *on or after* the given `start_date`. -fn find_first_date_on_or_after( +pub fn find_first_date_on_or_after( start_date: NaiveDate, freq: DateFreq, ) -> Result> { @@ -883,69 +920,42 @@ fn find_first_date_on_or_after( DateFreq::Daily => Ok(start_date), // The first daily date is the start date itself DateFreq::WeeklyMonday => move_to_day_of_week_on_or_after(start_date, Weekday::Mon), DateFreq::WeeklyFriday => move_to_day_of_week_on_or_after(start_date, Weekday::Fri), - DateFreq::MonthStart => { - let mut candidate = first_day_of_month(start_date.year(), start_date.month())?; + + DateFreq::MonthStart | DateFreq::MonthEnd => { + // let mut candidate = first_day_of_month(start_date.year(), start_date.month())?; + let get_cand_func = get_first_date_helper(freq); + let mut candidate = get_cand_func(start_date.year(), start_date.month())?; if candidate < start_date { let (next_y, next_m) = if start_date.month() == 12 { (start_date.year().checked_add(1).ok_or("Year overflow")?, 1) } else { (start_date.year(), start_date.month() + 1) }; - candidate = first_day_of_month(next_y, next_m)?; + candidate = get_cand_func(next_y, next_m)?; } Ok(candidate) } - DateFreq::MonthEnd => { - let mut candidate = last_day_of_month(start_date.year(), start_date.month())?; - if candidate < start_date { - let (next_y, next_m) = if start_date.month() == 12 { - (start_date.year().checked_add(1).ok_or("Year overflow")?, 1) - } else { - (start_date.year(), start_date.month() + 1) - }; - candidate = last_day_of_month(next_y, next_m)?; - } - Ok(candidate) - } - DateFreq::QuarterStart => { + DateFreq::QuarterStart | DateFreq::QuarterEnd => { let current_q = month_to_quarter(start_date.month()); - let mut candidate = first_day_of_quarter(start_date.year(), current_q)?; + let get_cand_func = get_first_date_helper(freq); + let mut candidate = get_cand_func(start_date.year(), current_q)?; if candidate < start_date { let (next_y, next_q) = if current_q == 4 { (start_date.year().checked_add(1).ok_or("Year overflow")?, 1) } else { (start_date.year(), current_q + 1) }; - candidate = first_day_of_quarter(next_y, next_q)?; + candidate = get_cand_func(next_y, next_q)?; } Ok(candidate) } - DateFreq::QuarterEnd => { - let current_q = month_to_quarter(start_date.month()); - let mut candidate = last_day_of_quarter(start_date.year(), current_q)?; - if candidate < start_date { - let (next_y, next_q) = if current_q == 4 { - (start_date.year().checked_add(1).ok_or("Year overflow")?, 1) - } else { - (start_date.year(), current_q + 1) - }; - candidate = last_day_of_quarter(next_y, next_q)?; - } - Ok(candidate) - } - DateFreq::YearStart => { - let mut candidate = first_day_of_year(start_date.year())?; + + DateFreq::YearStart | DateFreq::YearEnd => { + let get_cand_func = get_first_date_helper(freq); + let mut candidate = get_cand_func(start_date.year(), 0)?; if candidate < start_date { candidate = - first_day_of_year(start_date.year().checked_add(1).ok_or("Year overflow")?)?; - } - Ok(candidate) - } - DateFreq::YearEnd => { - let mut candidate = last_day_of_year(start_date.year())?; - if candidate < start_date { - candidate = - last_day_of_year(start_date.year().checked_add(1).ok_or("Year overflow")?)?; + get_cand_func(start_date.year().checked_add(1).ok_or("Year overflow")?, 0)?; } Ok(candidate) } @@ -954,7 +964,7 @@ fn find_first_date_on_or_after( /// Finds the *next* valid date according to the frequency, /// given the `current_date` (which is assumed to be a valid date previously generated). -fn find_next_date(current_date: NaiveDate, freq: DateFreq) -> Result> { +pub fn find_next_date(current_date: NaiveDate, freq: DateFreq) -> Result> { match freq { DateFreq::Daily => current_date .succ_opt() @@ -962,7 +972,8 @@ fn find_next_date(current_date: NaiveDate, freq: DateFreq) -> Result current_date .checked_add_signed(Duration::days(7)) .ok_or_else(|| "Date overflow adding 7 days".into()), - DateFreq::MonthStart => { + DateFreq::MonthStart | DateFreq::MonthEnd => { + let get_cand_func = get_first_date_helper(freq); let (next_y, next_m) = if current_date.month() == 12 { ( current_date.year().checked_add(1).ok_or("Year overflow")?, @@ -971,21 +982,11 @@ fn find_next_date(current_date: NaiveDate, freq: DateFreq) -> Result { - let (next_y, next_m) = if current_date.month() == 12 { - ( - current_date.year().checked_add(1).ok_or("Year overflow")?, - 1, - ) - } else { - (current_date.year(), current_date.month() + 1) - }; - last_day_of_month(next_y, next_m) - } - DateFreq::QuarterStart => { + DateFreq::QuarterStart | DateFreq::QuarterEnd => { let current_q = month_to_quarter(current_date.month()); + let get_cand_func = get_first_date_helper(freq); let (next_y, next_q) = if current_q == 4 { ( current_date.year().checked_add(1).ok_or("Year overflow")?, @@ -994,25 +995,14 @@ fn find_next_date(current_date: NaiveDate, freq: DateFreq) -> Result { - let current_q = month_to_quarter(current_date.month()); - let (next_y, next_q) = if current_q == 4 { - ( - current_date.year().checked_add(1).ok_or("Year overflow")?, - 1, - ) - } else { - (current_date.year(), current_q + 1) - }; - last_day_of_quarter(next_y, next_q) - } - DateFreq::YearStart => { - first_day_of_year(current_date.year().checked_add(1).ok_or("Year overflow")?) - } - DateFreq::YearEnd => { - last_day_of_year(current_date.year().checked_add(1).ok_or("Year overflow")?) + DateFreq::YearStart | DateFreq::YearEnd => { + let get_cand_func = get_first_date_helper(freq); + get_cand_func( + current_date.year().checked_add(1).ok_or("Year overflow")?, + 0, + ) } } } diff --git a/src/utils/dateutils/mod.rs b/src/utils/dateutils/mod.rs new file mode 100644 index 0000000..d95aef8 --- /dev/null +++ b/src/utils/dateutils/mod.rs @@ -0,0 +1,8 @@ +pub mod bdates; +// pub use bdates::{BDateFreq, BDatesList, BDatesGenerator}; + +pub mod dates; +// pub use dates::{DateFreq, DatesList, DatesGenerator}; +// pub mod base; +// pub use base::{BDatesGenerator, BDatesList}; +// pub use base::{DateFreq, DatesGenerator, DatesList}; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 384c2ca..f759f8e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,5 @@ -pub mod bdates; -pub use bdates::{BDateFreq, BDatesList, BDatesGenerator}; - -pub mod dates; -pub use dates::{DateFreq, DatesList, DatesGenerator}; - +pub mod dateutils; +pub use dateutils::bdates::{BDatesGenerator, BDatesList}; +pub use dateutils::dates::{DateFreq, DatesGenerator, DatesList}; +// pub use dateutils::{BDatesGenerator, BDatesList}; +// pub use dateutils::{DateFreq, DatesGenerator, DatesList};