diff --git a/src/lib.rs b/src/lib.rs index 694ffdc..6c9faf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod matrix; -pub mod frame; \ No newline at end of file +pub mod frame; + +pub mod utils; \ No newline at end of file diff --git a/src/utils/bdates.rs b/src/utils/bdates.rs new file mode 100644 index 0000000..da31d3e --- /dev/null +++ b/src/utils/bdates.rs @@ -0,0 +1,1450 @@ +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use std::collections::HashMap; +use std::error::Error; +use std::hash::Hash; +use std::result::Result; + +/// 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, // Indicates picking the first valid business date in a group's period. + End, // Indicates picking the last valid business day in a group's 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> { + Self::from_str(&freq) + } + + /// 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. + pub 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, // Added Y, YS, A, AS aliases + "ME" => BDateFreq::MonthEnd, + "QE" => BDateFreq::QuarterEnd, + "WF" => BDateFreq::WeeklyFriday, + "YE" | "AE" => BDateFreq::YearEnd, // Added AE alias + _ => return Err(format!("Invalid frequency specified: {}", freq).into()), + }; + Ok(r) + } + + /// 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", // Changed to "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, + } + } +} + +/// 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, +} + +// Helper enum to represent the key for grouping dates into periods. +// Deriving traits for comparison and hashing allows using it as a HashMap key +// and for sorting groups chronologically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum GroupKey { + Daily(NaiveDate), // Group by the specific date (for Daily frequency) + Weekly(i32, u32), // Group by year and ISO week number + Monthly(i32, u32), // Group by year and month (1-12) + Quarterly(i32, u32), // Group by year and quarter (1-4) + Yearly(i32), // Group by year +} + +impl BDatesList { + /// Creates a new `BDatesList` instance. + /// + /// # 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, + } + } + + /// 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> { + // Delegate the core logic to the internal 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 (as it + /// calls `list` internally). + pub fn count(&self) -> Result> { + // Get the list and return its length. Uses map to handle the Result elegantly. + 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 by period chronologically, and the + /// inner lists (dates within groups) are also sorted chronologically. + /// + /// For `Daily` frequency, each date forms its own group. For `Weekly` + /// frequencies, grouping is by ISO week number. For `Monthly`, `Quarterly`, + /// and `Yearly` frequencies, grouping is by the respective period. + /// + /// # Errors + /// + /// Returns an error if the start or end date strings cannot be parsed. + pub fn groups(&self) -> Result>, Box> { + // Get the sorted list of all dates first. This sorted order is crucial + // for ensuring the inner vectors (dates within groups) are also sorted + // as we insert into the HashMap. + let dates = self.list()?; + + // Use a HashMap to collect dates into their respective groups. + let mut groups: HashMap> = HashMap::new(); + + for date in dates { + // Determine the grouping key based on frequency. + let key = match self.freq { + BDateFreq::Daily => GroupKey::Daily(date), + BDateFreq::WeeklyMonday | BDateFreq::WeeklyFriday => { + // Use ISO week number for consistent weekly grouping across year boundaries + 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()), + }; + + // Add the current date to the vector corresponding to the determined key. + // entry().or_insert_with() gets a mutable reference to the vector for the key, + // inserting a new empty vector if the key doesn't exist yet. + groups.entry(key).or_insert_with(Vec::new).push(date); // Using or_insert_with is slightly more idiomatic + } + + // Convert the HashMap into a vector of (key, vector_of_dates) tuples. + let mut sorted_groups: Vec<(GroupKey, Vec)> = groups.into_iter().collect(); + + // Sort the vector of groups by the `GroupKey`. Since `GroupKey` derives `Ord`, + // this sorts the groups chronologically (Yearly < Quarterly < Monthly < Weekly < Daily, + // then by year, quarter, month, week, or date within each category). + sorted_groups.sort_by(|(k1, _), (k2, _)| k1.cmp(k2)); + + // The dates *within* each group (`Vec`) are already sorted + // because they were pushed in the order they appeared in the initially + // sorted `dates` vector obtained from `self.list()`. + // If the source `dates` wasn't guaranteed sorted, or for clarity, + // an inner sort could be added here: + // for (_, dates_in_group) in sorted_groups.iter_mut() { + // dates_in_group.sort(); + // } + + // Extract just the vectors of dates from the sorted tuples, discarding the keys. + let result_groups = sorted_groups.into_iter().map(|(_, dates)| dates).collect(); + + Ok(result_groups) + } + + /// Returns the start date parsed as a `NaiveDate`. + /// + /// # Errors + /// + /// Returns a `chrono::ParseError` 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 a `chrono::ParseError` 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() + } +} + +// --- Internal helper functions (not part of the public API) --- + +/// 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 the start and end dates, returning error if parsing fails. + 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")?; + + // Handle edge case where end date is before start date. + if start_date > end_date { + return Ok(Vec::new()); + } + + // Collect dates based on the specified 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, /*want_first_day=*/ true) + } + BDateFreq::MonthEnd => { + collect_monthly(start_date, end_date, /*want_first_day=*/ false) + } + BDateFreq::QuarterStart => { + collect_quarterly(start_date, end_date, /*want_first_day=*/ true) + } + BDateFreq::QuarterEnd => { + collect_quarterly(start_date, end_date, /*want_first_day=*/ false) + } + BDateFreq::YearStart => collect_yearly(start_date, end_date, /*want_first_day=*/ true), + BDateFreq::YearEnd => collect_yearly(start_date, end_date, /*want_first_day=*/ false), + }; + + // Filter out any weekend days. While the core logic aims for business days, + // this ensures robustness against edge cases where computed dates might fall + // on a weekend (e.g., first day of month being Saturday). + dates.retain(|d| is_weekday(*d)); + + // Ensure the final list is sorted. The `collect_*` functions generally + // produce sorted output, but an explicit sort guarantees it. + dates.sort(); + + Ok(dates) +} + +/* ---------------------- Low-Level Date Collection Functions (Internal) ---------------------- */ + +/// Returns all business days (Mon-Fri) day-by-day within the range. +fn collect_daily(start_date: NaiveDate, end_date: NaiveDate) -> Vec { + let mut result = Vec::new(); + let mut current = start_date; + while current <= end_date { + if is_weekday(current) { + result.push(current); + } + // Use succ_opt() and unwrap(), assuming valid date range and no overflow + current = current.succ_opt().unwrap(); + } + result +} + +/// Returns the specified `target_weekday` in each week within the range. +fn collect_weekly( + start_date: NaiveDate, + end_date: NaiveDate, + target_weekday: Weekday, +) -> Vec { + let mut result = Vec::new(); + + // Find the first target_weekday on or after the start date. + let mut current = move_to_weekday_on_or_after(start_date, target_weekday); + + // Step through the range in 7-day increments. + while current <= end_date { + result.push(current); + // Use checked_add_signed for safety, though basic addition is likely fine for 7 days. + current = current + .checked_add_signed(Duration::days(7)) + .expect("date overflow"); + } + result +} + +/// Returns either the first or last business day in each month of 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(); + + // Helper closure to advance year and month by one month. + let next_month = + |(yr, mo): (i32, u32)| -> (i32, u32) { if mo == 12 { (yr + 1, 1) } else { (yr, mo + 1) } }; + + // Iterate month by month from the start date's month up to or past the end date's month. + loop { + // Compute the candidate date (first or last business day) for the current month. + // Use _opt and unwrap(), expecting valid month/year combinations within realistic ranges. + let candidate = if want_first_day { + first_business_day_of_month(year, month) + } else { + last_business_day_of_month(year, month) + }; + + // 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 { + result.push(candidate); + } + // Note: We don't break if candidate < start_date because a later month's candidate + // might be within the range. + + // Advance to the next month. + let (ny, nm) = next_month((year, month)); + year = ny; + month = nm; + + // Optimization: Stop if we have moved clearly past the end date's year. + // If the year matches, we need to check the month. + if year > end_date.year() { + break; + } + if year == end_date.year() && month > end_date.month() { + 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 unwrap(), 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 { + result.push(candidate); + } + // Note: We don't break if candidate < start_date because a later quarter + // might be within the range. + + // Advance to the next quarter. + if q == 4 { + year += 1; + q = 1; + } else { + q += 1; + } + } + + 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 unwrap(), 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 { + result.push(candidate); + } else if candidate > end_date { + // Optimization: If the candidate for the current year is already past end_date, + // then candidates for all subsequent years will also be past end_date. + 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 unwrap(), assuming valid date and no overflow + current = current.succ_opt().unwrap(); + } + 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 unwrap(), 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 unwrap(), assuming valid date and no overflow. + d = d.succ_opt().unwrap(); + } + d +} + +/// Return the latest business day of the given (year, month). +fn last_business_day_of_month(year: i32, month: u32) -> NaiveDate { + let last_dom = days_in_month(year, month); + // Use _opt and unwrap(), assuming valid Y/M/D combination. + let mut d = + NaiveDate::from_ymd_opt(year, month, last_dom).expect("invalid year-month-day combination"); + // If it’s Sat/Sun, move backward until we find a weekday. + while !is_weekday(d) { + // Use pred_opt() and unwrap(), assuming valid date and no underflow. + d = d.pred_opt().unwrap(); + } + d +} + +/// Returns the number of days in a given month and year. +fn days_in_month(year: i32, month: u32) -> u32 { + // A common trick: find the first day of the *next* month, then subtract one day. + // This correctly handles leap years. + let (ny, nm) = if month == 12 { + (year + 1, 1) + } else { + (year, month + 1) + }; + // Use _opt and unwrap(), assuming valid Y/M combination (start of next month). + let first_of_next = NaiveDate::from_ymd_opt(ny, nm, 1).expect("invalid next year-month"); + // Use pred_opt() and unwrap(), assuming valid date and no underflow (first of month - 1). + let last_of_this = first_of_next + .pred_opt() + .expect("invalid date before first of month"); + last_of_this.day() +} + +/// Converts a month number (1-12) to a quarter number (1-4). +fn month_to_quarter(m: u32) -> u32 { + (m - 1) / 3 + 1 // Simple integer division for mapping +} + +/// Returns the 1st day of the month that starts a given (year, quarter). +fn quarter_to_first_date(year: i32, quarter: u32) -> NaiveDate { + let month = match quarter { + 1 => 1, + 2 => 4, + 3 => 7, + 4 => 10, + _ => panic!("invalid quarter: {}", quarter), // This function expects quarter 1-4 + }; + // Use _opt and unwrap(), assuming valid Y/M/D combination (first day of quarter month). + NaiveDate::from_ymd_opt(year, month, 1).expect("invalid year/month derived from quarter") +} + +/// Return the earliest business day in the given (year, quarter). +fn first_business_day_of_quarter(year: i32, quarter: u32) -> NaiveDate { + let mut d = quarter_to_first_date(year, quarter); + // If the first day is a weekend, move forward to the next weekday. + while !is_weekday(d) { + // Use succ_opt() and unwrap(), assuming valid date and no overflow. + d = d.succ_opt().unwrap(); + } + d +} + +/// Return the last business day in the given (year, quarter). +fn last_business_day_of_quarter(year: i32, quarter: u32) -> NaiveDate { + // The last month of a quarter is the start month + 2. + let start = quarter_to_first_date(year, quarter); + let last_month_in_quarter = start.month() + 2; + last_business_day_of_month(year, last_month_in_quarter) +} + +/// Returns the earliest business day (Mon-Fri) of the given year. +fn first_business_day_of_year(year: i32) -> NaiveDate { + // Start with Jan 1st. Use _opt and unwrap(), assuming valid Y/M/D combination. + let mut d = NaiveDate::from_ymd_opt(year, 1, 1).expect("invalid year for Jan 1st"); + // If Jan 1st is a weekend, move forward to the next weekday. + while !is_weekday(d) { + // Use succ_opt() and unwrap(), assuming valid date and no overflow. + d = d.succ_opt().unwrap(); + } + d +} + +/// Returns the last business day (Mon-Fri) of the given year. +fn last_business_day_of_year(year: i32) -> NaiveDate { + // Start with Dec 31st. Use _opt and unwrap(), assuming valid Y/M/D combination. + let mut d = NaiveDate::from_ymd_opt(year, 12, 31).expect("invalid year for Dec 31st"); + // If Dec 31st is a weekend, move backward to the previous weekday. + while !is_weekday(d) { + // Use pred_opt() and unwrap(), assuming valid date and no underflow. + d = d.pred_opt().unwrap(); + } + d +} + +// --- Example Usage and Tests --- + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + // Helper to create a NaiveDate for tests, handling the unwrap for fixed dates. + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + // --- BDateFreq Tests --- + + #[test] + fn test_bdatefreq_from_str() -> Result<(), Box> { + assert_eq!(BDateFreq::from_str("D")?, BDateFreq::Daily); + 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!(BDateFreq::from_str("ME")?, BDateFreq::MonthEnd); + assert_eq!(BDateFreq::from_str("QE")?, BDateFreq::QuarterEnd); + assert_eq!(BDateFreq::from_str("WF")?, BDateFreq::WeeklyFriday); + + // 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()); + 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() -> 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_invalid_date_string() { + 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 + + // 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] + /// Tests `quarter_to_first_date` for all quarters. + fn test_quarter_to_first_date() { + assert_eq!(quarter_to_first_date(2023, 1), date(2023, 1, 1)); + assert_eq!(quarter_to_first_date(2023, 2), date(2023, 4, 1)); + assert_eq!(quarter_to_first_date(2023, 3), date(2023, 7, 1)); + assert_eq!(quarter_to_first_date(2023, 4), date(2023, 10, 1)); + // Panics on invalid quarter + let result = std::panic::catch_unwind(|| quarter_to_first_date(2023, 5)); + assert!(result.is_err()); + } + + #[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) -> 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 31st) > 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 `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) -> 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) -> Included + 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)] + ); + } +} diff --git a/src/utils/dates.rs b/src/utils/dates.rs new file mode 100644 index 0000000..a104a5b --- /dev/null +++ b/src/utils/dates.rs @@ -0,0 +1,1258 @@ +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use std::collections::HashMap; +use std::error::Error; +use std::hash::Hash; +use std::result::Result; + +// --- Enums --- + +/// Represents the frequency at which calendar dates should be generated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DateFreq { + Daily, // Every single day + WeeklyMonday, // The Monday of each week + WeeklyFriday, // The Friday of each week + MonthStart, // The 1st day of each month + MonthEnd, // The actual last day of each month + QuarterStart, // The 1st day of each quarter (Jan 1, Apr 1, Jul 1, Oct 1) + QuarterEnd, // The actual last day of each quarter (Mar 31, Jun 30, Sep 30, Dec 31) + YearStart, // January 1st of each year + YearEnd, // December 31st of each year +} + +/// 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, // Indicates picking the first date in a group's period. + End, // Indicates picking the last date in a group's period. +} + +// Helper enum for grouping dates (Internal) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum GroupKey { + Daily(NaiveDate), // Group by the specific date (for Daily frequency) + Weekly(i32, u32), // Group by year and ISO week number + Monthly(i32, u32), // Group by year and month (1-12) + Quarterly(i32, u32), // Group by year and quarter (1-4) + Yearly(i32), // Group by year +} + +// --- DateFreq Implementation --- + +impl DateFreq { + /// Attempts to parse a frequency string into a `DateFreq` enum. + /// Supports various frequency codes and common aliases. + /// + /// | Code | Alias | Description | + /// |------|---------|-------------------------| + /// | D | | Daily | + /// | W | WS | Weekly Monday | + /// | WF | | Weekly Friday | + /// | M | MS | Month Start (1st) | + /// | ME | | Month End (actual last) | + /// | Q | QS | Quarter Start (1st) | + /// | QE | | Quarter End (actual last)| + /// | Y | A, AS, YS | Year Start (Jan 1st) | + /// | YE | AE | Year End (Dec 31st) | + /// + /// # Arguments + /// + /// * `freq` - The frequency string slice (e.g., "D", "W", "ME"). + /// + /// # Errors + /// + /// Returns an error if the string does not match any known frequency. + pub fn from_str(freq: &str) -> Result> { + let r = match freq { + "D" => DateFreq::Daily, + "W" | "WS" => DateFreq::WeeklyMonday, + "WF" => DateFreq::WeeklyFriday, + "M" | "MS" => DateFreq::MonthStart, + "ME" => DateFreq::MonthEnd, + "Q" | "QS" => DateFreq::QuarterStart, + "QE" => DateFreq::QuarterEnd, + "Y" | "A" | "AS" | "YS" => DateFreq::YearStart, + "YE" | "AE" => DateFreq::YearEnd, + _ => return Err(format!("Invalid frequency specified: {}", freq).into()), + }; + Ok(r) + } + + /// Attempts to parse a frequency string into a `DateFreq` enum. + /// Convenience wrapper around `from_str`. + pub fn from_string(freq: String) -> Result> { + Self::from_str(&freq) + } + + /// Returns the canonical string representation of the frequency. + pub fn to_string(&self) -> String { + let r = match self { + DateFreq::Daily => "D", + DateFreq::WeeklyMonday => "W", + DateFreq::WeeklyFriday => "WF", + DateFreq::MonthStart => "M", + DateFreq::MonthEnd => "ME", + DateFreq::QuarterStart => "Q", + DateFreq::QuarterEnd => "QE", + DateFreq::YearStart => "Y", + DateFreq::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 { + DateFreq::Daily + | DateFreq::WeeklyMonday + | DateFreq::MonthStart + | DateFreq::QuarterStart + | DateFreq::YearStart => AggregationType::Start, + + DateFreq::WeeklyFriday + | DateFreq::MonthEnd + | DateFreq::QuarterEnd + | DateFreq::YearEnd => AggregationType::End, + } + } +} + +// --- DatesList Struct --- + +/// Represents a list of calendar dates generated between a start and end date +/// at a specified frequency. Includes all days (weekends, etc.). +/// Provides methods to retrieve the full list, count, or dates grouped by period. +#[derive(Debug, Clone)] +pub struct DatesList { + start_date_str: String, + end_date_str: String, + freq: DateFreq, +} + +// --- DatesList Implementation --- + +impl DatesList { + /// Creates a new `DatesList` instance. + /// + /// # 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 (`DateFreq`) for generating dates. + pub fn new(start_date_str: String, end_date_str: String, freq: DateFreq) -> Self { + DatesList { + start_date_str, + end_date_str, + freq, + } + } + + /// Returns the flat list of calendar 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> { + get_dates_list_with_freq(&self.start_date_str, &self.end_date_str, self.freq) + } + + /// Returns the count of calendar 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> { + self.list().map(|list| list.len()) + } + + /// Returns a list of date lists, where each inner list contains the dates + /// generated by `self.list()` that belong to the same period (determined by `self.freq`). + /// + /// The outer list (groups) is sorted by period chronologically, and the + /// inner lists (dates within groups) are also sorted chronologically. + /// + /// - For `Daily` frequency, each date forms its own group. + /// - For `Weekly` frequencies, grouping is by ISO week number/year. + /// - For `Monthly` frequencies, grouping is by month/year. + /// - For `Quarterly` frequencies, grouping is by quarter/year. + /// - For `Yearly` frequencies, grouping is by year. + /// + /// # Errors + /// + /// Returns an error if the start or end date strings cannot be parsed. + pub fn groups(&self) -> Result>, Box> { + let dates = self.list()?; + if dates.is_empty() { + return Ok(Vec::new()); + } + + let mut groups: HashMap> = HashMap::new(); + + for date in dates { + 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(), month_to_quarter(date.month())) + } + DateFreq::YearStart | DateFreq::YearEnd => GroupKey::Yearly(date.year()), + }; + groups.entry(key).or_insert_with(Vec::new).push(date); + } + + let mut sorted_groups: Vec<(GroupKey, Vec)> = groups.into_iter().collect(); + sorted_groups.sort_by_key(|(k, _)| *k); + + let result_groups = sorted_groups.into_iter().map(|(_, dates)| dates).collect(); + Ok(result_groups) + } + + /// Returns the start date parsed as a `NaiveDate`. + 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`. + 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 (`DateFreq`). + 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() + } +} + +// --- Internal Helper Functions --- + +/// Generates the flat list of calendar dates for the given range and frequency. +fn get_dates_list_with_freq( + start_date_str: &str, + end_date_str: &str, + freq: DateFreq, +) -> Result, Box> { + 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")?; + + if start_date > end_date { + return Ok(Vec::new()); + } + + let mut dates = match freq { + DateFreq::Daily => collect_calendar_daily(start_date, end_date), + DateFreq::WeeklyMonday => collect_calendar_weekly(start_date, end_date, Weekday::Mon), + DateFreq::WeeklyFriday => collect_calendar_weekly(start_date, end_date, Weekday::Fri), + DateFreq::MonthStart => collect_calendar_monthly(start_date, end_date, true), + DateFreq::MonthEnd => collect_calendar_monthly(start_date, end_date, false), + DateFreq::QuarterStart => collect_calendar_quarterly(start_date, end_date, true), + DateFreq::QuarterEnd => collect_calendar_quarterly(start_date, end_date, false), + DateFreq::YearStart => collect_calendar_yearly(start_date, end_date, true), + DateFreq::YearEnd => collect_calendar_yearly(start_date, end_date, false), + }; + + // Ensure the final list is sorted (most collectors produce sorted, but good practice). + dates.sort_unstable(); // Slightly faster for non-pathological cases + + Ok(dates) +} + +// --- Internal Date Collection Logic --- + +fn collect_calendar_daily(start_date: NaiveDate, end_date: NaiveDate) -> Vec { + let mut result = Vec::new(); + let mut current = start_date; + while current <= end_date { + result.push(current); + match current.succ_opt() { + Some(next_day) => current = next_day, + None => break, // Avoid panic on date overflow near max date + } + } + result +} + +fn collect_calendar_weekly( + start_date: NaiveDate, + end_date: NaiveDate, + target_weekday: Weekday, +) -> Vec { + let mut result = Vec::new(); + let mut current = move_to_weekday_on_or_after(start_date, target_weekday); + + while current <= end_date { + result.push(current); + match current.checked_add_signed(Duration::days(7)) { + Some(next_week_day) => current = next_week_day, + None => break, // Avoid panic on date overflow + } + } + result +} + +fn collect_calendar_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(); + + let next_month = + |(yr, mo): (i32, u32)| -> (i32, u32) { if mo == 12 { (yr + 1, 1) } else { (yr, mo + 1) } }; + + loop { + let candidate_res = if want_first_day { + NaiveDate::from_ymd_opt(year, month, 1) + } else { + days_in_month(year, month).and_then(|day| NaiveDate::from_ymd_opt(year, month, day)) + }; + + let candidate = match candidate_res { + Some(date) => date, + None => { + // Should not happen with valid year/month logic, but break defensively + eprintln!("Warning: Invalid date generated for {}-{}", year, month); + break; + } + }; + + if candidate > end_date { + break; + } + if candidate >= start_date { + result.push(candidate); + } + + let (ny, nm) = next_month((year, month)); + year = ny; + month = nm; + + if year > end_date.year() { + break; + } + if year == end_date.year() && month > end_date.month() { + break; + } + } + result +} + +fn collect_calendar_quarterly( + start_date: NaiveDate, + end_date: NaiveDate, + want_first_day: bool, +) -> Vec { + let mut result = Vec::new(); + let mut year = start_date.year(); + let mut q = month_to_quarter(start_date.month()); + + loop { + let candidate = match if want_first_day { + first_day_of_quarter(year, q) + } else { + last_day_of_quarter(year, q) + } { + Ok(date) => date, + Err(_) => { + // Handle potential panic from helpers if q is invalid + eprintln!( + "Warning: Invalid date generated for quarter {}-Q{}", + year, q + ); + break; + } + }; + + if candidate > end_date { + break; + } + if candidate >= start_date { + result.push(candidate); + } + + if q == 4 { + year += 1; + q = 1; + } else { + q += 1; + } + + // Check if the *start* of the *next* quarter is already past the end date + // to potentially break earlier. + match first_day_of_quarter(year, q) { + Ok(next_q_start) => { + if next_q_start > end_date && want_first_day { + break; + } // If we want start, no need to check further + if next_q_start > end_date && !want_first_day && candidate < start_date { + break; + } // If we want end and haven't found one yet, no need to check further + } + Err(_) => break, // Invalid next quarter, stop + } + + // Basic loop guard + if year > end_date.year() + 2 { + eprintln!("Warning: Quarter loop seems excessive, breaking."); + break; + } + } + result +} + +fn collect_calendar_yearly( + start_date: NaiveDate, + end_date: NaiveDate, + want_first_day: bool, +) -> Vec { + let mut result = Vec::new(); + let mut year = start_date.year(); + + while year <= end_date.year() { + let candidate_res = if want_first_day { + NaiveDate::from_ymd_opt(year, 1, 1) + } else { + NaiveDate::from_ymd_opt(year, 12, 31) + }; + + let candidate = match candidate_res { + Some(date) => date, + None => { + // Should only happen near chrono::MINYEAR/MAXYEAR + eprintln!("Warning: Invalid date generated for year {}", year); + year += 1; // Try next year + continue; + } + }; + + if candidate > end_date { + break; + } // Candidate past range end + if candidate >= start_date { + result.push(candidate); + } + + year += 1; + } + result +} + +// --- Internal Date Utility Functions --- + +/// 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 current_weekday_num = date.weekday().num_days_from_monday(); + let target_weekday_num = target.num_days_from_monday(); + let days_forward = (target_weekday_num + 7 - current_weekday_num) % 7; + // Use checked_add for safety near date limits, though expect is common practice here + date.checked_add_signed(Duration::days(days_forward as i64)) + .expect("Date calculation overflow near MAX/MIN date") +} + +/// Returns the number of days in a given month and year. Returns None if month is invalid. +fn days_in_month(year: i32, month: u32) -> Option { + if !(1..=12).contains(&month) { + return None; // Explicitly handle invalid months + } + let (ny, nm) = if month == 12 { + (year.checked_add(1)?, 1) + } else { + (year, month + 1) + }; + let first_of_next = NaiveDate::from_ymd_opt(ny, nm, 1)?; + let last_of_this = first_of_next.pred_opt()?; + Some(last_of_this.day()) +} + +/// Converts a month number (1-12) to a quarter number (1-4). +/// Panics if month is invalid. +fn month_to_quarter(m: u32) -> u32 { + assert!((1..=12).contains(&m), "Invalid month: {}", m); + (m - 1) / 3 + 1 +} + +/// Returns the 1st day of the month that starts a given (year, quarter). +/// Returns Err if quarter is invalid or date calculation fails. +fn first_day_of_quarter(year: i32, quarter: u32) -> Result { + let month = match quarter { + 1 => 1, + 2 => 4, + 3 => 7, + 4 => 10, + _ => return Err("Invalid quarter"), + }; + NaiveDate::from_ymd_opt(year, month, 1).ok_or("Invalid date from quarter") +} + +/// Returns the *actual* last calendar day in the given (year, quarter). +/// Returns Err if quarter is invalid or date calculation fails. +fn last_day_of_quarter(year: i32, quarter: u32) -> Result { + let last_month_in_quarter = match quarter { + 1 => 3, + 2 => 6, + 3 => 9, + 4 => 12, + _ => return Err("Invalid quarter"), + }; + let last_day = + days_in_month(year, last_month_in_quarter).ok_or("Invalid month for quarter end")?; + NaiveDate::from_ymd_opt(year, last_month_in_quarter, last_day) + .ok_or("Invalid date for quarter end") +} + +// --- Unit Tests --- + +#[cfg(test)] +mod tests { + use super::*; // Import everything from the parent module + use chrono::NaiveDate; + + // Helper function to create NaiveDate instances easily in tests + fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + // --- Tests for DateFreq --- + + #[test] + fn test_date_freq_from_str_valid() { + assert_eq!(DateFreq::from_str("D").unwrap(), DateFreq::Daily); + assert_eq!(DateFreq::from_str("W").unwrap(), DateFreq::WeeklyMonday); + assert_eq!(DateFreq::from_str("WS").unwrap(), DateFreq::WeeklyMonday); + assert_eq!(DateFreq::from_str("WF").unwrap(), DateFreq::WeeklyFriday); + assert_eq!(DateFreq::from_str("M").unwrap(), DateFreq::MonthStart); + assert_eq!(DateFreq::from_str("MS").unwrap(), DateFreq::MonthStart); + assert_eq!(DateFreq::from_str("ME").unwrap(), DateFreq::MonthEnd); + assert_eq!(DateFreq::from_str("Q").unwrap(), DateFreq::QuarterStart); + assert_eq!(DateFreq::from_str("QS").unwrap(), DateFreq::QuarterStart); + assert_eq!(DateFreq::from_str("QE").unwrap(), DateFreq::QuarterEnd); + assert_eq!(DateFreq::from_str("Y").unwrap(), DateFreq::YearStart); + assert_eq!(DateFreq::from_str("A").unwrap(), DateFreq::YearStart); + assert_eq!(DateFreq::from_str("AS").unwrap(), DateFreq::YearStart); + assert_eq!(DateFreq::from_str("YS").unwrap(), DateFreq::YearStart); + assert_eq!(DateFreq::from_str("YE").unwrap(), DateFreq::YearEnd); + assert_eq!(DateFreq::from_str("AE").unwrap(), DateFreq::YearEnd); + } + + #[test] + fn test_date_freq_from_string_valid() { + // Test the wrapper function + assert_eq!( + DateFreq::from_string("D".to_string()).unwrap(), + DateFreq::Daily + ); + assert_eq!( + DateFreq::from_string("YE".to_string()).unwrap(), + DateFreq::YearEnd + ); + } + + #[test] + fn test_date_freq_from_str_invalid() { + assert!(DateFreq::from_str("X").is_err()); + assert!(DateFreq::from_str(" Monthly").is_err()); + assert!(DateFreq::from_str("").is_err()); + } + + #[test] + fn test_date_freq_from_string_invalid() { + assert!(DateFreq::from_string("Invalid Freq".to_string()).is_err()); + } + + #[test] + fn test_date_freq_to_string() { + assert_eq!(DateFreq::Daily.to_string(), "D"); + assert_eq!(DateFreq::WeeklyMonday.to_string(), "W"); + assert_eq!(DateFreq::WeeklyFriday.to_string(), "WF"); + assert_eq!(DateFreq::MonthStart.to_string(), "M"); + assert_eq!(DateFreq::MonthEnd.to_string(), "ME"); + assert_eq!(DateFreq::QuarterStart.to_string(), "Q"); + assert_eq!(DateFreq::QuarterEnd.to_string(), "QE"); + assert_eq!(DateFreq::YearStart.to_string(), "Y"); + assert_eq!(DateFreq::YearEnd.to_string(), "YE"); + } + + #[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); + } + + // --- Tests for DatesList Accessors --- + + #[test] + fn test_dates_list_accessors() { + let start = "2024-01-15"; + let end = "2024-02-20"; + let freq = DateFreq::WeeklyMonday; + let dl = DatesList::new(start.to_string(), end.to_string(), freq); + + assert_eq!(dl.start_date_str(), start); + assert_eq!(dl.end_date_str(), end); + assert_eq!(dl.start_date().unwrap(), d(2024, 1, 15)); + assert_eq!(dl.end_date().unwrap(), d(2024, 2, 20)); + assert_eq!(dl.freq(), freq); + assert_eq!(dl.freq_str(), "W"); // Canonical string for WeeklyMonday + } + + #[test] + fn test_dates_list_invalid_dates() { + let dl_bad_start = DatesList::new( + "2024-13-01".to_string(), + "2024-01-10".to_string(), + DateFreq::Daily, + ); + assert!(dl_bad_start.start_date().is_err()); + assert!(dl_bad_start.list().is_err()); // list() should propagate parse error + assert!(dl_bad_start.count().is_err()); + assert!(dl_bad_start.groups().is_err()); + + let dl_bad_end = DatesList::new( + "2024-01-01".to_string(), + "invalid-date".to_string(), + DateFreq::Daily, + ); + assert!(dl_bad_end.end_date().is_err()); + assert!(dl_bad_end.list().is_err()); + assert!(dl_bad_end.count().is_err()); + assert!(dl_bad_end.groups().is_err()); + } + + // --- Tests for DatesList::list() and DatesList::count() --- + + #[test] + fn test_dates_list_empty_range() { + let dl = DatesList::new( + "2024-01-10".to_string(), + "2024-01-09".to_string(), + DateFreq::Daily, + ); + assert_eq!(dl.list().unwrap(), Vec::new()); + assert_eq!(dl.count().unwrap(), 0); + assert_eq!(dl.groups().unwrap(), Vec::>::new()); // Ensure groups also handles empty + } + + #[test] + fn test_dates_list_single_day_range() { + let dl = DatesList::new( + "2024-01-10".to_string(), + "2024-01-10".to_string(), + DateFreq::Daily, + ); + assert_eq!(dl.list().unwrap(), vec![d(2024, 1, 10)]); + assert_eq!(dl.count().unwrap(), 1); + } + + #[test] + fn test_dates_list_daily() { + let dl = DatesList::new( + "2024-03-29".to_string(), + "2024-04-02".to_string(), + DateFreq::Daily, + ); + let expected = vec![ + d(2024, 3, 29), // Fri + d(2024, 3, 30), // Sat + d(2024, 3, 31), // Sun + d(2024, 4, 1), // Mon + d(2024, 4, 2), // Tue + ]; + assert_eq!(dl.list().unwrap(), expected); + assert_eq!(dl.count().unwrap(), 5); + } + + #[test] + fn test_dates_list_weekly_monday() { + // Start before first Mon, end after last Mon + let dl1 = DatesList::new( + "2024-01-02".to_string(), + "2024-01-20".to_string(), + DateFreq::WeeklyMonday, + ); + assert_eq!(dl1.list().unwrap(), vec![d(2024, 1, 8), d(2024, 1, 15)]); + assert_eq!(dl1.count().unwrap(), 2); + + // Start on a Mon, end on a Mon + let dl2 = DatesList::new( + "2024-01-08".to_string(), + "2024-01-22".to_string(), + DateFreq::WeeklyMonday, + ); + assert_eq!( + dl2.list().unwrap(), + vec![d(2024, 1, 8), d(2024, 1, 15), d(2024, 1, 22)] + ); + assert_eq!(dl2.count().unwrap(), 3); + + // Across year boundary + let dl3 = DatesList::new( + "2023-12-28".to_string(), + "2024-01-10".to_string(), + DateFreq::WeeklyMonday, + ); + assert_eq!(dl3.list().unwrap(), vec![d(2024, 1, 1), d(2024, 1, 8)]); + assert_eq!(dl3.count().unwrap(), 2); + } + + #[test] + fn test_dates_list_weekly_friday() { + // Start before first Fri, end after last Fri + let dl1 = DatesList::new( + "2024-01-01".to_string(), + "2024-01-20".to_string(), + DateFreq::WeeklyFriday, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2024, 1, 5), d(2024, 1, 12), d(2024, 1, 19)] + ); + assert_eq!(dl1.count().unwrap(), 3); + + // Start on a Fri, end on a Fri + let dl2 = DatesList::new( + "2024-01-12".to_string(), + "2024-01-26".to_string(), + DateFreq::WeeklyFriday, + ); + assert_eq!( + dl2.list().unwrap(), + vec![d(2024, 1, 12), d(2024, 1, 19), d(2024, 1, 26)] + ); + assert_eq!(dl2.count().unwrap(), 3); + + // Across year boundary + let dl3 = DatesList::new( + "2023-12-25".to_string(), + "2024-01-15".to_string(), + DateFreq::WeeklyFriday, + ); + assert_eq!( + dl3.list().unwrap(), + vec![d(2023, 12, 29), d(2024, 1, 5), d(2024, 1, 12)] + ); + assert_eq!(dl3.count().unwrap(), 3); + } + + #[test] + fn test_dates_list_month_start() { + // Basic range + let dl1 = DatesList::new( + "2024-01-15".to_string(), + "2024-04-10".to_string(), + DateFreq::MonthStart, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2024, 2, 1), d(2024, 3, 1), d(2024, 4, 1)] + ); + assert_eq!(dl1.count().unwrap(), 3); + + // Start exactly on MonthStart + let dl2 = DatesList::new( + "2024-02-01".to_string(), + "2024-03-15".to_string(), + DateFreq::MonthStart, + ); + assert_eq!(dl2.list().unwrap(), vec![d(2024, 2, 1), d(2024, 3, 1)]); + assert_eq!(dl2.count().unwrap(), 2); + + // Across year boundary + let dl3 = DatesList::new( + "2023-11-20".to_string(), + "2024-02-10".to_string(), + DateFreq::MonthStart, + ); + assert_eq!( + dl3.list().unwrap(), + vec![d(2023, 12, 1), d(2024, 1, 1), d(2024, 2, 1)] + ); + assert_eq!(dl3.count().unwrap(), 3); + } + + #[test] + fn test_dates_list_month_end() { + // Basic range including leap year + let dl1 = DatesList::new( + "2024-01-15".to_string(), + "2024-04-10".to_string(), + DateFreq::MonthEnd, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2024, 1, 31), d(2024, 2, 29), d(2024, 3, 31)] + ); // Feb 29 leap + assert_eq!(dl1.count().unwrap(), 3); + + // Start exactly on MonthEnd + let dl2 = DatesList::new( + "2023-11-30".to_string(), + "2024-01-15".to_string(), + DateFreq::MonthEnd, + ); + assert_eq!(dl2.list().unwrap(), vec![d(2023, 11, 30), d(2023, 12, 31)]); + assert_eq!(dl2.count().unwrap(), 2); + + // Non-leap year Feb + let dl3 = DatesList::new( + "2023-01-20".to_string(), + "2023-03-10".to_string(), + DateFreq::MonthEnd, + ); + assert_eq!(dl3.list().unwrap(), vec![d(2023, 1, 31), d(2023, 2, 28)]); // Feb 28 non-leap + assert_eq!(dl3.count().unwrap(), 2); + } + + #[test] + fn test_dates_list_quarter_start() { + // Basic range + let dl1 = DatesList::new( + "2024-02-15".to_string(), + "2024-08-10".to_string(), + DateFreq::QuarterStart, + ); + assert_eq!(dl1.list().unwrap(), vec![d(2024, 4, 1), d(2024, 7, 1)]); + assert_eq!(dl1.count().unwrap(), 2); + + // Start exactly on QuarterStart + let dl2 = DatesList::new( + "2024-01-01".to_string(), + "2024-07-01".to_string(), + DateFreq::QuarterStart, + ); + assert_eq!( + dl2.list().unwrap(), + vec![d(2024, 1, 1), d(2024, 4, 1), d(2024, 7, 1)] + ); + assert_eq!(dl2.count().unwrap(), 3); + + // Across year boundary + let dl3 = DatesList::new( + "2023-10-15".to_string(), + "2024-05-10".to_string(), + DateFreq::QuarterStart, + ); + assert_eq!(dl3.list().unwrap(), vec![d(2024, 1, 1), d(2024, 4, 1)]); + assert_eq!(dl3.count().unwrap(), 2); + } + + #[test] + fn test_dates_list_quarter_end() { + // Basic range + let dl1 = DatesList::new( + "2024-02-15".to_string(), + "2024-10-10".to_string(), + DateFreq::QuarterEnd, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2024, 3, 31), d(2024, 6, 30), d(2024, 9, 30)] + ); + assert_eq!(dl1.count().unwrap(), 3); + + // End exactly on QuarterEnd + let dl2 = DatesList::new( + "2024-05-01".to_string(), + "2024-09-30".to_string(), + DateFreq::QuarterEnd, + ); + assert_eq!(dl2.list().unwrap(), vec![d(2024, 6, 30), d(2024, 9, 30)]); + assert_eq!(dl2.count().unwrap(), 2); + + // Across year boundary (includes leap year effect on Mar 31 if applicable) + let dl3 = DatesList::new( + "2023-11-20".to_string(), + "2024-04-10".to_string(), + DateFreq::QuarterEnd, + ); + assert_eq!(dl3.list().unwrap(), vec![d(2023, 12, 31), d(2024, 3, 31)]); + assert_eq!(dl3.count().unwrap(), 2); + } + + #[test] + fn test_dates_list_year_start() { + // Basic range + let dl1 = DatesList::new( + "2023-05-15".to_string(), + "2026-08-10".to_string(), + DateFreq::YearStart, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2024, 1, 1), d(2025, 1, 1), d(2026, 1, 1)] + ); + assert_eq!(dl1.count().unwrap(), 3); + + // Start exactly on YearStart + let dl2 = DatesList::new( + "2024-01-01".to_string(), + "2025-01-01".to_string(), + DateFreq::YearStart, + ); + assert_eq!(dl2.list().unwrap(), vec![d(2024, 1, 1), d(2025, 1, 1)]); + assert_eq!(dl2.count().unwrap(), 2); + + // Short range within a year + let dl3 = DatesList::new( + "2024-02-01".to_string(), + "2024-11-30".to_string(), + DateFreq::YearStart, + ); + assert_eq!(dl3.list().unwrap(), Vec::::new()); // No Jan 1st within this range + assert_eq!(dl3.count().unwrap(), 0); + } + + #[test] + fn test_dates_list_year_end() { + // Basic range + let dl1 = DatesList::new( + "2023-05-15".to_string(), + "2026-08-10".to_string(), + DateFreq::YearEnd, + ); + assert_eq!( + dl1.list().unwrap(), + vec![d(2023, 12, 31), d(2024, 12, 31), d(2025, 12, 31)] + ); + assert_eq!(dl1.count().unwrap(), 3); + + // End exactly on YearEnd + let dl2 = DatesList::new( + "2024-01-01".to_string(), + "2025-12-31".to_string(), + DateFreq::YearEnd, + ); + assert_eq!(dl2.list().unwrap(), vec![d(2024, 12, 31), d(2025, 12, 31)]); + assert_eq!(dl2.count().unwrap(), 2); + + // Short range within a year + let dl3 = DatesList::new( + "2024-02-01".to_string(), + "2024-11-30".to_string(), + DateFreq::YearEnd, + ); + assert_eq!(dl3.list().unwrap(), Vec::::new()); // No Dec 31st within this range + assert_eq!(dl3.count().unwrap(), 0); + } + + // --- Tests for DatesList::groups() --- + + #[test] + fn test_dates_list_groups_daily() { + // Grouping daily just puts each date in its own group + let dl = DatesList::new( + "2024-01-01".to_string(), + "2024-01-03".to_string(), + DateFreq::Daily, + ); + let expected = vec![ + vec![d(2024, 1, 1)], + vec![d(2024, 1, 2)], + vec![d(2024, 1, 3)], + ]; + assert_eq!(dl.groups().unwrap(), expected); + } + + #[test] + fn test_dates_list_groups_weekly() { + // Test grouping by ISO week, crossing month/year + // Dates generated will be Mondays within the range + let dl = DatesList::new( + "2023-12-28".to_string(), + "2024-01-16".to_string(), + DateFreq::WeeklyMonday, + ); + // Dates: 2024-01-01 (Week 1 2024), 2024-01-08 (Week 2 2024), 2024-01-15 (Week 3 2024) + let expected = vec![ + vec![d(2024, 1, 1)], // Group for Week 1 2024 + vec![d(2024, 1, 8)], // Group for Week 2 2024 + vec![d(2024, 1, 15)], // Group for Week 3 2024 + ]; + assert_eq!(dl.groups().unwrap(), expected); + + // Test Weekly Friday grouping + let dl_fri = DatesList::new( + "2024-01-01".to_string(), + "2024-01-12".to_string(), + DateFreq::WeeklyFriday, + ); + // Dates: 2024-01-05 (Week 1), 2024-01-12 (Week 2) + let expected_fri = vec![ + vec![d(2024, 1, 5)], // Group for Week 1 2024 + vec![d(2024, 1, 12)], // Group for Week 2 2024 + ]; + assert_eq!(dl_fri.groups().unwrap(), expected_fri); + } + + #[test] + fn test_dates_list_groups_monthly() { + let dl = DatesList::new( + "2023-11-15".to_string(), + "2024-02-15".to_string(), + DateFreq::MonthStart, + ); + // Dates: 2023-12-01, 2024-01-01, 2024-02-01 + // Groups: (2023, 12), (2024, 1), (2024, 2) + let expected = vec![ + vec![d(2023, 12, 1)], // Group 2023-12 + vec![d(2024, 1, 1)], // Group 2024-01 + vec![d(2024, 2, 1)], // Group 2024-02 + ]; + assert_eq!(dl.groups().unwrap(), expected); + + // Test Month End grouping + let dl_me = DatesList::new( + "2024-01-20".to_string(), + "2024-03-10".to_string(), + DateFreq::MonthEnd, + ); + // Dates: 2024-01-31, 2024-02-29 + let expected_me = vec![ + vec![d(2024, 1, 31)], // Group 2024-01 + vec![d(2024, 2, 29)], // Group 2024-02 + ]; + assert_eq!(dl_me.groups().unwrap(), expected_me); + } + + #[test] + fn test_dates_list_groups_quarterly() { + let dl = DatesList::new( + "2023-08-01".to_string(), + "2024-05-01".to_string(), + DateFreq::QuarterStart, + ); + // Dates: 2023-10-01 (Q4), 2024-01-01 (Q1), 2024-04-01 (Q2) + // Groups: (2023, 4), (2024, 1), (2024, 2) + let expected = vec![ + vec![d(2023, 10, 1)], // Group 2023-Q4 + vec![d(2024, 1, 1)], // Group 2024-Q1 + vec![d(2024, 4, 1)], // Group 2024-Q2 + ]; + assert_eq!(dl.groups().unwrap(), expected); + + // Test Quarter End grouping + let dl_qe = DatesList::new( + "2023-11-01".to_string(), + "2024-04-15".to_string(), + DateFreq::QuarterEnd, + ); + // Dates: 2023-12-31 (Q4), 2024-03-31 (Q1) + let expected_qe = vec![ + vec![d(2023, 12, 31)], // Group 2023-Q4 + vec![d(2024, 3, 31)], // Group 2024-Q1 + ]; + assert_eq!(dl_qe.groups().unwrap(), expected_qe); + } + + #[test] + fn test_dates_list_groups_yearly() { + let dl = DatesList::new( + "2022-05-01".to_string(), + "2024-12-31".to_string(), + DateFreq::YearEnd, + ); + // Dates: 2022-12-31, 2023-12-31, 2024-12-31 + // Groups: (2022), (2023), (2024) + let expected = vec![ + vec![d(2022, 12, 31)], // Group 2022 + vec![d(2023, 12, 31)], // Group 2023 + vec![d(2024, 12, 31)], // Group 2024 + ]; + assert_eq!(dl.groups().unwrap(), expected); + + // Test Year Start grouping + let dl_ys = DatesList::new( + "2023-02-01".to_string(), + "2025-01-01".to_string(), + DateFreq::YearStart, + ); + // Dates: 2024-01-01, 2025-01-01 + let expected_ys = vec![ + vec![d(2024, 1, 1)], // Group 2024 + vec![d(2025, 1, 1)], // Group 2025 + ]; + assert_eq!(dl_ys.groups().unwrap(), expected_ys); + } + + // --- Tests for Utility Functions (Direct Testing) --- + + #[test] + fn test_days_in_month() { + assert_eq!(days_in_month(2024, 1).unwrap(), 31); // Jan + assert_eq!(days_in_month(2024, 2).unwrap(), 29); // Feb Leap + assert_eq!(days_in_month(2023, 2).unwrap(), 28); // Feb Non-Leap + assert_eq!(days_in_month(2024, 4).unwrap(), 30); // Apr + assert_eq!(days_in_month(2024, 12).unwrap(), 31); // Dec + + // Test invalid months + assert!(days_in_month(2024, 0).is_none()); + assert!(days_in_month(2024, 13).is_none()); + } + + // Panic tests for functions with assertions/panics + + #[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] + fn test_first_day_of_quarter() { + assert_eq!(first_day_of_quarter(2024, 1).unwrap(), d(2024, 1, 1)); + assert_eq!(first_day_of_quarter(2024, 2).unwrap(), d(2024, 4, 1)); + assert_eq!(first_day_of_quarter(2024, 3).unwrap(), d(2024, 7, 1)); + assert_eq!(first_day_of_quarter(2024, 4).unwrap(), d(2024, 10, 1)); + + assert!(first_day_of_quarter(2024, 0).is_err()); + assert!(first_day_of_quarter(2024, 5).is_err()); + } + + #[test] + fn test_last_day_of_quarter() { + assert_eq!(last_day_of_quarter(2024, 1).unwrap(), d(2024, 3, 31)); + assert_eq!(last_day_of_quarter(2024, 2).unwrap(), d(2024, 6, 30)); + assert_eq!(last_day_of_quarter(2024, 3).unwrap(), d(2024, 9, 30)); + assert_eq!(last_day_of_quarter(2024, 4).unwrap(), d(2024, 12, 31)); + // Check leap year effect indirectly via days_in_month tested elsewhere + assert_eq!(last_day_of_quarter(2023, 1).unwrap(), d(2023, 3, 31)); // Non-leap doesn't affect Q1 end + + assert!(last_day_of_quarter(2024, 0).is_err()); + assert!(last_day_of_quarter(2024, 5).is_err()); + } + + #[test] + fn test_move_to_weekday_on_or_after() { + // Start Mon -> target Mon + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 1), Weekday::Mon), + d(2024, 1, 1) + ); + // Start Mon -> target Fri + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 1), Weekday::Fri), + d(2024, 1, 5) + ); + // Start Tue -> target Mon + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 2), Weekday::Mon), + d(2024, 1, 8) + ); + // Start Sat -> target Mon + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 6), Weekday::Mon), + d(2024, 1, 8) + ); + // Start Sun -> target Sun + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 7), Weekday::Sun), + d(2024, 1, 7) + ); + // Start Sun -> target Sat (next week) + assert_eq!( + move_to_weekday_on_or_after(d(2024, 1, 7), Weekday::Sat), + d(2024, 1, 13) + ); + } + + // Test potential overflow cases + #[test] + fn test_collect_calendar_near_max_date() { + // Note: NaiveDate::MAX can cause issues with succ_opt/pred_opt in helpers like days_in_month + // Let's test slightly away from the absolute max/min + let end_date = NaiveDate::MAX.pred_opt().unwrap(); // Max - 1 day + let start_date = end_date.pred_opt().unwrap().pred_opt().unwrap(); // Max - 3 days + + let dl = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::Daily, + ); + let expected = vec![start_date, start_date.succ_opt().unwrap(), end_date]; + assert_eq!(dl.list().unwrap(), expected); + + // Test weekly near max date - just ensure it doesn't panic + let dl_weekly = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::WeeklyMonday, + ); + assert!(dl_weekly.list().is_ok()); + + // Test monthly near max date + let dl_monthly = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::MonthEnd, + ); + assert!(dl_monthly.list().is_ok()); + } + + #[test] + fn test_collect_calendar_near_min_date() { + let start_date = NaiveDate::MIN.succ_opt().unwrap(); // Min + 1 day + let end_date = start_date.succ_opt().unwrap().succ_opt().unwrap(); // Min + 3 days + + let dl = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::Daily, + ); + let expected = vec![start_date, start_date.succ_opt().unwrap(), end_date]; + assert_eq!(dl.list().unwrap(), expected); + + // Test weekly near min date + let dl_weekly = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::WeeklyMonday, + ); + assert!(dl_weekly.list().is_ok()); + + // Test monthly near min date + let dl_monthly = DatesList::new( + start_date.to_string(), + end_date.to_string(), + DateFreq::MonthStart, + ); + assert!(dl_monthly.list().is_ok()); + } +} // end mod tests diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..7c832f6 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,6 @@ +pub mod bdates; +pub use bdates::{BDateFreq, BDatesList}; + +pub mod dates; +pub use dates::{DateFreq, DatesList}; +