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 day from the date.
68    pub fn day(&self) -> Option<u32> {
69        match self.parse() {
70            RefDate::Edtf(edtf) => edtf.day().filter(|&d| d > 0),
71            RefDate::Literal(_) => None,
72        }
73    }
74
75    /// Check if the date is uncertain (has "?" qualifier).
76    pub fn is_uncertain(&self) -> bool {
77        self.value.contains('?')
78    }
79
80    /// Check if the date is approximate (has "~" qualifier).
81    pub fn is_approximate(&self) -> bool {
82        self.value.contains('~')
83    }
84
85    /// Check if the date is a range (interval).
86    pub fn is_range(&self) -> bool {
87        matches!(self.parse(), RefDate::Edtf(edtf) if edtf.is_range())
88    }
89
90    /// Check if the range is open-ended (ends with "..").
91    pub fn is_open_range(&self) -> bool {
92        matches!(self.parse(), RefDate::Edtf(edtf) if edtf.is_open_range())
93    }
94
95    /// Extract the time component from the date, if present.
96    pub fn time(&self) -> Option<citum_edtf::Time> {
97        match self.parse() {
98            RefDate::Edtf(edtf) => edtf.time(),
99            _ => None,
100        }
101    }
102
103    /// Check if the date has a time component.
104    pub fn has_time(&self) -> bool {
105        self.time().is_some()
106    }
107}
108
109impl fmt::Display for DateValue {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{}", self.value)
112    }
113}
114
115impl From<String> for DateValue {
116    fn from(value: String) -> Self {
117        Self::new(value)
118    }
119}
120
121impl From<&str> for DateValue {
122    fn from(value: &str) -> Self {
123        Self::new(value)
124    }
125}
126
127/// Wire representation used only to deserialize [`DateValue`]: either a bare
128/// EDTF string, or the explicit `{ value, note }` mapping. Unknown mapping
129/// keys are rejected.
130#[derive(Deserialize)]
131#[serde(deny_unknown_fields)]
132struct DateValueStructured {
133    value: String,
134    #[serde(default)]
135    note: Option<String>,
136}
137
138#[derive(Deserialize)]
139#[serde(untagged)]
140enum DateValueRepr {
141    Scalar(String),
142    Structured(DateValueStructured),
143}
144
145impl<'de> Deserialize<'de> for DateValue {
146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147    where
148        D: serde::Deserializer<'de>,
149    {
150        Ok(match DateValueRepr::deserialize(deserializer)? {
151            DateValueRepr::Scalar(value) => DateValue { value, note: None },
152            DateValueRepr::Structured(DateValueStructured { value, note }) => {
153                DateValue { value, note }
154            }
155        })
156    }
157}
158
159impl Serialize for DateValue {
160    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
161    where
162        S: serde::Serializer,
163    {
164        match &self.note {
165            None => serializer.serialize_str(&self.value),
166            Some(note) => {
167                use serde::ser::SerializeMap;
168                let mut map = serializer.serialize_map(Some(2))?;
169                map.serialize_entry("value", &self.value)?;
170                map.serialize_entry("note", note)?;
171                map.end()
172            }
173        }
174    }
175}
176
177#[cfg(feature = "schema")]
178impl JsonSchema for DateValue {
179    fn schema_name() -> std::borrow::Cow<'static, str> {
180        "DateValue".into()
181    }
182
183    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
184        let scalar_schema = generator.subschema_for::<String>();
185        let structured_schema = schemars::json_schema!({
186            "type": "object",
187            "properties": {
188                "value": generator.subschema_for::<String>(),
189                "note": generator.subschema_for::<Option<String>>()
190            },
191            "required": ["value"],
192            "additionalProperties": false
193        });
194        schemars::json_schema!({
195            "oneOf": [scalar_schema, structured_schema]
196        })
197    }
198}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used, reason = "Panicking is acceptable in tests.")]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn scalar_input_round_trips_byte_identically() {
207        let json = r#""1947""#;
208        let date: DateValue = serde_json::from_str(json).unwrap();
209        assert_eq!(date, DateValue::new("1947"));
210        assert_eq!(serde_json::to_string(&date).unwrap(), json);
211    }
212
213    #[test]
214    fn mapping_form_parses_value_and_note() {
215        let json = r#"{"value":"1947","note":"民国三十六年"}"#;
216        let date: DateValue = serde_json::from_str(json).unwrap();
217        assert_eq!(date.value, "1947");
218        assert_eq!(date.note.as_deref(), Some("民国三十六年"));
219    }
220
221    #[test]
222    fn mapping_form_without_note_defaults_to_none() {
223        let json = r#"{"value":"1947"}"#;
224        let date: DateValue = serde_json::from_str(json).unwrap();
225        assert_eq!(date, DateValue::new("1947"));
226    }
227
228    #[test]
229    fn mapping_form_rejects_unknown_fields() {
230        let json = r#"{"value":"1947","note":"民国三十六年","calendar":"minguo"}"#;
231        let err = serde_json::from_str::<DateValue>(json).unwrap_err();
232        assert!(
233            err.to_string()
234                .contains("did not match any variant of untagged enum")
235        );
236    }
237
238    #[test]
239    fn mapping_form_requires_value() {
240        let json = r#"{"note":"民国三十六年"}"#;
241        assert!(serde_json::from_str::<DateValue>(json).is_err());
242    }
243
244    #[test]
245    fn note_present_serializes_as_mapping_not_scalar() {
246        let date = DateValue {
247            value: "1947".to_string(),
248            note: Some("民国三十六年".to_string()),
249        };
250        let json = serde_json::to_string(&date).unwrap();
251        assert_eq!(json, r#"{"value":"1947","note":"民国三十六年"}"#);
252
253        let round_tripped: DateValue = serde_json::from_str(&json).unwrap();
254        assert_eq!(round_tripped, date);
255    }
256
257    #[test]
258    fn note_is_ignored_by_value_oriented_accessors() {
259        // The note must never influence anything computed off the date's
260        // canonical value: sorting, disambiguation, and fallback selection
261        // all read `value`, never `note`. See CALENDAR_DATE_ANNOTATIONS.md.
262        let annotated = DateValue {
263            value: "1947".to_string(),
264            note: Some("民国三十六年".to_string()),
265        };
266        let plain = DateValue::new("1947");
267        assert_eq!(annotated.value, plain.value);
268        assert_eq!(annotated.year(), plain.year());
269        assert_eq!(annotated.is_empty(), plain.is_empty());
270    }
271}