asciidoc_parser/parser/reference_time.rs
1//! A fixed reference instant for computing the time-dependent document
2//! attributes.
3
4/// A fixed reference instant used to compute the time-dependent document
5/// attributes (`docdate`, `doctime`, `docdatetime`, `docyear`, and their
6/// `local*` siblings).
7///
8/// AsciiDoc derives these attributes from a reference time: normally the
9/// modification time of the source file (for `doc*`) and the current wall-clock
10/// time (for `local*`). Because those values change from run to run, output
11/// that embeds them is not reproducible. Supplying a `ReferenceTime` pins the
12/// clock to a known instant so that the computed attributes – and any output
13/// derived from them – are stable and reproducible.
14///
15/// A `ReferenceTime` records a *local* wall-clock date and time together with
16/// the UTC offset in effect at that instant. The offset governs how the
17/// computed `doctime` / `localtime` prints its zone: `UTC` when the offset is
18/// zero, otherwise a numeric `±HHMM` suffix (matching Asciidoctor's use of
19/// `%Z`/`%z`).
20///
21/// Install one with [`Parser::with_reference_time`] (pins the whole clock) or
22/// [`Parser::with_input_mtime`] (pins only the source-file time that drives the
23/// `doc*` attributes). See also the `SOURCE_DATE_EPOCH` environment variable,
24/// which pins the clock when no `ReferenceTime` is supplied.
25///
26/// [`Parser::with_reference_time`]: crate::Parser::with_reference_time
27/// [`Parser::with_input_mtime`]: crate::Parser::with_input_mtime
28#[derive(Clone, Debug, Eq, Hash, PartialEq)]
29pub struct ReferenceTime {
30 /// Year (e.g. `2019`).
31 year: i64,
32
33 /// Month of year, `1..=12`.
34 month: u32,
35
36 /// Day of month, `1..=31`.
37 day: u32,
38
39 /// Hour of day, `0..=23`.
40 hour: u32,
41
42 /// Minute of hour, `0..=59`.
43 minute: u32,
44
45 /// Second of minute, `0..=60` (a leap second is permitted).
46 second: u32,
47
48 /// UTC offset, in seconds, of the local time recorded above. Zero prints as
49 /// the zone label `UTC`; any other value prints as `±HHMM`.
50 utc_offset_secs: i32,
51}
52
53impl ReferenceTime {
54 /// Creates a reference time from a count of seconds since the Unix epoch
55 /// (1970-01-01T00:00:00Z), interpreted as UTC.
56 ///
57 /// This is the shape of the `SOURCE_DATE_EPOCH` value defined by the
58 /// [reproducible builds specification]. The resulting `doctime` /
59 /// `localtime` prints its zone as `UTC`.
60 ///
61 /// [reproducible builds specification]: https://reproducible-builds.org/specs/source-date-epoch/
62 pub fn from_unix_timestamp(secs: i64) -> Self {
63 let days = secs.div_euclid(86_400);
64 let rem = secs.rem_euclid(86_400);
65 let (year, month, day) = civil_from_days(days);
66
67 Self {
68 year,
69 month,
70 day,
71 hour: (rem / 3600) as u32,
72 minute: ((rem % 3600) / 60) as u32,
73 second: (rem % 60) as u32,
74 utc_offset_secs: 0,
75 }
76 }
77
78 /// Creates a reference time from an explicit *local* wall-clock date and
79 /// time and the UTC offset (in seconds) in effect at that instant.
80 ///
81 /// This mirrors constructing a timezone-aware time such as Asciidoctor's
82 /// `input_mtime`. The offset controls the printed zone of the computed
83 /// `doctime` / `localtime`: `UTC` when `utc_offset_secs` is `0`, otherwise
84 /// a numeric `±HHMM` suffix (e.g. a `+06:00` offset prints as `+0600`).
85 ///
86 /// The components are stored as given and are not validated or normalized;
87 /// supplying out-of-range values yields correspondingly out-of-range
88 /// output.
89 pub fn from_local(
90 year: i64,
91 month: u32,
92 day: u32,
93 hour: u32,
94 minute: u32,
95 second: u32,
96 utc_offset_secs: i32,
97 ) -> Self {
98 Self {
99 year,
100 month,
101 day,
102 hour,
103 minute,
104 second,
105 utc_offset_secs,
106 }
107 }
108
109 /// Returns the reference time for the real wall clock, read as UTC.
110 ///
111 /// This crate carries no timezone database, so an un-pinned clock is read
112 /// as UTC rather than local time. For reproducible, timezone-correct
113 /// output, pin the clock with [`Parser::with_reference_time`] /
114 /// [`Parser::with_input_mtime`] or set the `SOURCE_DATE_EPOCH` environment
115 /// variable.
116 ///
117 /// [`Parser::with_reference_time`]: crate::Parser::with_reference_time
118 /// [`Parser::with_input_mtime`]: crate::Parser::with_input_mtime
119 pub(crate) fn now() -> Self {
120 let secs = std::time::SystemTime::now()
121 .duration_since(std::time::UNIX_EPOCH)
122 .map(|d| d.as_secs() as i64)
123 .unwrap_or(0);
124
125 Self::from_unix_timestamp(secs)
126 }
127
128 /// Formats the date as `%Y-%m-%d` (e.g. `2019-01-02`) – the value of
129 /// `docdate` / `localdate`.
130 pub(crate) fn date(&self) -> String {
131 format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
132 }
133
134 /// Formats the time as `%H:%M:%S <zone>` (e.g. `03:04:05 +0600` or
135 /// `03:04:05 UTC`) – the value of `doctime` / `localtime`.
136 pub(crate) fn time(&self) -> String {
137 format!(
138 "{:02}:{:02}:{:02} {}",
139 self.hour,
140 self.minute,
141 self.second,
142 self.zone_label()
143 )
144 }
145
146 /// Formats the year (e.g. `2019`) – the value of `docyear` / `localyear`.
147 pub(crate) fn year_string(&self) -> String {
148 self.year.to_string()
149 }
150
151 /// Formats the zone as Asciidoctor does: `UTC` for a zero UTC offset,
152 /// otherwise a numeric `±HHMM` suffix.
153 fn zone_label(&self) -> String {
154 if self.utc_offset_secs == 0 {
155 "UTC".to_string()
156 } else {
157 let sign = if self.utc_offset_secs < 0 { '-' } else { '+' };
158 let abs = self.utc_offset_secs.unsigned_abs();
159 let hours = abs / 3600;
160 let minutes = (abs % 3600) / 60;
161 format!("{sign}{hours:02}{minutes:02}")
162 }
163 }
164}
165
166/// The eight time-dependent document attributes computed by
167/// [`DatetimeContext`], in a fixed order (the `local*` family followed by the
168/// `doc*` family).
169pub(crate) const DATETIME_ATTRIBUTE_NAMES: [&str; 8] = [
170 "localdate",
171 "localtime",
172 "localdatetime",
173 "localyear",
174 "docdate",
175 "doctime",
176 "docdatetime",
177 "docyear",
178];
179
180/// Returns `true` if `name` is one of the time-dependent document attributes
181/// resolved by [`DatetimeContext::resolve`].
182pub(crate) fn is_datetime_attribute(name: &str) -> bool {
183 DATETIME_ATTRIBUTE_NAMES.contains(&name)
184}
185
186/// The parser's pinned reference-time configuration: an optional reference time
187/// (the value of "now") and an optional source modification time.
188///
189/// This is the deterministic *input* to the time-dependent attributes, retained
190/// on a [`ResolvedAttributes`](crate::parser::ResolvedAttributes) snapshot so
191/// it can resolve them on demand. Both fields are commonly `None` (no clock
192/// pinned), so a snapshot stores this behind a single `Option<Box<…>>` that
193/// adds only a pointer and allocates nothing in the common case.
194#[derive(Clone, Debug, Eq, Hash, PartialEq)]
195pub(crate) struct DatetimeInputs {
196 /// The pinned reference time (`Parser::with_reference_time`), if any.
197 pub(crate) reference_time: Option<ReferenceTime>,
198
199 /// The pinned source modification time (`Parser::with_input_mtime`), if
200 /// any.
201 pub(crate) input_mtime: Option<ReferenceTime>,
202}
203
204impl DatetimeInputs {
205 /// Builds the inputs from the two optional pinned times, returning `None`
206 /// when neither is set (so the snapshot stores nothing).
207 pub(crate) fn new(
208 reference_time: Option<ReferenceTime>,
209 input_mtime: Option<ReferenceTime>,
210 ) -> Option<Box<Self>> {
211 if reference_time.is_none() && input_mtime.is_none() {
212 None
213 } else {
214 Some(Box::new(Self {
215 reference_time,
216 input_mtime,
217 }))
218 }
219 }
220
221 /// Captures the reference instants from these inputs (see
222 /// [`DatetimeContext::capture`]).
223 pub(crate) fn capture(&self) -> DatetimeContext {
224 DatetimeContext::capture(self.reference_time.as_ref(), self.input_mtime.as_ref())
225 }
226}
227
228/// The reference instants from which the time-dependent document attributes are
229/// computed: `now` drives the `local*` family, and `doc` (the source document's
230/// modification time, defaulting to `now`) drives the `doc*` family.
231///
232/// This is captured once – lazily, the first time a time-dependent attribute is
233/// read – so that a parse which never references one does no clock,
234/// environment, or allocation work, and so that repeated reads within one parse
235/// observe a single, consistent instant. It ports Asciidoctor's
236/// `Document#fill_datetime_attributes`, but resolves each attribute on demand
237/// rather than materializing all eight up front.
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub(crate) struct DatetimeContext {
240 /// Drives the `local*` attributes.
241 now: ReferenceTime,
242
243 /// Drives the `doc*` attributes.
244 doc: ReferenceTime,
245}
246
247impl DatetimeContext {
248 /// Captures the reference instants from the parser's configuration.
249 ///
250 /// `now` resolves to, in order: the pinned `reference_time`
251 /// ([`Parser::with_reference_time`]), the `SOURCE_DATE_EPOCH` environment
252 /// variable, or the real wall clock (read as UTC). The `doc` instant
253 /// resolves to the pinned `input_mtime` ([`Parser::with_input_mtime`]) or,
254 /// absent that, to `now`.
255 ///
256 /// [`Parser::with_reference_time`]: crate::Parser::with_reference_time
257 /// [`Parser::with_input_mtime`]: crate::Parser::with_input_mtime
258 pub(crate) fn capture(
259 reference_time: Option<&ReferenceTime>,
260 input_mtime: Option<&ReferenceTime>,
261 ) -> Self {
262 let now = reference_time
263 .cloned()
264 .or_else(source_date_epoch_from_env)
265 .unwrap_or_else(ReferenceTime::now);
266
267 let doc = input_mtime.cloned().unwrap_or_else(|| now.clone());
268
269 Self { now, doc }
270 }
271
272 /// Resolves the time-dependent attribute `name` to its computed value, or
273 /// `None` when `name` is not a time-dependent attribute (or resolves to no
274 /// value, as `docyear` / `localyear` do for an explicit date without a
275 /// `YYYY-` prefix).
276 ///
277 /// `explicit` returns the *explicitly-set* value of a sibling attribute (an
278 /// API/header/body assignment), treating a value-less "set" as an empty
279 /// string and an unset/absent attribute as `None`. An explicit value always
280 /// wins over the computed one, and the derived `*year` / `*datetime` are
281 /// built from whichever value (explicit or computed) each sibling resolves
282 /// to – mirroring Asciidoctor's `||=` semantics.
283 pub(crate) fn resolve<F>(&self, name: &str, explicit: F) -> Option<String>
284 where
285 F: Fn(&str) -> Option<String>,
286 {
287 match name {
288 "localdate" => Some(self.date("localdate", &self.now, &explicit)),
289 "localtime" => Some(self.time("localtime", &self.now, &explicit)),
290 "localyear" => self.year("localyear", "localdate", &self.now, &explicit),
291 "localdatetime" => Some(self.datetime(
292 "localdatetime",
293 "localdate",
294 "localtime",
295 &self.now,
296 &explicit,
297 )),
298 "docdate" => Some(self.date("docdate", &self.doc, &explicit)),
299 "doctime" => Some(self.time("doctime", &self.doc, &explicit)),
300 "docyear" => self.year("docyear", "docdate", &self.doc, &explicit),
301 "docdatetime" => {
302 Some(self.datetime("docdatetime", "docdate", "doctime", &self.doc, &explicit))
303 }
304 _ => None,
305 }
306 }
307
308 /// Resolves a date attribute (`localdate` / `docdate`): an explicit value,
309 /// else the reference instant's date.
310 fn date<F: Fn(&str) -> Option<String>>(
311 &self,
312 attr: &str,
313 instant: &ReferenceTime,
314 explicit: &F,
315 ) -> String {
316 explicit(attr).unwrap_or_else(|| instant.date())
317 }
318
319 /// Resolves a time attribute (`localtime` / `doctime`): an explicit value,
320 /// else the reference instant's time.
321 fn time<F: Fn(&str) -> Option<String>>(
322 &self,
323 attr: &str,
324 instant: &ReferenceTime,
325 explicit: &F,
326 ) -> String {
327 explicit(attr).unwrap_or_else(|| instant.time())
328 }
329
330 /// Resolves a year attribute (`localyear` / `docyear`): an explicit value,
331 /// else derived from the date – the reference instant's year when the date
332 /// is computed, or the `YYYY-` prefix of an explicit date (which may yield
333 /// no value).
334 fn year<F: Fn(&str) -> Option<String>>(
335 &self,
336 year_attr: &str,
337 date_attr: &str,
338 instant: &ReferenceTime,
339 explicit: &F,
340 ) -> Option<String> {
341 if let Some(year) = explicit(year_attr) {
342 return Some(year);
343 }
344 match explicit(date_attr) {
345 Some(date) => year_from_date(&date),
346 None => Some(instant.year_string()),
347 }
348 }
349
350 /// Resolves a datetime attribute (`localdatetime` / `docdatetime`): an
351 /// explicit value, else `"{date} {time}"` built from the resolved date and
352 /// time siblings.
353 fn datetime<F: Fn(&str) -> Option<String>>(
354 &self,
355 datetime_attr: &str,
356 date_attr: &str,
357 time_attr: &str,
358 instant: &ReferenceTime,
359 explicit: &F,
360 ) -> String {
361 explicit(datetime_attr).unwrap_or_else(|| {
362 let date = self.date(date_attr, instant, explicit);
363 let time = self.time(time_attr, instant, explicit);
364 format!("{date} {time}")
365 })
366 }
367}
368
369/// Reads the `SOURCE_DATE_EPOCH` environment variable as a fixed reference
370/// time, if it is set to a non-empty, valid integer count of seconds since the
371/// Unix epoch (interpreted as UTC), per the [reproducible builds
372/// specification].
373///
374/// Returns `None` when the variable is unset, empty, or malformed. Asciidoctor
375/// raises on a malformed value; this crate has no error channel at this point,
376/// so a malformed value is ignored and the clock falls back to the next source
377/// (a pinned reference time, or the real wall clock) rather than aborting the
378/// parse.
379///
380/// [reproducible builds specification]: https://reproducible-builds.org/specs/source-date-epoch/
381fn source_date_epoch_from_env() -> Option<ReferenceTime> {
382 // An unset variable reads as empty, which `parse_source_date_epoch` rejects
383 // just like a set-but-empty value – so the (common) unset path exercises the
384 // same line as a set one, without the tests having to mutate the process
385 // environment.
386 parse_source_date_epoch(&std::env::var("SOURCE_DATE_EPOCH").unwrap_or_default())
387}
388
389/// Parses a `SOURCE_DATE_EPOCH` string into a reference time, returning `None`
390/// when it is empty or not a valid integer. Split out from
391/// [`source_date_epoch_from_env`] so the parsing rules can be tested without
392/// touching the process environment.
393fn parse_source_date_epoch(raw: &str) -> Option<ReferenceTime> {
394 let trimmed = raw.trim();
395 if trimmed.is_empty() {
396 return None;
397 }
398
399 let secs = trimmed.parse::<i64>().ok()?;
400 Some(ReferenceTime::from_unix_timestamp(secs))
401}
402
403/// Derives the year from a `docdate` / `localdate` value, mirroring
404/// Asciidoctor's `(date.index '-') == 4 ? (date.slice 0, 4) : nil`.
405///
406/// Returns the four-character prefix only when the first `-` is at index 4 (a
407/// `YYYY-` prefix); any other shape yields `None`, leaving the year attribute
408/// unset.
409fn year_from_date(date: &str) -> Option<String> {
410 if date.find('-') == Some(4) {
411 date.get(..4).map(str::to_string)
412 } else {
413 None
414 }
415}
416
417/// Converts a count of days since the Unix epoch to a `(year, month, day)`
418/// civil date, using Howard Hinnant's `civil_from_days` algorithm (valid for
419/// the full range of a proleptic Gregorian calendar).
420fn civil_from_days(days: i64) -> (i64, u32, u32) {
421 // Shift the epoch to 0000-03-01 so that leap days fall at the end of each
422 // 400-year era.
423 let z = days + 719_468;
424 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
425 let doe = z - era * 146_097; // day of era, [0, 146096]
426 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
427 let y = yoe + era * 400;
428 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
429 let mp = (5 * doy + 2) / 153; // month, shifted so March is 0: [0, 11]
430 let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
431 let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
432
433 (if month <= 2 { y + 1 } else { y }, month, day)
434}
435
436#[cfg(test)]
437mod tests {
438 #![allow(clippy::expect_used)]
439
440 use super::*;
441
442 #[test]
443 fn from_unix_timestamp_epoch() {
444 let t = ReferenceTime::from_unix_timestamp(0);
445 assert_eq!(t.date(), "1970-01-01");
446 assert_eq!(t.time(), "00:00:00 UTC");
447 assert_eq!(t.year_string(), "1970");
448 }
449
450 #[test]
451 fn from_unix_timestamp_known_instant() {
452 // 2015-01-01T10:00:00Z == 1420106400.
453 let t = ReferenceTime::from_unix_timestamp(1_420_106_400);
454 assert_eq!(t.date(), "2015-01-01");
455 assert_eq!(t.time(), "10:00:00 UTC");
456 assert_eq!(t.year_string(), "2015");
457 }
458
459 #[test]
460 fn from_unix_timestamp_leap_day() {
461 // 2016-02-29T23:59:59Z == 1456790399.
462 let t = ReferenceTime::from_unix_timestamp(1_456_790_399);
463 assert_eq!(t.date(), "2016-02-29");
464 assert_eq!(t.time(), "23:59:59 UTC");
465 }
466
467 #[test]
468 fn from_unix_timestamp_before_epoch() {
469 // 1969-12-31T23:59:59Z == -1.
470 let t = ReferenceTime::from_unix_timestamp(-1);
471 assert_eq!(t.date(), "1969-12-31");
472 assert_eq!(t.time(), "23:59:59 UTC");
473 }
474
475 #[test]
476 fn from_local_positive_offset() {
477 // Time.new(2019, 1, 2, 3, 4, 5, "+06:00").
478 let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600);
479 assert_eq!(t.date(), "2019-01-02");
480 assert_eq!(t.time(), "03:04:05 +0600");
481 assert_eq!(t.year_string(), "2019");
482 }
483
484 #[test]
485 fn from_local_negative_offset() {
486 let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, -(7 * 3600));
487 assert_eq!(t.time(), "03:04:05 -0700");
488 }
489
490 #[test]
491 fn from_local_half_hour_offset() {
492 let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 5 * 3600 + 30 * 60);
493 assert_eq!(t.time(), "03:04:05 +0530");
494 }
495
496 #[test]
497 fn from_local_zero_offset_prints_utc() {
498 let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 0);
499 assert_eq!(t.time(), "03:04:05 UTC");
500 }
501
502 #[test]
503 fn parse_source_date_epoch_valid() {
504 let t = parse_source_date_epoch("1420106400").expect("valid epoch");
505 assert_eq!(t.date(), "2015-01-01");
506 assert_eq!(t.time(), "10:00:00 UTC");
507 }
508
509 #[test]
510 fn parse_source_date_epoch_trims_whitespace() {
511 assert!(parse_source_date_epoch(" 1420106400 ").is_some());
512 }
513
514 #[test]
515 fn parse_source_date_epoch_rejects_empty_and_malformed() {
516 assert!(parse_source_date_epoch("").is_none());
517 assert!(parse_source_date_epoch(" ").is_none());
518 assert!(parse_source_date_epoch("not-a-number").is_none());
519 assert!(parse_source_date_epoch("12.5").is_none());
520 }
521
522 #[test]
523 fn year_from_date_extracts_yyyy_prefix() {
524 assert_eq!(year_from_date("2015-01-01").as_deref(), Some("2015"));
525 assert_eq!(year_from_date("2015-1-1").as_deref(), Some("2015"));
526 }
527
528 #[test]
529 fn year_from_date_rejects_other_shapes() {
530 assert_eq!(year_from_date("2015"), None); // no `-`
531 assert_eq!(year_from_date("20-15-01"), None); // first `-` not at index 4
532 assert_eq!(year_from_date("January 1, 2015"), None);
533 assert_eq!(year_from_date(""), None);
534 }
535
536 #[test]
537 fn is_datetime_attribute_recognizes_the_family() {
538 for name in DATETIME_ATTRIBUTE_NAMES {
539 assert!(is_datetime_attribute(name), "{name} should be recognized");
540 }
541 assert!(!is_datetime_attribute("docfile"));
542 assert!(!is_datetime_attribute("frog"));
543 }
544
545 /// A resolver with no explicit overrides, driven by a fixed instant.
546 fn ctx() -> DatetimeContext {
547 // now: 2015-01-01T10:00:00Z; doc: 2019-01-02 03:04:05 +0600.
548 DatetimeContext {
549 now: ReferenceTime::from_unix_timestamp(1_420_106_400),
550 doc: ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600),
551 }
552 }
553
554 #[test]
555 fn resolve_computes_the_family_from_instants() {
556 let ctx = ctx();
557 let none = |_: &str| None;
558
559 assert_eq!(
560 ctx.resolve("localdate", none).as_deref(),
561 Some("2015-01-01")
562 );
563 assert_eq!(
564 ctx.resolve("localtime", none).as_deref(),
565 Some("10:00:00 UTC")
566 );
567 assert_eq!(ctx.resolve("localyear", none).as_deref(), Some("2015"));
568 assert_eq!(
569 ctx.resolve("localdatetime", none).as_deref(),
570 Some("2015-01-01 10:00:00 UTC")
571 );
572
573 assert_eq!(ctx.resolve("docdate", none).as_deref(), Some("2019-01-02"));
574 assert_eq!(
575 ctx.resolve("doctime", none).as_deref(),
576 Some("03:04:05 +0600")
577 );
578 assert_eq!(ctx.resolve("docyear", none).as_deref(), Some("2019"));
579 assert_eq!(
580 ctx.resolve("docdatetime", none).as_deref(),
581 Some("2019-01-02 03:04:05 +0600")
582 );
583
584 assert_eq!(ctx.resolve("frog", none), None);
585 }
586
587 #[test]
588 fn resolve_honors_explicit_overrides() {
589 let ctx = ctx();
590
591 // Explicit docdate + doctime: docyear from docdate, docdatetime from
592 // both.
593 let explicit = |name: &str| match name {
594 "docdate" => Some("2015-01-01".to_string()),
595 "doctime" => Some("10:00:00-0700".to_string()),
596 _ => None,
597 };
598 assert_eq!(ctx.resolve("docyear", explicit).as_deref(), Some("2015"));
599 assert_eq!(
600 ctx.resolve("docdatetime", explicit).as_deref(),
601 Some("2015-01-01 10:00:00-0700")
602 );
603
604 // Explicit docdate only: doctime still computed from the instant.
605 let explicit = |name: &str| match name {
606 "docdate" => Some("2015-01-01".to_string()),
607 _ => None,
608 };
609 assert_eq!(
610 ctx.resolve("docdatetime", explicit).as_deref(),
611 Some("2015-01-01 03:04:05 +0600")
612 );
613
614 // An explicit date without a `YYYY-` prefix leaves the year unset.
615 let explicit = |name: &str| match name {
616 "docdate" => Some("January 1".to_string()),
617 _ => None,
618 };
619 assert_eq!(ctx.resolve("docyear", explicit), None);
620
621 // An explicit year wins outright over both the date-derived and the
622 // instant-derived year. (The reader path never reaches this – an
623 // explicitly-set attribute short-circuits before resolution – so it is
624 // covered here directly.)
625 let explicit = |name: &str| match name {
626 "docyear" => Some("1999".to_string()),
627 _ => None,
628 };
629 assert_eq!(ctx.resolve("docyear", explicit).as_deref(), Some("1999"));
630 }
631}