Skip to main content

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}