Skip to main content

hl7v2_datetime/
lib.rs

1//! HL7 v2 date/time parsing and validation.
2//!
3//! This crate provides comprehensive date/time handling for HL7 v2 messages,
4//! supporting various HL7 timestamp formats and precision levels.
5//!
6//! # Supported Formats
7//!
8//! - `DT` (Date): YYYYMMDD
9//! - `TM` (Time): HHMM\[SS\[.S\[S\[S\[S\]\]\]\]\]
10//! - `TS` (Timestamp): YYYYMMDD\[HHMM\[SS\[.S\[S\[S\[S\]\]\]\]\]\]
11//!
12//! # Example
13//!
14//! ```
15//! use hl7v2_datetime::{parse_hl7_ts, parse_hl7_dt, parse_hl7_tm, parse_hl7_ts_with_precision, TimestampPrecision};
16//! use chrono::Datelike;
17//!
18//! // Parse date (DT)
19//! let date = parse_hl7_dt("20250128").unwrap();
20//! assert_eq!(date.year(), 2025);
21//! assert_eq!(date.month(), 1);
22//! assert_eq!(date.day(), 28);
23//!
24//! // Parse timestamp (TS) with precision
25//! let ts = parse_hl7_ts_with_precision("20250128152312").unwrap();
26//! assert_eq!(ts.precision, TimestampPrecision::Second);
27//!
28//! // Compare timestamps with different precisions
29//! let ts1 = parse_hl7_ts_with_precision("20250128").unwrap();
30//! let ts2 = parse_hl7_ts_with_precision("20250128120000").unwrap();
31//! assert!(ts1.is_same_day(&ts2));
32//! ```
33
34use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike};
35
36/// Error type for date/time parsing
37#[derive(Debug, Clone, PartialEq, thiserror::Error)]
38pub enum DateTimeError {
39    #[error("Invalid date format: {0}")]
40    InvalidDateFormat(String),
41
42    #[error("Invalid time format: {0}")]
43    InvalidTimeFormat(String),
44
45    #[error("Invalid timestamp format: {0}")]
46    InvalidTimestampFormat(String),
47
48    #[error("Date out of range: {0}")]
49    DateOutOfRange(String),
50
51    #[error("Time out of range: {0}")]
52    TimeOutOfRange(String),
53}
54
55/// Precision levels for HL7 timestamps
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
57pub enum TimestampPrecision {
58    /// Year only (YYYY)
59    Year,
60    /// Year and month (YYYYMM)
61    Month,
62    /// Full date (YYYYMMDD)
63    Day,
64    /// Date with hour (YYYYMMDDHH)
65    Hour,
66    /// Date with hour and minute (YYYYMMDDHHMM)
67    Minute,
68    /// Full precision to second (YYYYMMDDHHMMSS)
69    Second,
70    /// With fractional seconds
71    FractionalSecond,
72}
73
74/// Parsed HL7 timestamp with precision information
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ParsedTimestamp {
77    /// The parsed datetime
78    pub datetime: NaiveDateTime,
79    /// The precision of the timestamp
80    pub precision: TimestampPrecision,
81    /// Fractional seconds (if present)
82    pub fractional_seconds: Option<u32>,
83}
84
85impl ParsedTimestamp {
86    /// Create a new parsed timestamp
87    pub fn new(datetime: NaiveDateTime, precision: TimestampPrecision) -> Self {
88        Self {
89            datetime,
90            precision,
91            fractional_seconds: None,
92        }
93    }
94
95    /// Create with fractional seconds
96    pub fn with_fractional(datetime: NaiveDateTime, fractional: u32) -> Self {
97        Self {
98            datetime,
99            precision: TimestampPrecision::FractionalSecond,
100            fractional_seconds: Some(fractional),
101        }
102    }
103
104    /// Check if two timestamps are on the same day
105    pub fn is_same_day(&self, other: &ParsedTimestamp) -> bool {
106        self.datetime.date() == other.datetime.date()
107    }
108
109    /// Check if this timestamp is before another (strictly less than)
110    pub fn is_before(&self, other: &ParsedTimestamp) -> bool {
111        // For timestamps with different precisions, compare at the finer precision
112        if self.precision != other.precision {
113            // Compare full datetime values - a date-only timestamp at midnight
114            // is considered equal to a datetime at midnight on that same day
115            return self.datetime < other.datetime;
116        }
117        self.datetime < other.datetime
118    }
119
120    /// Check if this timestamp is after another
121    pub fn is_after(&self, other: &ParsedTimestamp) -> bool {
122        other.is_before(self)
123    }
124
125    /// Check if this timestamp is equal to another (considering precision)
126    pub fn is_equal(&self, other: &ParsedTimestamp) -> bool {
127        let min_precision = std::cmp::min(self.precision, other.precision);
128        let truncated_self = truncate_to_precision(&self.datetime, min_precision);
129        let truncated_other = truncate_to_precision(&other.datetime, min_precision);
130        truncated_self == truncated_other
131    }
132
133    /// Format as HL7 TS string
134    pub fn to_hl7_string(&self) -> String {
135        match self.precision {
136            TimestampPrecision::Year => self.datetime.format("%Y").to_string(),
137            TimestampPrecision::Month => self.datetime.format("%Y%m").to_string(),
138            TimestampPrecision::Day => self.datetime.format("%Y%m%d").to_string(),
139            TimestampPrecision::Hour => self.datetime.format("%Y%m%d%H").to_string(),
140            TimestampPrecision::Minute => self.datetime.format("%Y%m%d%H%M").to_string(),
141            TimestampPrecision::Second => self.datetime.format("%Y%m%d%H%M%S").to_string(),
142            TimestampPrecision::FractionalSecond => {
143                if let Some(frac) = self.fractional_seconds {
144                    format!("{}{:06}", self.datetime.format("%Y%m%d%H%M%S"), frac)
145                } else {
146                    self.datetime.format("%Y%m%d%H%M%S").to_string()
147                }
148            }
149        }
150    }
151}
152
153/// Parse HL7 date (DT format: YYYYMMDD)
154pub fn parse_hl7_dt(s: &str) -> Result<NaiveDate, DateTimeError> {
155    let s = s.trim();
156
157    if s.len() != 8 {
158        return Err(DateTimeError::InvalidDateFormat(format!(
159            "Expected 8 characters, got {}",
160            s.len()
161        )));
162    }
163
164    if !s.chars().all(|c| c.is_ascii_digit()) {
165        return Err(DateTimeError::InvalidDateFormat(
166            "Contains non-digit characters".to_string(),
167        ));
168    }
169
170    NaiveDate::parse_from_str(s, "%Y%m%d")
171        .map_err(|e| DateTimeError::InvalidDateFormat(e.to_string()))
172}
173
174/// Parse HL7 time (TM format: HHMM[SS[.S...]])
175pub fn parse_hl7_tm(s: &str) -> Result<(u32, u32, u32, Option<u32>), DateTimeError> {
176    let s = s.trim();
177
178    if s.len() < 4 {
179        return Err(DateTimeError::InvalidTimeFormat(format!(
180            "Expected at least 4 characters, got {}",
181            s.len()
182        )));
183    }
184
185    if !s.is_ascii() {
186        return Err(DateTimeError::InvalidTimeFormat(
187            "Non-ASCII characters".into(),
188        ));
189    }
190
191    // Parse hour and minute (required)
192    let hour: u32 = s[0..2]
193        .parse()
194        .map_err(|_| DateTimeError::TimeOutOfRange("Invalid hour".to_string()))?;
195    let minute: u32 = s[2..4]
196        .parse()
197        .map_err(|_| DateTimeError::TimeOutOfRange("Invalid minute".to_string()))?;
198
199    // Validate hour and minute
200    if hour > 23 {
201        return Err(DateTimeError::TimeOutOfRange(format!(
202            "Hour {} out of range",
203            hour
204        )));
205    }
206    if minute > 59 {
207        return Err(DateTimeError::TimeOutOfRange(format!(
208            "Minute {} out of range",
209            minute
210        )));
211    }
212
213    // Parse seconds (optional)
214    let (second, fractional) = if s.len() > 4 {
215        // Check for fractional seconds
216        let (sec_part, frac_part) = if let Some(dot_pos) = s[4..].find('.') {
217            let sec = &s[4..4 + dot_pos];
218            let frac = &s[4 + dot_pos + 1..];
219            (sec, Some(frac))
220        } else {
221            (&s[4..], None)
222        };
223
224        let sec: u32 = sec_part
225            .parse()
226            .map_err(|_| DateTimeError::TimeOutOfRange("Invalid second".to_string()))?;
227        if sec > 59 {
228            return Err(DateTimeError::TimeOutOfRange(format!(
229                "Second {} out of range",
230                sec
231            )));
232        }
233
234        let frac = if let Some(f) = frac_part {
235            // Parse fractional seconds (up to 6 digits for microseconds)
236            let padded = format!("{:0<6}", f.chars().take(6).collect::<String>());
237            Some(padded.parse::<u32>().unwrap_or(0))
238        } else {
239            None
240        };
241
242        (sec, frac)
243    } else {
244        (0, None)
245    };
246
247    Ok((hour, minute, second, fractional))
248}
249
250/// Parse HL7 timestamp (TS format: YYYYMMDD[HHMM[SS[.S...]]])
251pub fn parse_hl7_ts(s: &str) -> Result<NaiveDateTime, DateTimeError> {
252    let s = s.trim();
253
254    if s.len() < 8 {
255        return Err(DateTimeError::InvalidTimestampFormat(format!(
256            "Expected at least 8 characters, got {}",
257            s.len()
258        )));
259    }
260
261    if !s.is_ascii() {
262        return Err(DateTimeError::InvalidTimestampFormat(
263            "Non-ASCII characters".into(),
264        ));
265    }
266
267    // Parse date part
268    let date = parse_hl7_dt(&s[0..8])?;
269
270    // If only date, return with midnight time
271    if s.len() == 8 {
272        return Ok(date.and_hms_opt(0, 0, 0).unwrap());
273    }
274
275    // Parse time part
276    let time_str = &s[8..];
277    let (hour, minute, second, _) = parse_hl7_tm(time_str)?;
278
279    date.and_hms_opt(hour, minute, second)
280        .ok_or_else(|| DateTimeError::TimeOutOfRange("Invalid time combination".to_string()))
281}
282
283/// Parse HL7 timestamp with precision information
284pub fn parse_hl7_ts_with_precision(s: &str) -> Result<ParsedTimestamp, DateTimeError> {
285    let s = s.trim();
286
287    if !s.is_ascii() {
288        return Err(DateTimeError::InvalidTimestampFormat(
289            "Non-ASCII characters".into(),
290        ));
291    }
292
293    // Determine precision from length
294    let precision = match s.len() {
295        4 => TimestampPrecision::Year,
296        6 => TimestampPrecision::Month,
297        8 => TimestampPrecision::Day,
298        10 => TimestampPrecision::Hour,
299        12 => TimestampPrecision::Minute,
300        14 => TimestampPrecision::Second,
301        n if n > 14 && s[14..].starts_with('.') => TimestampPrecision::FractionalSecond,
302        _ => {
303            return Err(DateTimeError::InvalidTimestampFormat(format!(
304                "Invalid length: {}",
305                s.len()
306            )));
307        }
308    };
309
310    // Parse based on precision
311    match precision {
312        TimestampPrecision::Year => {
313            let year: i32 = s
314                .parse()
315                .map_err(|_| DateTimeError::InvalidDateFormat("Invalid year".into()))?;
316            let date = NaiveDate::from_ymd_opt(year, 1, 1)
317                .ok_or_else(|| DateTimeError::DateOutOfRange("Invalid year".into()))?;
318            Ok(ParsedTimestamp::new(
319                date.and_hms_opt(0, 0, 0).unwrap(),
320                precision,
321            ))
322        }
323        TimestampPrecision::Month => {
324            let year: i32 = s[0..4]
325                .parse()
326                .map_err(|_| DateTimeError::InvalidDateFormat("Invalid year".into()))?;
327            let month: u32 = s[4..6]
328                .parse()
329                .map_err(|_| DateTimeError::InvalidDateFormat("Invalid month".into()))?;
330            let date = NaiveDate::from_ymd_opt(year, month, 1)
331                .ok_or_else(|| DateTimeError::DateOutOfRange("Invalid month".into()))?;
332            Ok(ParsedTimestamp::new(
333                date.and_hms_opt(0, 0, 0).unwrap(),
334                precision,
335            ))
336        }
337        TimestampPrecision::Day => {
338            let date = parse_hl7_dt(s)?;
339            Ok(ParsedTimestamp::new(
340                date.and_hms_opt(0, 0, 0).unwrap(),
341                precision,
342            ))
343        }
344        TimestampPrecision::Hour => {
345            let date = parse_hl7_dt(&s[0..8])?;
346            let hour: u32 = s[8..10]
347                .parse()
348                .map_err(|_| DateTimeError::TimeOutOfRange("Invalid hour".into()))?;
349            Ok(ParsedTimestamp::new(
350                date.and_hms_opt(hour, 0, 0).unwrap(),
351                precision,
352            ))
353        }
354        TimestampPrecision::Minute => {
355            let date = parse_hl7_dt(&s[0..8])?;
356            let hour: u32 = s[8..10]
357                .parse()
358                .map_err(|_| DateTimeError::TimeOutOfRange("Invalid hour".into()))?;
359            let minute: u32 = s[10..12]
360                .parse()
361                .map_err(|_| DateTimeError::TimeOutOfRange("Invalid minute".into()))?;
362            Ok(ParsedTimestamp::new(
363                date.and_hms_opt(hour, minute, 0).unwrap(),
364                precision,
365            ))
366        }
367        TimestampPrecision::Second => {
368            let dt = parse_hl7_ts(s)?;
369            Ok(ParsedTimestamp::new(dt, precision))
370        }
371        TimestampPrecision::FractionalSecond => {
372            // Parse base timestamp
373            let dt = parse_hl7_ts(&s[0..14])?;
374            // Parse fractional part
375            let frac_str = &s[15..]; // Skip the dot
376            let padded = format!("{:0<6}", frac_str.chars().take(6).collect::<String>());
377            let fractional: u32 = padded.parse().unwrap_or(0);
378            Ok(ParsedTimestamp::with_fractional(dt, fractional))
379        }
380    }
381}
382
383/// Truncate a datetime to a specific precision
384fn truncate_to_precision(dt: &NaiveDateTime, precision: TimestampPrecision) -> NaiveDateTime {
385    match precision {
386        TimestampPrecision::Year => NaiveDate::from_ymd_opt(dt.year(), 1, 1)
387            .and_then(|d| d.and_hms_opt(0, 0, 0))
388            .unwrap_or(*dt),
389        TimestampPrecision::Month => NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1)
390            .and_then(|d| d.and_hms_opt(0, 0, 0))
391            .unwrap_or(*dt),
392        TimestampPrecision::Day => dt.date().and_hms_opt(0, 0, 0).unwrap_or(*dt),
393        TimestampPrecision::Hour => dt
394            .with_minute(0)
395            .and_then(|d| d.with_second(0))
396            .unwrap_or(*dt),
397        TimestampPrecision::Minute => dt.with_second(0).unwrap_or(*dt),
398        TimestampPrecision::Second | TimestampPrecision::FractionalSecond => *dt,
399    }
400}
401
402/// Check if a string is a valid HL7 date (DT)
403pub fn is_valid_hl7_date(s: &str) -> bool {
404    parse_hl7_dt(s).is_ok()
405}
406
407/// Check if a string is a valid HL7 time (TM)
408pub fn is_valid_hl7_time(s: &str) -> bool {
409    parse_hl7_tm(s).is_ok()
410}
411
412/// Check if a string is a valid HL7 timestamp (TS)
413pub fn is_valid_hl7_timestamp(s: &str) -> bool {
414    parse_hl7_ts(s).is_ok()
415}
416
417/// Get current timestamp in HL7 format
418pub fn now_hl7() -> String {
419    chrono::Utc::now().format("%Y%m%d%H%M%S").to_string()
420}
421
422/// Get current date in HL7 format
423pub fn today_hl7() -> String {
424    chrono::Utc::now().format("%Y%m%d").to_string()
425}
426
427#[cfg(test)]
428mod tests;