1#![forbid(unsafe_code)]
2use 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}