Skip to main content

use_business_day/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive business-day helpers.
3//!
4//! The first pass treats Monday through Friday as business days and ignores
5//! holiday calendars.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use use_business_day::{BusinessDayConvention, add_business_days, adjust_business_day, is_business_day};
11//! use use_date::CalendarDate;
12//!
13//! let friday = CalendarDate::new(2024, 5, 17).unwrap();
14//! let saturday = CalendarDate::new(2024, 5, 18).unwrap();
15//!
16//! assert!(is_business_day(friday));
17//! assert!(!is_business_day(saturday));
18//! assert_eq!(add_business_days(friday, 1), CalendarDate::new(2024, 5, 20).unwrap());
19//! assert_eq!(adjust_business_day(saturday, BusinessDayConvention::Following), CalendarDate::new(2024, 5, 20).unwrap());
20//! ```
21
22use use_date::{add_days, CalendarDate};
23use use_weekday::weekday_for_date;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum BusinessDayConvention {
27    Following,
28    Preceding,
29    ModifiedFollowing,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BusinessDayError {
34    InvalidRange,
35}
36
37#[must_use]
38pub fn is_business_day(date: CalendarDate) -> bool {
39    weekday_for_date(date.year(), date.month(), date.day())
40        .unwrap()
41        .is_weekday()
42}
43
44#[must_use]
45pub fn next_business_day(date: CalendarDate) -> CalendarDate {
46    let mut current = add_days(date, 1);
47
48    while !is_business_day(current) {
49        current = add_days(current, 1);
50    }
51
52    current
53}
54
55#[must_use]
56pub fn previous_business_day(date: CalendarDate) -> CalendarDate {
57    let mut current = add_days(date, -1);
58
59    while !is_business_day(current) {
60        current = add_days(current, -1);
61    }
62
63    current
64}
65
66#[must_use]
67pub fn add_business_days(date: CalendarDate, days: i64) -> CalendarDate {
68    if days == 0 {
69        return date;
70    }
71
72    let mut current = date;
73    let mut remaining = days.abs();
74    let step = if days > 0 { 1 } else { -1 };
75
76    while remaining > 0 {
77        current = add_days(current, step);
78
79        if is_business_day(current) {
80            remaining -= 1;
81        }
82    }
83
84    current
85}
86
87pub fn business_days_between(
88    start: CalendarDate,
89    end: CalendarDate,
90) -> Result<usize, BusinessDayError> {
91    if start > end {
92        return Err(BusinessDayError::InvalidRange);
93    }
94
95    let mut count = 0;
96    let mut current = start;
97
98    loop {
99        if is_business_day(current) {
100            count += 1;
101        }
102
103        if current == end {
104            break;
105        }
106
107        current = add_days(current, 1);
108    }
109
110    Ok(count)
111}
112
113#[must_use]
114pub fn adjust_business_day(date: CalendarDate, convention: BusinessDayConvention) -> CalendarDate {
115    if is_business_day(date) {
116        return date;
117    }
118
119    match convention {
120        BusinessDayConvention::Following => next_business_day(date),
121        BusinessDayConvention::Preceding => previous_business_day(date),
122        BusinessDayConvention::ModifiedFollowing => {
123            let following = next_business_day(date);
124
125            if following.month() != date.month() {
126                previous_business_day(date)
127            } else {
128                following
129            }
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::{
137        add_business_days, adjust_business_day, business_days_between, is_business_day,
138        next_business_day, previous_business_day, BusinessDayConvention, BusinessDayError,
139    };
140    use use_date::CalendarDate;
141
142    #[test]
143    fn detects_business_days_and_adjusts() {
144        let friday = CalendarDate::new(2024, 5, 17).unwrap();
145        let saturday = CalendarDate::new(2024, 5, 18).unwrap();
146        let monday = CalendarDate::new(2024, 5, 20).unwrap();
147
148        assert!(is_business_day(friday));
149        assert!(!is_business_day(saturday));
150        assert_eq!(next_business_day(friday), monday);
151        assert_eq!(previous_business_day(monday), friday);
152        assert_eq!(
153            adjust_business_day(saturday, BusinessDayConvention::Following),
154            monday
155        );
156    }
157
158    #[test]
159    fn adds_and_counts_business_days() {
160        let friday = CalendarDate::new(2024, 5, 17).unwrap();
161        let monday = CalendarDate::new(2024, 5, 20).unwrap();
162        let tuesday = CalendarDate::new(2024, 5, 21).unwrap();
163
164        assert_eq!(add_business_days(friday, 1), monday);
165        assert_eq!(add_business_days(monday, -1), friday);
166        assert_eq!(business_days_between(friday, tuesday).unwrap(), 3);
167    }
168
169    #[test]
170    fn supports_modified_following_and_reversed_ranges() {
171        let saturday = CalendarDate::new(2024, 8, 31).unwrap();
172        let friday = CalendarDate::new(2024, 8, 30).unwrap();
173
174        assert_eq!(
175            adjust_business_day(saturday, BusinessDayConvention::ModifiedFollowing),
176            friday
177        );
178        assert_eq!(
179            business_days_between(
180                CalendarDate::new(2024, 5, 21).unwrap(),
181                CalendarDate::new(2024, 5, 17).unwrap()
182            ),
183            Err(BusinessDayError::InvalidRange)
184        );
185    }
186}