python-dateutil-rs 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/Cargo.lock +2 -2
  2. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/PKG-INFO +1 -1
  3. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/Cargo.toml +1 -1
  4. python_dateutil_rs-0.1.3/crates/dateutil-core/README.md +167 -0
  5. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/benches/benchmarks.rs +57 -18
  6. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/common.rs +70 -6
  7. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/easter.rs +195 -50
  8. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/error.rs +34 -8
  9. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/parser/isoparser.rs +23 -16
  10. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/parser/parserinfo.rs +50 -21
  11. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/parser/tokenizer.rs +7 -2
  12. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/parser.rs +193 -86
  13. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/relativedelta.rs +539 -215
  14. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/rrule/iter.rs +15 -12
  15. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/rrule/parse.rs +27 -71
  16. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/rrule/set.rs +21 -60
  17. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/rrule.rs +238 -91
  18. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/tz/file.rs +55 -75
  19. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/tz/local.rs +5 -6
  20. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/tz/offset.rs +1 -5
  21. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/tz/utc.rs +1 -5
  22. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/tz.rs +90 -31
  23. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/Cargo.toml +1 -1
  24. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/common.rs +7 -4
  25. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/conv.rs +7 -21
  26. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/easter.rs +1 -2
  27. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/parser.rs +13 -14
  28. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/relativedelta.rs +117 -62
  29. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/rrule.rs +107 -233
  30. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py/tz.rs +3 -4
  31. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/pyproject.toml +1 -1
  32. python_dateutil_rs-0.1.2/crates/dateutil-core/README.md +0 -209
  33. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/Cargo.toml +0 -0
  34. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/LICENSE +0 -0
  35. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/README.md +0 -0
  36. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/CLAUDE.md +0 -0
  37. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-core/src/lib.rs +0 -0
  38. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/lib.rs +0 -0
  39. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/crates/dateutil-py/src/py.rs +0 -0
  40. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/__init__.py +0 -0
  41. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/_native.pyi +0 -0
  42. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/easter.py +0 -0
  43. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/parser.py +0 -0
  44. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/py.typed +0 -0
  45. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/relativedelta.py +0 -0
  46. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/rrule.py +0 -0
  47. {python_dateutil_rs-0.1.2 → python_dateutil_rs-0.1.3}/python/dateutil_rs/tz.py +0 -0
@@ -220,7 +220,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
220
220
 
221
221
  [[package]]
222
222
  name = "dateutil"
223
- version = "0.1.2"
223
+ version = "0.1.3"
224
224
  dependencies = [
225
225
  "bitflags",
226
226
  "chrono",
@@ -234,7 +234,7 @@ dependencies = [
234
234
 
235
235
  [[package]]
236
236
  name = "dateutil-py"
237
- version = "0.1.2"
237
+ version = "0.1.3"
238
238
  dependencies = [
239
239
  "chrono",
240
240
  "dateutil",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dateutil-rs
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Classifier: Programming Language :: Python :: 3.10
5
5
  Classifier: Programming Language :: Python :: 3.11
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "dateutil"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  description = "Fast date utility library — parser, relativedelta, rrule, timezone (Rust reimplementation of python-dateutil)"
@@ -0,0 +1,167 @@
1
+ # dateutil
2
+
3
+ [![Crates.io](https://img.shields.io/crates/v/dateutil.svg?style=flat-square)](https://crates.io/crates/dateutil)
4
+ [![docs.rs](https://img.shields.io/docsrs/dateutil?style=flat-square)](https://docs.rs/dateutil)
5
+ [![License](https://img.shields.io/crates/l/dateutil.svg?style=flat-square)](https://crates.io/crates/dateutil)
6
+ [![CI](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/wakita181009/dateutil-rs/actions/workflows/ci.yml)
7
+
8
+ Fast date utility library for Rust — parser, relativedelta, rrule, timezone.
9
+
10
+ A performance-focused Rust reimplementation of [python-dateutil](https://github.com/dateutil/dateutil), designed for native Rust usage. Also available as a Python package ([python-dateutil-rs](https://pypi.org/project/python-dateutil-rs/)) via PyO3.
11
+
12
+ ## Features
13
+
14
+ - **Parser** — Parse human-readable date strings with a zero-copy tokenizer and PHF lookup tables
15
+ - **ISO 8601** — Strict ISO-8601 parsing via `isoparse()`
16
+ - **RelativeDelta** — Relative date arithmetic (months, years, weekdays, etc.)
17
+ - **RRule / RRuleSet** — RFC 5545 recurrence rules with bitflag filters and buffer-reusing iteration
18
+ - **Timezone** — `gettz()` with TZif file support, DST handling, and process-lifetime caching
19
+ - **Easter** — Easter date calculation (Julian, Orthodox, Western)
20
+ - **Weekday** — `MO`–`SU` constants with N-th occurrence support
21
+
22
+ ## Installation
23
+
24
+ ```toml
25
+ [dependencies]
26
+ dateutil = "0.1"
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ### Parsing date strings
32
+
33
+ ```rust
34
+ use chrono::NaiveDate;
35
+ use dateutil::parser;
36
+
37
+ // Parse a human-readable date string
38
+ let dt = parser::parse("January 15, 2026 10:30 AM", None, None, false)
39
+ .unwrap();
40
+ assert_eq!(dt.date(), NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
41
+
42
+ // ISO-8601 strict parsing
43
+ use dateutil::parser::isoparser::isoparse;
44
+ let dt = isoparse("2026-01-15T10:30:00").unwrap();
45
+ ```
46
+
47
+ ### Relative deltas
48
+
49
+ ```rust
50
+ use chrono::{NaiveDate, NaiveDateTime};
51
+ use dateutil::relativedelta::RelativeDelta;
52
+
53
+ let dt = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap().and_hms_opt(0, 0, 0).unwrap();
54
+ let rd = RelativeDelta::new().months(1).build();
55
+
56
+ // Jan 31 + 1 month = Feb 28 (clamped)
57
+ let result = rd.add_to_datetime(dt);
58
+ assert_eq!(result.date(), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap());
59
+ ```
60
+
61
+ ### Recurrence rules
62
+
63
+ ```rust
64
+ use chrono::NaiveDate;
65
+ use dateutil::rrule::{Frequency, Recurrence, RRuleBuilder};
66
+
67
+ let dtstart = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
68
+ .and_hms_opt(0, 0, 0).unwrap();
69
+
70
+ let rule = RRuleBuilder::new(Frequency::Monthly)
71
+ .dtstart(dtstart)
72
+ .count(3)
73
+ .build()
74
+ .unwrap();
75
+
76
+ let dates = rule.all();
77
+ assert_eq!(dates.len(), 3);
78
+
79
+ // Also works as an iterator
80
+ for dt in rule.iter().take(3) {
81
+ println!("{}", dt);
82
+ }
83
+ ```
84
+
85
+ ### Parsing RFC 5545 RRULE strings
86
+
87
+ ```rust
88
+ use dateutil::rrule::parse::rrulestr;
89
+
90
+ let result = rrulestr(
91
+ "DTSTART:20260101T000000\nRRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,WE,FR",
92
+ false,
93
+ ).unwrap();
94
+ ```
95
+
96
+ ### Timezones
97
+
98
+ ```rust
99
+ use chrono::NaiveDate;
100
+ use dateutil::tz::{self, TzOps};
101
+
102
+ // Look up an IANA timezone (cached after first call)
103
+ let tz = tz::gettz(Some("America/New_York")).unwrap();
104
+
105
+ let dt = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap()
106
+ .and_hms_opt(12, 0, 0).unwrap();
107
+
108
+ // UTC offset in seconds (EDT = -4h)
109
+ assert_eq!(tz.utcoffset(dt, false), -4 * 3600);
110
+
111
+ // DST gap/overlap utilities
112
+ assert!(tz::datetime_exists(dt, &tz));
113
+ assert!(!tz::datetime_ambiguous(dt, &tz));
114
+ ```
115
+
116
+ ### Easter
117
+
118
+ ```rust
119
+ use chrono::NaiveDate;
120
+ use dateutil::easter::{easter, EasterMethod};
121
+
122
+ let date = easter(2026, EasterMethod::Western).unwrap();
123
+ assert_eq!(date, NaiveDate::from_ymd_opt(2026, 4, 5).unwrap());
124
+ ```
125
+
126
+ ### Weekday constants
127
+
128
+ ```rust
129
+ use dateutil::common::{MO, TU, FR};
130
+
131
+ // N-th occurrence (e.g., 2nd Tuesday)
132
+ let second_tue = TU.nth(2);
133
+ assert_eq!(second_tue.n(), Some(2));
134
+ ```
135
+
136
+ ## Modules
137
+
138
+ | Module | Description |
139
+ |--------|-------------|
140
+ | `dateutil::parser` | Date string parsing (`parse`, `isoparse`, `parse_to_result`) |
141
+ | `dateutil::relativedelta` | Relative date arithmetic (`RelativeDelta`, `RelativeDeltaBuilder`) |
142
+ | `dateutil::rrule` | RFC 5545 recurrence rules (`RRule`, `RRuleBuilder`, `Recurrence`) |
143
+ | `dateutil::rrule::set` | Recurrence rule sets (`RRuleSet`) |
144
+ | `dateutil::rrule::parse` | RRULE string parsing (`rrulestr`) |
145
+ | `dateutil::tz` | Timezones (`gettz`, `TzOps`, `TimeZone`, `TzFile`, `TzOffset`, `TzUtc`, `TzLocal`) |
146
+ | `dateutil::easter` | Easter calculation (`easter`, `EasterMethod`) |
147
+ | `dateutil::common` | Weekday constants (`MO`–`SU`, `Weekday`) |
148
+ | `dateutil::error` | Error types (`ParseError`, `RRuleError`, `TzError`, etc.) |
149
+
150
+ ## Performance
151
+
152
+ Benchmarked against python-dateutil (via PyO3 bindings):
153
+
154
+ | Module | Speedup |
155
+ |--------|---------|
156
+ | Parser (parse) | 19.5x–36.0x |
157
+ | Parser (isoparse) | 13.0x–38.4x |
158
+ | RRule | 5.9x–63.7x |
159
+ | Timezone | 1.0x–896.7x |
160
+ | RelativeDelta | 2.0x–28.1x |
161
+ | Easter | 5.0x–7.3x |
162
+
163
+ Key optimizations: zero-copy tokenizer, PHF compile-time hash tables, bitflag-based filters, `SmallVec` buffer reuse, `FxHashMap` timezone cache, `TzOps` trait for generic zero-clone dispatch.
164
+
165
+ ## License
166
+
167
+ [MIT](../../LICENSE)
@@ -1,14 +1,14 @@
1
- use criterion::{criterion_group, criterion_main, Criterion};
2
- use std::hint::black_box;
3
1
  use chrono::NaiveDate;
2
+ use criterion::{criterion_group, criterion_main, Criterion};
4
3
  use dateutil::common::Weekday;
5
4
  use dateutil::easter::{easter, EasterMethod};
6
5
  use dateutil::parser;
7
6
  use dateutil::parser::tokenizer;
8
7
  use dateutil::relativedelta::RelativeDelta;
9
- use dateutil::rrule::{Frequency, Recurrence, RRuleBuilder};
10
8
  use dateutil::rrule::parse::rrulestr;
11
9
  use dateutil::rrule::set::RRuleSet;
10
+ use dateutil::rrule::{Frequency, RRuleBuilder, Recurrence};
11
+ use std::hint::black_box;
12
12
 
13
13
  fn bench_tokenizer(c: &mut Criterion) {
14
14
  c.bench_function("tokenize_simple_date", |b| {
@@ -47,21 +47,31 @@ fn bench_parser(c: &mut Criterion) {
47
47
 
48
48
  c.bench_function("parse_datetime", |b| {
49
49
  b.iter(|| {
50
- black_box(parser::parse(black_box("2024-01-15 10:30:45"), false, false, None, None).unwrap());
50
+ black_box(
51
+ parser::parse(black_box("2024-01-15 10:30:45"), false, false, None, None).unwrap(),
52
+ );
51
53
  })
52
54
  });
53
55
 
54
56
  c.bench_function("parse_month_name", |b| {
55
57
  b.iter(|| {
56
- black_box(parser::parse(black_box("January 15, 2024"), false, false, None, None).unwrap());
58
+ black_box(
59
+ parser::parse(black_box("January 15, 2024"), false, false, None, None).unwrap(),
60
+ );
57
61
  })
58
62
  });
59
63
 
60
64
  c.bench_function("parse_complex", |b| {
61
65
  b.iter(|| {
62
66
  black_box(
63
- parser::parse(black_box("Monday, January 15, 2024 3:30:45.123456 PM UTC"), false, false, None, None)
64
- .unwrap(),
67
+ parser::parse(
68
+ black_box("Monday, January 15, 2024 3:30:45.123456 PM UTC"),
69
+ false,
70
+ false,
71
+ None,
72
+ None,
73
+ )
74
+ .unwrap(),
65
75
  );
66
76
  })
67
77
  });
@@ -71,8 +81,11 @@ fn bench_parser(c: &mut Criterion) {
71
81
  black_box(
72
82
  parser::parse_to_result(
73
83
  black_box("Monday, January 15, 2024 3:30:45.123456 PM EST -05:00"),
74
- false, false, None,
75
- ).unwrap(),
84
+ false,
85
+ false,
86
+ None,
87
+ )
88
+ .unwrap(),
76
89
  );
77
90
  })
78
91
  });
@@ -97,7 +110,16 @@ fn bench_parser(c: &mut Criterion) {
97
110
 
98
111
  c.bench_function("parse_ampm", |b| {
99
112
  b.iter(|| {
100
- black_box(parser::parse(black_box("January 15, 2024 3:30 PM"), false, false, None, None).unwrap());
113
+ black_box(
114
+ parser::parse(
115
+ black_box("January 15, 2024 3:30 PM"),
116
+ false,
117
+ false,
118
+ None,
119
+ None,
120
+ )
121
+ .unwrap(),
122
+ );
101
123
  })
102
124
  });
103
125
 
@@ -203,8 +225,13 @@ fn bench_relativedelta(c: &mut Criterion) {
203
225
  c.bench_function("relativedelta_add_complex", |b| {
204
226
  let wd = Weekday::new(4, Some(2)).unwrap();
205
227
  let delta = RelativeDelta::builder()
206
- .years(1).months(2).days(3)
207
- .hours(4).minutes(30).seconds(15).microseconds(500_000)
228
+ .years(1)
229
+ .months(2)
230
+ .days(3)
231
+ .hours(4)
232
+ .minutes(30)
233
+ .seconds(15)
234
+ .microseconds(500_000)
208
235
  .weekday(wd)
209
236
  .build()
210
237
  .unwrap();
@@ -438,8 +465,12 @@ fn bench_rrulestr(c: &mut Criterion) {
438
465
  black_box(
439
466
  rrulestr(
440
467
  black_box("DTSTART:20200101T090000\nRRULE:FREQ=DAILY;COUNT=30"),
441
- None, false, false, false,
442
- ).unwrap(),
468
+ None,
469
+ false,
470
+ false,
471
+ false,
472
+ )
473
+ .unwrap(),
443
474
  );
444
475
  })
445
476
  });
@@ -449,8 +480,12 @@ fn bench_rrulestr(c: &mut Criterion) {
449
480
  black_box(
450
481
  rrulestr(
451
482
  black_box("DTSTART:20200101T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=52"),
452
- None, false, false, false,
453
- ).unwrap(),
483
+ None,
484
+ false,
485
+ false,
486
+ false,
487
+ )
488
+ .unwrap(),
454
489
  );
455
490
  })
456
491
  });
@@ -481,8 +516,12 @@ fn bench_rrulestr(c: &mut Criterion) {
481
516
  b.iter(|| {
482
517
  let result = rrulestr(
483
518
  black_box("DTSTART:20200101T090000\nRRULE:FREQ=DAILY;COUNT=100"),
484
- None, false, false, false,
485
- ).unwrap();
519
+ None,
520
+ false,
521
+ false,
522
+ false,
523
+ )
524
+ .unwrap();
486
525
  black_box(result.all());
487
526
  })
488
527
  });
@@ -97,6 +97,46 @@ pub const SU: Weekday = Weekday {
97
97
  n: None,
98
98
  };
99
99
 
100
+ // ---------------------------------------------------------------------------
101
+ // Calendar helpers
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /// Returns `true` if `year` is a leap year (Gregorian calendar).
105
+ #[inline]
106
+ pub(crate) fn is_leap_year(year: i32) -> bool {
107
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
108
+ }
109
+
110
+ /// Returns the number of days in the given `month` of `year`.
111
+ ///
112
+ /// `month` must be in `1..=12`; out-of-range values return `0`.
113
+ #[inline]
114
+ pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
115
+ match month {
116
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
117
+ 4 | 6 | 9 | 11 => 30,
118
+ 2 => {
119
+ if is_leap_year(year) {
120
+ 29
121
+ } else {
122
+ 28
123
+ }
124
+ }
125
+ _ => 0,
126
+ }
127
+ }
128
+
129
+ /// Create a `NaiveDateTime` from year, month, day, hour, minute, second.
130
+ ///
131
+ /// Panics on invalid inputs — intended for test code only.
132
+ #[cfg(test)]
133
+ pub(crate) fn dt(y: i32, m: u32, d: u32, h: u32, mi: u32, s: u32) -> chrono::NaiveDateTime {
134
+ chrono::NaiveDate::from_ymd_opt(y, m, d)
135
+ .unwrap()
136
+ .and_hms_opt(h, mi, s)
137
+ .unwrap()
138
+ }
139
+
100
140
  #[cfg(test)]
101
141
  mod tests {
102
142
  use super::*;
@@ -213,12 +253,6 @@ mod tests {
213
253
  assert_eq!(set.len(), 2);
214
254
  }
215
255
 
216
- #[test]
217
- fn test_weekday_error_display() {
218
- let err = Weekday::new(7, None).unwrap_err();
219
- assert_eq!(err.to_string(), "invalid weekday: 7 (must be 0..=6)");
220
- }
221
-
222
256
  #[test]
223
257
  fn test_weekday_i32_min_max_n() {
224
258
  let wd = Weekday::new(0, Some(i32::MAX)).unwrap();
@@ -270,4 +304,34 @@ mod tests {
270
304
  let result: Result<Weekday, _> = 7u8.try_into();
271
305
  assert!(result.is_err());
272
306
  }
307
+
308
+ // -----------------------------------------------------------------------
309
+ // Calendar helpers
310
+ // -----------------------------------------------------------------------
311
+
312
+ #[test]
313
+ fn test_is_leap_year() {
314
+ assert!(is_leap_year(2024));
315
+ assert!(!is_leap_year(2023));
316
+ assert!(!is_leap_year(1900)); // century non-leap
317
+ assert!(is_leap_year(2000)); // 400-year leap
318
+ assert!(!is_leap_year(2100)); // century non-leap
319
+ }
320
+
321
+ #[test]
322
+ fn test_days_in_month() {
323
+ assert_eq!(days_in_month(2024, 1), 31);
324
+ assert_eq!(days_in_month(2024, 2), 29); // leap year
325
+ assert_eq!(days_in_month(2023, 2), 28); // non-leap
326
+ assert_eq!(days_in_month(2024, 4), 30);
327
+ assert_eq!(days_in_month(2024, 12), 31);
328
+ assert_eq!(days_in_month(1900, 2), 28);
329
+ assert_eq!(days_in_month(2000, 2), 29);
330
+ }
331
+
332
+ #[test]
333
+ fn test_days_in_month_invalid() {
334
+ assert_eq!(days_in_month(2024, 0), 0);
335
+ assert_eq!(days_in_month(2024, 13), 0);
336
+ }
273
337
  }