icu_calendar/options.rs
1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5//! Options used by types in this crate
6
7#[cfg(feature = "unstable")]
8pub use unstable::{
9 DateAddOptions, DateDifferenceOptions, DateFromFieldsOptions, MissingFieldsStrategy, Overflow,
10};
11#[cfg(not(feature = "unstable"))]
12pub(crate) use unstable::{
13 DateAddOptions, DateDifferenceOptions, DateFromFieldsOptions, MissingFieldsStrategy, Overflow,
14};
15
16mod unstable {
17 /// Options bag for [`Date::try_from_fields`](crate::Date::try_from_fields).
18 ///
19 /// <div class="stab unstable">
20 /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways,
21 /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break.
22 ///
23 /// Graduation tracking issue: [issue #7161](https://github.com/unicode-org/icu4x/issues/7161).
24 /// </div>
25 ///
26 /// ✨ *Enabled with the `unstable` Cargo feature.*
27 #[derive(Copy, Clone, Debug, PartialEq, Default)]
28 #[non_exhaustive]
29 pub struct DateFromFieldsOptions {
30 /// How to behave with out-of-bounds fields.
31 ///
32 /// Defaults to [`Overflow::Reject`].
33 ///
34 /// # Examples
35 ///
36 /// ```
37 /// use icu::calendar::options::DateFromFieldsOptions;
38 /// use icu::calendar::options::Overflow;
39 /// use icu::calendar::types::DateFields;
40 /// use icu::calendar::Date;
41 /// use icu::calendar::Iso;
42 ///
43 /// // There is no day 31 in September.
44 /// let mut fields = DateFields::default();
45 /// fields.extended_year = Some(2025);
46 /// fields.ordinal_month = Some(9);
47 /// fields.day = Some(31);
48 ///
49 /// let options_default = DateFromFieldsOptions::default();
50 /// assert!(Date::try_from_fields(fields, options_default, Iso).is_err());
51 ///
52 /// let mut options_reject = options_default;
53 /// options_reject.overflow = Some(Overflow::Reject);
54 /// assert!(Date::try_from_fields(fields, options_reject, Iso).is_err());
55 ///
56 /// let mut options_constrain = options_default;
57 /// options_constrain.overflow = Some(Overflow::Constrain);
58 /// assert_eq!(
59 /// Date::try_from_fields(fields, options_constrain, Iso).unwrap(),
60 /// Date::try_new_iso(2025, 9, 30).unwrap()
61 /// );
62 /// ```
63 pub overflow: Option<Overflow>,
64 /// How to behave when the fields that are present do not fully constitute a Date.
65 ///
66 /// This option can be used to fill in a missing year given a month and a day according to
67 /// the ECMAScript Temporal specification.
68 ///
69 /// # Examples
70 ///
71 /// ```
72 /// use icu::calendar::options::DateFromFieldsOptions;
73 /// use icu::calendar::options::MissingFieldsStrategy;
74 /// use icu::calendar::types::DateFields;
75 /// use icu::calendar::Date;
76 /// use icu::calendar::Iso;
77 ///
78 /// // These options are missing a year.
79 /// let mut fields = DateFields::default();
80 /// fields.month_code = Some(b"M02");
81 /// fields.day = Some(1);
82 ///
83 /// let options_default = DateFromFieldsOptions::default();
84 /// assert!(Date::try_from_fields(fields, options_default, Iso).is_err());
85 ///
86 /// let mut options_reject = options_default;
87 /// options_reject.missing_fields_strategy =
88 /// Some(MissingFieldsStrategy::Reject);
89 /// assert!(Date::try_from_fields(fields, options_reject, Iso).is_err());
90 ///
91 /// let mut options_ecma = options_default;
92 /// options_ecma.missing_fields_strategy = Some(MissingFieldsStrategy::Ecma);
93 /// assert_eq!(
94 /// Date::try_from_fields(fields, options_ecma, Iso).unwrap(),
95 /// Date::try_new_iso(1972, 2, 1).unwrap()
96 /// );
97 /// ```
98 pub missing_fields_strategy: Option<MissingFieldsStrategy>,
99 }
100
101 impl DateFromFieldsOptions {
102 pub(crate) fn from_add_options(options: DateAddOptions) -> Self {
103 Self {
104 overflow: options.overflow,
105 missing_fields_strategy: Default::default(),
106 }
107 }
108 }
109
110 /// Options for adding a duration to a date.
111 ///
112 /// <div class="stab unstable">
113 /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways,
114 /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break.
115 ///
116 /// Graduation tracking issue: [issue #3964](https://github.com/unicode-org/icu4x/issues/3964).
117 /// </div>
118 ///
119 /// ✨ *Enabled with the `unstable` Cargo feature.*
120 #[derive(Copy, Clone, PartialEq, Debug, Default)]
121 #[non_exhaustive]
122 pub struct DateAddOptions {
123 /// How to behave with out-of-bounds fields during arithmetic.
124 ///
125 /// Defaults to [`Overflow::Constrain`].
126 ///
127 /// # Examples
128 ///
129 /// ```
130 /// use icu::calendar::options::DateAddOptions;
131 /// use icu::calendar::options::Overflow;
132 /// use icu::calendar::types::DateDuration;
133 /// use icu::calendar::Date;
134 ///
135 /// // There is a day 31 in October but not in November.
136 /// let d1 = Date::try_new_iso(2025, 10, 31).unwrap();
137 /// let duration = DateDuration::for_months(1);
138 ///
139 /// let options_default = DateAddOptions::default();
140 /// assert_eq!(
141 /// d1.try_added_with_options(duration, options_default)
142 /// .unwrap(),
143 /// Date::try_new_iso(2025, 11, 30).unwrap()
144 /// );
145 ///
146 /// let mut options_reject = options_default;
147 /// options_reject.overflow = Some(Overflow::Reject);
148 /// assert!(d1.try_added_with_options(duration, options_reject).is_err());
149 ///
150 /// let mut options_constrain = options_default;
151 /// options_constrain.overflow = Some(Overflow::Constrain);
152 /// assert_eq!(
153 /// d1.try_added_with_options(duration, options_constrain)
154 /// .unwrap(),
155 /// Date::try_new_iso(2025, 11, 30).unwrap()
156 /// );
157 /// ```
158 pub overflow: Option<Overflow>,
159 }
160
161 /// Options for taking the difference between two dates.
162 ///
163 /// <div class="stab unstable">
164 /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways,
165 /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break.
166 ///
167 /// Graduation tracking issue: [issue #3964](https://github.com/unicode-org/icu4x/issues/3964).
168 /// </div>
169 ///
170 /// ✨ *Enabled with the `unstable` Cargo feature.*
171 #[derive(Copy, Clone, PartialEq, Debug, Default)]
172 #[non_exhaustive]
173 pub struct DateDifferenceOptions {
174 /// Which date field to allow as the largest in a duration when taking the difference.
175 ///
176 /// When choosing [`Months`] or [`Years`], the resulting [`DateDuration`] might not be
177 /// associative or commutative in subsequent arithmetic operations, and it might require
178 /// [`Overflow::Constrain`] in addition.
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// use icu::calendar::options::DateDifferenceOptions;
184 /// use icu::calendar::types::DateDuration;
185 /// use icu::calendar::types::DateDurationUnit;
186 /// use icu::calendar::Date;
187 ///
188 /// let d1 = Date::try_new_iso(2025, 3, 31).unwrap();
189 /// let d2 = Date::try_new_iso(2026, 5, 15).unwrap();
190 ///
191 /// let options_default = DateDifferenceOptions::default();
192 /// assert_eq!(
193 /// d1.try_until_with_options(&d2, options_default).unwrap(),
194 /// DateDuration::for_days(410)
195 /// );
196 ///
197 /// let mut options_days = options_default;
198 /// options_days.largest_unit = Some(DateDurationUnit::Days);
199 /// assert_eq!(
200 /// d1.try_until_with_options(&d2, options_default).unwrap(),
201 /// DateDuration::for_days(410)
202 /// );
203 ///
204 /// let mut options_weeks = options_default;
205 /// options_weeks.largest_unit = Some(DateDurationUnit::Weeks);
206 /// assert_eq!(
207 /// d1.try_until_with_options(&d2, options_weeks).unwrap(),
208 /// DateDuration {
209 /// weeks: 58,
210 /// days: 4,
211 /// ..Default::default()
212 /// }
213 /// );
214 ///
215 /// let mut options_months = options_default;
216 /// options_months.largest_unit = Some(DateDurationUnit::Months);
217 /// assert_eq!(
218 /// d1.try_until_with_options(&d2, options_months).unwrap(),
219 /// DateDuration {
220 /// months: 13,
221 /// days: 15,
222 /// ..Default::default()
223 /// }
224 /// );
225 ///
226 /// let mut options_years = options_default;
227 /// options_years.largest_unit = Some(DateDurationUnit::Years);
228 /// assert_eq!(
229 /// d1.try_until_with_options(&d2, options_years).unwrap(),
230 /// DateDuration {
231 /// years: 1,
232 /// months: 1,
233 /// days: 15,
234 /// ..Default::default()
235 /// }
236 /// );
237 /// ```
238 ///
239 /// [`Months`]: crate::types::DateDurationUnit::Months
240 /// [`Years`]: crate::types::DateDurationUnit::Years
241 /// [`DateDuration`]: crate::types::DateDuration
242 pub largest_unit: Option<crate::duration::DateDurationUnit>,
243 }
244
245 /// Whether to constrain or reject out-of-bounds values when constructing a Date.
246 ///
247 /// The behavior conforms to the ECMAScript Temporal specification.
248 ///
249 /// <div class="stab unstable">
250 /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways,
251 /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break.
252 ///
253 /// Graduation tracking issue: [issue #7161](https://github.com/unicode-org/icu4x/issues/7161).
254 /// </div>
255 ///
256 /// ✨ *Enabled with the `unstable` Cargo feature.*
257 #[derive(Copy, Clone, Debug, PartialEq, Default)]
258 #[non_exhaustive]
259 pub enum Overflow {
260 /// Constrain out-of-bounds fields to the nearest in-bounds value.
261 ///
262 /// Only the out-of-bounds field is constrained. If the other fields are not themselves
263 /// out of bounds, they are not changed.
264 ///
265 /// This is the [default option](
266 /// https://tc39.es/proposal-temporal/#sec-temporal-gettemporaloverflowoption),
267 /// following the ECMAScript Temporal specification. See also the [docs on MDN](
268 /// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainDate#invalid_date_clamping).
269 ///
270 /// # Examples
271 ///
272 /// ```
273 /// use icu::calendar::cal::Hebrew;
274 /// use icu::calendar::options::DateFromFieldsOptions;
275 /// use icu::calendar::options::Overflow;
276 /// use icu::calendar::types::DateFields;
277 /// use icu::calendar::Date;
278 /// use icu::calendar::DateError;
279 ///
280 /// let mut options = DateFromFieldsOptions::default();
281 /// options.overflow = Some(Overflow::Constrain);
282 ///
283 /// // 5784, a leap year, contains M05L, but the day is too big.
284 /// let mut fields = DateFields::default();
285 /// fields.extended_year = Some(5784);
286 /// fields.month_code = Some(b"M05L");
287 /// fields.day = Some(50);
288 ///
289 /// let date = Date::try_from_fields(fields, options, Hebrew).unwrap();
290 ///
291 /// // Constrained to the 30th day of M05L of year 5784
292 /// assert_eq!(date.year().extended_year(), 5784);
293 /// assert_eq!(date.month().standard_code.0, "M05L");
294 /// assert_eq!(date.day_of_month().0, 30);
295 ///
296 /// // 5785, a common year, does not contain M05L.
297 /// fields.extended_year = Some(5785);
298 ///
299 /// let date = Date::try_from_fields(fields, options, Hebrew).unwrap();
300 ///
301 /// // Constrained to the 29th day of M06 of year 5785
302 /// assert_eq!(date.year().extended_year(), 5785);
303 /// assert_eq!(date.month().standard_code.0, "M06");
304 /// assert_eq!(date.day_of_month().0, 29);
305 /// ```
306 Constrain,
307 /// Return an error if any fields are out of bounds.
308 ///
309 /// # Examples
310 ///
311 /// ```
312 /// use icu::calendar::cal::Hebrew;
313 /// use icu::calendar::error::DateFromFieldsError;
314 /// use icu::calendar::options::DateFromFieldsOptions;
315 /// use icu::calendar::options::Overflow;
316 /// use icu::calendar::types::DateFields;
317 /// use icu::calendar::Date;
318 /// use tinystr::tinystr;
319 ///
320 /// let mut options = DateFromFieldsOptions::default();
321 /// options.overflow = Some(Overflow::Reject);
322 ///
323 /// // 5784, a leap year, contains M05L, but the day is too big.
324 /// let mut fields = DateFields::default();
325 /// fields.extended_year = Some(5784);
326 /// fields.month_code = Some(b"M05L");
327 /// fields.day = Some(50);
328 ///
329 /// let err = Date::try_from_fields(fields, options, Hebrew)
330 /// .expect_err("Day is out of bounds");
331 /// assert!(matches!(err, DateFromFieldsError::Range { .. }));
332 ///
333 /// // Set the day to one that exists
334 /// fields.day = Some(1);
335 /// Date::try_from_fields(fields, options, Hebrew)
336 /// .expect("A valid Hebrew date");
337 ///
338 /// // 5785, a common year, does not contain M05L.
339 /// fields.extended_year = Some(5785);
340 ///
341 /// let err = Date::try_from_fields(fields, options, Hebrew)
342 /// .expect_err("Month is out of bounds");
343 /// assert!(matches!(err, DateFromFieldsError::MonthCodeNotInYear));
344 /// ```
345 #[default]
346 Reject,
347 }
348
349 /// How to infer missing fields when the fields that are present do not fully constitute a Date.
350 ///
351 /// In order for the fields to fully constitute a Date, they must identify a year, a month,
352 /// and a day. The fields `era`, `era_year`, and `extended_year` identify the year:
353 ///
354 /// | Era? | Era Year? | Extended Year? | Outcome |
355 /// |------|-----------|----------------|---------|
356 /// | - | - | - | Error |
357 /// | Some | - | - | Error |
358 /// | - | Some | - | Error |
359 /// | - | - | Some | OK |
360 /// | Some | Some | - | OK |
361 /// | Some | - | Some | Error (era requires era year) |
362 /// | - | Some | Some | Error (era year requires era) |
363 /// | Some | Some | Some | OK (but error if inconsistent) |
364 ///
365 /// The fields `month_code` and `ordinal_month` identify the month:
366 ///
367 /// | Month Code? | Ordinal Month? | Outcome |
368 /// |-------------|----------------|---------|
369 /// | - | - | Error |
370 /// | Some | - | OK |
371 /// | - | Some | OK |
372 /// | Some | Some | OK (but error if inconsistent) |
373 ///
374 /// The field `day` identifies the day.
375 ///
376 /// If the fields identify a year, a month, and a day, then there are no missing fields, so
377 /// the strategy chosen here has no effect (fields that are out-of-bounds or inconsistent
378 /// are handled by other errors).
379 ///
380 /// <div class="stab unstable">
381 /// 🚧 This code is considered unstable; it may change at any time, in breaking or non-breaking ways,
382 /// including in SemVer minor releases. Do not use this type unless you are prepared for things to occasionally break.
383 ///
384 /// Graduation tracking issue: [issue #7161](https://github.com/unicode-org/icu4x/issues/7161).
385 /// </div>
386 ///
387 /// ✨ *Enabled with the `unstable` Cargo feature.*
388 #[derive(Copy, Clone, Debug, PartialEq, Default)]
389 #[non_exhaustive]
390 pub enum MissingFieldsStrategy {
391 /// If the fields that are present do not fully constitute a Date,
392 /// return [`DateFromFieldsError::NotEnoughFields`].
393 ///
394 /// [`DateFromFieldsError::NotEnoughFields`]: crate::error::DateFromFieldsError::NotEnoughFields
395 #[default]
396 Reject,
397 /// If the fields that are present do not fully constitute a Date,
398 /// follow the ECMAScript specification when possible.
399 ///
400 /// ⚠️ This option causes a year or day to be implicitly added to the Date!
401 ///
402 /// This strategy makes the following changes:
403 ///
404 /// 1. If the fields identify a year and a month, but not a day, then set `day` to 1.
405 /// 2. If `month_code` and `day` are set but nothing else, then set the year to a
406 /// _reference year_: some year in the calendar that contains the specified month
407 /// and day, according to the ECMAScript specification.
408 ///
409 /// Note that the reference year is _not_ added if an ordinal month is present, since
410 /// the identity of an ordinal month changes from year to year.
411 Ecma,
412 }
413}
414#[cfg(test)]
415mod tests {
416 use crate::{error::DateFromFieldsError, types::DateFields, Date, Gregorian};
417 use itertools::Itertools;
418 use std::collections::{BTreeMap, BTreeSet};
419
420 use super::*;
421
422 #[test]
423 #[allow(clippy::field_reassign_with_default)] // want out-of-crate code style
424 fn test_missing_fields_strategy() {
425 // The sets of fields that identify a year, according to the table in the docs
426 let valid_year_field_sets = [
427 &["era", "era_year"][..],
428 &["extended_year"][..],
429 &["era", "era_year", "extended_year"][..],
430 ]
431 .into_iter()
432 .map(|field_names| field_names.iter().copied().collect())
433 .collect::<Vec<BTreeSet<&str>>>();
434
435 // The sets of fields that identify a month, according to the table in the docs
436 let valid_month_field_sets = [
437 &["month_code"][..],
438 &["ordinal_month"][..],
439 &["month_code", "ordinal_month"][..],
440 ]
441 .into_iter()
442 .map(|field_names| field_names.iter().copied().collect())
443 .collect::<Vec<BTreeSet<&str>>>();
444
445 // The sets of fields that identify a day, according to the table in the docs
446 let valid_day_field_sets = [&["day"][..]]
447 .into_iter()
448 .map(|field_names| field_names.iter().copied().collect())
449 .collect::<Vec<BTreeSet<&str>>>();
450
451 // All possible valid sets of fields
452 let all_valid_field_sets = valid_year_field_sets
453 .iter()
454 .cartesian_product(valid_month_field_sets.iter())
455 .cartesian_product(valid_day_field_sets.iter())
456 .map(|((year_fields, month_fields), day_fields)| {
457 year_fields
458 .iter()
459 .chain(month_fields.iter())
460 .chain(day_fields.iter())
461 .copied()
462 .collect::<BTreeSet<&str>>()
463 })
464 .collect::<BTreeSet<BTreeSet<&str>>>();
465
466 // Field sets with year and month but without day that ECMA accepts
467 let field_sets_without_day = valid_year_field_sets
468 .iter()
469 .cartesian_product(valid_month_field_sets.iter())
470 .map(|(year_fields, month_fields)| {
471 year_fields
472 .iter()
473 .chain(month_fields.iter())
474 .copied()
475 .collect::<BTreeSet<&str>>()
476 })
477 .collect::<BTreeSet<BTreeSet<&str>>>();
478
479 // Field sets with month and day but without year that ECMA accepts
480 let field_sets_without_year = [&["month_code", "day"][..]]
481 .into_iter()
482 .map(|field_names| field_names.iter().copied().collect())
483 .collect::<Vec<BTreeSet<&str>>>();
484
485 // A map from field names to a function that sets that field
486 let mut field_fns = BTreeMap::<&str, &dyn Fn(&mut DateFields)>::new();
487 field_fns.insert("era", &|fields| fields.era = Some(b"ad"));
488 field_fns.insert("era_year", &|fields| fields.era_year = Some(2000));
489 field_fns.insert("extended_year", &|fields| fields.extended_year = Some(2000));
490 field_fns.insert("month_code", &|fields| fields.month_code = Some(b"M04"));
491 field_fns.insert("ordinal_month", &|fields| fields.ordinal_month = Some(4));
492 field_fns.insert("day", &|fields| fields.day = Some(20));
493
494 for field_set in field_fns.keys().copied().powerset() {
495 let field_set = field_set.into_iter().collect::<BTreeSet<&str>>();
496
497 // Check whether this case should succeed: whether it identifies a year,
498 // a month, and a day.
499 let should_succeed_rejecting = all_valid_field_sets.contains(&field_set);
500
501 // Check whether it should succeed in ECMA mode.
502 let should_succeed_ecma = should_succeed_rejecting
503 || field_sets_without_day.contains(&field_set)
504 || field_sets_without_year.contains(&field_set);
505
506 // Populate the fields in the field set
507 let mut fields = Default::default();
508 for field_name in field_set {
509 field_fns.get(field_name).unwrap()(&mut fields);
510 }
511
512 // Check whether we were able to successfully construct the date
513 let mut options = DateFromFieldsOptions::default();
514 options.missing_fields_strategy = Some(MissingFieldsStrategy::Reject);
515 match Date::try_from_fields(fields, options, Gregorian) {
516 Ok(_) => assert!(
517 should_succeed_rejecting,
518 "Succeeded, but should have rejected: {fields:?}"
519 ),
520 Err(DateFromFieldsError::NotEnoughFields) => assert!(
521 !should_succeed_rejecting,
522 "Rejected, but should have succeeded: {fields:?}"
523 ),
524 Err(e) => panic!("Unexpected error: {e} for {fields:?}"),
525 }
526
527 // Check ECMA mode
528 let mut options = DateFromFieldsOptions::default();
529 options.missing_fields_strategy = Some(MissingFieldsStrategy::Ecma);
530 match Date::try_from_fields(fields, options, Gregorian) {
531 Ok(_) => assert!(
532 should_succeed_ecma,
533 "Succeeded, but should have rejected (ECMA): {fields:?}"
534 ),
535 Err(DateFromFieldsError::NotEnoughFields) => assert!(
536 !should_succeed_ecma,
537 "Rejected, but should have succeeded (ECMA): {fields:?}"
538 ),
539 Err(e) => panic!("Unexpected error: {e} for {fields:?}"),
540 }
541 }
542 }
543
544 #[test]
545 fn test_constrain_large_months() {
546 let fields = DateFields {
547 extended_year: Some(2004),
548 ordinal_month: Some(15),
549 day: Some(1),
550 ..Default::default()
551 };
552 let options = DateFromFieldsOptions {
553 overflow: Some(Overflow::Constrain),
554 ..Default::default()
555 };
556
557 let _ = Date::try_from_fields(fields, options, crate::cal::Persian).unwrap();
558 }
559}