Skip to main content

commonmeta/
date_utils.rs

1//! Date and datetime utilities.
2//!
3//! EDTF level 0 (ISO 8601 subset used by DataCite and InvenioRDM) is the
4//! primary parser. A manual fallback handles malformed dates from upstream
5//! sources (e.g. single-digit months like "2023-8-25").
6//!
7//! Mirrors the Go `dateutils` package in front-matter/commonmeta.
8
9use chrono::{NaiveDateTime, Timelike};
10use edtf::level_0::Edtf;
11
12// ── Building dates from parts ─────────────────────────────────────────────────
13
14/// Build an ISO 8601 partial date from numeric parts.
15/// Pass `0` for month or day to omit that component.
16/// `date_from_parts(2023, 8, 5)` → `"2023-08-05"`.
17pub fn date_from_parts(year: i32, month: u32, day: u32) -> String {
18    if year == 0 {
19        return String::new();
20    }
21    match (month, day) {
22        (0, _) => format!("{:04}", year),
23        (m, 0) => format!("{:04}-{:02}", year, m),
24        (m, d) => format!("{:04}-{:02}-{:02}", year, m, d),
25    }
26}
27
28/// Build an ISO 8601 partial date from string parts (empty string = omit).
29/// Used for Crossref XML and RIS date fields.
30pub fn date_from_str_parts(year: &str, month: &str, day: &str) -> String {
31    let y: i32 = year.trim().parse().unwrap_or(0);
32    let m: u32 = month.trim().parse().unwrap_or(0);
33    let d: u32 = day.trim().parse().unwrap_or(0);
34    date_from_parts(y, m, d)
35}
36
37// ── Parsing ───────────────────────────────────────────────────────────────────
38
39/// Parse a date or datetime string and return an ISO 8601 **date**
40/// (`"YYYY"`, `"YYYY-MM"`, or `"YYYY-MM-DD"`). Time components are discarded.
41///
42/// Uses EDTF level 0 as the primary parser (covers DataCite and InvenioRDM).
43/// Falls back to manual extraction for malformed dates (e.g. single-digit months).
44/// Returns `""` on failure.
45pub fn parse_date(s: &str) -> String {
46    if s.is_empty() {
47        return String::new();
48    }
49
50    // Primary: EDTF level 0 parser
51    if let Ok(edtf) = Edtf::parse(s) {
52        if let Some(date) = edtf.as_date() {
53            return date_from_parts(date.year(), date.month(), date.day());
54        }
55        if let Some(dt) = edtf.as_datetime() {
56            let d = dt.date();
57            return date_from_parts(d.year(), d.month(), d.day());
58        }
59        if let Some((start, _)) = edtf.as_interval() {
60            return date_from_parts(start.year(), start.month(), start.day());
61        }
62    }
63
64    // Fallback: manual extraction for non-conforming strings
65    let date_part = {
66        let s = match s.find('T') { Some(p) => &s[..p], None => s };
67        match s.find(' ') { Some(p) => &s[..p], None => s }
68    };
69    let mut parts = date_part.splitn(3, '-');
70    let year: i32 = match parts.next().and_then(|s| s.trim().parse().ok()) {
71        Some(y) if y != 0 => y,
72        _ => return String::new(),
73    };
74    let month: u32 = parts.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
75    let day: u32 = parts.next().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
76    date_from_parts(year, month, day)
77}
78
79/// Parse a date or datetime string and return an ISO 8601 datetime
80/// (`"YYYY-MM-DDTHH:MM:SSZ"`), or a date-only string when the time is midnight.
81/// Returns `""` on failure.
82///
83/// Uses EDTF level 0 as the primary parser. Strips sub-second fractions (e.g.
84/// from InvenioRDM timestamps) before attempting EDTF, then falls back to chrono.
85pub fn parse_datetime(s: &str) -> String {
86    if s.is_empty() {
87        return String::new();
88    }
89
90    // Pre-process: strip sub-second fractions that EDTF doesn't accept
91    let stripped = strip_milliseconds(s);
92    let input = if stripped.is_empty() { s } else { &stripped };
93
94    // Primary: EDTF level 0
95    if let Ok(edtf) = Edtf::parse(input) {
96        if let Some(dt) = edtf.as_datetime() {
97            let t = dt.time();
98            if t.hour() == 0 && t.minute() == 0 && t.second() == 0 {
99                let d = dt.date();
100                return date_from_parts(d.year(), d.month(), d.day());
101            }
102            let d = dt.date();
103            return format!(
104                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
105                d.year(), d.month(), d.day(),
106                t.hour(), t.minute(), t.second()
107            );
108        }
109        if let Some(date) = edtf.as_date() {
110            return date_from_parts(date.year(), date.month(), date.day());
111        }
112    }
113
114    // Fallback: chrono multi-format parsing
115    let bare = input.trim_end_matches('Z');
116    for fmt in &["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S", "%Y%m%d%H%M%S"] {
117        if let Ok(dt) = NaiveDateTime::parse_from_str(bare, fmt) {
118            if dt.hour() == 0 && dt.minute() == 0 && dt.second() == 0 {
119                return parse_date(input);
120            }
121            return dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
122        }
123    }
124
125    parse_date(s)
126}
127
128/// Pad single-digit month/day components to two digits so they are valid EDTF/ISO 8601.
129/// `"2023-8-5"` → `"2023-08-05"`, `"2023-8-5T12:00:00"` → `"2023-08-05T12:00:00"`.
130///
131/// This is a pre-processing step for malformed upstream data before EDTF parsing.
132pub fn normalize_date(date: &str) -> String {
133    let (date_part, time_suffix) = match date.find('T') {
134        Some(pos) => (&date[..pos], &date[pos..]),
135        None => (date, ""),
136    };
137    let parts: Vec<&str> = date_part.split('-').collect();
138    let padded = match parts.as_slice() {
139        [y] => y.to_string(),
140        [y, m] => format!("{}-{:0>2}", y, m),
141        [y, m, d] => format!("{}-{:0>2}-{:0>2}", y, m, d),
142        _ => date_part.to_string(),
143    };
144    format!("{}{}", padded, time_suffix)
145}
146
147/// Sanitize an arbitrary date string from upstream sources into valid EDTF / ISO 8601.
148/// Handles, in order:
149/// 1. Already-valid EDTF (returned as-is).
150/// 2. Single-digit month/day after zero-padding (`normalize_date`).
151/// 3. European DD.MM.YYYY format → `"YYYY-MM-DD"`.
152/// Returns `""` if the input cannot be recognized.
153pub fn sanitize_date(s: &str) -> String {
154    if s.is_empty() {
155        return String::new();
156    }
157    // 1. Try EDTF — handles dates, datetimes, and intervals.
158    //    Intervals yield the start date only.
159    if let Ok(edtf) = Edtf::parse(s) {
160        return match edtf {
161            Edtf::Date(d) => d.to_string(),
162            Edtf::DateTime(_) => s.to_string(),
163            Edtf::Interval(start, _) => start.to_string(),
164        };
165    }
166    // 2. Zero-pad single-digit month/day then retry EDTF
167    let padded = normalize_date(s);
168    if let Ok(edtf) = Edtf::parse(&padded) {
169        return match edtf {
170            Edtf::Date(d) => d.to_string(),
171            Edtf::DateTime(_) => padded,
172            Edtf::Interval(start, _) => start.to_string(),
173        };
174    }
175    // 3. European DD.MM.YYYY  (e.g. "11.03.2016")
176    let b = s.as_bytes();
177    if s.len() == 10 && b.get(2) == Some(&b'.') && b.get(5) == Some(&b'.') {
178        let r = date_from_str_parts(&s[6..10], &s[3..5], &s[0..2]);
179        if !r.is_empty() {
180            return r;
181        }
182    }
183    String::new()
184}
185
186// ── Unix timestamps ───────────────────────────────────────────────────────────
187
188/// Convert a Unix timestamp to an ISO 8601 date string (`"YYYY-MM-DD"`).
189pub fn unix_to_date(ts: i64) -> String {
190    if ts == 0 {
191        return String::new();
192    }
193    chrono::DateTime::from_timestamp(ts, 0)
194        .map(|dt: chrono::DateTime<chrono::Utc>| dt.format("%Y-%m-%d").to_string())
195        .unwrap_or_default()
196}
197
198/// Convert a Unix timestamp to an ISO 8601 datetime string (`"YYYY-MM-DDTHH:MM:SSZ"`).
199pub fn unix_to_datetime(ts: i64) -> String {
200    if ts == 0 {
201        return String::new();
202    }
203    chrono::DateTime::from_timestamp(ts, 0)
204        .map(|dt: chrono::DateTime<chrono::Utc>| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
205        .unwrap_or_default()
206}
207
208// ── Stripping / truncating ────────────────────────────────────────────────────
209
210/// Remove sub-second fractions from an ISO 8601 datetime string.
211/// A midnight time component (`T00:00:00`) is stripped entirely, leaving only the date.
212/// Normalises `+00:00` timezone suffix to `Z`.
213///
214/// `"2024-01-15T12:34:56.789Z"` → `"2024-01-15T12:34:56Z"`.
215/// `"2024-01-15T00:00:00"` → `"2024-01-15"`.
216pub fn strip_milliseconds(s: &str) -> String {
217    if s.is_empty() {
218        return String::new();
219    }
220    if s.contains("T00:00:00") {
221        return s.split('T').next().unwrap_or(s).to_string();
222    }
223    if let Some(dot) = s.find('.') {
224        let rest = &s[dot + 1..];
225        let frac_end = rest
226            .find(|c: char| !c.is_ascii_digit())
227            .map(|i| i + dot + 1)
228            .unwrap_or(s.len());
229        let suffix = &s[frac_end..];
230        let suffix = if suffix.is_empty() { "Z" } else { suffix };
231        return format!("{}{}", &s[..dot], suffix);
232    }
233    if let Some(plus) = s.rfind("+00:00") {
234        return format!("{}Z", &s[..plus]);
235    }
236    s.to_string()
237}
238
239/// Return only the date portion of a datetime string (first 10 characters).
240/// `"2024-01-15T12:34:56Z"` → `"2024-01-15"`. Pass-through for shorter strings.
241pub fn date_only(s: &str) -> String {
242    if s.len() >= 10 {
243        s[..10].to_string()
244    } else {
245        s.to_string()
246    }
247}
248
249// ── Tests ─────────────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_date_from_parts() {
257        assert_eq!(date_from_parts(2023, 8, 5), "2023-08-05");
258        assert_eq!(date_from_parts(2023, 8, 0), "2023-08");
259        assert_eq!(date_from_parts(2023, 0, 0), "2023");
260        assert_eq!(date_from_parts(0, 0, 0), "");
261    }
262
263    #[test]
264    fn test_date_from_str_parts() {
265        assert_eq!(date_from_str_parts("2023", "8", "5"), "2023-08-05");
266        assert_eq!(date_from_str_parts("2023", "8", ""), "2023-08");
267        assert_eq!(date_from_str_parts("2023", "", ""), "2023");
268    }
269
270    #[test]
271    fn test_parse_date() {
272        // Valid EDTF (DataCite/InvenioRDM)
273        assert_eq!(parse_date("2023-08-25"), "2023-08-25");
274        assert_eq!(parse_date("2023-08-25T00:00:00Z"), "2023-08-25");
275        assert_eq!(parse_date("2023-08-25T12:30:00+05:00"), "2023-08-25");
276        assert_eq!(parse_date("2023-08"), "2023-08");
277        assert_eq!(parse_date("2023"), "2023");
278        // EDTF interval: take start date
279        assert_eq!(parse_date("2019-04-04/2021-06-06"), "2019-04-04");
280        // Malformed (fallback path)
281        assert_eq!(parse_date("2023-8-25"), "2023-08-25");
282        assert_eq!(parse_date("2023-08-25T00:00:00"), "2023-08-25");
283        assert_eq!(parse_date(""), "");
284    }
285
286    #[test]
287    fn test_sanitize_date() {
288        assert_eq!(sanitize_date("2023-08-25"), "2023-08-25");
289        assert_eq!(sanitize_date("2023-8-25"), "2023-08-25");
290        assert_eq!(sanitize_date("1996-04-04T00:00:00"), "1996-04-04T00:00:00");
291        assert_eq!(sanitize_date("11.03.2016"), "2016-03-11");
292        assert_eq!(sanitize_date("2020/2020"), "2020");
293        assert_eq!(sanitize_date("2023-01-01/2023-12-31"), "2023-01-01");
294        assert_eq!(sanitize_date(""), "");
295        assert_eq!(sanitize_date("not-a-date"), "");
296    }
297
298    #[test]
299    fn test_normalize_date() {
300        assert_eq!(normalize_date("2023-8-25"), "2023-08-25");
301        assert_eq!(normalize_date("2023-8-5T12:00:00"), "2023-08-05T12:00:00");
302        assert_eq!(normalize_date("2023-08-25"), "2023-08-25");
303        assert_eq!(normalize_date("2023"), "2023");
304    }
305
306    #[test]
307    fn test_parse_datetime() {
308        assert_eq!(parse_datetime("2023-08-25T12:30:00Z"), "2023-08-25T12:30:00Z");
309        assert_eq!(parse_datetime("2023-08-25T00:00:00Z"), "2023-08-25");
310        assert_eq!(parse_datetime("2023-08-25T00:00:00"), "2023-08-25");
311        // InvenioRDM microseconds
312        assert_eq!(parse_datetime("2024-01-15T12:34:56.789012+00:00"), "2024-01-15T12:34:56Z");
313    }
314
315    #[test]
316    fn test_strip_milliseconds() {
317        assert_eq!(strip_milliseconds("2024-01-15T12:34:56.789Z"), "2024-01-15T12:34:56Z");
318        assert_eq!(strip_milliseconds("2024-01-15T00:00:00"), "2024-01-15");
319        assert_eq!(strip_milliseconds("2024-01-15T00:00:00.000Z"), "2024-01-15");
320        assert_eq!(strip_milliseconds("2024-01-15T12:34:56+00:00"), "2024-01-15T12:34:56Z");
321        assert_eq!(strip_milliseconds(""), "");
322    }
323
324    #[test]
325    fn test_unix_to_date() {
326        assert_eq!(unix_to_date(1711238400), "2024-03-24");
327        assert_eq!(unix_to_date(0), "");
328    }
329
330    #[test]
331    fn test_unix_to_datetime() {
332        assert_eq!(unix_to_datetime(1711238400), "2024-03-24T00:00:00Z");
333        assert_eq!(unix_to_datetime(0), "");
334    }
335}