Skip to main content

citum_schema_data/reference/
date.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use crate::reference::types::RefDate;
7#[cfg(feature = "schema")]
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// An EDTF date value, with an optional opaque note.
13///
14/// `value` is the canonical EDTF string driving all date computation
15/// (sorting, disambiguation, fallback selection). `note` is optional,
16/// uninterpreted display text a data producer supplies alongside the date —
17/// e.g. source-calendar wording such as `民国三十六年` next to a Gregorian
18/// `value` of `"1947"`. Citum never parses, converts, or validates `note`;
19/// see [`docs/specs/CALENDAR_DATE_ANNOTATIONS.md`](../../../../docs/specs/CALENDAR_DATE_ANNOTATIONS.md).
20///
21/// The wire format is backward compatible: a bare EDTF string (`"1947"`)
22/// deserializes with `note: None` and serializes back to the same bare
23/// string. Supplying a `note` requires the mapping form
24/// `{ value: "1947", note: "..." }`, and only then does serialization emit
25/// the mapping form.
26#[derive(Debug, Clone, Default, PartialEq)]
27#[cfg_attr(feature = "bindings", derive(specta::Type))]
28pub struct DateValue {
29    /// The EDTF value.
30    pub value: String,
31    /// Optional opaque, uninterpreted text alongside the date. Preserved
32    /// verbatim; never parsed, converted, or validated.
33    #[cfg_attr(feature = "bindings", specta(optional))]
34    pub note: Option<String>,
35}
36
37impl DateValue {
38    /// Construct a `DateValue` with no note, from any string-like value.
39    pub fn new(value: impl Into<String>) -> Self {
40        Self {
41            value: value.into(),
42            note: None,
43        }
44    }
45
46    /// Check if the date value is empty.
47    pub fn is_empty(&self) -> bool {
48        self.value.is_empty()
49    }
50
51    /// Parse the string as an EDTF date etc, or return the string as a literal.
52    pub fn parse(&self) -> RefDate {
53        match self.value.parse::<citum_edtf::Edtf>() {
54            Ok(edtf) => RefDate::Edtf(edtf),
55            Err(_) => RefDate::Literal(self.value.clone()),
56        }
57    }
58
59    /// Extract the year from the date.
60    pub fn year(&self) -> String {
61        match self.parse() {
62            RefDate::Edtf(edtf) => edtf.year().to_string(),
63            RefDate::Literal(_) => String::new(),
64        }
65    }
66
67    /// Extract the month from the date.
68    pub fn month(&self) -> Option<u32> {
69        match self.parse() {
70            RefDate::Edtf(edtf) => edtf.month(),
71            RefDate::Literal(_) => None,
72        }
73    }
74
75    /// Extract the day from the date.
76    pub fn day(&self) -> Option<u32> {
77        match self.parse() {
78            RefDate::Edtf(edtf) => edtf.day().filter(|&d| d > 0),
79            RefDate::Literal(_) => None,
80        }
81    }
82
83    /// Extract (year, month, day) from a single EDTF parse, avoiding the
84    /// separate re-parse each of `.year()`, `.month()`, and `.day()` would
85    /// otherwise incur. `None` for an unparseable (`Literal`) date, matching
86    /// `.year()`'s empty-string / `.month()`'s and `.day()`'s `None`
87    /// behavior for that case.
88    pub fn date_parts(&self) -> Option<(i64, Option<u32>, Option<u32>)> {
89        match self.parse() {
90            RefDate::Edtf(edtf) => Some((edtf.year(), edtf.month(), edtf.day().filter(|&d| d > 0))),
91            RefDate::Literal(_) => None,
92        }
93    }
94
95    /// Check if the date is uncertain (has "?" qualifier).
96    pub fn is_uncertain(&self) -> bool {
97        self.value.contains('?')
98    }
99
100    /// Check if the date is approximate (has "~" qualifier).
101    pub fn is_approximate(&self) -> bool {
102        self.value.contains('~')
103    }
104
105    /// Check if the date is a range (interval).
106    pub fn is_range(&self) -> bool {
107        matches!(self.parse(), RefDate::Edtf(edtf) if edtf.is_range())
108    }
109
110    /// Check if the range is open-ended (ends with "..").
111    pub fn is_open_range(&self) -> bool {
112        matches!(self.parse(), RefDate::Edtf(edtf) if edtf.is_open_range())
113    }
114
115    /// Extract the time component from the date, if present.
116    pub fn time(&self) -> Option<citum_edtf::Time> {
117        match self.parse() {
118            RefDate::Edtf(edtf) => edtf.time(),
119            _ => None,
120        }
121    }
122
123    /// Check if the date has a time component.
124    pub fn has_time(&self) -> bool {
125        self.time().is_some()
126    }
127}
128
129impl fmt::Display for DateValue {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}", self.value)
132    }
133}
134
135impl From<String> for DateValue {
136    fn from(value: String) -> Self {
137        Self::new(value)
138    }
139}
140
141impl From<&str> for DateValue {
142    fn from(value: &str) -> Self {
143        Self::new(value)
144    }
145}
146
147/// Wire representation used only to deserialize [`DateValue`]: either a bare
148/// EDTF string, or the explicit `{ value, note }` mapping. Unknown mapping
149/// keys are rejected.
150#[derive(Deserialize)]
151#[serde(deny_unknown_fields)]
152struct DateValueStructured {
153    value: String,
154    #[serde(default)]
155    note: Option<String>,
156}
157
158#[derive(Deserialize)]
159#[serde(untagged)]
160enum DateValueRepr {
161    Scalar(String),
162    Structured(DateValueStructured),
163}
164
165impl<'de> Deserialize<'de> for DateValue {
166    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167    where
168        D: serde::Deserializer<'de>,
169    {
170        Ok(match DateValueRepr::deserialize(deserializer)? {
171            DateValueRepr::Scalar(value) => DateValue { value, note: None },
172            DateValueRepr::Structured(DateValueStructured { value, note }) => {
173                DateValue { value, note }
174            }
175        })
176    }
177}
178
179impl Serialize for DateValue {
180    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
181    where
182        S: serde::Serializer,
183    {
184        match &self.note {
185            None => serializer.serialize_str(&self.value),
186            Some(note) => {
187                use serde::ser::SerializeMap;
188                let mut map = serializer.serialize_map(Some(2))?;
189                map.serialize_entry("value", &self.value)?;
190                map.serialize_entry("note", note)?;
191                map.end()
192            }
193        }
194    }
195}
196
197#[cfg(feature = "schema")]
198impl JsonSchema for DateValue {
199    fn schema_name() -> std::borrow::Cow<'static, str> {
200        "DateValue".into()
201    }
202
203    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
204        let scalar_schema = generator.subschema_for::<String>();
205        let structured_schema = schemars::json_schema!({
206            "type": "object",
207            "properties": {
208                "value": generator.subschema_for::<String>(),
209                "note": generator.subschema_for::<Option<String>>()
210            },
211            "required": ["value"],
212            "additionalProperties": false
213        });
214        schemars::json_schema!({
215            "oneOf": [scalar_schema, structured_schema]
216        })
217    }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used, reason = "Panicking is acceptable in tests.")]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn scalar_input_round_trips_byte_identically() {
227        let json = r#""1947""#;
228        let date: DateValue = serde_json::from_str(json).unwrap();
229        assert_eq!(date, DateValue::new("1947"));
230        assert_eq!(serde_json::to_string(&date).unwrap(), json);
231    }
232
233    #[test]
234    fn mapping_form_parses_value_and_note() {
235        let json = r#"{"value":"1947","note":"民国三十六年"}"#;
236        let date: DateValue = serde_json::from_str(json).unwrap();
237        assert_eq!(date.value, "1947");
238        assert_eq!(date.note.as_deref(), Some("民国三十六年"));
239    }
240
241    #[test]
242    fn mapping_form_without_note_defaults_to_none() {
243        let json = r#"{"value":"1947"}"#;
244        let date: DateValue = serde_json::from_str(json).unwrap();
245        assert_eq!(date, DateValue::new("1947"));
246    }
247
248    #[test]
249    fn mapping_form_rejects_unknown_fields() {
250        let json = r#"{"value":"1947","note":"民国三十六年","calendar":"minguo"}"#;
251        let err = serde_json::from_str::<DateValue>(json).unwrap_err();
252        assert!(
253            err.to_string()
254                .contains("did not match any variant of untagged enum")
255        );
256    }
257
258    #[test]
259    fn mapping_form_requires_value() {
260        let json = r#"{"note":"民国三十六年"}"#;
261        assert!(serde_json::from_str::<DateValue>(json).is_err());
262    }
263
264    #[test]
265    fn note_present_serializes_as_mapping_not_scalar() {
266        let date = DateValue {
267            value: "1947".to_string(),
268            note: Some("民国三十六年".to_string()),
269        };
270        let json = serde_json::to_string(&date).unwrap();
271        assert_eq!(json, r#"{"value":"1947","note":"民国三十六年"}"#);
272
273        let round_tripped: DateValue = serde_json::from_str(&json).unwrap();
274        assert_eq!(round_tripped, date);
275    }
276
277    #[test]
278    fn note_is_ignored_by_value_oriented_accessors() {
279        // The note must never influence anything computed off the date's
280        // canonical value: sorting, disambiguation, and fallback selection
281        // all read `value`, never `note`. See CALENDAR_DATE_ANNOTATIONS.md.
282        let annotated = DateValue {
283            value: "1947".to_string(),
284            note: Some("民国三十六年".to_string()),
285        };
286        let plain = DateValue::new("1947");
287        assert_eq!(annotated.value, plain.value);
288        assert_eq!(annotated.year(), plain.year());
289        assert_eq!(annotated.is_empty(), plain.is_empty());
290    }
291}