Skip to main content

holidays_ru/
lib.rs

1//! # holidays-ru
2//!
3//! Библиотека для определения рабочих, выходных и праздничных дней в России.
4//!
5//! ## Основное API
6//!
7//! Библиотека предоставляет набор pure-функций, которые по дате возвращают
8//! [`Resolved<DayFlags>`](Resolved) или [`Resolved<bool>`](Resolved):
9//!
10//! ```rust,ignore
11//! # #[cfg(feature = "chrono")] {
12//! use holidays_ru::{flags, is_day_off, Resolved};
13//! use chrono::NaiveDate;
14//!
15//! let date = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
16//! let result = flags(date);
17//!
18//! match result {
19//!     Resolved::Fact(flags) => println!("Официально: {flags:?}"),
20//!     Resolved::Predict(flags) => println!("Прогноз: {flags:?}"),
21//! }
22//! # }
23//! ```
24//!
25//! ## Без внешних зависимостей (ymd API)
26//!
27//! ```rust
28//! use holidays_ru;
29//!
30//! let result = holidays_ru::flags_ymd(2026, 1, 9).unwrap();
31//!
32//! if result.value().is_day_off() {
33//!     println!("9 января 2026 — выходной день");
34//! }
35//! ```
36//!
37//! ## Поддерживаемые годы
38//!
39//! - **1993–2026**: официальные данные производственного календаря
40//!   (возвращаются как [`Resolved::Fact`]).
41//! - **1900–2100 вне диапазона официальных данных**: алгоритмический прогноз
42//!   на основе ТК РФ (возвращаются как [`Resolved::Predict`]).
43//!
44//! ## Feature flags
45//!
46//! - `chrono` — поддержка [`chrono::NaiveDate`]
47//! - `time` — поддержка [`time::Date`]
48//! - `serde` — сериализация [`DayFlags`] и [`Resolved<T>`]
49//!
50//! Без фич библиотека работает только через `_ymd` API.
51
52mod data;
53mod day_flags;
54mod predict;
55mod range;
56mod raw_date;
57mod resolved;
58
59#[cfg(any(feature = "time", feature = "chrono"))]
60pub mod date;
61
62mod official;
63
64pub use day_flags::DayFlags;
65pub use range::WorkWeek;
66pub use resolved::Resolved;
67
68#[cfg(any(feature = "time", feature = "chrono"))]
69pub use date::CalendarDate;
70
71/// Первый год, для которого есть официальные данные производственного календаря.
72pub const FIRST_FACT_YEAR: i32 = data::FACT_FIRST_YEAR;
73
74/// Последний год, для которого есть официальные данные производственного календаря.
75pub const LAST_FACT_YEAR: i32 = data::FACT_LAST_YEAR;
76
77/// Минимальный год, принимаемый `_ymd` API.
78pub const MIN_YEAR: i32 = 1900;
79
80/// Максимальный год, принимаемый `_ymd` API.
81///
82/// Верхняя граница оставляет место для внутреннего просмотра следующего дня
83/// в prediction-алгоритме.
84pub const MAX_YEAR: i32 = 2100;
85
86use predict as predict_mod;
87use raw_date::RawDate;
88
89// ---------------------------------------------------------------------------
90// Generic API (CalendarDate)
91// ---------------------------------------------------------------------------
92
93#[cfg(any(feature = "time", feature = "chrono"))]
94mod generic {
95    use super::*;
96    use crate::date::CalendarDate;
97
98    /// Возвращает [`DayFlags`] для указанной даты.
99    ///
100    /// Если для года даты есть официальные данные, возвращается [`Resolved::Fact`].
101    /// Иначе — [`Resolved::Predict`] с алгоритмическим прогнозом.
102    ///
103    /// # Пример
104    ///
105    /// ```rust,ignore
106    /// # #[cfg(feature = "chrono")] {
107    /// use holidays_ru::flags;
108    /// use chrono::NaiveDate;
109    ///
110    /// let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
111    /// let result = flags(date);
112    ///
113    /// assert!(result.is_fact());
114    /// assert!(result.value().is_holiday());
115    /// # }
116    /// ```
117    #[inline]
118    pub fn flags<D: CalendarDate>(date: D) -> Resolved<DayFlags> {
119        let raw = RawDate::from_calendar_date(date);
120        super::flags_raw(raw)
121    }
122
123    /// Возвращает `true`, если день выходной.
124    ///
125    /// Объединяет weekend, праздники и дополнительные выходные.
126    /// Не различает факт и прогноз — для этого используйте `match` на результате.
127    #[inline]
128    pub fn is_day_off<D: CalendarDate>(date: D) -> Resolved<bool> {
129        flags(date).map(DayFlags::is_day_off)
130    }
131
132    /// Возвращает `true`, если день рабочий.
133    #[inline]
134    pub fn is_working_day<D: CalendarDate>(date: D) -> Resolved<bool> {
135        flags(date).map(DayFlags::is_working_day)
136    }
137
138    /// Возвращает `true`, если день является федеральным нерабочим праздничным днём.
139    #[inline]
140    pub fn is_holiday<D: CalendarDate>(date: D) -> Resolved<bool> {
141        flags(date).map(DayFlags::is_holiday)
142    }
143
144    /// Возвращает `true`, если день является сокращённым рабочим днём.
145    #[inline]
146    pub fn is_short_day<D: CalendarDate>(date: D) -> Resolved<bool> {
147        flags(date).map(DayFlags::is_short_day)
148    }
149
150    /// Возвращает `true`, если день — суббота или воскресенье.
151    #[inline]
152    pub fn is_weekend<D: CalendarDate>(date: D) -> Resolved<bool> {
153        flags(date).map(DayFlags::is_weekend)
154    }
155
156    /// Возвращает `true`, если день затронут переносом выходного.
157    #[inline]
158    pub fn is_transferred<D: CalendarDate>(date: D) -> Resolved<bool> {
159        flags(date).map(DayFlags::is_transferred)
160    }
161
162    /// Считает нерабочие дни в полуоткрытом диапазоне дат `[start, end)`.
163    ///
164    /// Возвращает `None`, если `start > end` или даты вне поддерживаемого
165    /// диапазона. Для `end` дополнительно допускается `MAX_YEAR + 1`-01-01,
166    /// чтобы диапазон мог включать [`MAX_YEAR`]-12-31.
167    /// Если хотя бы один день диапазона рассчитан прогнозом, итоговый результат
168    /// будет [`Resolved::Predict`].
169    #[inline]
170    pub fn non_working_days_between<D: CalendarDate>(start: D, end: D) -> Option<Resolved<u32>> {
171        let start = RawDate::from_calendar_date(start);
172        let end = RawDate::from_calendar_date(end);
173
174        range::non_working_days_between_raw(start, end)
175    }
176
177    /// Считает рабочее время в минутах в полуоткрытом диапазоне дат `[start, end)`.
178    ///
179    /// Нерабочие дни дают 0 минут. Сокращённые рабочие дни уменьшают норму
180    /// выбранной рабочей недели на 60 минут.
181    #[inline]
182    pub fn working_minutes_between<D: CalendarDate>(
183        start: D,
184        end: D,
185        week: WorkWeek,
186    ) -> Option<Resolved<u32>> {
187        let start = RawDate::from_calendar_date(start);
188        let end = RawDate::from_calendar_date(end);
189
190        range::working_minutes_between_raw(start, end, week)
191    }
192
193    /// Считает рабочее время в часах в полуоткрытом диапазоне дат `[start, end)`.
194    ///
195    /// Для точных расчётов используйте [`working_minutes_between`].
196    #[inline]
197    pub fn working_hours_between<D: CalendarDate>(
198        start: D,
199        end: D,
200        week: WorkWeek,
201    ) -> Option<Resolved<f64>> {
202        working_minutes_between(start, end, week).map(|r| r.map(|minutes| minutes as f64 / 60.0))
203    }
204}
205
206#[cfg(any(feature = "time", feature = "chrono"))]
207pub use generic::{
208    flags, is_day_off, is_holiday, is_short_day, is_transferred, is_weekend, is_working_day,
209    non_working_days_between, working_hours_between, working_minutes_between,
210};
211
212// ---------------------------------------------------------------------------
213// YMD API (без внешних зависимостей)
214// ---------------------------------------------------------------------------
215
216/// Возвращает [`DayFlags`] для даты, заданной годом, месяцем и днём.
217///
218/// Возвращает `None`, если дата недействительна
219/// (например, 31 февраля, 29 февраля в невисокосный год или год вне
220/// [`MIN_YEAR`]..=[`MAX_YEAR`]).
221///
222/// # Пример
223///
224/// ```rust
225/// use holidays_ru;
226///
227/// let result = holidays_ru::flags_ymd(2026, 1, 9).unwrap();
228/// assert!(result.value().is_day_off());
229/// ```
230#[inline]
231pub fn flags_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<DayFlags>> {
232    let raw = RawDate::from_ymd(year, month, day)?;
233    Some(flags_raw(raw))
234}
235
236/// Возвращает `true`, если день выходной.
237///
238/// `None` означает невалидную дату.
239#[inline]
240pub fn is_day_off_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
241    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_day_off))
242}
243
244/// Возвращает `true`, если день рабочий.
245///
246/// `None` означает невалидную дату.
247#[inline]
248pub fn is_working_day_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
249    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_working_day))
250}
251
252/// Возвращает `true`, если день является федеральным нерабочим праздничным днём.
253///
254/// `None` означает невалидную дату.
255#[inline]
256pub fn is_holiday_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
257    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_holiday))
258}
259
260/// Возвращает `true`, если день является сокращённым рабочим днём.
261///
262/// `None` означает невалидную дату.
263#[inline]
264pub fn is_short_day_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
265    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_short_day))
266}
267
268/// Возвращает `true`, если день — суббота или воскресенье.
269///
270/// `None` означает невалидную дату.
271#[inline]
272pub fn is_weekend_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
273    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_weekend))
274}
275
276/// Возвращает `true`, если день затронут переносом выходного.
277///
278/// `None` означает невалидную дату.
279#[inline]
280pub fn is_transferred_ymd(year: i32, month: u8, day: u8) -> Option<Resolved<bool>> {
281    flags_ymd(year, month, day).map(|r| r.map(DayFlags::is_transferred))
282}
283
284/// Считает нерабочие дни в полуоткрытом диапазоне дат `[start, end)`.
285///
286/// Возвращает `None`, если одна из дат недействительна или `start > end`.
287/// Для `end` дополнительно допускается `MAX_YEAR + 1`-01-01, чтобы диапазон
288/// мог включать [`MAX_YEAR`]-12-31.
289/// Если хотя бы один день диапазона рассчитан прогнозом, итоговый результат
290/// будет [`Resolved::Predict`].
291#[inline]
292pub fn non_working_days_between_ymd(
293    start_year: i32,
294    start_month: u8,
295    start_day: u8,
296    end_year: i32,
297    end_month: u8,
298    end_day: u8,
299) -> Option<Resolved<u32>> {
300    let start = RawDate::from_ymd(start_year, start_month, start_day)?;
301    let end = range_end_raw_ymd(end_year, end_month, end_day)?;
302
303    range::non_working_days_between_raw(start, end)
304}
305
306/// Считает рабочее время в минутах в полуоткрытом диапазоне дат `[start, end)`.
307///
308/// Нерабочие дни дают 0 минут. Сокращённые рабочие дни уменьшают норму
309/// выбранной рабочей недели на 60 минут. Возвращает `None`, если одна из дат
310/// недействительна или `start > end`. Для `end` дополнительно допускается
311/// `MAX_YEAR + 1`-01-01, чтобы диапазон мог включать [`MAX_YEAR`]-12-31.
312#[inline]
313pub fn working_minutes_between_ymd(
314    start_year: i32,
315    start_month: u8,
316    start_day: u8,
317    end_year: i32,
318    end_month: u8,
319    end_day: u8,
320    week: WorkWeek,
321) -> Option<Resolved<u32>> {
322    let start = RawDate::from_ymd(start_year, start_month, start_day)?;
323    let end = range_end_raw_ymd(end_year, end_month, end_day)?;
324
325    range::working_minutes_between_raw(start, end, week)
326}
327
328/// Считает рабочее время в часах в полуоткрытом диапазоне дат `[start, end)`.
329///
330/// Для точных расчётов используйте [`working_minutes_between_ymd`].
331#[inline]
332pub fn working_hours_between_ymd(
333    start_year: i32,
334    start_month: u8,
335    start_day: u8,
336    end_year: i32,
337    end_month: u8,
338    end_day: u8,
339    week: WorkWeek,
340) -> Option<Resolved<f64>> {
341    working_minutes_between_ymd(
342        start_year,
343        start_month,
344        start_day,
345        end_year,
346        end_month,
347        end_day,
348        week,
349    )
350    .map(|r| r.map(|minutes| minutes as f64 / 60.0))
351}
352
353#[inline]
354fn range_end_raw_ymd(year: i32, month: u8, day: u8) -> Option<RawDate> {
355    RawDate::from_ymd(year, month, day).or_else(|| {
356        (year == MAX_YEAR + 1 && month == 1 && day == 1)
357            .then(|| RawDate::from_ymd_unchecked(year, month, day))
358    })
359}
360
361// ---------------------------------------------------------------------------
362// Внутренняя логика разрешения (Fact vs Predict)
363// ---------------------------------------------------------------------------
364
365/// Основной поток разрешения: пытается получить фактические данные,
366/// иначе использует прогноз.
367#[inline]
368pub(crate) fn flags_raw(date: RawDate) -> Resolved<DayFlags> {
369    if let Some(flags) = official::flags(date) {
370        Resolved::Fact(flags)
371    } else {
372        Resolved::Predict(predict_mod::flags(date))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    // -----------------------------------------------------------------------
381    // YMD API tests
382    // -----------------------------------------------------------------------
383
384    #[test]
385    fn test_flags_ymd_valid() {
386        let r = flags_ymd(2026, 1, 9).unwrap();
387        assert!(r.is_fact());
388        assert!(r.value().is_day_off());
389    }
390
391    #[test]
392    fn test_flags_ymd_invalid() {
393        assert!(flags_ymd(2026, 2, 31).is_none());
394        assert!(flags_ymd(2026, 13, 1).is_none());
395        assert!(flags_ymd(2026, 1, 0).is_none());
396    }
397
398    #[test]
399    fn test_flags_ymd_predict() {
400        // 2027 год — за пределами официальных данных.
401        let r = flags_ymd(2027, 1, 1).unwrap();
402        assert!(r.is_predict());
403        assert!(r.value().is_holiday());
404    }
405
406    #[test]
407    fn test_is_day_off_ymd() {
408        let r = is_day_off_ymd(2026, 1, 9).unwrap();
409        assert!(r.value());
410    }
411
412    #[test]
413    fn test_is_holiday_ymd() {
414        let r = is_holiday_ymd(2026, 1, 1).unwrap();
415        assert!(r.value());
416
417        let r = is_holiday_ymd(2026, 1, 9).unwrap();
418        assert!(!r.value());
419    }
420
421    #[test]
422    fn test_is_working_day_ymd() {
423        let r = is_working_day_ymd(2026, 1, 12).unwrap();
424        // 12 января 2026 — понедельник, после каникул, рабочий день.
425        assert!(r.value());
426    }
427
428    #[test]
429    fn test_is_short_day_ymd() {
430        // 3 ноября 2026 — вторник, короткий день перед 4 ноября.
431        let r = is_short_day_ymd(2026, 11, 3).unwrap();
432        assert!(r.value());
433    }
434
435    #[test]
436    fn test_is_weekend_ymd() {
437        // 11 января 2026 — воскресенье.
438        let r = is_weekend_ymd(2026, 1, 11).unwrap();
439        assert!(r.value());
440    }
441
442    #[test]
443    fn test_flags_ymd_year_range() {
444        assert!(flags_ymd(MIN_YEAR - 1, 1, 1).is_none());
445        assert!(flags_ymd(MIN_YEAR, 1, 1).is_some());
446        assert!(flags_ymd(MAX_YEAR, 12, 31).is_some());
447        assert!(flags_ymd(MAX_YEAR + 1, 1, 1).is_none());
448    }
449
450    #[test]
451    fn test_range_ymd_can_include_last_supported_day() {
452        assert!(non_working_days_between_ymd(MAX_YEAR, 12, 31, MAX_YEAR + 1, 1, 1).is_some());
453        assert!(
454            working_minutes_between_ymd(
455                MAX_YEAR,
456                12,
457                31,
458                MAX_YEAR + 1,
459                1,
460                1,
461                WorkWeek::FortyHours,
462            )
463            .is_some()
464        );
465    }
466
467    #[test]
468    fn test_fact_year_invariants() {
469        for year in FIRST_FACT_YEAR..=LAST_FACT_YEAR {
470            let mut date = RawDate::from_ymd(year, 1, 1).unwrap();
471
472            loop {
473                let flags = flags_raw(date).value();
474
475                assert_ne!(
476                    flags.is_day_off(),
477                    flags.is_working_day(),
478                    "{year}-{:02}-{:02}: day cannot be both off and working",
479                    date.month,
480                    date.day,
481                );
482                assert!(
483                    !flags.is_short_day() || flags.is_working_day(),
484                    "{year}-{:02}-{:02}: short day must be working",
485                    date.month,
486                    date.day,
487                );
488                assert!(
489                    !flags.is_holiday() || flags.is_day_off(),
490                    "{year}-{:02}-{:02}: holiday must be day off",
491                    date.month,
492                    date.day,
493                );
494
495                if date.month == 12 && date.day == 31 {
496                    break;
497                }
498
499                date = date.next_day();
500            }
501        }
502    }
503
504    // -----------------------------------------------------------------------
505    // chrono feature tests
506    // -----------------------------------------------------------------------
507
508    #[cfg(feature = "chrono")]
509    mod chrono_tests {
510        use super::*;
511        use chrono::NaiveDate;
512
513        #[test]
514        fn test_flags_with_naive_date() {
515            let date = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
516            let r = flags(date);
517            assert!(r.is_fact());
518            assert!(r.value().is_day_off());
519        }
520
521        #[test]
522        fn test_is_day_off_with_naive_date() {
523            let date = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap();
524            assert!(is_day_off(date).value());
525        }
526
527        #[test]
528        fn test_predict_with_naive_date() {
529            let date = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap();
530            let r = flags(date);
531            assert!(r.is_predict());
532            assert!(r.value().is_holiday());
533        }
534
535        #[test]
536        fn test_chrono_matches_ymd() {
537            for (year, month, day) in [
538                (2000, 1, 1),
539                (2010, 1, 6),
540                (2024, 4, 27),
541                (2026, 12, 31),
542                (2027, 1, 11),
543            ] {
544                let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
545                assert_eq!(
546                    flags(date),
547                    flags_ymd(year, month as u8, day as u8).unwrap()
548                );
549            }
550        }
551    }
552
553    // -----------------------------------------------------------------------
554    // time feature tests
555    // -----------------------------------------------------------------------
556
557    #[cfg(feature = "time")]
558    mod time_tests {
559        use super::*;
560        use time::Date;
561        use time::Month;
562
563        #[test]
564        fn test_flags_with_time_date() {
565            let date = Date::from_calendar_date(2026, Month::January, 9).unwrap();
566            let r = flags(date);
567            assert!(r.is_fact());
568            assert!(r.value().is_day_off());
569        }
570
571        #[test]
572        fn test_is_day_off_with_time_date() {
573            let date = Date::from_calendar_date(2026, Month::January, 9).unwrap();
574            assert!(is_day_off(date).value());
575        }
576
577        #[test]
578        fn test_predict_with_time_date() {
579            let date = Date::from_calendar_date(2027, Month::January, 1).unwrap();
580            let r = flags(date);
581            assert!(r.is_predict());
582            assert!(r.value().is_holiday());
583        }
584
585        #[test]
586        fn test_time_matches_ymd() {
587            for (year, month, day) in [
588                (2000, Month::January, 1),
589                (2010, Month::January, 6),
590                (2024, Month::April, 27),
591                (2026, Month::December, 31),
592                (2027, Month::January, 11),
593            ] {
594                let date = Date::from_calendar_date(year, month, day).unwrap();
595                assert_eq!(flags(date), flags_ymd(year, month.into(), day).unwrap());
596            }
597        }
598    }
599}